Add agentic paper search and chat
This commit is contained in:
+675
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user