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 正式启动复验 ## 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` 现在可直接用于正式启动,访问本机配置端口。 从当前源码成功执行 `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`)。 2. 将离线嵌入模型放在 `models/bge-m3/`(或修改 `EMBEDDING_MODEL_PATH`)。
3. 运行 `docker compose up`,访问 <http://127.0.0.1:18080>。 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 fastapi.testclient import TestClient
from sqlalchemy import select 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.actions import confirm, prepare
from wisegrade.agent_tools import execute from wisegrade.agent_tools import execute
from wisegrade.db import get_db from wisegrade.db import get_db
@@ -70,6 +70,36 @@ def test_tool_scope_snapshots_and_project_citations(db):
assert rules["source"] == "batch_snapshot" 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): def test_import_confirmation_hash_change_index_and_idempotency(environment, db, monkeypatch, session_factory):
folder = environment / "inbox" / "batch" / "123450003_张三_机器人" folder = environment / "inbox" / "batch" / "123450003_张三_机器人"
folder.mkdir(parents=True) 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 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 chat_api._result_summary({"found": True, "citations": [{"quote": "学生原文"}]})
assert "学生原文" not in str(chat_api._safe_result({"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): 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) batch_id: int = Field(gt=0)
class ScoresArgs(BatchId): class ScoresFilterArgs(BatchId):
project_id: int | None = Field(default=None, gt=0) project_id: int | None = Field(default=None, gt=0)
status: Literal["pending", "scored", "reviewed", "failed", "unscorable"] | None = None 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): class CreateBatch(VersionId):
rubric_version_id: int = Field(gt=0) rubric_version_id: int = Field(gt=0)
@@ -254,30 +259,39 @@ def scores(db, convo, args, _user):
if convo.project_id: if convo.project_id:
selected = [p for p in selected if p.id == convo.project_id] selected = [p for p in selected if p.id == convo.project_id]
results = [] results = []
citations = []
for item in selected: for item in selected:
result = project_result(db, batch, item) result = project_result(db, batch, item)
if args.status: if args.status:
result["rows"] = [row for row in result["rows"] if row["status"] == args.status] result["rows"] = [row for row in result["rows"] if row["status"] == args.status]
if result["rows"]: if result["rows"]:
result["url"] = f"/?page=scores&batch_id={batch.id}&project_id={item.id}" results.append((item, result))
results.append({"project_id": item.id, "student_no": result["student_no"], "name": result["name"], page = results[args.project_offset:args.project_offset + 1]
"totals": result["totals"], "url": result["url"], output = []
"rows": [{"criterion_id": row["criterion"]["criterion_id"], citations = []
"criterion_name": row["criterion"]["name"], 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"], "max_score": row["criterion"]["max_score"], "score_id": row["score_id"],
"model_score": row["model_score"], "teacher_score": row["teacher_score"], "model_score": row["model_score"], "teacher_score": row["teacher_score"],
"final_score": row["final_score"], "status": row["status"], "final_score": row["final_score"], "status": row["status"],
"error": row["error"], "evidence": [{"page": ev["page"], "error": str(row["error"] or "")[:300], "evidence": [{"page": ev["page"],
"quote": ev["quote"][:240], "document": ev["document"]} "quote": ev["quote"][:160], "document": str(ev["document"])[:200]}
for ev in row["evidence"][:2]]} for row in result["rows"]]}) for ev in row["evidence"][:2]]} for row in rows]})
for row in result["rows"]: for row in rows:
for evidence in row["evidence"][:2]: for evidence in row["evidence"][:2]:
citations.append({"title": evidence["document"], "page": evidence["page"], citations.append({"title": str(evidence["document"])[:200], "page": evidence["page"],
"quote": evidence["quote"][:500], "quote": evidence["quote"][:320],
"url": evidence["image_url"] or result["url"]}) "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), 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): def summary(db, convo, args, _user):
@@ -411,8 +425,8 @@ TOOLS: dict[str, Tool] = {
"search_project_material": Tool("只检索指定学生项目报告,返回原文片段和引用。", SearchProject, search_project), "search_project_material": Tool("只检索指定学生项目报告,返回原文片段和引用。", SearchProject, search_project),
"list_batches": Tool("列出可访问的评分批次。", Empty, batch_list), "list_batches": Tool("列出可访问的评分批次。", Empty, batch_list),
"get_batch_progress": Tool("查询评分批次进度、状态和固定项目范围。", BatchId, progress), "get_batch_progress": Tool("查询评分批次进度、状态和固定项目范围。", BatchId, progress),
"get_scores": Tool("查询单学生或单批次评分详情,可按评分项状态筛选。", ScoresArgs, scores), "get_scores": Tool("分页查询单学生或单批次评分详情,可按状态筛选。每次返回一个项目、最多六项评分;如需其余评分项,用 next_criterion_offset 作为 criterion_offset 再查;如需下个项目,用 next_project_offset 作为 project_offset,并将 criterion_offset 设为 0。", ScoresArgs, scores),
"summarize_batch": Tool("用数据库计算批次或单学生完成数、未完成数和最终分布,可按评分项状态筛选,不补零。", ScoresArgs, summary), "summarize_batch": Tool("用数据库计算批次或单学生完成数、未完成数和最终分布,可按评分项状态筛选,不补零。", ScoresFilterArgs, summary),
"list_index_tasks": Tool("查询课程版本的材料解析与索引任务进度。", VersionId, index_tasks), "list_index_tasks": Tool("查询课程版本的材料解析与索引任务进度。", VersionId, index_tasks),
"prepare_import": Tool("从只读扫描结果准备导入确认卡片;每页最多20个项目,文件有歧义时要求教师在卡片中修正。", PrepareImport, prepare_import), "prepare_import": Tool("从只读扫描结果准备导入确认卡片;每页最多20个项目,文件有歧义时要求教师在卡片中修正。", PrepareImport, prepare_import),
"prepare_course_ingest": Tool("准备课程资料入库确认卡片。", VersionId, prepare_kind("course_ingest")), "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 "待确认操作已准备" return f"待确认操作 #{action.get('id', '—')}" if isinstance(action, dict) else "待确认操作已准备"
if "found" in result: if "found" in result:
return f"找到 {len(result.get('citations') or [])} 条引用" if result["found"] else "未找到相关资料" 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"): for key in ("courses", "versions", "projects", "scores", "batches", "items"):
if isinstance(result.get(key), list): if isinstance(result.get(key), list):
return f"返回 {len(result[key])} 条{key}记录" return f"返回 {len(result[key])} 条{key}记录"
return "工具已返回结果" return "工具已返回结果"
def _safe_result(result): def _safe_citation(item):
"""Persist a small structural preview; original student text stays in citations or business records.""" 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): if not isinstance(result, dict):
return {"summary": "工具已返回结果"} 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_text = {"status", "source", "format", "kind", "category"}
allowed_number = {"id", "course_version_id", "batch_id", "project_id", "score_id", allowed_number = {"id", "course_version_id", "batch_id", "project_id", "score_id",
"task_id", "page", "count", "total", "progress_done", "progress_total"} "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") content = reply.get("content")
if not isinstance(content, str) or not content.strip(): if not isinstance(content, str) or not content.strip():
raise ModelError("会话模型未返回文字或工具调用") raise ModelError("会话模型未返回文字或工具调用")
answer_citations = _answer_citations(citations)
db.add(ChatMessage(conversation_id=conversation_id, role="assistant", content=content, 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() run.status, run.finished_at = "completed", now()
conversation.updated_at = 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} done = {"run_id": run.id}
run.events = [*(run.events or []), {"event": "message", "data": answer}, run.events = [*(run.events or []), {"event": "message", "data": answer},
{"event": "done", "data": done}][-140:] {"event": "done", "data": done}][-140:]
@@ -293,12 +348,12 @@ def _run_agent(factory, conversation_id, run_id, user_text):
if isinstance(result, dict): if isinstance(result, dict):
citations.extend(result.get("citations", [])) citations.extend(result.get("citations", []))
output = json.dumps(result, ensure_ascii=False, default=str) output = json.dumps(result, ensure_ascii=False, default=str)
truncated = len(output) > 12000 if len(output) > 12000:
if truncated: output = json.dumps({"notice": "结果已按结构压缩;可使用工具分页参数继续查询",
output = json.dumps({"truncated": True, "message": "结果过长,请缩小查询范围"}, ensure_ascii=False) **_safe_result(result, name)}, ensure_ascii=False, default=str)
messages.append({"role": "tool", "tool_call_id": call_id, "content": output}) messages.append({"role": "tool", "tool_call_id": call_id, "content": output})
entry = {**entry, "status": "completed", "summary": "结果过长,已截断" if truncated else _result_summary(result), entry = {**entry, "status": "completed", "summary": _result_summary(result),
"result": {"truncated": True} if truncated else _safe_result(result)} "result": _safe_result(result, name)}
except Exception as exc: except Exception as exc:
# Tool errors are model-visible, but do not grant broader access or leak provider responses. # Tool errors are model-visible, but do not grant broader access or leak provider responses.
db.rollback() db.rollback()
+3 -3
View File
@@ -6,7 +6,7 @@
打开系统默认进入空白“会话”草稿,点击“新建”立即切换到另一空白草稿,不弹窗、不写数据库。可在输入区直接提问,并在首次发送前反复选择或清除课程版本、评分批次和学生项目。发送首条非空消息时,会话和消息在同一事务保存;此后范围固定且在界面显示为只读。旧版留下的空会话仍可编辑范围。选择批次或项目会自动带入所属课程版本,清除课程版本会同时清除下级选择。范围固定后,工具不能读取范围之外的对象。会话列表、消息、引用、待确认操作和运行状态保存在数据库,刷新后可查看历史会话;刷新或再次进入页面仍打开新草稿。 打开系统默认进入空白“会话”草稿,点击“新建”立即切换到另一空白草稿,不弹窗、不写数据库。可在输入区直接提问,并在首次发送前反复选择或清除课程版本、评分批次和学生项目。发送首条非空消息时,会话和消息在同一事务保存;此后范围固定且在界面显示为只读。旧版留下的空会话仍可编辑范围。选择批次或项目会自动带入所属课程版本,清除课程版本会同时清除下级选择。范围固定后,工具不能读取范围之外的对象。会话列表、消息、引用、待确认操作和运行状态保存在数据库,刷新后可查看历史会话;刷新或再次进入页面仍打开新草稿。
可先问“列出课程版本”“扫描 batch 目录”“查看批次 3 的失败评分项”“查询项目 12 的报告实验数据”。课程目标与规则来自数据库;指定批次时使用批次创建时的快照。课程资料和报告通过 BGE-M3/pgvector 检索,回答附资料名、页码或幻灯片编号、原文片段和可打开的页面链接。没有证据时应明确说明。 可先问“列出课程版本”“扫描 batch 目录”“查看批次 3 的失败评分项”“查询项目 12 的报告实验数据”。课程目标与规则来自数据库;指定批次时使用批次创建时的快照。课程资料和报告通过 BGE-M3/pgvector 检索,回答附资料名、页码或幻灯片编号、原文片段和可打开的页面链接。没有证据时应明确说明。正式回答上方的“工具调用”先整体折叠,展开后每次调用仍独立折叠,可逐项查看参数和结果;回答末尾的“引用”也默认整体折叠。会话页不显示全局后台进度卡片,正在运行的会话仍在列表中标记。
扫描只读,可直接执行。导入、课程资料入库、创建批次、启动或重试、教师复核、导出和索引重试会生成确认卡片。卡片列出目标、范围、现值、新值及异常;教师点击“确认执行”才写入。模型工具白名单没有确认工具,聊天文字也不能代替按钮。导入候选有重复、识别异常或待确认文件时,卡片禁用确认;教师在卡片中修正身份、类别和文件选择,再“更新预览”。导入确认同时授权后台解析、必要 OCR 和项目索引。 扫描只读,可直接执行。导入、课程资料入库、创建批次、启动或重试、教师复核、导出和索引重试会生成确认卡片。卡片列出目标、范围、现值、新值及异常;教师点击“确认执行”才写入。模型工具白名单没有确认工具,聊天文字也不能代替按钮。导入候选有重复、识别异常或待确认文件时,卡片禁用确认;教师在卡片中修正身份、类别和文件选择,再“更新预览”。导入确认同时授权后台解析、必要 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 行为。 会话使用模型设置页的 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 与故障恢复 ## 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 秒读取超时。 草稿仅存在于浏览器;`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> }}> img: ({ alt, src }) => safeUrl(src) ? <a href={safeUrl(src)} target="_blank" rel="noopener noreferrer">{alt || '查看图片'}</a> : <span>{alt}</span> }}>
{text || ''}</ReactMarkdown></div>; {text || ''}</ReactMarkdown></div>;
} }
function Citations({ items }: { items: any[] }) { function CitationCards({ items }: { items: any[] }) {
const seen = new Set<string>(); return <div className="citation-grid">{items.map((item: any, index) => { const name = String(item.title || item.document || '未命名资料');
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 || '未命名资料');
const page = item.page ?? item.slide; const place = page == null ? '' : `第 ${page} ${/\.pptx?$/i.test(name) ? '张幻灯片' : '页'}`; const page = item.page ?? item.slide; const place = page == null ? '' : `第 ${page} ${/\.pptx?$/i.test(name) ? '张幻灯片' : '页'}`;
const quote = typeof item.quote === 'string' ? item.quote.trim() : ''; 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> 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>)} {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>} <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>; {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; if (!calls?.length) return null;
return <section className="chat-tools" aria-label="工具调用"><Text type="secondary">工具调用 · {calls.length}</Text> const active = calls.filter(call => call.status === 'running').length;
{calls.map((call, index) => <details key={call.id || index} className="chat-tool"><summary><Space wrap size="small"><Text code>{call.name || '未知工具'}</Text> 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> <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> <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> <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> <Text strong>{call.error ? '错误' : '调用结果'}</Text><pre>{call.error || (typeof call.result === 'string' ? call.result : JSON.stringify(call.result ?? '运行中', null, 2))}</pre>
</details>)}</section>; {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() { export default function Chat() {
const [conversations, setConversations] = useState<any[]>([]); const [conversations, setConversations] = useState<any[]>([]);
@@ -131,7 +139,6 @@ export default function Chat() {
const [versions, setVersions] = useState<any[]>([]); const [versions, setVersions] = useState<any[]>([]);
const [batches, setBatches] = useState<any[]>([]); const [batches, setBatches] = useState<any[]>([]);
const [projects, setProjects] = useState<any[]>([]); const [projects, setProjects] = useState<any[]>([]);
const [indexTasks, setIndexTasks] = useState<any[]>([]);
const selection = useRef<string>(`draft-${token}`); const selection = useRef<string>(`draft-${token}`);
const sending = useRef<Set<string>>(new Set()); const sending = useRef<Set<string>>(new Set());
const deletedIds = useRef<Set<number>>(new Set()); const deletedIds = useRef<Set<number>>(new Set());
@@ -157,7 +164,6 @@ export default function Chat() {
useEffect(() => { localStorage.removeItem('wisegrade_conversation_id'); refresh(); 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('/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('/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 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) => { 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> <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>} {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' ? '教师' : '智评'}> </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' ? <><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>}
{item.role === 'assistant' && <><Citations items={item.citations} /><Tools calls={item.tool_calls} /></>}
</Card>)} </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>}> {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>)} <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> {!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)} />)} {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> <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); }}> </Col></Row><Modal open={deleteId !== undefined} title="删除会话?" okText="删除会话" okButtonProps={{ danger: true, loading: deleting }} cancelText="取消" onOk={remove} onCancel={() => { setDeleteError(''); setDeleteId(undefined); }}>
<Paragraph>会话消息、工具记录和待确认卡片将被删除。已确认的业务操作与审计记录仍保留。</Paragraph> <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 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-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-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-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-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-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 p { margin: 5px 0; color: #44566d; white-space: pre-wrap; }
.citation-card summary { cursor: pointer; color: #44566d; overflow-wrap: anywhere; } .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 { 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; } .chat-tool summary { cursor: pointer; list-style-position: inside; overflow-wrap: anywhere; }
.tool-detail { margin-top: 8px; padding-left: 12px; } .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, 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', 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```', 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' }], 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 条记录' }] }], { 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: [] }] }, actions: [], runs: [{ id: 1, status: 'completed', tool_calls: [] }] },
}; };
let nextId = 8; 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 pre')).toBeVisible();
await expect(page.locator('.chat-markdown img')).toHaveCount(0); await expect(page.locator('.chat-markdown img')).toHaveCount(0);
await expect(page.getByText('危险链接')).not.toHaveAttribute('href'); await expect(page.getByText('危险链接')).not.toHaveAttribute('href');
await expect(page.getByText('引用 · 1')).toBeVisible(); await expect(page.getByText('引用 · 2')).toBeVisible();
await expect(page.getByText('资料.pdf')).toHaveCount(1); await expect(page.getByText('资料.pdf')).not.toBeVisible();
await expect(page.locator('.tool-detail')).not.toBeVisible(); await expect(page.getByText('后台进度')).toHaveCount(0);
await page.locator('.chat-tool summary').click(); await expect(page.locator('.chat-tools')).toBeVisible();
await expect(page.locator('.tool-detail')).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 page.setViewportSize({ width: 390, height: 844 });
await expect(page.locator('.ant-layout-sider')).toHaveClass(/ant-layout-sider-zero-width/); await expect(page.locator('.ant-layout-sider')).toHaveClass(/ant-layout-sider-zero-width/);
await expect(page.locator('.chat-scope .ant-select').first()).toBeVisible(); await expect(page.locator('.chat-scope .ant-select').first()).toBeVisible();