Add author detail, idea detail, and gap-draft reverse link pages
- Author detail page (/authors/<person_id>): shows author info, all drafts with ratings, and co-authors with shared draft counts. Public route. - Idea detail page (/ideas/<idea_id>): shows idea metadata, source draft, and top-5 most similar ideas via embedding cosine similarity. Admin route. - Gap detail page: added "Related Drafts" section that finds drafts by extracting draft names from evidence text and searching by topic keywords. - Updated author links across templates to use /authors/<person_id> URLs. - Added DB methods: get_author_by_id, get_author_drafts, get_coauthors. - Extended top_authors to include person_id (5th tuple element). Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -639,10 +639,10 @@ class Database:
|
|||||||
def author_count(self) -> int:
|
def author_count(self) -> int:
|
||||||
return self.conn.execute("SELECT COUNT(*) FROM authors").fetchone()[0]
|
return self.conn.execute("SELECT COUNT(*) FROM authors").fetchone()[0]
|
||||||
|
|
||||||
def top_authors(self, limit: int = 20) -> list[tuple[str, str, int, list[str]]]:
|
def top_authors(self, limit: int = 20) -> list[tuple[str, str, int, list[str], int]]:
|
||||||
"""Return (name, affiliation, draft_count, [draft_names])."""
|
"""Return (name, affiliation, draft_count, [draft_names], person_id)."""
|
||||||
rows = self.conn.execute(
|
rows = self.conn.execute(
|
||||||
"""SELECT a.name, a.affiliation, COUNT(da.draft_name) as cnt,
|
"""SELECT a.person_id, a.name, a.affiliation, COUNT(da.draft_name) as cnt,
|
||||||
GROUP_CONCAT(da.draft_name, '||') as drafts
|
GROUP_CONCAT(da.draft_name, '||') as drafts
|
||||||
FROM authors a
|
FROM authors a
|
||||||
JOIN draft_authors da ON a.person_id = da.person_id
|
JOIN draft_authors da ON a.person_id = da.person_id
|
||||||
@@ -653,10 +653,50 @@ class Database:
|
|||||||
).fetchall()
|
).fetchall()
|
||||||
return [
|
return [
|
||||||
(r["name"], r["affiliation"], r["cnt"],
|
(r["name"], r["affiliation"], r["cnt"],
|
||||||
r["drafts"].split("||") if r["drafts"] else [])
|
r["drafts"].split("||") if r["drafts"] else [],
|
||||||
|
r["person_id"])
|
||||||
for r in rows
|
for r in rows
|
||||||
]
|
]
|
||||||
|
|
||||||
|
def get_author_by_id(self, person_id: int) -> dict | None:
|
||||||
|
"""Return author info by person_id, or None if not found."""
|
||||||
|
row = self.conn.execute(
|
||||||
|
"SELECT * FROM authors WHERE person_id = ?", (person_id,)
|
||||||
|
).fetchone()
|
||||||
|
if not row:
|
||||||
|
return None
|
||||||
|
return {
|
||||||
|
"person_id": row["person_id"],
|
||||||
|
"name": row["name"],
|
||||||
|
"ascii_name": row["ascii_name"],
|
||||||
|
"affiliation": row["affiliation"],
|
||||||
|
"resource_uri": row["resource_uri"],
|
||||||
|
}
|
||||||
|
|
||||||
|
def get_author_drafts(self, person_id: int) -> list[str]:
|
||||||
|
"""Return draft names for a given author."""
|
||||||
|
rows = self.conn.execute(
|
||||||
|
"SELECT draft_name FROM draft_authors WHERE person_id = ? ORDER BY draft_name",
|
||||||
|
(person_id,),
|
||||||
|
).fetchall()
|
||||||
|
return [r["draft_name"] for r in rows]
|
||||||
|
|
||||||
|
def get_coauthors(self, person_id: int) -> list[dict]:
|
||||||
|
"""Return co-authors for a given person (authors on the same drafts)."""
|
||||||
|
rows = self.conn.execute(
|
||||||
|
"""SELECT DISTINCT a.person_id, a.name, a.affiliation, COUNT(DISTINCT da2.draft_name) as shared
|
||||||
|
FROM draft_authors da1
|
||||||
|
JOIN draft_authors da2 ON da1.draft_name = da2.draft_name AND da2.person_id != da1.person_id
|
||||||
|
JOIN authors a ON da2.person_id = a.person_id
|
||||||
|
WHERE da1.person_id = ?
|
||||||
|
GROUP BY a.person_id
|
||||||
|
ORDER BY shared DESC""",
|
||||||
|
(person_id,),
|
||||||
|
).fetchall()
|
||||||
|
return [{"person_id": r["person_id"], "name": r["name"],
|
||||||
|
"affiliation": r["affiliation"], "shared_drafts": r["shared"]}
|
||||||
|
for r in rows]
|
||||||
|
|
||||||
def top_orgs(self, limit: int = 20) -> list[tuple[str, int, int]]:
|
def top_orgs(self, limit: int = 20) -> list[tuple[str, int, int]]:
|
||||||
"""Return (org, author_count, draft_count)."""
|
"""Return (org, author_count, draft_count)."""
|
||||||
rows = self.conn.execute(
|
rows = self.conn.execute(
|
||||||
|
|||||||
@@ -433,7 +433,7 @@ class Reporter:
|
|||||||
"| # | Author | Organization | Drafts | Categories |",
|
"| # | Author | Organization | Drafts | Categories |",
|
||||||
"|--:|--------|-------------|-------:|------------|",
|
"|--:|--------|-------------|-------:|------------|",
|
||||||
])
|
])
|
||||||
for rank, (name, aff, cnt, draft_names) in enumerate(top, 1):
|
for rank, (name, aff, cnt, draft_names, _pid) in enumerate(top, 1):
|
||||||
cats: set[str] = set()
|
cats: set[str] = set()
|
||||||
for dn in draft_names:
|
for dn in draft_names:
|
||||||
r = rating_map.get(dn)
|
r = rating_map.get(dn)
|
||||||
|
|||||||
@@ -452,7 +452,7 @@ class Visualizer:
|
|||||||
# Get affiliations for coloring (normalized)
|
# Get affiliations for coloring (normalized)
|
||||||
from .orgs import normalize_org
|
from .orgs import normalize_org
|
||||||
top_authors = self.db.top_authors(limit=200)
|
top_authors = self.db.top_authors(limit=200)
|
||||||
author_aff = {name: normalize_org(aff) for name, aff, _, _ in top_authors}
|
author_aff = {name: normalize_org(aff) for name, aff, _, _, _pid in top_authors}
|
||||||
|
|
||||||
# Node sizing by degree
|
# Node sizing by degree
|
||||||
degrees = dict(G.degree())
|
degrees = dict(G.degree())
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ from webui.data import (
|
|||||||
get_rating_distributions,
|
get_rating_distributions,
|
||||||
get_all_gaps,
|
get_all_gaps,
|
||||||
get_gap_detail,
|
get_gap_detail,
|
||||||
|
get_drafts_for_gap,
|
||||||
get_generated_drafts,
|
get_generated_drafts,
|
||||||
read_generated_draft,
|
read_generated_draft,
|
||||||
get_monitor_status,
|
get_monitor_status,
|
||||||
@@ -30,6 +31,7 @@ from webui.data import (
|
|||||||
get_citation_influence,
|
get_citation_influence,
|
||||||
get_bcp_analysis,
|
get_bcp_analysis,
|
||||||
get_idea_analysis,
|
get_idea_analysis,
|
||||||
|
get_idea_detail,
|
||||||
get_trends_data,
|
get_trends_data,
|
||||||
get_complexity_data,
|
get_complexity_data,
|
||||||
get_all_proposals,
|
get_all_proposals,
|
||||||
@@ -113,7 +115,9 @@ def gap_detail(gap_id: int):
|
|||||||
abort(404)
|
abort(404)
|
||||||
generated = get_generated_drafts()
|
generated = get_generated_drafts()
|
||||||
gap_proposals = get_proposals_for_gap(db(), gap_id)
|
gap_proposals = get_proposals_for_gap(db(), gap_id)
|
||||||
return render_template("gap_detail.html", gap=gap, generated_drafts=generated, proposals=gap_proposals)
|
related_drafts = get_drafts_for_gap(db(), gap_id)
|
||||||
|
return render_template("gap_detail.html", gap=gap, generated_drafts=generated,
|
||||||
|
proposals=gap_proposals, related_drafts=related_drafts)
|
||||||
|
|
||||||
|
|
||||||
@admin_bp.route("/gaps/<int:gap_id>/generate", methods=["POST"])
|
@admin_bp.route("/gaps/<int:gap_id>/generate", methods=["POST"])
|
||||||
@@ -382,6 +386,17 @@ def api_idea_analysis():
|
|||||||
return jsonify(data)
|
return jsonify(data)
|
||||||
|
|
||||||
|
|
||||||
|
# ── Idea Detail ──────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
@admin_bp.route("/ideas/<int:idea_id>")
|
||||||
|
@admin_required
|
||||||
|
def idea_detail(idea_id: int):
|
||||||
|
idea = get_idea_detail(db(), idea_id)
|
||||||
|
if not idea:
|
||||||
|
abort(404)
|
||||||
|
return render_template("idea_detail.html", idea=idea)
|
||||||
|
|
||||||
|
|
||||||
# ── Trends & Complexity ──────────────────────────────────────────────────
|
# ── Trends & Complexity ──────────────────────────────────────────────────
|
||||||
|
|
||||||
@admin_bp.route("/trends")
|
@admin_bp.route("/trends")
|
||||||
|
|||||||
@@ -27,6 +27,7 @@ from webui.data import (
|
|||||||
get_ask_search,
|
get_ask_search,
|
||||||
get_citation_influence,
|
get_citation_influence,
|
||||||
get_bcp_analysis,
|
get_bcp_analysis,
|
||||||
|
get_author_detail,
|
||||||
)
|
)
|
||||||
|
|
||||||
pages_bp = Blueprint("pages", __name__)
|
pages_bp = Blueprint("pages", __name__)
|
||||||
@@ -144,6 +145,14 @@ def architecture():
|
|||||||
return render_template("architecture.html", arch=data)
|
return render_template("architecture.html", arch=data)
|
||||||
|
|
||||||
|
|
||||||
|
@pages_bp.route("/authors/<int:person_id>")
|
||||||
|
def author_detail(person_id: int):
|
||||||
|
detail = get_author_detail(db(), person_id)
|
||||||
|
if not detail:
|
||||||
|
abort(404)
|
||||||
|
return render_template("author_detail.html", author=detail)
|
||||||
|
|
||||||
|
|
||||||
@pages_bp.route("/authors")
|
@pages_bp.route("/authors")
|
||||||
def authors():
|
def authors():
|
||||||
top = get_top_authors(db(), limit=50)
|
top = get_top_authors(db(), limit=50)
|
||||||
|
|||||||
@@ -37,6 +37,7 @@ from webui.data.authors import ( # noqa: F401
|
|||||||
get_coauthor_network,
|
get_coauthor_network,
|
||||||
get_cross_org_data,
|
get_cross_org_data,
|
||||||
get_author_network_full,
|
get_author_network_full,
|
||||||
|
get_author_detail,
|
||||||
)
|
)
|
||||||
|
|
||||||
# Ratings
|
# Ratings
|
||||||
@@ -51,6 +52,7 @@ from webui.data.ratings import ( # noqa: F401
|
|||||||
from webui.data.gaps import ( # noqa: F401
|
from webui.data.gaps import ( # noqa: F401
|
||||||
get_all_gaps,
|
get_all_gaps,
|
||||||
get_gap_detail,
|
get_gap_detail,
|
||||||
|
get_drafts_for_gap,
|
||||||
)
|
)
|
||||||
|
|
||||||
# Analysis & Visualization
|
# Analysis & Visualization
|
||||||
@@ -74,6 +76,7 @@ from webui.data.analysis import ( # noqa: F401
|
|||||||
get_comparison_data,
|
get_comparison_data,
|
||||||
get_architecture,
|
get_architecture,
|
||||||
get_idea_analysis,
|
get_idea_analysis,
|
||||||
|
get_idea_detail,
|
||||||
get_trends_data,
|
get_trends_data,
|
||||||
get_complexity_data,
|
get_complexity_data,
|
||||||
get_source_comparison,
|
get_source_comparison,
|
||||||
|
|||||||
@@ -103,6 +103,78 @@ def get_ideas_by_type(db: Database) -> dict:
|
|||||||
"ideas": all_ideas,
|
"ideas": all_ideas,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
def get_idea_detail(db: Database, idea_id: int) -> dict | None:
|
||||||
|
"""Return a single idea with source draft info and similar ideas."""
|
||||||
|
row = db.conn.execute("SELECT * FROM ideas WHERE id = ?", (idea_id,)).fetchone()
|
||||||
|
if not row:
|
||||||
|
return None
|
||||||
|
|
||||||
|
idea = {
|
||||||
|
"id": row["id"],
|
||||||
|
"title": row["title"],
|
||||||
|
"description": row["description"],
|
||||||
|
"type": row["idea_type"],
|
||||||
|
"draft_name": row["draft_name"],
|
||||||
|
"novelty_score": row["novelty_score"],
|
||||||
|
}
|
||||||
|
|
||||||
|
# Get source draft info
|
||||||
|
draft = db.get_draft(row["draft_name"])
|
||||||
|
if draft:
|
||||||
|
idea["draft_title"] = draft.title
|
||||||
|
idea["draft_date"] = draft.date
|
||||||
|
|
||||||
|
# Get category from ratings
|
||||||
|
rated = db.drafts_with_ratings(limit=2000)
|
||||||
|
for d, r in rated:
|
||||||
|
if d.name == row["draft_name"]:
|
||||||
|
idea["categories"] = r.categories
|
||||||
|
break
|
||||||
|
|
||||||
|
# Find similar ideas using embeddings
|
||||||
|
similar = []
|
||||||
|
emb_row = db.conn.execute(
|
||||||
|
"SELECT vector FROM idea_embeddings WHERE idea_id = ?", (idea_id,)
|
||||||
|
).fetchone()
|
||||||
|
if emb_row:
|
||||||
|
target_vec = np.frombuffer(emb_row["vector"], dtype=np.float32)
|
||||||
|
all_embs = db.all_idea_embeddings()
|
||||||
|
# Compute cosine similarities
|
||||||
|
scores = []
|
||||||
|
for other_id, other_vec in all_embs.items():
|
||||||
|
if other_id == idea_id:
|
||||||
|
continue
|
||||||
|
cos_sim = float(np.dot(target_vec, other_vec) / (
|
||||||
|
np.linalg.norm(target_vec) * np.linalg.norm(other_vec) + 1e-9))
|
||||||
|
scores.append((other_id, cos_sim))
|
||||||
|
scores.sort(key=lambda x: x[1], reverse=True)
|
||||||
|
top_5 = scores[:5]
|
||||||
|
|
||||||
|
# Fetch idea details for top 5
|
||||||
|
if top_5:
|
||||||
|
ids = [s[0] for s in top_5]
|
||||||
|
sim_map = {s[0]: s[1] for s in top_5}
|
||||||
|
placeholders = ",".join("?" * len(ids))
|
||||||
|
sim_rows = db.conn.execute(
|
||||||
|
f"SELECT id, title, idea_type, draft_name FROM ideas WHERE id IN ({placeholders})",
|
||||||
|
ids,
|
||||||
|
).fetchall()
|
||||||
|
sim_dict = {r["id"]: r for r in sim_rows}
|
||||||
|
for sid, score in top_5:
|
||||||
|
sr = sim_dict.get(sid)
|
||||||
|
if sr:
|
||||||
|
similar.append({
|
||||||
|
"id": sr["id"],
|
||||||
|
"title": sr["title"],
|
||||||
|
"type": sr["idea_type"],
|
||||||
|
"draft_name": sr["draft_name"],
|
||||||
|
"similarity": round(score, 3),
|
||||||
|
})
|
||||||
|
|
||||||
|
idea["similar"] = similar
|
||||||
|
return idea
|
||||||
|
|
||||||
|
|
||||||
def get_timeline_data(db: Database) -> TimelineData:
|
def get_timeline_data(db: Database) -> TimelineData:
|
||||||
"""Return monthly counts by category for timeline chart."""
|
"""Return monthly counts by category for timeline chart."""
|
||||||
pairs = db.drafts_with_ratings(limit=1000)
|
pairs = db.drafts_with_ratings(limit=1000)
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ class AuthorInfo(TypedDict):
|
|||||||
affiliation: str
|
affiliation: str
|
||||||
draft_count: int
|
draft_count: int
|
||||||
drafts: list[str]
|
drafts: list[str]
|
||||||
|
person_id: int
|
||||||
|
|
||||||
class AuthorNetworkNode(TypedDict):
|
class AuthorNetworkNode(TypedDict):
|
||||||
"""Node in the author network graph."""
|
"""Node in the author network graph."""
|
||||||
@@ -50,8 +51,9 @@ def get_top_authors(db: Database, limit: int = 30) -> list[AuthorInfo]:
|
|||||||
"""Return top authors by draft count."""
|
"""Return top authors by draft count."""
|
||||||
rows = db.top_authors(limit=limit)
|
rows = db.top_authors(limit=limit)
|
||||||
return [
|
return [
|
||||||
{"name": name, "affiliation": aff, "draft_count": cnt, "drafts": drafts}
|
{"name": name, "affiliation": aff, "draft_count": cnt, "drafts": drafts,
|
||||||
for name, aff, cnt, drafts in rows
|
"person_id": pid}
|
||||||
|
for name, aff, cnt, drafts, pid in rows
|
||||||
]
|
]
|
||||||
|
|
||||||
def get_org_data(db: Database, limit: int = 20) -> list[dict]:
|
def get_org_data(db: Database, limit: int = 20) -> list[dict]:
|
||||||
@@ -71,7 +73,7 @@ def get_coauthor_network(db: Database, min_shared: int = 1) -> dict:
|
|||||||
top = db.top_authors(limit=100)
|
top = db.top_authors(limit=100)
|
||||||
|
|
||||||
# Build node set from authors who have co-authorships
|
# Build node set from authors who have co-authorships
|
||||||
author_info = {name: {"org": aff, "draft_count": cnt} for name, aff, cnt, _ in top}
|
author_info = {name: {"org": aff, "draft_count": cnt} for name, aff, cnt, _, _pid in top}
|
||||||
node_set = set()
|
node_set = set()
|
||||||
edges = []
|
edges = []
|
||||||
for a, b, shared in pairs:
|
for a, b, shared in pairs:
|
||||||
@@ -92,6 +94,49 @@ def get_coauthor_network(db: Database, min_shared: int = 1) -> dict:
|
|||||||
|
|
||||||
return {"nodes": nodes, "edges": edges}
|
return {"nodes": nodes, "edges": edges}
|
||||||
|
|
||||||
|
def get_author_detail(db: Database, person_id: int) -> dict | None:
|
||||||
|
"""Return author detail with drafts, ratings, and co-authors."""
|
||||||
|
author = db.get_author_by_id(person_id)
|
||||||
|
if not author:
|
||||||
|
return None
|
||||||
|
|
||||||
|
draft_names = db.get_author_drafts(person_id)
|
||||||
|
drafts_map = db.get_drafts_by_names(draft_names)
|
||||||
|
|
||||||
|
# Get ratings for each draft
|
||||||
|
rated = db.drafts_with_ratings(limit=2000)
|
||||||
|
rating_map = {d.name: r for d, r in rated}
|
||||||
|
|
||||||
|
drafts = []
|
||||||
|
for dn in draft_names:
|
||||||
|
d = drafts_map.get(dn)
|
||||||
|
if not d:
|
||||||
|
continue
|
||||||
|
r = rating_map.get(dn)
|
||||||
|
drafts.append({
|
||||||
|
"name": d.name,
|
||||||
|
"title": d.title,
|
||||||
|
"date": d.date,
|
||||||
|
"status": d.status,
|
||||||
|
"categories": r.categories if r else [],
|
||||||
|
"score": round(r.composite_score, 2) if r else None,
|
||||||
|
"novelty": r.novelty if r else None,
|
||||||
|
"maturity": r.maturity if r else None,
|
||||||
|
"relevance": r.relevance if r else None,
|
||||||
|
})
|
||||||
|
|
||||||
|
coauthors = db.get_coauthors(person_id)
|
||||||
|
|
||||||
|
return {
|
||||||
|
"person_id": author["person_id"],
|
||||||
|
"name": author["name"],
|
||||||
|
"affiliation": author["affiliation"],
|
||||||
|
"ascii_name": author["ascii_name"],
|
||||||
|
"drafts": drafts,
|
||||||
|
"coauthors": coauthors,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
def get_cross_org_data(db: Database, limit: int = 20) -> list[dict]:
|
def get_cross_org_data(db: Database, limit: int = 20) -> list[dict]:
|
||||||
"""Return cross-org collaboration pairs."""
|
"""Return cross-org collaboration pairs."""
|
||||||
rows = db.cross_org_collaborations(limit=limit)
|
rows = db.cross_org_collaborations(limit=limit)
|
||||||
@@ -122,7 +167,7 @@ def _compute_author_network_full(db: Database) -> AuthorNetwork:
|
|||||||
|
|
||||||
# Author info map
|
# Author info map
|
||||||
author_info = {}
|
author_info = {}
|
||||||
for name, aff, cnt, drafts in top:
|
for name, aff, cnt, drafts, _pid in top:
|
||||||
scores = [draft_score[dn] for dn in drafts if dn in draft_score]
|
scores = [draft_score[dn] for dn in drafts if dn in draft_score]
|
||||||
avg = round(sum(scores) / len(scores), 2) if scores else 0
|
avg = round(sum(scores) / len(scores), 2) if scores else 0
|
||||||
author_info[name] = {
|
author_info[name] = {
|
||||||
|
|||||||
@@ -94,7 +94,7 @@ def get_category_summary(db: Database, category: str) -> dict | None:
|
|||||||
|
|
||||||
# Author lookup: draft_name -> [author names]
|
# Author lookup: draft_name -> [author names]
|
||||||
author_drafts_map: dict[str, list[str]] = defaultdict(list)
|
author_drafts_map: dict[str, list[str]] = defaultdict(list)
|
||||||
for name, aff, cnt, drafts in all_authors:
|
for name, aff, cnt, drafts, *_ in all_authors:
|
||||||
for dn in drafts:
|
for dn in drafts:
|
||||||
author_drafts_map[dn].append(name)
|
author_drafts_map[dn].append(name)
|
||||||
|
|
||||||
@@ -116,7 +116,7 @@ def get_category_summary(db: Database, category: str) -> dict | None:
|
|||||||
author_counter: Counter = Counter()
|
author_counter: Counter = Counter()
|
||||||
org_counter: Counter = Counter()
|
org_counter: Counter = Counter()
|
||||||
author_aff: dict[str, str] = {}
|
author_aff: dict[str, str] = {}
|
||||||
for name, aff, cnt, drafts in all_authors:
|
for name, aff, cnt, drafts, *_ in all_authors:
|
||||||
author_aff[name] = aff or ""
|
author_aff[name] = aff or ""
|
||||||
for d, r in cat_pairs:
|
for d, r in cat_pairs:
|
||||||
for a in author_drafts_map.get(d.name, []):
|
for a in author_drafts_map.get(d.name, []):
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
"""Gap analysis data access functions."""
|
"""Gap analysis data access functions."""
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import re
|
||||||
|
|
||||||
from ietf_analyzer.db import Database
|
from ietf_analyzer.db import Database
|
||||||
|
|
||||||
|
|
||||||
@@ -18,3 +20,68 @@ def get_gap_detail(db: Database, gap_id: int) -> dict | None:
|
|||||||
if g["id"] == gap_id:
|
if g["id"] == gap_id:
|
||||||
return g
|
return g
|
||||||
return None
|
return None
|
||||||
|
|
||||||
|
def get_drafts_for_gap(db: Database, gap_id: int) -> list[dict]:
|
||||||
|
"""Find drafts related to a gap by searching evidence for draft names
|
||||||
|
and searching draft titles/abstracts for gap topic keywords."""
|
||||||
|
gap = get_gap_detail(db, gap_id)
|
||||||
|
if not gap:
|
||||||
|
return []
|
||||||
|
|
||||||
|
found_names: set[str] = set()
|
||||||
|
|
||||||
|
# 1. Extract draft names mentioned in evidence text
|
||||||
|
evidence = gap.get("evidence", "") or ""
|
||||||
|
# Match draft-xxx-yyy-zzz patterns
|
||||||
|
draft_refs = re.findall(r'draft-[\w-]+', evidence)
|
||||||
|
found_names.update(draft_refs)
|
||||||
|
|
||||||
|
# 2. Search drafts by gap topic keywords
|
||||||
|
topic = gap.get("topic", "")
|
||||||
|
# Extract meaningful keywords (3+ chars, skip common words)
|
||||||
|
stopwords = {"the", "and", "for", "with", "from", "that", "this", "are", "was",
|
||||||
|
"not", "but", "have", "has", "had", "will", "can", "all", "each",
|
||||||
|
"which", "their", "been", "into", "more", "other", "some", "than",
|
||||||
|
"may", "its", "also", "between", "should", "would", "could", "does"}
|
||||||
|
words = [w.lower() for w in re.findall(r'[A-Za-z]{3,}', topic) if w.lower() not in stopwords]
|
||||||
|
|
||||||
|
# Search for drafts matching topic keywords
|
||||||
|
if words:
|
||||||
|
# Use the most specific keywords (longer words first)
|
||||||
|
keywords = sorted(words, key=len, reverse=True)[:3]
|
||||||
|
for kw in keywords:
|
||||||
|
like = f"%{kw}%"
|
||||||
|
rows = db.conn.execute(
|
||||||
|
"""SELECT name FROM drafts
|
||||||
|
WHERE title LIKE ? OR abstract LIKE ?
|
||||||
|
LIMIT 20""",
|
||||||
|
(like, like),
|
||||||
|
).fetchall()
|
||||||
|
for r in rows:
|
||||||
|
found_names.add(r["name"])
|
||||||
|
|
||||||
|
if not found_names:
|
||||||
|
return []
|
||||||
|
|
||||||
|
# Fetch draft details + ratings
|
||||||
|
drafts_map = db.get_drafts_by_names(list(found_names))
|
||||||
|
rated = db.drafts_with_ratings(limit=2000)
|
||||||
|
rating_map = {d.name: r for d, r in rated}
|
||||||
|
|
||||||
|
results = []
|
||||||
|
for name in sorted(found_names):
|
||||||
|
d = drafts_map.get(name)
|
||||||
|
if not d:
|
||||||
|
continue
|
||||||
|
r = rating_map.get(name)
|
||||||
|
results.append({
|
||||||
|
"name": d.name,
|
||||||
|
"title": d.title,
|
||||||
|
"date": d.date,
|
||||||
|
"score": round(r.composite_score, 2) if r else None,
|
||||||
|
"categories": r.categories if r else [],
|
||||||
|
})
|
||||||
|
|
||||||
|
# Sort by score descending
|
||||||
|
results.sort(key=lambda x: x.get("score") or 0, reverse=True)
|
||||||
|
return results
|
||||||
|
|||||||
@@ -115,7 +115,7 @@ def build_obsidian_vault(db: Database) -> bytes:
|
|||||||
# Author info by draft
|
# Author info by draft
|
||||||
author_drafts: dict[str, list[str]] = defaultdict(list)
|
author_drafts: dict[str, list[str]] = defaultdict(list)
|
||||||
author_info: dict[str, dict] = {}
|
author_info: dict[str, dict] = {}
|
||||||
for name, aff, cnt, drafts in all_authors:
|
for name, aff, cnt, drafts, *_ in all_authors:
|
||||||
author_info[name] = {"affiliation": aff or "", "draft_count": cnt, "drafts": drafts}
|
author_info[name] = {"affiliation": aff or "", "draft_count": cnt, "drafts": drafts}
|
||||||
for dn in drafts:
|
for dn in drafts:
|
||||||
author_drafts[dn].append(name)
|
author_drafts[dn].append(name)
|
||||||
@@ -269,11 +269,11 @@ generated: {date.today().isoformat()}
|
|||||||
f"**{len(all_authors)}** authors contributing to AI/agent Internet-Drafts.\n\n",
|
f"**{len(all_authors)}** authors contributing to AI/agent Internet-Drafts.\n\n",
|
||||||
"| Author | Affiliation | Drafts |\n|---|---|---|\n",
|
"| Author | Affiliation | Drafts |\n|---|---|---|\n",
|
||||||
]
|
]
|
||||||
for name, aff, cnt, drafts in sorted(all_authors, key=lambda x: x[2], reverse=True):
|
for name, aff, cnt, drafts, *_ in sorted(all_authors, key=lambda x: x[2], reverse=True):
|
||||||
author_index_lines.append(f"| [[{name}]] | {aff or ''} | {cnt} |\n")
|
author_index_lines.append(f"| [[{name}]] | {aff or ''} | {cnt} |\n")
|
||||||
zf.writestr(f"{prefix}/Authors/index.md", "".join(author_index_lines))
|
zf.writestr(f"{prefix}/Authors/index.md", "".join(author_index_lines))
|
||||||
|
|
||||||
for name, aff, cnt, drafts in all_authors:
|
for name, aff, cnt, drafts, *_ in all_authors:
|
||||||
fm = f"---\ntags: [author]\naffiliation: \"{aff or ''}\"\ndraft_count: {cnt}\n---\n"
|
fm = f"---\ntags: [author]\naffiliation: \"{aff or ''}\"\ndraft_count: {cnt}\n---\n"
|
||||||
body = f"\n# {name}\n\n"
|
body = f"\n# {name}\n\n"
|
||||||
if aff:
|
if aff:
|
||||||
|
|||||||
111
src/webui/templates/author_detail.html
Normal file
111
src/webui/templates/author_detail.html
Normal file
@@ -0,0 +1,111 @@
|
|||||||
|
{% extends "base.html" %}
|
||||||
|
{% set active_page = "authors" %}
|
||||||
|
|
||||||
|
{% block title %}{{ author.name }} — IETF Draft Analyzer{% endblock %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<!-- Breadcrumb -->
|
||||||
|
<div class="mb-6">
|
||||||
|
<a href="/authors" class="inline-flex items-center gap-1.5 text-sm text-slate-500 hover:text-slate-300 transition group">
|
||||||
|
<svg class="w-4 h-4 group-hover:-translate-x-0.5 transition-transform" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 19l-7-7 7-7"/>
|
||||||
|
</svg>
|
||||||
|
Back to Authors
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Author Header -->
|
||||||
|
<div class="bg-slate-900 rounded-xl border border-slate-800 p-6 mb-6">
|
||||||
|
<div class="flex items-start gap-4">
|
||||||
|
<div class="w-14 h-14 rounded-full bg-slate-800 border border-slate-700 flex items-center justify-center flex-shrink-0">
|
||||||
|
<span class="text-xl font-bold text-slate-400">{{ author.name[0]|upper if author.name else '?' }}</span>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h1 class="text-2xl font-bold text-white">{{ author.name }}</h1>
|
||||||
|
{% if author.affiliation %}
|
||||||
|
<p class="text-sm text-slate-400 mt-1">{{ author.affiliation }}</p>
|
||||||
|
{% endif %}
|
||||||
|
<div class="flex items-center gap-4 mt-3">
|
||||||
|
<span class="text-xs text-slate-500">{{ author.drafts|length }} draft{{ 's' if author.drafts|length != 1 }}</span>
|
||||||
|
<span class="text-xs text-slate-500">{{ author.coauthors|length }} co-author{{ 's' if author.coauthors|length != 1 }}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="grid grid-cols-1 lg:grid-cols-3 gap-6">
|
||||||
|
<!-- Left column: Drafts -->
|
||||||
|
<div class="lg:col-span-2 space-y-6">
|
||||||
|
<div class="bg-slate-900 rounded-xl border border-slate-800 p-6">
|
||||||
|
<h2 class="text-sm font-semibold text-slate-300 mb-4 flex items-center gap-2">
|
||||||
|
<svg class="w-4 h-4 text-slate-500" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z"/></svg>
|
||||||
|
Authored Drafts <span class="text-slate-600 font-normal">({{ author.drafts|length }})</span>
|
||||||
|
</h2>
|
||||||
|
<div class="space-y-3">
|
||||||
|
{% for d in author.drafts %}
|
||||||
|
<a href="/drafts/{{ d.name }}" class="block bg-slate-800/30 rounded-lg border border-slate-800/50 hover:border-slate-700 p-4 transition group">
|
||||||
|
<div class="flex items-start justify-between gap-3">
|
||||||
|
<div class="min-w-0">
|
||||||
|
<h3 class="text-sm font-medium text-slate-200 group-hover:text-blue-400 transition truncate">{{ d.title }}</h3>
|
||||||
|
<div class="flex items-center gap-2 mt-1">
|
||||||
|
<span class="text-xs text-slate-500 font-mono">{{ d.name }}</span>
|
||||||
|
{% if d.date %}
|
||||||
|
<span class="text-xs text-slate-600">{{ d.date }}</span>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
{% if d.categories %}
|
||||||
|
<div class="flex flex-wrap gap-1 mt-2">
|
||||||
|
{% for cat in d.categories[:3] %}
|
||||||
|
<span class="px-2 py-0.5 rounded-full text-[10px] bg-slate-800/60 text-slate-400 border border-slate-700">{{ cat }}</span>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
{% if d.score is not none %}
|
||||||
|
<span class="flex-shrink-0 px-2 py-1 rounded text-sm font-bold
|
||||||
|
{% if d.score >= 3.5 %}text-green-400
|
||||||
|
{% elif d.score >= 2.5 %}text-amber-400
|
||||||
|
{% else %}text-red-400{% endif %}">
|
||||||
|
{{ d.score }}
|
||||||
|
</span>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</a>
|
||||||
|
{% endfor %}
|
||||||
|
{% if not author.drafts %}
|
||||||
|
<p class="text-sm text-slate-500">No drafts found.</p>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Right column: Co-authors -->
|
||||||
|
<div class="space-y-6">
|
||||||
|
<div class="bg-slate-900 rounded-xl border border-slate-800 p-5">
|
||||||
|
<h2 class="text-sm font-semibold text-slate-300 mb-3 flex items-center gap-2">
|
||||||
|
<svg class="w-4 h-4 text-slate-500" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M17 20h5v-2a3 3 0 00-5.356-1.857M17 20H7m10 0v-2c0-.656-.126-1.283-.356-1.857M7 20H2v-2a3 3 0 015.356-1.857M7 20v-2c0-.656.126-1.283.356-1.857m0 0a5.002 5.002 0 019.288 0M15 7a3 3 0 11-6 0 3 3 0 016 0z"/></svg>
|
||||||
|
Co-Authors <span class="text-slate-600 font-normal">({{ author.coauthors|length }})</span>
|
||||||
|
</h2>
|
||||||
|
<ul class="space-y-2.5">
|
||||||
|
{% for ca in author.coauthors %}
|
||||||
|
<li class="flex items-start gap-2">
|
||||||
|
<div class="w-7 h-7 rounded-full bg-slate-800 border border-slate-700 flex items-center justify-center flex-shrink-0 mt-0.5">
|
||||||
|
<span class="text-xs font-semibold text-slate-400">{{ ca.name[0]|upper if ca.name else '?' }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="min-w-0">
|
||||||
|
<a href="/authors/{{ ca.person_id }}" class="text-sm text-blue-400 hover:text-blue-300 transition">{{ ca.name }}</a>
|
||||||
|
{% if ca.affiliation %}
|
||||||
|
<div class="text-xs text-slate-500 truncate">{{ ca.affiliation }}</div>
|
||||||
|
{% endif %}
|
||||||
|
<div class="text-[10px] text-slate-600">{{ ca.shared_drafts }} shared draft{{ 's' if ca.shared_drafts != 1 }}</div>
|
||||||
|
</div>
|
||||||
|
</li>
|
||||||
|
{% endfor %}
|
||||||
|
{% if not author.coauthors %}
|
||||||
|
<p class="text-sm text-slate-500">No co-authors found.</p>
|
||||||
|
{% endif %}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endblock %}
|
||||||
@@ -211,7 +211,7 @@
|
|||||||
<tr class="hover:bg-slate-800/50 transition author-row" data-name="{{ a.name }}" data-org="{{ a.affiliation }}" data-count="{{ a.draft_count }}">
|
<tr class="hover:bg-slate-800/50 transition author-row" data-name="{{ a.name }}" data-org="{{ a.affiliation }}" data-count="{{ a.draft_count }}">
|
||||||
<td class="px-4 py-2.5 text-slate-500 text-xs">{{ loop.index }}</td>
|
<td class="px-4 py-2.5 text-slate-500 text-xs">{{ loop.index }}</td>
|
||||||
<td class="px-4 py-2.5">
|
<td class="px-4 py-2.5">
|
||||||
<a href="/drafts?q={{ a.name | urlencode }}" class="text-blue-400 hover:text-blue-300 transition">{{ a.name }}</a>
|
<a href="/authors/{{ a.person_id }}" class="text-blue-400 hover:text-blue-300 transition">{{ a.name }}</a>
|
||||||
</td>
|
</td>
|
||||||
<td class="px-4 py-2.5 text-slate-500 text-xs truncate max-w-[200px]" title="{{ a.affiliation }}">{{ a.affiliation }}</td>
|
<td class="px-4 py-2.5 text-slate-500 text-xs truncate max-w-[200px]" title="{{ a.affiliation }}">{{ a.affiliation }}</td>
|
||||||
<td class="px-4 py-2.5 text-right">
|
<td class="px-4 py-2.5 text-right">
|
||||||
|
|||||||
@@ -331,7 +331,7 @@
|
|||||||
<span class="text-xs font-semibold text-slate-400">{{ a.name[0]|upper if a.name else '?' }}</span>
|
<span class="text-xs font-semibold text-slate-400">{{ a.name[0]|upper if a.name else '?' }}</span>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<a href="/drafts?q={{ a.name | urlencode }}" class="text-sm text-blue-400 hover:text-blue-300 transition">{{ a.name }}</a>
|
<a href="/authors/{{ a.person_id }}" class="text-sm text-blue-400 hover:text-blue-300 transition">{{ a.name }}</a>
|
||||||
{% if a.affiliation %}
|
{% if a.affiliation %}
|
||||||
<div class="text-xs text-slate-500">{{ a.affiliation }}</div>
|
<div class="text-xs text-slate-500">{{ a.affiliation }}</div>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|||||||
@@ -86,6 +86,51 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Related Drafts -->
|
||||||
|
{% if related_drafts %}
|
||||||
|
<div class="bg-slate-900 rounded-xl border border-slate-800 p-6 mb-6">
|
||||||
|
<h2 class="text-lg font-semibold text-white mb-4 flex items-center gap-2">
|
||||||
|
<svg class="w-5 h-5 text-slate-500" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z"/></svg>
|
||||||
|
Related Drafts <span class="text-sm text-slate-500 font-normal">({{ related_drafts|length }})</span>
|
||||||
|
</h2>
|
||||||
|
<div class="space-y-3">
|
||||||
|
{% for d in related_drafts[:15] %}
|
||||||
|
<a href="/drafts/{{ d.name }}" class="block bg-slate-800/50 rounded-lg border border-slate-700 hover:border-slate-600 p-4 transition group">
|
||||||
|
<div class="flex items-start justify-between gap-3">
|
||||||
|
<div class="min-w-0">
|
||||||
|
<h3 class="text-sm font-semibold text-white group-hover:text-blue-400 transition truncate">{{ d.title }}</h3>
|
||||||
|
<div class="flex items-center gap-2 mt-1">
|
||||||
|
<span class="text-xs text-slate-500 font-mono">{{ d.name }}</span>
|
||||||
|
{% if d.date %}
|
||||||
|
<span class="text-xs text-slate-600">{{ d.date }}</span>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
{% if d.categories %}
|
||||||
|
<div class="flex flex-wrap gap-1 mt-2">
|
||||||
|
{% for cat in d.categories[:3] %}
|
||||||
|
<span class="px-2 py-0.5 rounded-full text-[10px] bg-slate-700/60 text-slate-400 border border-slate-600">{{ cat }}</span>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
{% if d.score is not none %}
|
||||||
|
<span class="flex-shrink-0 px-2 py-1 rounded text-sm font-bold
|
||||||
|
{% if d.score >= 3.5 %}text-green-400
|
||||||
|
{% elif d.score >= 2.5 %}text-amber-400
|
||||||
|
{% else %}text-red-400{% endif %}">
|
||||||
|
{{ d.score }}
|
||||||
|
</span>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</a>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
{% if related_drafts|length > 15 %}
|
||||||
|
<p class="text-xs text-slate-500 mt-3">Showing 15 of {{ related_drafts|length }} related drafts.</p>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
<!-- Linked Proposals -->
|
<!-- Linked Proposals -->
|
||||||
<div class="bg-slate-900 rounded-xl border border-slate-800 p-6 mb-6">
|
<div class="bg-slate-900 rounded-xl border border-slate-800 p-6 mb-6">
|
||||||
<div class="flex items-center justify-between mb-4">
|
<div class="flex items-center justify-between mb-4">
|
||||||
|
|||||||
123
src/webui/templates/idea_detail.html
Normal file
123
src/webui/templates/idea_detail.html
Normal file
@@ -0,0 +1,123 @@
|
|||||||
|
{% extends "base.html" %}
|
||||||
|
{% set active_page = "ideas" %}
|
||||||
|
|
||||||
|
{% block title %}{{ idea.title }} — Idea Detail{% endblock %}
|
||||||
|
|
||||||
|
{% block extra_head %}
|
||||||
|
<style>
|
||||||
|
.idea-type-protocol { background: rgba(59, 130, 246, 0.15); color: #60a5fa; border-color: rgba(59, 130, 246, 0.3); }
|
||||||
|
.idea-type-mechanism { background: rgba(168, 85, 247, 0.15); color: #c084fc; border-color: rgba(168, 85, 247, 0.3); }
|
||||||
|
.idea-type-framework { background: rgba(34, 197, 94, 0.15); color: #4ade80; border-color: rgba(34, 197, 94, 0.3); }
|
||||||
|
.idea-type-architecture { background: rgba(234, 179, 8, 0.15); color: #facc15; border-color: rgba(234, 179, 8, 0.3); }
|
||||||
|
.idea-type-default { background: rgba(100, 116, 139, 0.15); color: #94a3b8; border-color: rgba(100, 116, 139, 0.3); }
|
||||||
|
</style>
|
||||||
|
{% endblock %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<!-- Breadcrumb -->
|
||||||
|
<nav class="mb-6 text-sm">
|
||||||
|
<a href="/idea-analysis" class="text-blue-400 hover:text-blue-300 transition">Idea Analysis</a>
|
||||||
|
<span class="text-slate-600 mx-2">/</span>
|
||||||
|
<span class="text-slate-400">{{ idea.title[:60] }}{% if idea.title|length > 60 %}...{% endif %}</span>
|
||||||
|
</nav>
|
||||||
|
|
||||||
|
<!-- Idea Header -->
|
||||||
|
<div class="bg-slate-900 rounded-xl border border-slate-800 p-6 mb-6">
|
||||||
|
<div class="flex items-start justify-between gap-4 mb-4">
|
||||||
|
<h1 class="text-xl font-bold text-white">{{ idea.title }}</h1>
|
||||||
|
<div class="flex items-center gap-2 flex-shrink-0">
|
||||||
|
{% if idea.type %}
|
||||||
|
{% set type_lower = idea.type|lower %}
|
||||||
|
<span class="px-2.5 py-1 rounded-full text-xs font-medium border
|
||||||
|
{% if type_lower == 'protocol' %}idea-type-protocol
|
||||||
|
{% elif type_lower == 'mechanism' %}idea-type-mechanism
|
||||||
|
{% elif type_lower == 'framework' %}idea-type-framework
|
||||||
|
{% elif type_lower == 'architecture' %}idea-type-architecture
|
||||||
|
{% else %}idea-type-default{% endif %}">
|
||||||
|
{{ idea.type }}
|
||||||
|
</span>
|
||||||
|
{% endif %}
|
||||||
|
{% if idea.novelty_score is not none and idea.novelty_score %}
|
||||||
|
<span class="px-2 py-1 rounded text-xs font-mono
|
||||||
|
{% if idea.novelty_score >= 4 %}bg-green-500/20 text-green-400
|
||||||
|
{% elif idea.novelty_score >= 3 %}bg-amber-500/20 text-amber-400
|
||||||
|
{% elif idea.novelty_score >= 2 %}bg-orange-500/20 text-orange-400
|
||||||
|
{% else %}bg-red-500/20 text-red-400{% endif %}"
|
||||||
|
title="Novelty score">N:{{ idea.novelty_score }}</span>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{% if idea.description %}
|
||||||
|
<div class="mb-4">
|
||||||
|
<h3 class="text-xs font-semibold text-slate-500 uppercase tracking-wider mb-2">Description</h3>
|
||||||
|
<p class="text-sm text-slate-300 leading-relaxed">{{ idea.description }}</p>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
<!-- Source Draft -->
|
||||||
|
<div class="bg-slate-800/50 rounded-lg p-4">
|
||||||
|
<h3 class="text-xs font-semibold text-slate-500 uppercase tracking-wider mb-2">Source Draft</h3>
|
||||||
|
<a href="/drafts/{{ idea.draft_name }}" class="text-sm text-blue-400 hover:text-blue-300 transition">
|
||||||
|
{{ idea.draft_title or idea.draft_name }}
|
||||||
|
</a>
|
||||||
|
<span class="text-xs text-slate-500 font-mono ml-2">{{ idea.draft_name }}</span>
|
||||||
|
{% if idea.draft_date %}
|
||||||
|
<span class="text-xs text-slate-600 ml-2">{{ idea.draft_date }}</span>
|
||||||
|
{% endif %}
|
||||||
|
{% if idea.categories %}
|
||||||
|
<div class="flex flex-wrap gap-1 mt-2">
|
||||||
|
{% for cat in idea.categories[:3] %}
|
||||||
|
<span class="px-2 py-0.5 rounded-full text-[10px] bg-slate-700/60 text-slate-400 border border-slate-600">{{ cat }}</span>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Similar Ideas -->
|
||||||
|
{% if idea.similar %}
|
||||||
|
<div class="bg-slate-900 rounded-xl border border-slate-800 p-6">
|
||||||
|
<h2 class="text-sm font-semibold text-slate-300 mb-4 flex items-center gap-2">
|
||||||
|
<svg class="w-4 h-4 text-slate-500" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8 16H6a2 2 0 01-2-2V6a2 2 0 012-2h8a2 2 0 012 2v2m-6 12h8a2 2 0 002-2v-8a2 2 0 00-2-2h-8a2 2 0 00-2 2v8a2 2 0 002 2z"/></svg>
|
||||||
|
Most Similar Ideas <span class="text-slate-600 font-normal">(by embedding cosine similarity)</span>
|
||||||
|
</h2>
|
||||||
|
<div class="space-y-3">
|
||||||
|
{% for sim in idea.similar %}
|
||||||
|
<a href="/ideas/{{ sim.id }}" class="block bg-slate-800/30 rounded-lg border border-slate-800/50 hover:border-slate-700 p-4 transition group">
|
||||||
|
<div class="flex items-start justify-between gap-3">
|
||||||
|
<div class="min-w-0">
|
||||||
|
<h3 class="text-sm font-medium text-slate-200 group-hover:text-blue-400 transition">{{ sim.title }}</h3>
|
||||||
|
<div class="flex items-center gap-2 mt-1">
|
||||||
|
<span class="text-xs text-slate-500 font-mono">{{ sim.draft_name }}</span>
|
||||||
|
{% if sim.type %}
|
||||||
|
{% set st = sim.type|lower %}
|
||||||
|
<span class="px-1.5 py-0.5 rounded-full text-[10px] font-medium border
|
||||||
|
{% if st == 'protocol' %}idea-type-protocol
|
||||||
|
{% elif st == 'mechanism' %}idea-type-mechanism
|
||||||
|
{% elif st == 'framework' %}idea-type-framework
|
||||||
|
{% elif st == 'architecture' %}idea-type-architecture
|
||||||
|
{% else %}idea-type-default{% endif %}">
|
||||||
|
{{ sim.type }}
|
||||||
|
</span>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<span class="flex-shrink-0 px-2 py-1 rounded text-xs font-mono
|
||||||
|
{% if sim.similarity >= 0.9 %}bg-green-500/20 text-green-400
|
||||||
|
{% elif sim.similarity >= 0.8 %}bg-emerald-500/20 text-emerald-400
|
||||||
|
{% elif sim.similarity >= 0.7 %}bg-amber-500/20 text-amber-400
|
||||||
|
{% else %}bg-slate-500/20 text-slate-400{% endif %}"
|
||||||
|
title="Cosine similarity">{{ sim.similarity }}</span>
|
||||||
|
</div>
|
||||||
|
</a>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% else %}
|
||||||
|
<div class="bg-slate-900 rounded-xl border border-slate-800 p-6">
|
||||||
|
<h2 class="text-sm font-semibold text-slate-300 mb-2">Similar Ideas</h2>
|
||||||
|
<p class="text-sm text-slate-500">No embeddings available for this idea. Run the embedding pipeline to enable similarity search.</p>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
{% endblock %}
|
||||||
Reference in New Issue
Block a user