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:
@@ -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,
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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] = {
|
||||
|
||||
@@ -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, []):
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user