fix: restore chat citations and tool details
This commit is contained in:
@@ -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)
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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")),
|
||||
|
||||
@@ -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()
|
||||
|
||||
Reference in New Issue
Block a user