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
+31 -1
View File
@@ -9,7 +9,7 @@ from fastapi import HTTPException
from fastapi.testclient import TestClient
from sqlalchemy import select
from wisegrade import chat_api, services
from wisegrade import agent_tools, chat_api, services
from wisegrade.actions import confirm, prepare
from wisegrade.agent_tools import execute
from wisegrade.db import get_db
@@ -70,6 +70,36 @@ def test_tool_scope_snapshots_and_project_citations(db):
assert rules["source"] == "batch_snapshot"
def test_get_scores_pages_projects_and_criteria(db, monkeypatch):
first = add_project(db, "123450010")
second = add_project(db, "123450011")
created = services.create_batch(SimpleNamespace(course_version_id=1, rubric_version_id=1), db)
conversation = Conversation(title="分页评分", batch_id=created["id"])
db.add(conversation)
db.commit()
def result(_db, _batch, item):
return {"student_no": f"{item.id}", "name": "学生", "totals": {"overall": 80},
"rows": [{"criterion": {"criterion_id": f"C{index}", "name": f"评分项{index}", "max_score": 10},
"score_id": index, "model_score": 8, "teacher_score": None, "final_score": 8,
"status": "scored", "error": "", "evidence": [{"page": index + 1,
"quote": "证据" * 200, "document": "报告.pdf", "image_url": None}]}
for index in range(14)]}
monkeypatch.setattr(agent_tools, "project_result", result)
first_page = execute(db, conversation, "get_scores", {"batch_id": created["id"]}, "")
assert first_page["count"] == 2 and len(first_page["results"]) == 1
assert first_page["results"][0]["project_id"] == first.id
assert len(first_page["results"][0]["rows"]) == 6
assert first_page["next_criterion_offset"] == 6 and first_page["next_project_offset"] == 1
assert len(json.dumps(first_page, ensure_ascii=False)) < 12000
later = execute(db, conversation, "get_scores", {"batch_id": created["id"],
"project_offset": 1, "criterion_offset": 12}, "")
assert later["results"][0]["project_id"] == second.id
assert [row["criterion_id"] for row in later["results"][0]["rows"]] == ["C12", "C13"]
assert later["next_project_offset"] is None and later["next_criterion_offset"] is None
def test_import_confirmation_hash_change_index_and_idempotency(environment, db, monkeypatch, session_factory):
folder = environment / "inbox" / "batch" / "123450003_张三_机器人"
folder.mkdir(parents=True)
+74
View File
@@ -168,6 +168,80 @@ def test_tool_record_redacts_text_and_secrets():
assert "学生原文" not in str(saved) and "secret" not in str(saved) and "敏感评语" not in str(saved)
assert "学生原文" not in chat_api._result_summary({"found": True, "citations": [{"quote": "学生原文"}]})
assert "学生原文" not in str(chat_api._safe_result({"citations": [{"quote": "学生原文"}]}))
search = chat_api._safe_result({"found": True, "citations": [{"title": "报告.pdf", "page": 48,
"quote": "可核验片段", "url": "/api/pages/48", "api_key": "secret"}]}, "search_project_material")
assert search['citations'][0]['quote'] == '可核验片段'
assert 'secret' not in str(search)
def test_late_citations_and_search_snippets_survive_restoration(session_factory, monkeypatch):
class FakeClient:
def tool_call(self, messages, _tools):
if not any(message['role'] == 'tool' for message in messages):
return {"role": "assistant", "content": None, "tool_calls": [
{"id": f"find-{index}", "type": "function", "function": {"name": "search_project_material",
"arguments": '{"project_id": 1, "query": "检索原文"}'}} for index in range(5)]}
return {"role": "assistant", "content": "综合报告第48页给出改进方向。", "tool_calls": []}
count = 0
def execute(_db, _conversation, _name, _arguments, _text):
nonlocal count
count += 1
page = 48 if count == 5 else count
return {"found": True, "citations": [{"document_id": 10, "chunk_id": count * 10 + index,
"title": "综合报告.pdf", "page": page, "quote": f"第{page}页原文片段{index}",
"url": f"/api/knowledge/documents/10/pages/{page}"} for index in range(6)]}
monkeypatch.setattr(chat_api, "client_for_chat", lambda _db: FakeClient())
monkeypatch.setattr(chat_api, "execute", execute)
client = client_for(session_factory)
try:
created = client.post('/api/conversations', json={"title": "引用恢复"}).json()
response = client.post(f"/api/conversations/{created['id']}/messages", json={"content": "总结"})
assert response.status_code == 200
detail = client.get(f"/api/conversations/{created['id']}").json()
answer = detail['messages'][-1]
assert len(answer['citations']) == 30
assert [item['page'] for item in answer['citations'][-6:]] == [48] * 6
assert answer['tool_calls'][-1]['result']['citations'][0]['quote'] == '第48页原文片段0'
assert detail['runs'][0]['tool_calls'][-1]['result']['citations'][0]['page'] == 48
finally:
app.dependency_overrides.clear()
def test_large_score_result_keeps_structured_rows_for_model_and_history(session_factory, monkeypatch):
seen = []
class FakeClient:
def tool_call(self, messages, _tools):
if not any(message['role'] == 'tool' for message in messages):
return {"role": "assistant", "content": None, "tool_calls": [{"id": "score-call", "type": "function",
"function": {"name": "get_scores", "arguments": '{"batch_id": 3}'}}]}
seen.append(messages[-1]['content'])
return {"role": "assistant", "content": "已读取评分项目。", "tool_calls": []}
def execute(_db, _conversation, _name, _arguments, _text):
return {"batch_id": 3, "count": 1, "project_offset": 0, "criterion_offset": 0,
"next_project_offset": None, "next_criterion_offset": 6,
"results": [{"project_id": 1, "student_no": "1001", "name": "学生甲", "totals": {},
"url": "/?page=scores&batch_id=3&project_id=1", "total_rows": 22,
"rows": [{"criterion_id": "C1", "criterion_name": "功能", "score_id": 7,
"model_score": 8, "teacher_score": None, "final_score": 8,
"max_score": 10, "status": "scored", "error": "", "evidence": []}]}],
"citations": [], "unrelated_large_field": "x" * 13000}
monkeypatch.setattr(chat_api, "client_for_chat", lambda _db: FakeClient())
monkeypatch.setattr(chat_api, "execute", execute)
client = client_for(session_factory)
try:
created = client.post('/api/conversations', json={"title": "评分恢复"}).json()
assert client.post(f"/api/conversations/{created['id']}/messages", json={"content": "查分"}).status_code == 200
assert seen and 'C1' in seen[0] and 'next_criterion_offset' in seen[0]
assert '结果过长' not in seen[0]
result = client.get(f"/api/conversations/{created['id']}").json()['messages'][-1]['tool_calls'][0]['result']
assert result['results'][0]['rows'][0]['criterion_id'] == 'C1'
assert result['next_criterion_offset'] == 6
finally:
app.dependency_overrides.clear()
def test_upgrade_from_legacy_0003_preserves_history(session_factory):
+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()