Add agentic paper search and chat

This commit is contained in:
wuyang
2026-07-08 14:02:03 +08:00
parent cda6f1aabe
commit 894c914e26
5 changed files with 1129 additions and 29 deletions
+5
View File
@@ -35,6 +35,8 @@ https://lab.k1412.top/
- 深度分析模型:`ChatGPT-5.6:large`,只在前端手动选择时使用
- Atlas 解释:`ChatGPT-5.6:fast`
- 主题导读、阅读路径、主题比较:`ChatGPT-5.6:large`,只在前端手动确认后调用
- Search Agent 查询理解:`ChatGPT-5.6:light`
- Search Agent 结果简报、搜索追问、论文对话:`ChatGPT-5.6:fast`
- Ollama 请求单并发执行,并缓存结果到 `web/cache/ai/`
- arXiv 摘要缓存到 `web/cache/arxiv/`
@@ -56,3 +58,6 @@ https://lab.k1412.top/
- 生成阅读路径
- 比较技术路线
- 单篇论文摘要、翻译、深度研读
- 搜索意图理解、关键词扩展、结果简报
- 基于搜索结果继续追问
- 基于单篇论文继续对话式研读
+675
View File
@@ -39,6 +39,10 @@ DEFAULT_MODELS = {
"topic": "ChatGPT-5.6:large",
"path": "ChatGPT-5.6:large",
"compare": "ChatGPT-5.6:large",
"search_plan": "ChatGPT-5.6:light",
"search_summary": "ChatGPT-5.6:fast",
"search_followup": "ChatGPT-5.6:fast",
"paper_chat": "ChatGPT-5.6:fast",
}
NS = {
@@ -48,8 +52,11 @@ NS = {
INDEX_LOCK = threading.Lock()
OLLAMA_LOCK = threading.Lock()
SEARCH_LOCK = threading.Lock()
INDEX_CACHE: dict[str, Any] = {"mtime": 0.0, "papers": [], "by_id": {}}
ATLAS_CACHE: dict[str, Any] = {"mtime": 0.0, "atlas": None}
NOTE_CACHE: dict[str, dict[str, Any]] = {}
SEARCH_JOBS: dict[str, dict[str, Any]] = {}
TOPIC_PROFILES = {
"agent-evaluation": {
@@ -305,6 +312,556 @@ def paper_card(paper: dict[str, Any]) -> dict[str, Any]:
}
def strip_note_frontmatter(markdown: str) -> str:
text = str(markdown or "")
match = re.match(r"^# .+?\n\n---[\s\S]*?\n---\n\n?", text)
return text[match.end() :] if match else text
def paper_note_text(paper: dict[str, Any]) -> str:
path = ROOT / paper["path"]
try:
mtime = path.stat().st_mtime
except FileNotFoundError:
return ""
cache_key = str(path)
cached = NOTE_CACHE.get(cache_key)
if cached and cached.get("mtime") == mtime:
return str(cached.get("text") or "")
text = strip_note_frontmatter(path.read_text(encoding="utf-8", errors="replace"))
text = normalize_space(text)
NOTE_CACHE[cache_key] = {"mtime": mtime, "text": text}
return text
def topic_label_map() -> dict[str, str]:
return {topic: profile_for(topic)["label"] for topic in TOPIC_PROFILES}
def tokenize_query(value: str) -> list[str]:
text = str(value or "").lower()
tokens = re.findall(r"[a-z0-9][a-z0-9.+#/-]{1,}|[\u4e00-\u9fff]{2,}", text)
return [token.strip(".,;:()[]{}") for token in tokens if token.strip(".,;:()[]{}")]
def unique_strings(items: list[Any], limit: int = 20) -> list[str]:
seen: set[str] = set()
output: list[str] = []
for item in items:
text = str(item or "").strip()
key = text.lower()
if not text or key in seen:
continue
seen.add(key)
output.append(text)
if len(output) >= limit:
break
return output
def extract_json_object(text: str) -> dict[str, Any]:
raw = clean_model_response(text)
start = raw.find("{")
end = raw.rfind("}")
if start < 0 or end <= start:
raise ValueError("model did not return a JSON object")
return json.loads(raw[start : end + 1])
QUERY_EXPANSIONS = {
"强化学习": {
"keywords_en": [
"reinforcement learning",
"RL",
"policy optimization",
"reward",
"rollout",
"self-feedback",
"agent reinforcement learning",
"credit assignment",
"trajectory optimization",
],
"topics": ["planning", "reasoning", "agent-evaluation", "multi-agent"],
},
"评估": {
"keywords_en": ["evaluation", "benchmark", "trajectory", "judge", "rubric", "agent benchmark"],
"topics": ["agent-evaluation"],
},
"记忆": {
"keywords_en": ["memory", "long-horizon", "state", "retention", "retrieval memory"],
"topics": ["memory", "rag"],
},
"工具": {
"keywords_en": ["tool use", "tool calling", "function calling", "tool failure", "tool selection"],
"topics": ["tool-use"],
},
"安全": {
"keywords_en": ["safety", "security", "privacy", "prompt injection", "guardrail", "permission"],
"topics": ["agent-safety"],
},
}
SEARCH_STOP_TERMS = {
"agent",
"agents",
"ai",
"llm",
"model",
"models",
"system",
"systems",
"method",
"methods",
"approach",
"approaches",
"algorithm",
"algorithms",
"application",
"applications",
"research",
"study",
"paper",
"learning",
}
def filter_search_keywords(items: list[Any], limit: int = 24) -> list[str]:
output: list[str] = []
for item in unique_strings(items, limit * 3):
key = item.lower().strip()
if key in SEARCH_STOP_TERMS:
continue
if len(key) < 2:
continue
output.append(item)
if len(output) >= limit:
break
return output
def heuristic_search_plan(query: str) -> dict[str, Any]:
tokens = tokenize_query(query)
keywords_en: list[str] = []
topics: list[str] = []
for key, expansion in QUERY_EXPANSIONS.items():
if key in query:
keywords_en.extend(expansion["keywords_en"])
topics.extend(expansion["topics"])
for topic, label in topic_label_map().items():
haystack = f"{topic} {label}".lower()
if any(token.lower() in haystack for token in tokens):
topics.append(topic)
keywords_en.append(label)
keywords_en.append(topic.replace("-", " "))
return {
"intent": "topic_search",
"interpreted_query": query,
"keywords_zh": [token for token in tokens if re.search(r"[\u4e00-\u9fff]", token)],
"keywords_en": filter_search_keywords(keywords_en + [token for token in tokens if re.search(r"[a-z0-9]", token)], 18),
"topics": unique_strings(topics, 8),
"filters": {},
"search_rationale": "基于本地规则扩展中文/英文关键词,并映射到已有 Agent 主题。",
}
def search_plan_prompt(query: str, scope_topic: str = "") -> str:
topics = [
{"id": topic, "label": profile_for(topic)["label"], "question": profile_for(topic)["question"]}
for topic in TOPIC_PROFILES
]
no_think = "不要输出思考过程,不要输出 <think> 标签。"
return (
"你是 Agent 论文库的搜索规划器。请理解用户输入,生成用于本地检索的 JSON。\n"
"只返回 JSON 对象,不要 Markdown。字段:\n"
"intent: topic_search|paper_lookup|question_answer|trend_scan|method_search|benchmark_search\n"
"interpreted_query: 中文解释\nkeywords_zh: 中文关键词数组\nkeywords_en: 英文关键词数组\n"
"topics: 从给定 topic id 中选择相关项数组\nfilters: 可为空对象,支持 recent_months 整数\n"
"search_rationale: 一句话说明为什么这样搜。\n"
f"当前 topic scope: {scope_topic or 'none'}\n"
f"可选 topics:\n{json.dumps(topics, ensure_ascii=False)}\n"
f"{no_think}\n\n用户输入:{query}"
)
def plan_search_query(query: str, scope_topic: str = "") -> tuple[dict[str, Any], dict[str, Any] | None]:
fallback = heuristic_search_plan(query)
model = DEFAULT_MODELS["search_plan"]
result: dict[str, Any] | None = None
try:
with OLLAMA_LOCK:
result = call_ollama(model, search_plan_prompt(query, scope_topic), "search_plan")
plan = extract_json_object(result["response"])
merged = {
**fallback,
**{key: value for key, value in plan.items() if value not in (None, "", [])},
}
merged["keywords_zh"] = unique_strings(as_list(merged.get("keywords_zh")) + fallback["keywords_zh"], 16)
merged["keywords_en"] = filter_search_keywords(as_list(merged.get("keywords_en")) + fallback["keywords_en"], 24)
merged["topics"] = [topic for topic in unique_strings(as_list(merged.get("topics")) + fallback["topics"], 10) if topic in TOPIC_PROFILES]
if not isinstance(merged.get("filters"), dict):
merged["filters"] = {}
return merged, result
except Exception as exc: # noqa: BLE001
fallback["planner_error"] = str(exc)
return fallback, result
def term_score(term: str, title: str, meta: str, note: str) -> float:
if not term:
return 0.0
term_lower = term.lower()
score = 0.0
if term_lower in title:
score += 50
if term_lower in meta:
score += 18
if term_lower in note:
score += min(22, 5 + note.count(term_lower) * 2)
for token in tokenize_query(term_lower):
if len(token) < 2:
continue
if token in title:
score += 14
elif token in meta:
score += 8
elif token in note:
score += 3
return score
def score_search_matches(
papers: list[dict[str, Any]],
query: str,
plan: dict[str, Any],
scope_topic: str = "",
limit: int = 80,
) -> list[dict[str, Any]]:
raw_terms = [query, *as_list(plan.get("keywords_zh")), *as_list(plan.get("keywords_en"))]
terms = unique_strings(raw_terms, 32)
plan_topics = [topic for topic in as_list(plan.get("topics")) if topic in TOPIC_PROFILES]
scored: list[tuple[float, dict[str, Any], list[str]]] = []
for paper in papers:
if scope_topic and scope_topic not in paper["topics"]:
continue
title = paper["title"].lower()
meta = " ".join(
[
paper["_search"],
" ".join(profile_for(topic)["label"] for topic in paper["topics"]),
" ".join(topic.replace("-", " ") for topic in paper["topics"]),
]
).lower()
note = paper_note_text(paper).lower()
evidence_score = 0.0
matched_terms: list[str] = []
for term in terms:
value = term_score(term, title, meta, note)
if value > 0:
evidence_score += value
matched_terms.append(term)
topic_overlap = sorted(set(plan_topics) & set(paper["topics"]))
if topic_overlap:
evidence_score += 34 + 9 * len(topic_overlap)
matched_terms.extend(profile_for(topic)["label"] for topic in topic_overlap)
if query.lower() in title:
evidence_score += 70
if evidence_score <= 0:
continue
score = evidence_score + float(paper["collection_score"]) * 0.35
item = paper_card(paper)
item["match_score"] = round(score, 2)
item["matched_terms"] = unique_strings(matched_terms, 8)
scored.append((score, item, matched_terms))
scored.sort(key=lambda row: (row[0], row[1].get("published_at") or "", row[1]["collection_score"]), reverse=True)
return [item for _, item, _ in scored[:limit]]
def aggregate_search_results(items: list[dict[str, Any]]) -> dict[str, Any]:
topic_counts: Counter = Counter()
month_counts: Counter = Counter()
year_counts: Counter = Counter()
for item in items:
month = month_key(str(item.get("published_at") or ""))
if month != "unknown":
month_counts[month] += 1
year_counts[month[:4]] += 1
for topic in item.get("topics") or []:
topic_counts[topic] += 1
topic_rows = [
{"topic": topic, "label": profile_for(topic)["label"], "count": count}
for topic, count in topic_counts.most_common(10)
]
months = [{"month": month, "count": count} for month, count in sorted(month_counts.items())]
recent_months = months[-6:]
date_values = [str(item.get("published_at") or "") for item in items if re.match(r"^\d{4}-\d{2}-\d{2}", str(item.get("published_at") or ""))]
return {
"total": len(items),
"topics": topic_rows,
"months": months,
"recent_months": recent_months,
"years": [{"year": year, "count": count} for year, count in sorted(year_counts.items())],
"date_range": {
"start": min(date_values) if date_values else "",
"end": max(date_values) if date_values else "",
},
}
def search_summary_prompt(query: str, plan: dict[str, Any], items: list[dict[str, Any]], aggregation: dict[str, Any]) -> str:
compact_items = [
{
"title": item["title"],
"published_at": item["published_at"],
"topics": item["topics"],
"score": item["collection_score"],
"matched_terms": item.get("matched_terms", []),
}
for item in items[:18]
]
context = {
"query": query,
"plan": plan,
"aggregation": aggregation,
"top_papers": compact_items,
}
no_think = "不要输出思考过程,不要输出 <think> 标签。"
return (
"你是 Agent 研究助理。请基于本地论文检索结果生成中文搜索简报。\n"
"只基于给定结果,不要编造论文细节。输出要短,包含:\n"
"1. 搜索理解\n2. 主要发现\n3. 时间/趋势\n4. 建议先读\n5. 可以继续追问的方向。\n"
f"{no_think}\n\n{json.dumps(context, ensure_ascii=False, indent=2)[:14000]}"
)
def fallback_search_summary(query: str, plan: dict[str, Any], aggregation: dict[str, Any], items: list[dict[str, Any]]) -> str:
topics = "".join(item["label"] for item in aggregation["topics"][:5]) or "暂无明显主题"
papers = "".join(item["title"] for item in items[:3]) or "暂无命中论文"
keywords = ", ".join(as_list(plan.get("keywords_en"))[:8] + as_list(plan.get("keywords_zh"))[:4])
return (
f"搜索理解:你在查「{query}」,系统扩展了 {keywords or '原始关键词'}\n\n"
f"主要发现:命中 {aggregation['total']} 篇候选,集中在 {topics}\n\n"
f"时间/趋势:日期范围 {aggregation['date_range']['start'] or 'unknown'}{aggregation['date_range']['end'] or 'unknown'}\n\n"
f"建议先读:{papers}\n\n"
"可以继续追问:只看最近论文、按主题拆分、生成阅读路径、比较工程/理论方向。"
)
def generate_search_summary(query: str, plan: dict[str, Any], items: list[dict[str, Any]], aggregation: dict[str, Any]) -> dict[str, Any]:
model = DEFAULT_MODELS["search_summary"]
try:
with OLLAMA_LOCK:
result = call_ollama(model, search_summary_prompt(query, plan, items, aggregation), "search_summary")
return {
"text": result["response"],
"model": result["model"],
"duration_seconds": result["duration_seconds"],
"cached": False,
}
except Exception as exc: # noqa: BLE001
return {
"text": fallback_search_summary(query, plan, aggregation, items),
"model": "fallback",
"error": str(exc),
"cached": False,
}
def new_search_job(query: str, scope_topic: str = "") -> dict[str, Any]:
job_id = hashlib.sha1(f"{query}:{scope_topic}:{time.time()}".encode("utf-8")).hexdigest()[:14]
now = time.strftime("%Y-%m-%dT%H:%M:%S%z")
job = {
"id": job_id,
"query": query,
"scope_topic": scope_topic,
"status": "queued",
"created_at": now,
"updated_at": now,
"steps": [
{"id": "understand", "label": "理解问题", "status": "pending", "detail": ""},
{"id": "expand", "label": "扩展关键词", "status": "pending", "detail": ""},
{"id": "retrieve", "label": "检索论文库", "status": "pending", "detail": ""},
{"id": "aggregate", "label": "统计主题/趋势", "status": "pending", "detail": ""},
{"id": "summarize", "label": "生成搜索结论", "status": "pending", "detail": ""},
],
"result": None,
"error": "",
}
with SEARCH_LOCK:
SEARCH_JOBS[job_id] = job
prune_search_jobs_locked()
thread = threading.Thread(target=run_search_job, args=(job_id,), daemon=True)
thread.start()
return public_search_job(job)
def public_search_job(job: dict[str, Any]) -> dict[str, Any]:
return {
"id": job["id"],
"query": job["query"],
"scope_topic": job.get("scope_topic", ""),
"status": job["status"],
"created_at": job["created_at"],
"updated_at": job["updated_at"],
"steps": job["steps"],
"result": job.get("result"),
"error": job.get("error", ""),
}
def get_search_job(job_id: str) -> dict[str, Any] | None:
with SEARCH_LOCK:
job = SEARCH_JOBS.get(job_id)
return public_search_job(job) if job else None
def update_search_job(job_id: str, *, status: str | None = None, result: Any = None, error: str = "") -> None:
with SEARCH_LOCK:
job = SEARCH_JOBS.get(job_id)
if not job:
return
if status:
job["status"] = status
if result is not None:
job["result"] = result
if error:
job["error"] = error
job["updated_at"] = time.strftime("%Y-%m-%dT%H:%M:%S%z")
def update_search_step(job_id: str, step_id: str, status: str, detail: str = "") -> None:
with SEARCH_LOCK:
job = SEARCH_JOBS.get(job_id)
if not job:
return
for step in job["steps"]:
if step["id"] == step_id:
step["status"] = status
if detail:
step["detail"] = detail
step["updated_at"] = time.strftime("%H:%M:%S")
break
job["updated_at"] = time.strftime("%Y-%m-%dT%H:%M:%S%z")
def prune_search_jobs_locked() -> None:
if len(SEARCH_JOBS) <= 24:
return
ordered = sorted(SEARCH_JOBS.values(), key=lambda item: item.get("created_at", ""))
for job in ordered[: len(SEARCH_JOBS) - 24]:
SEARCH_JOBS.pop(job["id"], None)
def run_search_job(job_id: str) -> None:
job = get_search_job(job_id)
if not job:
return
query = job["query"]
scope_topic = job.get("scope_topic", "")
try:
update_search_job(job_id, status="running")
update_search_step(job_id, "understand", "running", "调用轻量模型理解搜索意图")
plan, planner_result = plan_search_query(query, scope_topic)
update_search_step(job_id, "understand", "done", str(plan.get("interpreted_query") or query))
keywords = unique_strings(as_list(plan.get("keywords_en")) + as_list(plan.get("keywords_zh")), 12)
update_search_step(job_id, "expand", "done", " / ".join(keywords[:8]) or "使用原始输入")
update_search_step(job_id, "retrieve", "running", "扫描标题、主题、collection query 和本地卡片")
papers, _ = load_papers()
items = score_search_matches(papers, query, plan, scope_topic, limit=80)
update_search_step(job_id, "retrieve", "done", f"找到 {len(items)} 篇候选论文")
update_search_step(job_id, "aggregate", "running", "统计日期、主题和最近趋势")
aggregation = aggregate_search_results(items)
update_search_step(
job_id,
"aggregate",
"done",
f"{len(aggregation['topics'])} 个相关主题,日期到 {aggregation['date_range']['end'] or 'unknown'}",
)
update_search_step(job_id, "summarize", "running", "调用 fast 模型生成搜索简报")
summary = generate_search_summary(query, plan, items, aggregation)
update_search_step(job_id, "summarize", "done", summary.get("model", "fallback"))
result = {
"query": query,
"scope_topic": scope_topic,
"plan": plan,
"planner": {
"model": planner_result.get("model") if planner_result else "fallback",
"duration_seconds": planner_result.get("duration_seconds") if planner_result else None,
},
"summary": summary,
"aggregation": aggregation,
"items": items[:60],
"total": len(items),
}
update_search_job(job_id, status="done", result=result)
except Exception as exc: # noqa: BLE001
update_search_step(job_id, "summarize", "error", str(exc))
update_search_job(job_id, status="error", error=str(exc))
def search_followup_prompt(job: dict[str, Any], question: str) -> str:
result = job.get("result") or {}
compact = {
"query": result.get("query"),
"plan": result.get("plan"),
"summary": (result.get("summary") or {}).get("text"),
"aggregation": result.get("aggregation"),
"top_papers": [
{
"title": item["title"],
"published_at": item["published_at"],
"topics": item["topics"],
"score": item["collection_score"],
"matched_terms": item.get("matched_terms", []),
}
for item in (result.get("items") or [])[:18]
],
}
return (
"你是 Agent 论文库搜索助手。用户正在基于一次搜索结果继续追问。\n"
"请只基于给定搜索上下文回答,中文,简洁但有判断。不要编造论文细节。\n"
"不要输出思考过程,不要输出 <think> 标签。\n\n"
f"搜索上下文:\n{json.dumps(compact, ensure_ascii=False, indent=2)[:14000]}\n\n"
f"用户追问:{question}"
)
def paper_chat_prompt(paper: dict[str, Any], abstract: dict[str, Any] | None, question: str, history: list[dict[str, str]]) -> str:
recent_history = [
{"role": str(item.get("role") or ""), "content": str(item.get("content") or "")[:1200]}
for item in history[-6:]
if item.get("content")
]
context = {
"paper": {
"title": paper["title"],
"authors": paper["authors"],
"published_at": paper["published_at"],
"venue": paper["venue"],
"topics": paper["topics"],
"collection_score": paper["collection_score"],
"url": paper["url"],
},
"abstract": abstract,
"local_note": paper_note_text(paper)[:5000],
"history": recent_history,
"question": question,
}
return (
"你是 Agent 论文研读助手。请基于论文上下文和对话历史回答用户问题。\n"
"中文回答,优先给出判断和证据;信息不足就明确说不足。不要编造论文细节。\n"
"不要输出思考过程,不要输出 <think> 标签。\n\n"
f"{json.dumps(context, ensure_ascii=False, indent=2)[:15000]}"
)
def topic_counts(papers: list[dict[str, Any]]) -> list[dict[str, Any]]:
counts: dict[str, int] = {}
for paper in papers:
@@ -671,6 +1228,10 @@ def call_ollama(model: str, prompt: str, mode: str) -> dict[str, Any]:
options.update({"num_ctx": 8192, "num_predict": 900, "temperature": 0.25})
elif mode in {"atlas", "topic", "path", "compare"}:
options.update({"num_ctx": 8192, "num_predict": 950, "temperature": 0.22})
elif mode == "search_plan":
options.update({"num_ctx": 4096, "num_predict": 420, "temperature": 0.1})
elif mode in {"search_summary", "search_followup", "paper_chat"}:
options.update({"num_ctx": 8192, "num_predict": 780, "temperature": 0.18})
payload = {
"model": model,
@@ -746,6 +1307,9 @@ class PaperBrowserHandler(SimpleHTTPRequestHandler):
if path == "/api/papers":
self.handle_paper_search(query)
return
if path == "/api/search/agent":
self.handle_search_job(query)
return
if path == "/api/paper":
self.handle_paper_detail(query)
return
@@ -763,6 +1327,15 @@ class PaperBrowserHandler(SimpleHTTPRequestHandler):
if parsed.path == "/api/atlas/ai":
self.handle_atlas_ai()
return
if parsed.path == "/api/search/agent":
self.handle_search_agent_start()
return
if parsed.path == "/api/search/followup":
self.handle_search_followup()
return
if parsed.path == "/api/paper/chat":
self.handle_paper_chat()
return
write_error(self, HTTPStatus.NOT_FOUND, "not found")
def serve_file(self, target: Path, content_type: str) -> None:
@@ -862,6 +1435,64 @@ class PaperBrowserHandler(SimpleHTTPRequestHandler):
},
)
def handle_search_agent_start(self) -> None:
try:
payload = read_json_body(self)
query = str(payload.get("query") or "").strip()
scope_topic = str(payload.get("topic") or "").strip()
if not query:
write_error(self, HTTPStatus.BAD_REQUEST, "query is required")
return
if scope_topic and scope_topic not in TOPIC_PROFILES:
scope_topic = ""
write_json(self, {"job": new_search_job(query, scope_topic)})
except Exception as exc: # noqa: BLE001
write_error(self, HTTPStatus.INTERNAL_SERVER_ERROR, str(exc))
def handle_search_job(self, query: dict[str, list[str]]) -> None:
job_id = (query.get("id") or [""])[0].strip()
if not job_id:
write_error(self, HTTPStatus.BAD_REQUEST, "id is required")
return
job = get_search_job(job_id)
if not job:
write_error(self, HTTPStatus.NOT_FOUND, "search job not found")
return
write_json(self, {"job": job})
def handle_search_followup(self) -> None:
try:
payload = read_json_body(self)
job_id = str(payload.get("job_id") or "").strip()
question = str(payload.get("question") or "").strip()
model = str(payload.get("model") or DEFAULT_MODELS["search_followup"])
if not job_id or not question:
write_error(self, HTTPStatus.BAD_REQUEST, "job_id and question are required")
return
job = get_search_job(job_id)
if not job or job.get("status") != "done":
write_error(self, HTTPStatus.NOT_FOUND, "completed search job not found")
return
if not OLLAMA_LOCK.acquire(blocking=False):
write_error(self, HTTPStatus.TOO_MANY_REQUESTS, "ollama is busy; try again later")
return
try:
result = call_ollama(model, search_followup_prompt(job, question), "search_followup")
write_json(
self,
{
"job_id": job_id,
"question": question,
"answer": result["response"],
"model": result["model"],
"duration_seconds": result["duration_seconds"],
},
)
finally:
OLLAMA_LOCK.release()
except Exception as exc: # noqa: BLE001
write_error(self, HTTPStatus.INTERNAL_SERVER_ERROR, str(exc))
def handle_paper_detail(self, query: dict[str, list[str]]) -> None:
paper_id = (query.get("id") or [""])[0]
_, by_id = load_papers()
@@ -898,6 +1529,50 @@ class PaperBrowserHandler(SimpleHTTPRequestHandler):
except Exception as exc: # noqa: BLE001
write_error(self, HTTPStatus.BAD_GATEWAY, f"arXiv fetch failed: {exc}")
def handle_paper_chat(self) -> None:
try:
payload = read_json_body(self)
paper_id = str(payload.get("id") or "").strip()
question = str(payload.get("question") or "").strip()
history = payload.get("history") or []
model = str(payload.get("model") or DEFAULT_MODELS["paper_chat"])
if not paper_id or not question:
write_error(self, HTTPStatus.BAD_REQUEST, "id and question are required")
return
if not isinstance(history, list):
history = []
_, by_id = load_papers()
paper = by_id.get(paper_id)
if not paper:
write_error(self, HTTPStatus.NOT_FOUND, "paper not found")
return
if not OLLAMA_LOCK.acquire(blocking=False):
write_error(self, HTTPStatus.TOO_MANY_REQUESTS, "ollama is busy; try again later")
return
try:
abstract = None
arxiv_id = arxiv_id_from_paper(paper)
if arxiv_id:
try:
abstract = fetch_arxiv(arxiv_id)
except Exception:
abstract = None
result = call_ollama(model, paper_chat_prompt(paper, abstract, question, history), "paper_chat")
write_json(
self,
{
"id": paper_id,
"question": question,
"answer": result["response"],
"model": result["model"],
"duration_seconds": result["duration_seconds"],
},
)
finally:
OLLAMA_LOCK.release()
except Exception as exc: # noqa: BLE001
write_error(self, HTTPStatus.INTERNAL_SERVER_ERROR, str(exc))
def handle_ai(self) -> None:
try:
payload = read_json_body(self)
+321 -27
View File
@@ -8,8 +8,13 @@ const state = {
selectedMarkdown: "",
searchItems: [],
searchTotal: 0,
searchJobId: "",
searchResult: null,
searchPollTimer: null,
searchFollowups: [],
aiBusy: false,
atlasChart: null,
paperChatHistory: {},
};
const $ = (selector) => document.querySelector(selector);
@@ -498,6 +503,10 @@ function paperList(items, limit = items.length) {
`;
}
function compactPaperList(items, limit = items.length) {
return paperList(items, limit);
}
function routeButtons(routes) {
return `
<div class="chip-row">
@@ -512,6 +521,169 @@ function routeButtons(routes) {
`;
}
function searchSteps(steps = []) {
return `
<div class="agent-steps">
${steps
.map(
(step) => `
<div class="agent-step ${escapeHtml(step.status)}">
<span class="step-dot"></span>
<div>
<strong>${escapeHtml(step.label)}</strong>
<span>${escapeHtml(step.detail || step.status)}</span>
</div>
</div>
`,
)
.join("")}
</div>
`;
}
function searchAggregationView(aggregation) {
if (!aggregation) return "";
const topTopics = aggregation.topics || [];
const recentMonths = aggregation.recent_months || [];
return `
<section class="section">
${statGrid([
{ value: aggregation.total || 0, label: "matches" },
{ value: topTopics.length, label: "topics" },
{ value: aggregation.date_range?.end || "unknown", label: "latest" },
])}
<div class="mini-chart-row">
${recentMonths
.map((row) => `<span class="month-chip">${escapeHtml(row.month)} · ${escapeHtml(row.count)}</span>`)
.join("")}
</div>
<div class="chip-row search-topic-row">
${topTopics
.slice(0, 8)
.map(
(topic) => `
<button class="pill-button" type="button" data-topic="${escapeHtml(topic.topic)}">
${escapeHtml(topic.label)} · ${topic.count}
</button>
`,
)
.join("")}
</div>
</section>
`;
}
function searchPlanView(plan) {
if (!plan) return "";
const keywords = [...(plan.keywords_en || []), ...(plan.keywords_zh || [])].slice(0, 14);
return `
<section class="section compact-section">
<div class="eyebrow">Search Plan</div>
<p class="statement">${escapeHtml(plan.interpreted_query || "")}</p>
<p class="microcopy">${escapeHtml(plan.search_rationale || "")}</p>
<div class="chip-row">
${keywords.map((keyword) => `<span class="mini-badge">${escapeHtml(keyword)}</span>`).join("")}
</div>
</section>
`;
}
function renderSearchRunning(job) {
setReaderMode(false);
state.view = "search";
setModeButtons("search");
elements.lensEyebrow.textContent = "Search Agent";
elements.lensTitle.textContent = job?.query || elements.searchInput.value.trim() || "搜索";
elements.lensBody.innerHTML = `
<section class="section">
<p class="statement">Search Agent 正在理解问题、扩展关键词并检索本地论文库。</p>
</section>
<section class="section">
${searchSteps(job?.steps || [])}
</section>
`;
}
function renderSearchHome() {
setReaderMode(false);
state.view = "search";
setModeButtons("search");
elements.lensEyebrow.textContent = "Search Agent";
elements.lensTitle.textContent = "智能搜索";
elements.lensBody.innerHTML = `
<section class="section">
<p class="statement">输入主题、论文线索或研究问题。Search Agent 会理解意图、扩展中英文关键词、检索本地论文库,并生成一份简短结论。</p>
</section>
<section class="section">
<h3>可以这样搜</h3>
<div class="chip-row">
<button class="pill-button" type="button" data-search-example="强化学习">强化学习</button>
<button class="pill-button" type="button" data-search-example="最近的 agent evaluation benchmark">最近的 agent evaluation benchmark</button>
<button class="pill-button" type="button" data-search-example="长期记忆和 RAG 有什么关系">长期记忆和 RAG 有什么关系</button>
</div>
</section>
`;
}
function renderSearchError(jobOrError) {
const message = typeof jobOrError === "string" ? jobOrError : jobOrError?.error || "search failed";
elements.lensBody.innerHTML = `
<section class="section">
<div class="empty-state">${escapeHtml(message)}</div>
</section>
`;
}
function followupList() {
const rows = state.searchFollowups || [];
if (!rows.length) return "";
return `
<div class="dialogue-list">
${rows
.map(
(row) => `
<div class="dialogue-row ${escapeHtml(row.role)}">
<strong>${row.role === "user" ? "你" : "Search Agent"}</strong>
<div>${escapeHtml(row.content)}</div>
</div>
`,
)
.join("")}
</div>
`;
}
function renderSearchResult(result) {
setReaderMode(false);
state.view = "search";
setModeButtons("search");
elements.lensEyebrow.textContent = "Search Agent";
elements.lensTitle.textContent = result.query;
const summary = result.summary || {};
elements.lensBody.innerHTML = `
<section class="section">
<div class="ai-box search-brief">
<h3>${escapeHtml(summary.model || "Search Agent")}</h3>
<div class="ai-output">${escapeHtml(summary.text || "")}</div>
</div>
</section>
${searchAggregationView(result.aggregation)}
${searchPlanView(result.plan)}
<section class="section">
<h3>继续追问</h3>
${followupList()}
<div class="followup-row">
<input id="searchFollowupInput" type="text" placeholder="基于当前结果继续问,比如:只看最近的 / 给阅读顺序" />
<button class="tool-button primary" type="button" data-search-followup>Ask</button>
</div>
</section>
<section class="section">
<h3>相关论文</h3>
${compactPaperList(result.items || [], 60)}
</section>
`;
}
function renderOverview(aiHtml = "") {
setReaderMode(false);
state.view = "atlas";
@@ -681,32 +853,85 @@ function renderRoute(routeId) {
async function runSearch() {
const q = elements.searchInput.value.trim();
if (!q) {
renderOverview();
renderSearchHome();
return;
}
const scopeTopic = state.topicFocus && state.view === "atlas" ? state.activeTopic : "";
setReaderMode(false);
state.view = "search";
setModeButtons("search");
elements.lensEyebrow.textContent = "Search";
elements.lensTitle.textContent = q;
elements.lensBody.innerHTML = `<div class="empty-state">searching</div>`;
const params = new URLSearchParams({ q, limit: "60", sort: "score" });
if (state.activeTopic) params.set("topic", state.activeTopic);
const payload = await api(`/api/papers?${params.toString()}`);
state.searchItems = payload.items;
state.searchTotal = payload.total;
elements.lensBody.innerHTML = `
<section class="section">
${statGrid([
{ value: payload.total, label: "matches" },
{ value: state.activeTopic ? topicLabel(state.activeTopic) : "all", label: "scope" },
{ value: "score", label: "sort" },
])}
</section>
<section class="section">
${paperList(payload.items, payload.items.length)}
</section>
`;
state.searchFollowups = [];
state.searchResult = null;
const starter = {
query: q,
steps: [
{ label: "理解问题", status: "running", detail: "准备启动 Search Agent" },
{ label: "扩展关键词", status: "pending", detail: "" },
{ label: "检索论文库", status: "pending", detail: "" },
{ label: "统计主题/趋势", status: "pending", detail: "" },
{ label: "生成搜索结论", status: "pending", detail: "" },
],
};
renderSearchRunning(starter);
try {
const payload = await api("/api/search/agent", {
method: "POST",
body: JSON.stringify({ query: q, topic: scopeTopic }),
});
state.searchJobId = payload.job.id;
renderSearchRunning(payload.job);
pollSearchJob(payload.job.id);
} catch (error) {
renderSearchError(error.message);
}
}
async function pollSearchJob(jobId) {
window.clearTimeout(state.searchPollTimer);
try {
const payload = await api(`/api/search/agent?id=${encodeURIComponent(jobId)}`);
const job = payload.job;
if (job.status === "done") {
state.searchResult = job.result;
state.searchItems = job.result.items || [];
state.searchTotal = job.result.total || 0;
renderSearchResult(job.result);
return;
}
if (job.status === "error") {
renderSearchError(job);
return;
}
renderSearchRunning(job);
state.searchPollTimer = window.setTimeout(() => pollSearchJob(jobId), 900);
} catch (error) {
renderSearchError(error.message);
}
}
async function runSearchFollowup() {
const input = $("#searchFollowupInput");
const question = input?.value.trim();
if (!question || !state.searchJobId) return;
state.searchFollowups.push({ role: "user", content: question });
state.searchFollowups.push({ role: "assistant", content: "thinking..." });
renderSearchResult(state.searchResult);
try {
const payload = await api("/api/search/followup", {
method: "POST",
body: JSON.stringify({ job_id: state.searchJobId, question }),
});
state.searchFollowups[state.searchFollowups.length - 1] = {
role: "assistant",
content: `${payload.answer}\n\n${payload.model} · ${payload.duration_seconds}s`,
};
} catch (error) {
state.searchFollowups[state.searchFollowups.length - 1] = {
role: "assistant",
content: error.message,
};
}
renderSearchResult(state.searchResult);
}
function renderReading() {
@@ -755,6 +980,7 @@ function renderPaperReader(aiHtml = "", abstractHtml = "") {
const paper = state.selectedPaper;
if (!paper) return;
setReaderMode(true);
const chatRows = state.paperChatHistory[paper.id] || [];
elements.lensEyebrow.textContent = "Paper";
elements.lensTitle.textContent = "研读";
elements.lensBody.innerHTML = `
@@ -784,6 +1010,26 @@ function renderPaperReader(aiHtml = "", abstractHtml = "") {
<section class="section" id="paperAbstract" style="${abstractHtml ? "" : "display:none"}">${abstractHtml}</section>
<section class="section" id="paperAi" style="${aiHtml ? "" : "display:none"}">${aiHtml}</section>
<section class="section">
<h3>继续研读</h3>
<div class="dialogue-list paper-chat-list">
${chatRows
.map(
(row) => `
<div class="dialogue-row ${escapeHtml(row.role)}">
<strong>${row.role === "user" ? "你" : "Paper Agent"}</strong>
<div>${escapeHtml(row.content)}</div>
</div>
`,
)
.join("")}
</div>
<div class="followup-row">
<input id="paperChatInput" type="text" placeholder="继续问这篇论文,比如:实验设计有什么问题?和工程实践有什么关系?" />
<button class="tool-button primary" type="button" data-paper-chat>Ask</button>
</div>
</section>
<section class="section">
<h3>本地卡片</h3>
<div class="reader-box markdown-preview">${renderMarkdown(state.selectedMarkdown)}</div>
@@ -808,6 +1054,39 @@ async function loadAbstract() {
}
}
async function runPaperChat() {
if (!state.selectedPaper) return;
const input = $("#paperChatInput");
const question = input?.value.trim();
if (!question) return;
const paperId = state.selectedPaper.id;
const history = state.paperChatHistory[paperId] || [];
history.push({ role: "user", content: question });
history.push({ role: "assistant", content: "thinking..." });
state.paperChatHistory[paperId] = history;
const aiHtml = $("#paperAi")?.innerHTML || "";
const abstractHtml = $("#paperAbstract")?.innerHTML || "";
renderPaperReader(aiHtml, abstractHtml);
try {
const payload = await api("/api/paper/chat", {
method: "POST",
body: JSON.stringify({
id: paperId,
question,
history: history.slice(0, -1),
}),
});
history[history.length - 1] = {
role: "assistant",
content: `${payload.answer}\n\n${payload.model} · ${payload.duration_seconds}s`,
};
} catch (error) {
history[history.length - 1] = { role: "assistant", content: error.message };
}
state.paperChatHistory[paperId] = history;
renderPaperReader($("#paperAi")?.innerHTML || aiHtml, $("#paperAbstract")?.innerHTML || abstractHtml);
}
function setAiBusy(isBusy, label = "") {
state.aiBusy = isBusy;
document.querySelectorAll(".tool-button, .quiet-button").forEach((button) => {
@@ -904,6 +1183,9 @@ function bindEvents() {
const compareButton = event.target.closest("[data-compare-topic]");
const paperAction = event.target.closest("[data-paper-action]");
const readerAction = event.target.closest("[data-reader-action]");
const searchFollowup = event.target.closest("[data-search-followup]");
const paperChat = event.target.closest("[data-paper-chat]");
const searchExample = event.target.closest("[data-search-example]");
if (topicButton) {
await selectTopic(topicButton.dataset.topic);
@@ -926,16 +1208,28 @@ function bindEvents() {
} else if (readerAction) {
if (state.topicFocus) renderTopic(state.topicFocus);
else renderOverview();
} else if (searchFollowup) {
await runSearchFollowup();
} else if (paperChat) {
await runPaperChat();
} else if (searchExample) {
elements.searchInput.value = searchExample.dataset.searchExample || "";
await runSearch();
}
});
elements.lensBody.addEventListener("keydown", async (event) => {
if (event.key === "Enter" && event.target.closest("#searchFollowupInput")) {
await runSearchFollowup();
}
if (event.key === "Enter" && event.target.closest("#paperChatInput")) {
await runPaperChat();
}
});
elements.searchBtn.addEventListener("click", runSearch);
elements.searchInput.addEventListener(
"input",
debounce(() => {
if (elements.searchInput.value.trim()) runSearch();
}),
);
elements.searchInput.addEventListener("keydown", (event) => {
if (event.key === "Enter") runSearch();
});
elements.explainAtlasBtn.addEventListener("click", () => runAtlasAi("atlas", state.activeTopic));
elements.readingPathBtn.addEventListener("click", () => runAtlasAi("path", state.activeTopic));
+2 -2
View File
@@ -5,7 +5,7 @@
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Agent Knowledge Atlas</title>
<link rel="icon" href="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 64 64'%3E%3Crect width='64' height='64' rx='12' fill='%23cfe8e3'/%3E%3Ctext x='32' y='40' text-anchor='middle' font-size='24' font-family='Arial' font-weight='700' fill='%23064c47'%3EAK%3C/text%3E%3C/svg%3E" />
<link rel="stylesheet" href="/static/styles.css?v=atlas-20260708-8" />
<link rel="stylesheet" href="/static/styles.css?v=atlas-20260708-10" />
</head>
<body>
<main class="atlas-app">
@@ -79,6 +79,6 @@
</footer>
</main>
<script src="/static/app.js?v=atlas-20260708-8"></script>
<script src="/static/app.js?v=atlas-20260708-10"></script>
</body>
</html>
+126
View File
@@ -521,6 +521,132 @@ button {
padding: 3px 6px;
}
.agent-steps {
display: grid;
gap: 10px;
}
.agent-step {
display: grid;
grid-template-columns: 13px 1fr;
gap: 10px;
align-items: start;
color: var(--muted);
}
.step-dot {
width: 9px;
height: 9px;
margin-top: 6px;
border: 1px solid #aab3ad;
border-radius: 50%;
background: #ffffff;
}
.agent-step strong {
display: block;
color: #17201d;
font-size: 13px;
}
.agent-step span:last-child {
display: block;
margin-top: 2px;
font-size: 12px;
line-height: 1.42;
}
.agent-step.running .step-dot {
border-color: var(--teal);
background: var(--teal);
box-shadow: 0 0 0 5px rgba(13, 118, 111, 0.12);
}
.agent-step.done .step-dot {
border-color: var(--green);
background: var(--green);
}
.agent-step.error .step-dot {
border-color: var(--coral);
background: var(--coral);
}
.search-brief h3 {
margin: 0 0 8px;
font-size: 14px;
}
.compact-section {
display: grid;
gap: 8px;
}
.mini-chart-row,
.search-topic-row {
margin-top: 10px;
}
.month-chip {
display: inline-flex;
align-items: center;
min-height: 24px;
margin: 0 6px 6px 0;
border: 1px solid var(--line);
border-radius: 5px;
background: #ffffff;
padding: 0 7px;
color: #4c5550;
font-size: 11px;
font-weight: 720;
}
.followup-row {
display: grid;
grid-template-columns: minmax(0, 1fr) auto;
gap: 8px;
margin-top: 10px;
}
.followup-row input {
min-width: 0;
min-height: 36px;
border: 1px solid var(--line);
border-radius: 7px;
background: #ffffff;
padding: 0 10px;
outline: 0;
}
.dialogue-list {
display: grid;
gap: 8px;
}
.dialogue-row {
border: 1px solid var(--line);
border-radius: 8px;
background: #ffffff;
padding: 10px;
}
.dialogue-row.user {
background: #f4f8f6;
}
.dialogue-row strong {
display: block;
margin-bottom: 4px;
font-size: 12px;
}
.dialogue-row div {
white-space: pre-wrap;
color: #27312d;
font-size: 13px;
line-height: 1.58;
}
.ai-box,
.reader-box {
border: 1px solid var(--line);