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
+8
View File
@@ -88,3 +88,11 @@ uv run pytest -q
## 2026-09-27 正式启动复验
从当前源码成功执行 `docker compose build api worker web` 与 `docker compose up -d`。业务 PostgreSQL 迁移从 `0003` 到 `0004`,迁移前备份保存在本机 `data/backups/wisegrade-pre-0004-20260927-020450.dump`(权限 `600`,不提交)。Compose 已调整为 API 健康后启动 Web 和 worker,服务设置自动重启。Web `/api/health` 为 200,API、数据库、Web 均健康,worker 在运行,Nginx 检查通过。经 Web 入口实际提交两条无学生资料的只读消息,DeepSeek 调用成功;SSE 收到 `run`、`tool`、`message`、`done`,首事件约 764 毫秒;刷新后打开新草稿,历史回答仍可查看。测试会话已通过界面删除,业务会话数恢复到原先 4 条。`docker compose stop` 后执行 `docker compose up -d --wait` 冷启动复验通过,五个正式服务恢复健康,Web `/api/health` 为 200。`docker compose up` 现在可直接用于正式启动,访问本机配置端口。
## 2026-09-27 会话引用与工具过程修复
正式回答上方的工具过程现在先整体折叠,每次调用再单独折叠;引用移至回答最后并整体折叠。会话页面移除了全局“后台进度”卡片。`get_scores` 改为按项目和评分项分页,每次最多一个项目、六项评分,并提供后续偏移量;长结果不再以空泛的截断提示替代全部评分。新检索调用会在运行记录中保存最多六条有界原文片段;回答引用对全部工具结果去重,不再只保存前 20 条。
业务数据库 6 号会话的旧回答原先只有前 20 条引用。已在备份 `data/backups/wisegrade-pre-chat-citation-20260927.dump`(权限 `600`,不提交)后,用当前项目知识库中逐字匹配的片段补入 14 条来源引用,包括综合报告第 48 页;旧记录的三个 `get_scores` 截断调用附上明确标注的**当前数据库重查结果**,不宣称模型当时读过这些值。旧 `search_project_material` 调用仅持久化了引用数量,查询文本和每次调用的六条原文当时未保存,无法精确还原调用归属;工具详情会展示本轮已核验的相关原文,并明确标明不能归属到这一次调用,也可跳转到回答末尾的引用。新调用保存实际每次找到的片段。
隔离 PostgreSQL 测试库执行 `pytest -q -m 'not integration'`:35 通过、2 个集成测试未运行。前端 `npm run build` 通过,Chromium `npm run test:e2e`:3 通过。API 与 Web 镜像已重新构建并以 `docker compose up -d --wait api web` 更新,Web 真实会话 6 的引用第 48 页、默认折叠、旧工具详情中的 14 条已核验来源片段及无后台进度卡片已用浏览器核对;新评分工具对该会话范围返回六项,序列化后 6,820 字符。用真实项目检索“视觉检测 Otsu 光照鲁棒性 第48页”返回六条带原文与链接的结果,其中包含第 48 页。前端产物仍有 Vite 大块警告。
+1 -1
View File
@@ -28,4 +28,4 @@ WiseGrade 是面向《自动化专业综合实践》课程的教师端自动评
2. 将离线嵌入模型放在 `models/bge-m3/`(或修改 `EMBEDDING_MODEL_PATH`)。
3. 运行 `docker compose up`,访问 <http://127.0.0.1:18080>。
打开系统默认进入空白“会话”草稿;点击“新建”也只切换草稿,发送第一条消息才保存会话。历史会话可从列表继续或删除;查询可直接执行,导入、入库、建批、启动、复核和导出先展示确认卡片,由教师点击后执行。学生材料只从只读挂载的 `/data/inbox` 读取。没有本地嵌入模型或 API 密钥时,系统仍可启动和完成导入;索引、会话或评分会显示明确错误。详细部署和使用步骤见 [部署与使用指南](docs/DEPLOYMENT.md)。
打开系统默认进入空白“会话”草稿;点击“新建”也只切换草稿,发送第一条消息才保存会话。历史会话可从列表继续或删除;回答上方可逐层展开工具调用,末尾可展开引用原文与页面链接。查询可直接执行,导入、入库、建批、启动、复核和导出先展示确认卡片,由教师点击后执行。学生材料只从只读挂载的 `/data/inbox` 读取。没有本地嵌入模型或 API 密钥时,系统仍可启动和完成导入;索引、会话或评分会显示明确错误。详细部署和使用步骤见 [部署与使用指南](docs/DEPLOYMENT.md)。
+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):
+31 -17
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"],
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": 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"]:
"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": evidence["document"], "page": evidence["page"],
"quote": evidence["quote"][:500],
"url": evidence["image_url"] or result["url"]})
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()
+3 -3
View File
@@ -6,7 +6,7 @@
打开系统默认进入空白“会话”草稿,点击“新建”立即切换到另一空白草稿,不弹窗、不写数据库。可在输入区直接提问,并在首次发送前反复选择或清除课程版本、评分批次和学生项目。发送首条非空消息时,会话和消息在同一事务保存;此后范围固定且在界面显示为只读。旧版留下的空会话仍可编辑范围。选择批次或项目会自动带入所属课程版本,清除课程版本会同时清除下级选择。范围固定后,工具不能读取范围之外的对象。会话列表、消息、引用、待确认操作和运行状态保存在数据库,刷新后可查看历史会话;刷新或再次进入页面仍打开新草稿。
可先问“列出课程版本”“扫描 batch 目录”“查看批次 3 的失败评分项”“查询项目 12 的报告实验数据”。课程目标与规则来自数据库;指定批次时使用批次创建时的快照。课程资料和报告通过 BGE-M3/pgvector 检索,回答附资料名、页码或幻灯片编号、原文片段和可打开的页面链接。没有证据时应明确说明。
可先问“列出课程版本”“扫描 batch 目录”“查看批次 3 的失败评分项”“查询项目 12 的报告实验数据”。课程目标与规则来自数据库;指定批次时使用批次创建时的快照。课程资料和报告通过 BGE-M3/pgvector 检索,回答附资料名、页码或幻灯片编号、原文片段和可打开的页面链接。没有证据时应明确说明。正式回答上方的“工具调用”先整体折叠,展开后每次调用仍独立折叠,可逐项查看参数和结果;回答末尾的“引用”也默认整体折叠。会话页不显示全局后台进度卡片,正在运行的会话仍在列表中标记。
扫描只读,可直接执行。导入、课程资料入库、创建批次、启动或重试、教师复核、导出和索引重试会生成确认卡片。卡片列出目标、范围、现值、新值及异常;教师点击“确认执行”才写入。模型工具白名单没有确认工具,聊天文字也不能代替按钮。导入候选有重复、识别异常或待确认文件时,卡片禁用确认;教师在卡片中修正身份、类别和文件选择,再“更新预览”。导入确认同时授权后台解析、必要 OCR 和项目索引。
@@ -26,10 +26,10 @@
会话使用模型设置页的 OpenAI 兼容 `/chat/completions` 地址、模型和后端 API Key。适配层按 [DeepSeek 原生工具调用文档](https://api-docs.deepseek.com/guides/tool_calls) 发送 `tools`,在每轮前用不含学生资料的 `wisegrade_ping` 最小探针验证端点会返回 `tool_calls`。当前 `deepseek-flash` 端点支持自动工具选择,但拒绝强制 `tool_choice="required"`,因此探针采用 `auto` 并检查返回结构。模型不支持时会显示明确配置错误,不会伪装工具已执行。既有评分 `json_call` 保持原有 JSON 行为。
工具输入由 Pydantic 校验,后端再校验课程、批次和项目关系。每条教师消息默认最多调用工具 32 次,成功与失败的尝试均计数;模型最多请求 33 轮,以便用完工具次数后还能生成回答。可在 `.env` 中通过 `CHAT_MAX_TOOL_CALLS` 调整工具上限(1–64),修改后重新创建 API 容器。工具输出和会话上下文限量。工具没有任意 SQL、任意文件路径、HTTP 请求、代码执行或确认能力。检索片段只作为数据,不作为工具指令。模型只收到当前问题所需的规则摘要、检索片段或紧凑统计;API Key 不写入日志或会话表。
工具输入由 Pydantic 校验,后端再校验课程、批次和项目关系。每条教师消息默认最多调用工具 32 次,成功与失败的尝试均计数;模型最多请求 33 轮,以便用完工具次数后还能生成回答。可在 `.env` 中通过 `CHAT_MAX_TOOL_CALLS` 调整工具上限(1–64),修改后重新创建 API 容器。`get_scores` 每次最多返回一个项目的六项评分,响应给出 `next_criterion_offset` 和 `next_project_offset` 供继续查询;长结果保留结构化评分数据,不再变成无内容的截断提示。检索工具的运行记录保留每次调用找到的有界原文片段;回答引用对各次调用的证据去重后保存,不再只取前 20 条。工具没有任意 SQL、任意文件路径、HTTP 请求、代码执行或确认能力。检索片段只作为数据,不作为工具指令。模型只收到当前问题所需的规则摘要、检索片段或紧凑统计;API Key 不写入日志或会话表。
## API 与故障恢复
草稿仅存在于浏览器;`POST /api/conversations/first-message` 以 UUID 请求键原子创建会话、首条教师消息及运行,重复请求返回同一 ID。`POST /api/conversations` 保留给旧客户端。`GET /api/conversations` 返回列表和各会话运行标识;`GET /api/conversations/{id}` 恢复消息、卡片、工具记录和运行状态;`PUT /api/conversations/{id}/scope` 仅允许首条教师消息前编辑范围;`DELETE /api/conversations/{id}` 删除非运行中的会话。删除会话会清理消息、运行与待确认卡片,不回滚已经确认的业务操作,相关业务审计仍保留。`POST /api/conversations/{id}/messages/start` 在提交消息后启动独立后台运行;`GET /api/conversations/{id}/runs/{run_id}/events` 可按运行 ID 订阅持久 SSE 事件。旧版 `POST /api/conversations/{id}/messages` 继续提供 SSE 的 `run`、`tool`、`message`、`error`、`done` 事件。确认、取消和导入编辑分别为 `POST /api/conversations/{id}/actions/{action_id}/confirm|cancel|edit-import`。索引任务可通过 `GET /api/index-tasks`、`GET /api/index-tasks/{id}` 查询,失败任务可重试。Nginx 对消息流关闭代理缓冲并设置 900 秒读取超时。
回答生成由后台线程执行,页面切换或 SSE 断开不会停止运行;前端按运行 ID 订阅 SSE,并轮询列表和当前会话详情恢复进度,可同时运行多个不同会话。同一会话仍拒绝并发发送。刷新后默认打开新草稿,点击历史会话即可查看正在运行的工具状态或最终结果。运行中的会话删除返回冲突,应待完成后重试。服务进程异常留下的 `running` 记录超过 15 分钟,在读取会话时标为中断。模型失败时,已提交的教师消息仍保留,可在同一会话继续提问。工具参数仅保存必要标识,文本和敏感字段以占位符代替,结果保存简短摘要。回答按安全 Markdown 渲染,HTML 不执行,链接仅允许 HTTP(S);结构化引用位于默认折叠的工具过程上方,待确认卡片始终独立显示。若嵌入模型或 OCR 缺失,任务保留具体失败原因;补齐离线模型后重试。若课程资料缺失、原件哈希变化、课程或批次状态变化,应重新预览。
回答生成由后台线程执行,页面切换或 SSE 断开不会停止运行;前端按运行 ID 订阅 SSE,并轮询列表和当前会话详情恢复进度,可同时运行多个不同会话。同一会话仍拒绝并发发送。刷新后默认打开新草稿,点击历史会话即可查看正在运行的工具状态或最终结果。运行中的会话删除返回冲突,应待完成后重试。服务进程异常留下的 `running` 记录超过 15 分钟,在读取会话时标为中断。模型失败时,已提交的教师消息仍保留,可在同一会话继续提问。工具参数仅保存必要标识,文本和敏感字段以占位符代替;检索工具记录有界原文片段,评分工具记录有界评分行,其他工具记录结构摘要。回答按安全 Markdown 渲染,HTML 不执行,链接仅允许 HTTP(S);工具过程在回答上方,引用在回答最后,二者默认折叠。待确认卡片始终独立显示。若嵌入模型或 OCR 缺失,任务保留具体失败原因;补齐离线模型后重试。若课程资料缺失、原件哈希变化、课程或批次状态变化,应重新预览。
+23 -19
View File
@@ -90,31 +90,39 @@ function Answer({ text }: { text: string }) {
img: ({ alt, src }) => safeUrl(src) ? <a href={safeUrl(src)} target="_blank" rel="noopener noreferrer">{alt || '查看图片'}</a> : <span>{alt}</span> }}>
{text || ''}</ReactMarkdown></div>;
}
function Citations({ items }: { items: any[] }) {
const seen = new Set<string>();
const unique = (items || []).filter(item => { if (!item || typeof item !== 'object') return false;
const key = JSON.stringify([item.title || item.document, item.page, item.quote, item.url, item.image_url]);
if (seen.has(key)) return false; seen.add(key); return true; });
if (!unique.length) return null;
return <section className="chat-citations" aria-label="引用资料"><Text strong>引用 · {unique.length}</Text><div className="citation-grid">
{unique.map((item: any, index) => { const name = String(item.title || item.document || '未命名资料');
function CitationCards({ items }: { items: any[] }) {
return <div className="citation-grid">{items.map((item: any, index) => { const name = String(item.title || item.document || '未命名资料');
const page = item.page ?? item.slide; const place = page == null ? '' : `第 ${page} ${/\.pptx?$/i.test(name) ? '张幻灯片' : '页'}`;
const quote = typeof item.quote === 'string' ? item.quote.trim() : '';
return <div className="citation-card" key={`${name}-${page}-${index}`}><div className="citation-heading"><Text strong>{name}</Text>{place && <Tag>{place}</Tag>}</div>
{quote && (quote.length > 320 ? <details><summary>{quote.slice(0, 240)}… 展开原文</summary><p>{quote}</p></details> : <p>{quote}</p>)}
<Space wrap size="small">{safeUrl(item.url) && <a href={safeUrl(item.url)} target="_blank" rel="noopener noreferrer">打开页面</a>}
{safeUrl(item.image_url) && <a href={safeUrl(item.image_url)} target="_blank" rel="noopener noreferrer">查看图像</a>}</Space></div>;
})}</div></section>;
})}</div>;
}
function Tools({ calls }: { calls: any[] }) {
function Citations({ items, messageId }: { items: any[]; messageId: number }) {
const seen = new Set<string>();
const unique = (items || []).filter(item => { if (!item || typeof item !== 'object') return false;
const key = JSON.stringify([item.title || item.document, item.page, item.quote, item.url, item.image_url]);
if (seen.has(key)) return false; seen.add(key); return true; });
if (!unique.length) return null;
return <section className="chat-citations" aria-label="引用资料"><details id={`citations-${messageId}`} className="chat-citation-group"><summary><Text strong>引用 · {unique.length}</Text></summary><CitationCards items={unique} /></details></section>;
}
function Tools({ calls, messageId, legacyEvidence = [] }: { calls: any[]; messageId?: number; legacyEvidence?: any[] }) {
if (!calls?.length) return null;
return <section className="chat-tools" aria-label="工具调用"><Text type="secondary">工具调用 · {calls.length}</Text>
{calls.map((call, index) => <details key={call.id || index} className="chat-tool"><summary><Space wrap size="small"><Text code>{call.name || '未知工具'}</Text>
const active = calls.filter(call => call.status === 'running').length;
const failed = calls.filter(call => call.status === 'failed').length;
return <section className="chat-tools" aria-label="工具调用"><details className="chat-tool-group"><summary><Text strong>工具调用 · {calls.length}</Text>
{active > 0 && <Tag color="processing">{active} 项进行中</Tag>}{failed > 0 && <Tag color="red">{failed} 项失败</Tag>}</summary>
<div className="chat-tool-list">{calls.map((call, index) => <details key={call.id || index} className="chat-tool"><summary><Space wrap size="small"><Text code>{call.name || '未知工具'}</Text>
<Tag color={statusColor(call.status || 'completed')}>{call.status === 'running' ? '进行中' : call.status === 'failed' ? '失败' : '成功'}</Tag>
<Text type="secondary">{call.status === 'running' ? '正在执行' : call.error ? String(call.error).slice(0, 80) : call.summary || (typeof call.result === 'string' ? call.result : '已完成')}</Text></Space></summary>
<div className="tool-detail"><Text strong>调用参数</Text><pre>{JSON.stringify(call.arguments || {}, null, 2)}</pre>
<Text strong>{call.error ? '错误' : '调用结果'}</Text><pre>{call.error || (typeof call.result === 'string' ? call.result : JSON.stringify(call.result ?? '运行中', null, 2))}</pre></div>
</details>)}</section>;
<Text strong>{call.error ? '错误' : '调用结果'}</Text><pre>{call.error || (typeof call.result === 'string' ? call.result : JSON.stringify(call.result ?? '运行中', null, 2))}</pre>
{call.result?.citations?.count !== undefined && <div><Text type="secondary">旧运行只保存了引用数量,无法还原各次检索对应的原文。</Text>{messageId && <Button type="link" size="small" onClick={() => { const target = document.getElementById(`citations-${messageId}`) as HTMLDetailsElement | null; if (target) { target.open = true; target.scrollIntoView({ behavior: 'smooth', block: 'nearest' }); } }}>查看本轮已核验原文</Button>}
{legacyEvidence.length > 0 && <div className="legacy-evidence"><Text strong>本轮回答已核验的相关原文(无法确认属于当前这一次检索)</Text><CitationCards items={legacyEvidence} /></div>}</div>}
{call.result?.truncated && <Text type="secondary">旧运行的原始结果未保存。{call.result?.rechecked_current ? '附带的是当前数据库重查结果,不代表当时模型看到的内容。' : '再次提问可使用分页评分查询。'}</Text>}</div>
</details>)}</div></details></section>;
}
export default function Chat() {
const [conversations, setConversations] = useState<any[]>([]);
@@ -131,7 +139,6 @@ export default function Chat() {
const [versions, setVersions] = useState<any[]>([]);
const [batches, setBatches] = useState<any[]>([]);
const [projects, setProjects] = useState<any[]>([]);
const [indexTasks, setIndexTasks] = useState<any[]>([]);
const selection = useRef<string>(`draft-${token}`);
const sending = useRef<Set<string>>(new Set());
const deletedIds = useRef<Set<number>>(new Set());
@@ -157,7 +164,6 @@ export default function Chat() {
useEffect(() => { localStorage.removeItem('wisegrade_conversation_id'); refresh();
api('/courses').then(async (courses: any[]) => setVersions((await Promise.all(courses.map(course => api(`/courses/${course.id}/versions`)))).flat())).catch(reportError);
api('/scoring-batches').then(setBatches).catch(reportError); api('/projects').then(setProjects).catch(reportError);
api('/index-tasks').then(setIndexTasks).catch(reportError);
const timer = window.setInterval(refresh, 1800); return () => { window.clearInterval(timer); subscriptions.current.forEach(item => item.controller.abort()); subscriptions.current.clear(); };
}, []);
const watchRun = (id: number, runId: number) => {
@@ -223,14 +229,12 @@ export default function Chat() {
<Select aria-label="学生项目" allowClear disabled={locked || scopeSaving || running} placeholder="学生项目(可选)" value={currentScope.project_id} options={projects.filter(p => (!currentScope.course_version_id || p.course_version_id === currentScope.course_version_id) && (!currentScope.batch_id || !batches.find(b => b.id === currentScope.batch_id)?.project_ids || batches.find(b => b.id === currentScope.batch_id).project_ids.includes(p.id))).map(p => ({ value: p.id, label: `${p.student_no} ${p.name}` }))} onChange={changeProject} /></div>
{locked && <Text type="secondary">首条消息已提交,课程版本、批次和学生项目范围不可修改。</Text>}
</Card><div className="chat-messages">{detail?.messages?.map((item: any) => <Card key={item.id} size="small" className={`chat-message chat-${item.role}`} title={item.role === 'user' ? '教师' : '智评'}>
{item.role === 'assistant' ? <Answer text={item.content} /> : <Paragraph className="chat-content">{item.content}</Paragraph>}
{item.role === 'assistant' && <><Citations items={item.citations} /><Tools calls={item.tool_calls} /></>}
{item.role === 'assistant' ? <><Tools calls={item.tool_calls} messageId={item.id} legacyEvidence={(item.citations || []).filter((source: any) => source.chunk_id)} /><Answer text={item.content} /><Citations items={item.citations} messageId={item.id} /></> : <Paragraph className="chat-content">{item.content}</Paragraph>}
</Card>)}
{detail?.runs?.filter((run: any) => run.status !== 'completed' && (run.tool_calls?.length || run.error || run.status === 'running')).map((run: any) => <Card key={`run-${run.id}`} size="small" className="chat-message chat-assistant" title={<Space>智评 <Tag color={statusColor(run.status)}>{run.status === 'running' ? '生成中' : run.status === 'failed' ? '失败' : '已中断'}</Tag></Space>}>
<Tools calls={run.tool_calls} />{run.error && <Alert type="error" message={run.error} />}</Card>)}
{!currentId && <Card size="small" className="chat-message"><Text type="secondary">输入问题,开始新的会话。</Text></Card>}</div>
{detail?.actions?.map((action: any) => <ActionCard key={action.id} action={action} conversationId={detail.id} reload={() => loadDetail(detail.id).catch(reportError)} />)}
{(indexTasks.length > 0 || batches.length > 0) && currentId && <Card size="small" className="top-gap" title="后台进度"><Space wrap>{indexTasks.slice(0, 5).map(task => <Tag key={`i${task.id}`} color={statusColor(task.status)}>索引 #{task.id} {task.status}{task.error ? `:${task.error}` : ''}</Tag>)}{batches.slice(0, 3).map(batch => <Tag key={`b${batch.id}`} color={statusColor(batch.status)}>批次 #{batch.id} {batch.status} {batch.progress_done}/{batch.progress_total}</Tag>)}</Space></Card>}
<Space.Compact className="chat-compose"><Input.TextArea value={input} rows={3} disabled={running || !!pending[key]} placeholder="例如:查看批次 3 的未完成评分项;给评分项 42 打 8 分,评语……" onChange={e => setComposers(old => ({ ...old, [key]: e.target.value }))} onPressEnter={e => { if (!e.shiftKey) { e.preventDefault(); send(); } }} /><Button type="primary" loading={!!pending[key]} disabled={running || !input.trim()} onClick={send}>发送</Button></Space.Compact>
</Col></Row><Modal open={deleteId !== undefined} title="删除会话?" okText="删除会话" okButtonProps={{ danger: true, loading: deleting }} cancelText="取消" onOk={remove} onCancel={() => { setDeleteError(''); setDeleteId(undefined); }}>
<Paragraph>会话消息、工具记录和待确认卡片将被删除。已确认的业务操作与审计记录仍保留。</Paragraph>
+7 -1
View File
@@ -46,12 +46,18 @@ body { margin: 0; }
.chat-markdown table { display: block; width: max-content; max-width: 100%; overflow-x: auto; border-collapse: collapse; margin: 10px 0; }
.chat-markdown th, .chat-markdown td { border: 1px solid #d8e1eb; padding: 6px 9px; vertical-align: top; }
.chat-citations { margin-top: 14px; padding-top: 10px; border-top: 1px solid #e2eaf3; }
.chat-tool-group, .chat-citation-group { min-width: 0; }
.chat-tool-group > summary, .chat-citation-group > summary { cursor: pointer; padding: 7px 2px; overflow-wrap: anywhere; }
.chat-tool-group > summary .ant-tag { margin-left: 8px; }
.chat-tool-list { padding: 0 0 5px 8px; border-left: 2px solid #d6e4f5; }
.citation-grid { display: grid; gap: 8px; margin-top: 7px; }
.citation-card { border-left: 3px solid #5b91d9; background: #f8fbff; padding: 8px 10px; min-width: 0; overflow-wrap: anywhere; }
.citation-heading { display: flex; flex-wrap: wrap; align-items: baseline; gap: 6px; }
.citation-card p { margin: 5px 0; color: #44566d; white-space: pre-wrap; }
.citation-card summary { cursor: pointer; color: #44566d; overflow-wrap: anywhere; }
.chat-tools { margin-top: 12px; }
.legacy-evidence { margin-top: 10px; }
.legacy-evidence .citation-grid { max-height: 300px; overflow-y: auto; }
.chat-tools { margin-bottom: 12px; padding-bottom: 6px; border-bottom: 1px solid #e2eaf3; }
.chat-tool { border: 1px solid #e2e8ef; border-radius: 6px; margin-top: 5px; padding: 6px 9px; }
.chat-tool summary { cursor: pointer; list-style-position: inside; overflow-wrap: anywhere; }
.tool-detail { margin-top: 8px; padding-left: 12px; }
+34 -7
View File
@@ -6,8 +6,14 @@ async function mockApi(page: Page) {
7: { id: 7, title: '历史会话', course_version_id: 1, batch_id: null, project_id: null,
scope_locked: true, messages: [{ id: 1, role: 'user', content: '旧问题' }, { id: 2, role: 'assistant',
content: '# 标题\n\n段落 [安全链接](https://example.com) [危险链接](javascript:alert(1)) <img src=x onerror=alert(1)>\n\n> 引用\n\n- 列表\n\n| A | B |\n|---|---|\n| 1 | 2 |\n\n```js\nconst x = 1;\n```',
citations: [{ title: '资料.pdf', page: 2, quote: '原文摘录', url: '/api/pages/2' }, { title: '资料.pdf', page: 2, quote: '原文摘录', url: '/api/pages/2' }],
tool_calls: [{ id: 'call-one', name: 'list_courses', status: 'completed', arguments: { course_version_id: 1 }, result: '返回 1 条记录' }] }],
citations: [{ title: '资料.pdf', page: 2, quote: '原文摘录', url: '/api/pages/2' }, { title: '资料.pdf', page: 2, quote: '原文摘录', url: '/api/pages/2' },
{ chunk_id: 2977, title: '综合报告.pdf', page: 48, quote: '第48页原文片段', url: '/api/pages/48' }],
tool_calls: [{ id: 'call-one', name: 'search_project_material', status: 'completed', arguments: { project_id: 1 },
summary: '找到 1 条引用', result: { found: true, citations: [{ page: 48, quote: '第48页原文片段' }] } },
{ id: 'call-two', name: 'get_scores', status: 'completed', arguments: { batch_id: 3, criterion_offset: 0 },
summary: '返回 1 项评分', result: { next_criterion_offset: 6, results: [{ rows: [{ criterion_id: 'C1', final_score: 8 }] }] } },
{ id: 'call-legacy', name: 'search_project_material', status: 'completed', arguments: { project_id: 1 },
summary: '找到 6 条引用', result: { found: true, citations: { count: 6 } } }] }],
actions: [], runs: [{ id: 1, status: 'completed', tool_calls: [] }] },
};
let nextId = 8;
@@ -85,11 +91,32 @@ test('markdown, citations, tool folding and narrow layout are safe', async ({ pa
await expect(page.locator('.chat-markdown pre')).toBeVisible();
await expect(page.locator('.chat-markdown img')).toHaveCount(0);
await expect(page.getByText('危险链接')).not.toHaveAttribute('href');
await expect(page.getByText('引用 · 1')).toBeVisible();
await expect(page.getByText('资料.pdf')).toHaveCount(1);
await expect(page.locator('.tool-detail')).not.toBeVisible();
await page.locator('.chat-tool summary').click();
await expect(page.locator('.tool-detail')).toBeVisible();
await expect(page.getByText('引用 · 2')).toBeVisible();
await expect(page.getByText('资料.pdf')).not.toBeVisible();
await expect(page.getByText('后台进度')).toHaveCount(0);
await expect(page.locator('.chat-tools')).toBeVisible();
await expect(page.locator('.chat-tool').first()).not.toBeVisible();
await expect(page.locator('.tool-detail').first()).not.toBeVisible();
await page.locator('.chat-tool-group > summary').click();
await expect(page.locator('.chat-tool').first()).toBeVisible();
await expect(page.locator('.tool-detail').first()).not.toBeVisible();
await page.locator('.chat-tool summary').first().click();
await expect(page.locator('.tool-detail').first()).toBeVisible();
await expect(page.locator('.tool-detail').first()).toContainText('第48页原文片段');
await page.locator('.chat-tool').nth(1).locator('summary').click();
await expect(page.locator('.chat-tool').nth(1).locator('.tool-detail')).toContainText('C1');
await page.locator('.chat-tool').nth(2).locator('summary').click();
await expect(page.locator('.chat-tool').nth(2).locator('.legacy-evidence')).toContainText('第48页原文片段');
await page.getByRole('button', { name: '查看本轮已核验原文' }).click();
await expect(page.locator('.chat-citation-group')).toHaveAttribute('open', '');
await expect(page.getByText('资料.pdf')).toBeVisible();
await expect(page.getByText('第 48 页').last()).toBeVisible();
const positions = await page.evaluate(() => {
const card = document.querySelector('.chat-assistant .ant-card-body');
return ['.chat-tools', '.chat-markdown', '.chat-citations'].map(selector =>
Array.from(card?.children || []).findIndex(child => child.matches(selector)));
});
expect(positions).toEqual([0, 1, 2]);
await page.setViewportSize({ width: 390, height: 844 });
await expect(page.locator('.ant-layout-sider')).toHaveClass(/ant-layout-sider-zero-width/);
await expect(page.locator('.chat-scope .ant-select').first()).toBeVisible();