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:
2026-03-09 03:45:00 +01:00
parent 4a368bde62
commit c755b2bbf3
16 changed files with 548 additions and 18 deletions

View File

@@ -37,6 +37,7 @@ from webui.data.authors import ( # noqa: F401
get_coauthor_network,
get_cross_org_data,
get_author_network_full,
get_author_detail,
)
# Ratings
@@ -51,6 +52,7 @@ from webui.data.ratings import ( # noqa: F401
from webui.data.gaps import ( # noqa: F401
get_all_gaps,
get_gap_detail,
get_drafts_for_gap,
)
# Analysis & Visualization
@@ -74,6 +76,7 @@ from webui.data.analysis import ( # noqa: F401
get_comparison_data,
get_architecture,
get_idea_analysis,
get_idea_detail,
get_trends_data,
get_complexity_data,
get_source_comparison,

View File

@@ -103,6 +103,78 @@ def get_ideas_by_type(db: Database) -> dict:
"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:
"""Return monthly counts by category for timeline chart."""
pairs = db.drafts_with_ratings(limit=1000)

View File

@@ -15,6 +15,7 @@ class AuthorInfo(TypedDict):
affiliation: str
draft_count: int
drafts: list[str]
person_id: int
class AuthorNetworkNode(TypedDict):
"""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."""
rows = db.top_authors(limit=limit)
return [
{"name": name, "affiliation": aff, "draft_count": cnt, "drafts": drafts}
for name, aff, cnt, drafts in rows
{"name": name, "affiliation": aff, "draft_count": cnt, "drafts": drafts,
"person_id": pid}
for name, aff, cnt, drafts, pid in rows
]
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)
# 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()
edges = []
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}
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]:
"""Return cross-org collaboration pairs."""
rows = db.cross_org_collaborations(limit=limit)
@@ -122,7 +167,7 @@ def _compute_author_network_full(db: Database) -> AuthorNetwork:
# Author info map
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]
avg = round(sum(scores) / len(scores), 2) if scores else 0
author_info[name] = {

View File

@@ -94,7 +94,7 @@ def get_category_summary(db: Database, category: str) -> dict | None:
# Author lookup: draft_name -> [author names]
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:
author_drafts_map[dn].append(name)
@@ -116,7 +116,7 @@ def get_category_summary(db: Database, category: str) -> dict | None:
author_counter: Counter = Counter()
org_counter: Counter = Counter()
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 ""
for d, r in cat_pairs:
for a in author_drafts_map.get(d.name, []):

View File

@@ -1,6 +1,8 @@
"""Gap analysis data access functions."""
from __future__ import annotations
import re
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:
return g
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