From c7d1ba1cd6e67da9813512c9b7397d94a8bacfc4 Mon Sep 17 00:00:00 2001 From: SkyLi Date: Sun, 27 Sep 2026 12:06:44 +0800 Subject: [PATCH] fix: restore chat citations and tool details --- HANDOFF.md | 8 ++++ README.md | 2 +- backend/tests/test_chat_agent.py | 32 ++++++++++++- backend/tests/test_chat_sessions.py | 74 +++++++++++++++++++++++++++++ backend/wisegrade/agent_tools.py | 56 ++++++++++++++-------- backend/wisegrade/chat_api.py | 73 ++++++++++++++++++++++++---- docs/CHAT_AGENT.md | 6 +-- frontend/src/Chat.tsx | 44 +++++++++-------- frontend/src/style.css | 8 +++- frontend/tests/chat.spec.ts | 41 +++++++++++++--- 10 files changed, 281 insertions(+), 63 deletions(-) diff --git a/HANDOFF.md b/HANDOFF.md index 6c3e087..2466fe2 100644 --- a/HANDOFF.md +++ b/HANDOFF.md @@ -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 大块警告。 diff --git a/README.md b/README.md index 4f11503..52fc274 100644 --- a/README.md +++ b/README.md @@ -28,4 +28,4 @@ WiseGrade 是面向《自动化专业综合实践》课程的教师端自动评 2. 将离线嵌入模型放在 `models/bge-m3/`(或修改 `EMBEDDING_MODEL_PATH`)。 3. 运行 `docker compose up`,访问 。 -打开系统默认进入空白“会话”草稿;点击“新建”也只切换草稿,发送第一条消息才保存会话。历史会话可从列表继续或删除;查询可直接执行,导入、入库、建批、启动、复核和导出先展示确认卡片,由教师点击后执行。学生材料只从只读挂载的 `/data/inbox` 读取。没有本地嵌入模型或 API 密钥时,系统仍可启动和完成导入;索引、会话或评分会显示明确错误。详细部署和使用步骤见 [部署与使用指南](docs/DEPLOYMENT.md)。 +打开系统默认进入空白“会话”草稿;点击“新建”也只切换草稿,发送第一条消息才保存会话。历史会话可从列表继续或删除;回答上方可逐层展开工具调用,末尾可展开引用原文与页面链接。查询可直接执行,导入、入库、建批、启动、复核和导出先展示确认卡片,由教师点击后执行。学生材料只从只读挂载的 `/data/inbox` 读取。没有本地嵌入模型或 API 密钥时,系统仍可启动和完成导入;索引、会话或评分会显示明确错误。详细部署和使用步骤见 [部署与使用指南](docs/DEPLOYMENT.md)。 diff --git a/backend/tests/test_chat_agent.py b/backend/tests/test_chat_agent.py index 64a2a1a..f3fa7d0 100644 --- a/backend/tests/test_chat_agent.py +++ b/backend/tests/test_chat_agent.py @@ -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) diff --git a/backend/tests/test_chat_sessions.py b/backend/tests/test_chat_sessions.py index 4f78f51..57380ca 100644 --- a/backend/tests/test_chat_sessions.py +++ b/backend/tests/test_chat_sessions.py @@ -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): diff --git a/backend/wisegrade/agent_tools.py b/backend/wisegrade/agent_tools.py index a5b3859..58ae2c8 100644 --- a/backend/wisegrade/agent_tools.py +++ b/backend/wisegrade/agent_tools.py @@ -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")), diff --git a/backend/wisegrade/chat_api.py b/backend/wisegrade/chat_api.py index 9ee3bbd..02e8106 100644 --- a/backend/wisegrade/chat_api.py +++ b/backend/wisegrade/chat_api.py @@ -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() diff --git a/docs/CHAT_AGENT.md b/docs/CHAT_AGENT.md index 59564d4..c4e4eaf 100644 --- a/docs/CHAT_AGENT.md +++ b/docs/CHAT_AGENT.md @@ -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 缺失,任务保留具体失败原因;补齐离线模型后重试。若课程资料缺失、原件哈希变化、课程或批次状态变化,应重新预览。 diff --git a/frontend/src/Chat.tsx b/frontend/src/Chat.tsx index 49c6c6d..bdab88d 100644 --- a/frontend/src/Chat.tsx +++ b/frontend/src/Chat.tsx @@ -90,31 +90,39 @@ function Answer({ text }: { text: string }) { img: ({ alt, src }) => safeUrl(src) ? {alt || '查看图片'} : {alt} }}> {text || ''}; } -function Citations({ items }: { items: any[] }) { +function CitationCards({ items }: { items: any[] }) { + return
{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
{name}{place && {place}}
+ {quote && (quote.length > 320 ?
{quote.slice(0, 240)}… 展开原文

{quote}

:

{quote}

)} + {safeUrl(item.url) && 打开页面} + {safeUrl(item.image_url) && 查看图像}
; + })}
; +} +function Citations({ items, messageId }: { items: any[]; messageId: number }) { const seen = new Set(); 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
引用 · {unique.length}
- {unique.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
{name}{place && {place}}
- {quote && (quote.length > 320 ?
{quote.slice(0, 240)}… 展开原文

{quote}

:

{quote}

)} - {safeUrl(item.url) && 打开页面} - {safeUrl(item.image_url) && 查看图像}
; - })}
; + return
引用 · {unique.length}
; } -function Tools({ calls }: { calls: any[] }) { +function Tools({ calls, messageId, legacyEvidence = [] }: { calls: any[]; messageId?: number; legacyEvidence?: any[] }) { if (!calls?.length) return null; - return
工具调用 · {calls.length} - {calls.map((call, index) =>
{call.name || '未知工具'} + const active = calls.filter(call => call.status === 'running').length; + const failed = calls.filter(call => call.status === 'failed').length; + return
工具调用 · {calls.length} + {active > 0 && {active} 项进行中}{failed > 0 && {failed} 项失败} +
{calls.map((call, index) =>
{call.name || '未知工具'} {call.status === 'running' ? '进行中' : call.status === 'failed' ? '失败' : '成功'} {call.status === 'running' ? '正在执行' : call.error ? String(call.error).slice(0, 80) : call.summary || (typeof call.result === 'string' ? call.result : '已完成')}
调用参数
{JSON.stringify(call.arguments || {}, null, 2)}
- {call.error ? '错误' : '调用结果'}
{call.error || (typeof call.result === 'string' ? call.result : JSON.stringify(call.result ?? '运行中', null, 2))}
-
)}
; + {call.error ? '错误' : '调用结果'}
{call.error || (typeof call.result === 'string' ? call.result : JSON.stringify(call.result ?? '运行中', null, 2))}
+ {call.result?.citations?.count !== undefined &&
旧运行只保存了引用数量,无法还原各次检索对应的原文。{messageId && } + {legacyEvidence.length > 0 &&
本轮回答已核验的相关原文(无法确认属于当前这一次检索)
}
} + {call.result?.truncated && 旧运行的原始结果未保存。{call.result?.rechecked_current ? '附带的是当前数据库重查结果,不代表当时模型看到的内容。' : '再次提问可使用分页评分查询。'}} +
)}
; } export default function Chat() { const [conversations, setConversations] = useState([]); @@ -131,7 +139,6 @@ export default function Chat() { const [versions, setVersions] = useState([]); const [batches, setBatches] = useState([]); const [projects, setProjects] = useState([]); - const [indexTasks, setIndexTasks] = useState([]); const selection = useRef(`draft-${token}`); const sending = useRef>(new Set()); const deletedIds = useRef>(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() {