fix: restore chat citations and tool details

This commit is contained in:
2026-09-27 12:06:44 +08:00
parent 09d730058c
commit c7d1ba1cd6
10 changed files with 281 additions and 63 deletions
+35 -21
View File
@@ -64,11 +64,16 @@ class BatchId(Args):
batch_id: int = Field(gt=0)
class ScoresArgs(BatchId):
class ScoresFilterArgs(BatchId):
project_id: int | None = Field(default=None, gt=0)
status: Literal["pending", "scored", "reviewed", "failed", "unscorable"] | None = None
class ScoresArgs(ScoresFilterArgs):
project_offset: int = Field(default=0, ge=0)
criterion_offset: int = Field(default=0, ge=0)
class CreateBatch(VersionId):
rubric_version_id: int = Field(gt=0)
@@ -254,30 +259,39 @@ def scores(db, convo, args, _user):
if convo.project_id:
selected = [p for p in selected if p.id == convo.project_id]
results = []
citations = []
for item in selected:
result = project_result(db, batch, item)
if args.status:
result["rows"] = [row for row in result["rows"] if row["status"] == args.status]
if result["rows"]:
result["url"] = f"/?page=scores&batch_id={batch.id}&project_id={item.id}"
results.append({"project_id": item.id, "student_no": result["student_no"], "name": result["name"],
"totals": result["totals"], "url": result["url"],
"rows": [{"criterion_id": row["criterion"]["criterion_id"],
"criterion_name": row["criterion"]["name"],
"max_score": row["criterion"]["max_score"], "score_id": row["score_id"],
"model_score": row["model_score"], "teacher_score": row["teacher_score"],
"final_score": row["final_score"], "status": row["status"],
"error": row["error"], "evidence": [{"page": ev["page"],
"quote": ev["quote"][:240], "document": ev["document"]}
for ev in row["evidence"][:2]]} for row in result["rows"]]})
for row in result["rows"]:
for evidence in row["evidence"][:2]:
citations.append({"title": evidence["document"], "page": evidence["page"],
"quote": evidence["quote"][:500],
"url": evidence["image_url"] or result["url"]})
results.append((item, result))
page = results[args.project_offset:args.project_offset + 1]
output = []
citations = []
for item, result in page:
url = f"/?page=scores&batch_id={batch.id}&project_id={item.id}"
rows = result["rows"][args.criterion_offset:args.criterion_offset + 6]
output.append({"project_id": item.id, "student_no": str(result["student_no"])[:50], "name": str(result["name"])[:100],
"totals": result["totals"], "url": url, "total_rows": len(result["rows"]),
"rows": [{"criterion_id": str(row["criterion"]["criterion_id"])[:100],
"criterion_name": str(row["criterion"]["name"])[:150],
"max_score": row["criterion"]["max_score"], "score_id": row["score_id"],
"model_score": row["model_score"], "teacher_score": row["teacher_score"],
"final_score": row["final_score"], "status": row["status"],
"error": str(row["error"] or "")[:300], "evidence": [{"page": ev["page"],
"quote": ev["quote"][:160], "document": str(ev["document"])[:200]}
for ev in row["evidence"][:2]]} for row in rows]})
for row in rows:
for evidence in row["evidence"][:2]:
citations.append({"title": str(evidence["document"])[:200], "page": evidence["page"],
"quote": evidence["quote"][:320],
"url": evidence["image_url"] or url})
next_criterion_offset = args.criterion_offset + 6 if page and args.criterion_offset + 6 < len(page[0][1]["rows"]) else None
next_project_offset = args.project_offset + 1 if args.project_offset + 1 < len(results) else None
return {"batch_id": batch.id, "status_filter": args.status, "count": len(results),
"results": results[:2], "citations": citations[:12], "truncated": len(results) > 2}
"project_offset": args.project_offset, "criterion_offset": args.criterion_offset,
"next_project_offset": next_project_offset, "next_criterion_offset": next_criterion_offset,
"results": output, "citations": citations}
def summary(db, convo, args, _user):
@@ -411,8 +425,8 @@ TOOLS: dict[str, Tool] = {
"search_project_material": Tool("只检索指定学生项目报告,返回原文片段和引用。", SearchProject, search_project),
"list_batches": Tool("列出可访问的评分批次。", Empty, batch_list),
"get_batch_progress": Tool("查询评分批次进度、状态和固定项目范围。", BatchId, progress),
"get_scores": Tool("查询单学生或单批次评分详情,可按评分项状态筛选。", ScoresArgs, scores),
"summarize_batch": Tool("用数据库计算批次或单学生完成数、未完成数和最终分布,可按评分项状态筛选,不补零。", ScoresArgs, summary),
"get_scores": Tool("分页查询单学生或单批次评分详情,可按状态筛选。每次返回一个项目、最多六项评分;如需其余评分项,用 next_criterion_offset 作为 criterion_offset 再查;如需下个项目,用 next_project_offset 作为 project_offset,并将 criterion_offset 设为 0。", ScoresArgs, scores),
"summarize_batch": Tool("用数据库计算批次或单学生完成数、未完成数和最终分布,可按评分项状态筛选,不补零。", ScoresFilterArgs, summary),
"list_index_tasks": Tool("查询课程版本的材料解析与索引任务进度。", VersionId, index_tasks),
"prepare_import": Tool("从只读扫描结果准备导入确认卡片;每页最多20个项目,文件有歧义时要求教师在卡片中修正。", PrepareImport, prepare_import),
"prepare_course_ingest": Tool("准备课程资料入库确认卡片。", VersionId, prepare_kind("course_ingest")),
+64 -9
View File
@@ -194,16 +194,70 @@ def _result_summary(result):
return f"待确认操作 #{action.get('id', '—')}" if isinstance(action, dict) else "待确认操作已准备"
if "found" in result:
return f"找到 {len(result.get('citations') or [])} 条引用" if result["found"] else "未找到相关资料"
if isinstance(result.get("results"), list) and "count" in result:
rows = sum(len(item.get("rows") or []) for item in result["results"] if isinstance(item, dict))
return f"返回 {rows} 项评分(共 {result['count']} 个项目)"
for key in ("courses", "versions", "projects", "scores", "batches", "items"):
if isinstance(result.get(key), list):
return f"返回 {len(result[key])} 条{key}记录"
return "工具已返回结果"
def _safe_result(result):
"""Persist a small structural preview; original student text stays in citations or business records."""
def _safe_citation(item):
if not isinstance(item, dict):
return None
return {key: value[:limit] if isinstance(value, str) else value for key, limit in
(("title", 200), ("quote", 500), ("url", 500), ("image_url", 500))
if isinstance((value := item.get(key)), str)} | {
key: item[key] for key in ("chunk_id", "document_id", "page", "project_id")
if isinstance(item.get(key), int)}
def _answer_citations(items):
"""Keep each distinct source passage, including later tool calls and pages cited by the answer."""
seen = set()
citations = []
for item in items:
safe = _safe_citation(item)
if not safe:
continue
key = (safe.get("document_id"), safe.get("chunk_id"), safe.get("title"),
safe.get("page"), safe.get("quote"), safe.get("url"))
if key not in seen:
seen.add(key)
citations.append(safe)
return citations
def _safe_result(result, name=""):
"""Persist bounded useful details without credentials or unrelated source text."""
if not isinstance(result, dict):
return {"summary": "工具已返回结果"}
if name in {"search_course_material", "search_project_material"}:
return {"found": bool(result.get("found")),
"citations": [citation for item in (result.get("citations") or [])[:6]
if (citation := _safe_citation(item))]}
if name == "get_scores":
preview = {key: result.get(key) for key in ("batch_id", "status_filter", "count", "project_offset",
"criterion_offset", "next_project_offset", "next_criterion_offset")}
preview["results"] = []
for project in (result.get("results") or [])[:1]:
if not isinstance(project, dict):
continue
row = {key: project.get(key) for key in ("project_id", "student_no", "name", "totals", "url", "total_rows")}
row["rows"] = []
for score in (project.get("rows") or [])[:6]:
if not isinstance(score, dict):
continue
entry = {key: score.get(key) for key in ("criterion_id", "criterion_name", "max_score", "score_id",
"model_score", "teacher_score", "final_score", "status")}
entry["error"] = str(score.get("error") or "")[:300]
entry["evidence"] = [{"document": str(ev.get("document") or "")[:200],
"page": ev.get("page"), "quote": str(ev.get("quote") or "")[:160]}
for ev in (score.get("evidence") or [])[:2] if isinstance(ev, dict)]
row["rows"].append(entry)
preview["results"].append(row)
return preview
allowed_text = {"status", "source", "format", "kind", "category"}
allowed_number = {"id", "course_version_id", "batch_id", "project_id", "score_id",
"task_id", "page", "count", "total", "progress_done", "progress_total"}
@@ -258,11 +312,12 @@ def _run_agent(factory, conversation_id, run_id, user_text):
content = reply.get("content")
if not isinstance(content, str) or not content.strip():
raise ModelError("会话模型未返回文字或工具调用")
answer_citations = _answer_citations(citations)
db.add(ChatMessage(conversation_id=conversation_id, role="assistant", content=content,
citations=citations[:20], tool_calls=used))
citations=answer_citations, tool_calls=used))
run.status, run.finished_at = "completed", now()
conversation.updated_at = now()
answer = {"content": content, "citations": citations[:20], "tool_calls": used}
answer = {"content": content, "citations": answer_citations, "tool_calls": used}
done = {"run_id": run.id}
run.events = [*(run.events or []), {"event": "message", "data": answer},
{"event": "done", "data": done}][-140:]
@@ -293,12 +348,12 @@ def _run_agent(factory, conversation_id, run_id, user_text):
if isinstance(result, dict):
citations.extend(result.get("citations", []))
output = json.dumps(result, ensure_ascii=False, default=str)
truncated = len(output) > 12000
if truncated:
output = json.dumps({"truncated": True, "message": "结果过长,请缩小查询范围"}, ensure_ascii=False)
if len(output) > 12000:
output = json.dumps({"notice": "结果已按结构压缩;可使用工具分页参数继续查询",
**_safe_result(result, name)}, ensure_ascii=False, default=str)
messages.append({"role": "tool", "tool_call_id": call_id, "content": output})
entry = {**entry, "status": "completed", "summary": "结果过长,已截断" if truncated else _result_summary(result),
"result": {"truncated": True} if truncated else _safe_result(result)}
entry = {**entry, "status": "completed", "summary": _result_summary(result),
"result": _safe_result(result, name)}
except Exception as exc:
# Tool errors are model-visible, but do not grant broader access or leak provider responses.
db.rollback()