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
+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)