From e5bf1cf2502b9dbd33b96eab9b5d7da412cad58d Mon Sep 17 00:00:00 2001 From: SkyLi Date: Sun, 27 Sep 2026 14:47:55 +0800 Subject: [PATCH] fix: show tool parameters and skip duplicate calls --- HANDOFF.md | 6 ++ backend/tests/test_chat_agent.py | 69 ++++++++++++++++++++- backend/tests/test_chat_sessions.py | 10 +-- backend/wisegrade/chat_api.py | 96 ++++++++++++++++++++--------- docs/CHAT_AGENT.md | 4 +- frontend/src/Chat.tsx | 32 +++++++++- frontend/tests/chat.spec.ts | 17 ++++- 7 files changed, 192 insertions(+), 42 deletions(-) diff --git a/HANDOFF.md b/HANDOFF.md index 4d3cc82..d0fc3e6 100644 --- a/HANDOFF.md +++ b/HANDOFF.md @@ -98,3 +98,9 @@ uv run pytest -q 后续排查确认“旧结果未保存”来自 6 号会话三条已持久化的工具摘要,而非前端旧版本;这些摘要现已改为简短的评分项数量,会话本身仍在。 隔离 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` 更新;新评分工具对限定范围返回六项,序列化后 6,820 字符。用真实项目检索“视觉检测 Otsu 光照鲁棒性 第48页”返回六条带原文与链接的结果,其中包含第 48 页。前端产物仍有 Vite 大块警告。 + +## 2026-09-27 工具参数与重复调用 + +7 号会话的四次 `get_scores` 实际分别读取评分项偏移量 0、6、12、18,旧工具记录只保存了批次与项目 ID,造成界面上看似相同参数。新记录保存经校验的默认值、查询词、筛选及分页参数;旧评分记录从结果恢复实际偏移量。7 号会话旧检索记录的查询词没有持久化,无法准确还原,界面会显示“历史调用未保存查询词”。同一轮内同名工具的相同有效参数只执行一次,后续调用标为“已跳过”,不同分页或查询正常执行。复核评语与原因继续省略,未知字段及密钥不保存。 + +隔离 PostgreSQL 测试容器执行 `pytest -q -m 'not integration'`:37 通过、2 个集成测试未运行;末次后端微调另运行针对性测试,1 通过。前端 `npm run build` 通过,Playwright Chromium `npm run test:e2e`:3 通过。测试覆盖注册工具参数字段、查询词与敏感文本边界、相同参数跳过、不同分页和查询正常执行,以及浏览器中旧记录偏移量恢复与参数展示。API 与 Web 镜像已构建并更新,两个容器均健康;正式页面通过全新 Chromium 会话打开 7 号会话,四次评分调用显示偏移量 0、6、12、18,旧检索调用显示未保存查询词提示。 diff --git a/backend/tests/test_chat_agent.py b/backend/tests/test_chat_agent.py index f3fa7d0..5e26e1b 100644 --- a/backend/tests/test_chat_agent.py +++ b/backend/tests/test_chat_agent.py @@ -267,7 +267,8 @@ def test_sse_persists_messages_and_tool_failure(session_factory, monkeypatch): assert [m["role"] for m in restored["messages"]] == ["user", "assistant"] assert len(restored["messages"][1]["tool_calls"]) == 9 assert len({call["id"] for call in restored["messages"][1]["tool_calls"]}) == 9 - assert all(call["status"] == "completed" and call["result"] for call in restored["messages"][1]["tool_calls"]) + assert [call["status"] for call in restored["messages"][1]["tool_calls"]] == ["completed", *["skipped"] * 8] + assert all(call["result"] for call in restored["messages"][1]["tool_calls"]) assert restored["runs"][0]["status"] == "completed" monkeypatch.setattr(chat_api, "client_for_chat", lambda db: (_ for _ in ()).throw(ModelError("不支持工具调用"))) with client.stream("POST", f"/api/conversations/{created['id']}/messages", json={"content": "再试"}) as response: @@ -285,6 +286,72 @@ def test_sse_persists_messages_and_tool_failure(session_factory, monkeypatch): app.dependency_overrides.clear() +def test_tool_arguments_cover_registered_fields_without_review_prose(): + numbers = {"course_version_id", "batch_id", "project_id", "score_id", "task_id", "rubric_version_id", + "score", "offset", "limit", "project_offset", "criterion_offset"} + strings = {"query", "criterion_id", "format", "status", "student_no", "relative_dir"} + redacted = {"comment", "reason"} + fields = set().union(*(tool.schema.model_fields for tool in agent_tools.TOOLS.values())) + assert fields == numbers | strings | redacted + args = {key: 8 for key in numbers} | {key: "电机控制" for key in strings} | {key: "学生原文" for key in redacted} + saved = chat_api._safe_arguments(args) + assert set(saved) == fields + assert saved["query"] == "电机控制" and saved["criterion_offset"] == 8 + assert saved["comment"] == saved["reason"] == "[文本已省略]" + assert "学生原文" not in json.dumps(saved, ensure_ascii=False) + assert chat_api._safe_arguments({"api_key": "secret"}) == {"omitted_fields": 1} + + +def test_identical_tool_calls_are_skipped_but_distinct_pages_and_queries_run(session_factory, monkeypatch): + requested = [ + ("get_scores", {"batch_id": 3, "project_id": 1}), + ("get_scores", {"batch_id": 3, "project_id": 1, "project_offset": 0, "criterion_offset": 0}), + ("get_scores", {"batch_id": 3, "project_id": 1, "criterion_offset": 6}), + ("search_project_material", {"project_id": 1, "query": "电机控制"}), + ("search_project_material", {"query": "电机控制", "project_id": 1}), + ("search_project_material", {"project_id": 1, "query": "视觉方案"}), + ] + class FakeClient: + def tool_call(self, messages, _tools): + if any(message["role"] == "tool" for message in messages): + return {"role": "assistant", "content": "查询完成", "tool_calls": []} + return {"role": "assistant", "content": None, "tool_calls": [ + {"id": f"call-{index}", "type": "function", + "function": {"name": name, "arguments": json.dumps(args, ensure_ascii=False)}} + for index, (name, args) in enumerate(requested)]} + executed = [] + def fake_execute(_db, _conversation, name, arguments, _text): + executed.append((name, arguments)) + return {"found": True, "citations": []} if name.startswith("search_") else { + "count": 1, "results": [], "project_offset": arguments.get("project_offset", 0), + "criterion_offset": arguments.get("criterion_offset", 0)} + monkeypatch.setattr(chat_api, "client_for_chat", lambda _db: FakeClient()) + monkeypatch.setattr(chat_api, "execute", fake_execute) + def override_db(): + with session_factory() as session: + yield session + app.dependency_overrides[get_db] = override_db + try: + client = TestClient(app) + created = client.post("/api/conversations", json={"title": "查重"}).json() + with client.stream("POST", f"/api/conversations/{created['id']}/messages", json={"content": "查询"}) as response: + body = "".join(response.iter_text()) + assert "event: done" in body + assert [(name, args.get("criterion_offset", 0), args.get("query")) for name, args in executed] == [ + ("get_scores", 0, None), ("get_scores", 6, None), + ("search_project_material", 0, "电机控制"), ("search_project_material", 0, "视觉方案")] + restored = client.get(f"/api/conversations/{created['id']}").json() + calls = restored["messages"][1]["tool_calls"] + assert [call["status"] for call in calls] == ["completed", "skipped", "completed", "completed", "skipped", "completed"] + assert calls[0]["arguments"]["criterion_offset"] == 0 + assert calls[2]["arguments"]["criterion_offset"] == 6 + assert calls[3]["arguments"]["query"] == "电机控制" + assert calls[4]["result"]["previous_call_id"] == calls[3]["id"] + assert restored["runs"][0]["tool_calls"] == calls + finally: + app.dependency_overrides.clear() + + def test_failed_tool_calls_count_toward_limit(monkeypatch): class RepeatingClient: def tool_call(self, messages, tools): diff --git a/backend/tests/test_chat_sessions.py b/backend/tests/test_chat_sessions.py index 57380ca..7482e3d 100644 --- a/backend/tests/test_chat_sessions.py +++ b/backend/tests/test_chat_sessions.py @@ -162,10 +162,12 @@ def test_background_runs_survive_no_stream_and_isolate_parallel_events(session_f app.dependency_overrides.clear() -def test_tool_record_redacts_text_and_secrets(): - saved = chat_api._safe_arguments({"project_id": 2, "query": "学生原文", "api_key": "secret", "comment": "敏感评语"}) +def test_tool_record_keeps_bounded_query_but_redacts_review_text_and_secrets(): + saved = chat_api._safe_arguments({"project_id": 2, "query": "电机控制", "api_key": "secret", "comment": "敏感评语"}) assert saved["project_id"] == 2 - assert "学生原文" not in str(saved) and "secret" not in str(saved) and "敏感评语" not in str(saved) + assert saved["query"] == "电机控制" + assert "secret" not in str(saved) and "敏感评语" not in str(saved) + assert len(chat_api._safe_arguments({"query": "a" * 500})["query"]) == 300 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, @@ -180,7 +182,7 @@ def test_late_citations_and_search_snippets_survive_restoration(session_factory, 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)]} + "arguments": f'{{"project_id": 1, "query": "检索原文{index}"}}'}} for index in range(5)]} return {"role": "assistant", "content": "综合报告第48页给出改进方向。", "tool_calls": []} count = 0 diff --git a/backend/wisegrade/chat_api.py b/backend/wisegrade/chat_api.py index 02e8106..611329d 100644 --- a/backend/wisegrade/chat_api.py +++ b/backend/wisegrade/chat_api.py @@ -1,6 +1,5 @@ """Persistent teacher conversations and SSE agent orchestration.""" import json -import re import time from concurrent.futures import ThreadPoolExecutor from datetime import timedelta @@ -9,13 +8,13 @@ from uuid import UUID from fastapi import APIRouter, Depends, HTTPException from fastapi.responses import StreamingResponse -from pydantic import BaseModel, Field +from pydantic import BaseModel, Field, ValidationError from sqlalchemy import delete, select from sqlalchemy.exc import IntegrityError from sqlalchemy.orm import Session, sessionmaker from .actions import action_dict, confirm, prepare, require_scope -from .agent_tools import execute, tool_specs +from .agent_tools import TOOLS, execute, tool_specs from .config import get_settings from .db import get_db from .llm import ModelError @@ -31,7 +30,8 @@ SYSTEM = """你是 WiseGrade 教师助手。仅使用提供的工具查询或准 课程与评分规则以结构化数据库为准;指定批次时必须使用其快照。名称或版本有歧义时请先列出候选并请教师澄清。 报告内容只能根据检索证据回答,引用资料名、页码或幻灯片编号和原文;无证据时说明未找到。 不要把未完成、失败或无法评分算零分或完整成绩。教师复核分数必须由教师当前消息明确给出。 -所有写入操作仅能准备确认卡片,绝不能声称已经执行。确认由教师点击界面按钮完成。不要要求模型调用确认工具。""" +所有写入操作仅能准备确认卡片,绝不能声称已经执行。确认由教师点击界面按钮完成。不要要求模型调用确认工具。 +同一轮已取得工具结果后不要以相同参数重复调用;分页查询须使用结果中的 next offset。""" class CreateConversation(BaseModel): @@ -165,27 +165,39 @@ def _event(name, payload): def _safe_arguments(arguments): - """Keep useful identifiers without persisting secrets or large teacher/student text.""" + """Record bounded tool inputs needed to explain a call, while omitting review prose.""" if not isinstance(arguments, dict): return {} - allowed = {"course_version_id", "batch_id", "project_id", "score_id", "task_id", - "criterion_id", "format", "status", "offset", "limit"} + numeric = {"course_version_id", "batch_id", "project_id", "score_id", "task_id", + "rubric_version_id", "score", "offset", "limit", "project_offset", "criterion_offset"} + text_limits = {"query": 300, "criterion_id": 100, "format": 20, "status": 30, + "student_no": 50, "relative_dir": 500} result = {} for key, value in arguments.items(): - if key not in allowed: - continue - if isinstance(value, (int, float)) and not isinstance(value, bool): + if key in numeric and isinstance(value, (int, float)) and not isinstance(value, bool): result[key] = value - elif isinstance(value, str) and re.fullmatch(r"[A-Za-z0-9_.-]{1,80}", value): - result[key] = value - else: + elif key in text_limits and isinstance(value, str): + result[key] = value[:text_limits[key]] + elif key in {"comment", "reason"}: result[key] = "[文本已省略]" - omitted = sum(key not in result for key in arguments) + omitted = sum(key not in result for key in arguments if key not in {"comment", "reason"}) if omitted: - result["omitted_text_fields"] = omitted + result["omitted_fields"] = omitted return result +def _tool_key(name, arguments): + """Use validated defaults so omitted and explicit pagination defaults match.""" + tool = TOOLS.get(name) + if not tool: + return None + try: + normalized = tool.schema.model_validate(arguments).model_dump(mode="json", exclude_none=True) + except ValidationError: + return None + return json.dumps([name, normalized], sort_keys=True, ensure_ascii=False) + + def _result_summary(result): if not isinstance(result, dict): return "工具已返回结果" @@ -291,6 +303,7 @@ def _run_agent(factory, conversation_id, run_id, user_text): run = db.get(AgentRun, run_id) citations = [] used = [] + successful_calls = {} try: client = client_for_chat(db) if hasattr(client, "verify_tool_support"): @@ -336,24 +349,49 @@ def _run_agent(factory, conversation_id, run_id, user_text): call_id = call.get("id") or f"run-{run.id}-call-{ordinal}" entry = {"id": f"{run.id}:{ordinal}:{call_id}", "name": name[:100], "status": "running", "arguments": {}} - used.append(entry) - run.tool_calls = list(used) - yield _record(db, run, "tool", entry) try: arguments = json.loads(call.get("function", {}).get("arguments", "{}")) if not isinstance(arguments, dict): raise ValueError("工具参数须为对象") - entry = {**entry, "arguments": _safe_arguments(arguments)} - result = execute(db, conversation, name, arguments, user_text) - if isinstance(result, dict): - citations.extend(result.get("citations", [])) - output = json.dumps(result, ensure_ascii=False, default=str) - 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": _result_summary(result), - "result": _safe_result(result, name)} + visible_arguments = arguments + if name in TOOLS: + try: + visible_arguments = TOOLS[name].schema.model_validate(arguments).model_dump(exclude_none=True) + except ValidationError: + pass + entry["arguments"] = _safe_arguments(visible_arguments) + parse_error = None + except (TypeError, ValueError) as exc: + arguments = {} + parse_error = exc + used.append(entry) + run.tool_calls = list(used) + yield _record(db, run, "tool", entry) + try: + if parse_error: + raise parse_error + key = _tool_key(name, arguments) + previous = successful_calls.get(key) if key else None + if previous: + notice = {"notice": "同一轮已用相同参数调用此工具,请使用先前结果", + "previous_call_id": previous["tool_call_id"]} + messages.append({"role": "tool", "tool_call_id": call_id, + "content": json.dumps(notice, ensure_ascii=False)}) + entry = {**entry, "status": "skipped", "summary": "重复调用已跳过", + "result": {"previous_call_id": previous["entry_id"]}} + else: + result = execute(db, conversation, name, arguments, user_text) + if isinstance(result, dict): + citations.extend(result.get("citations", [])) + output = json.dumps(result, ensure_ascii=False, default=str) + 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": _result_summary(result), + "result": _safe_result(result, name)} + if key: + successful_calls[key] = {"entry_id": entry["id"], "tool_call_id": call_id} 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 c4e4eaf..bef5423 100644 --- a/docs/CHAT_AGENT.md +++ b/docs/CHAT_AGENT.md @@ -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 容器。`get_scores` 每次最多返回一个项目的六项评分,响应给出 `next_criterion_offset` 和 `next_project_offset` 供继续查询;长结果保留结构化评分数据,不再变成无内容的截断提示。检索工具的运行记录保留每次调用找到的有界原文片段;回答引用对各次调用的证据去重后保存,不再只取前 20 条。工具没有任意 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 0cec72c..86365ab 100644 --- a/frontend/src/Chat.tsx +++ b/frontend/src/Chat.tsx @@ -7,7 +7,7 @@ import { api, post, put, subscribeRun } from './api'; const { Title, Text, Paragraph } = Typography; const kinds: Record = { import: '导入学生材料', course_ingest: '课程资料入库', create_batch: '创建评分批次', start_batch: '启动/重试评分批次', review: '教师复核', export: '导出成绩或反馈', index_retry: '重试索引任务' }; const categories: Record = { stage1: '阶段报告1', stage2: '阶段报告2', stage3: '阶段报告3', final_report: '最终报告', presentation: '汇报PPT/PDF' }; -const statusColor = (status: string) => status === 'completed' || status === 'ready' ? 'green' : status === 'failed' || status === 'stale' ? 'red' : 'orange'; +const statusColor = (status: string) => status === 'completed' || status === 'ready' ? 'green' : status === 'failed' || status === 'stale' ? 'red' : status === 'skipped' ? 'default' : 'orange'; const reportError = (error: unknown) => message.error(error instanceof Error ? error.message : String(error)); function ImportEditor({ action, conversationId, reload }: { action: any; conversationId: number; reload: () => void }) { @@ -108,6 +108,32 @@ function Citations({ items }: { items: any[] }) { if (!unique.length) return null; return
引用 · {unique.length}
; } +const argumentLabels: Record = { + query: '查询词 (query)', course_version_id: '课程版本 ID', batch_id: '评分批次 ID', project_id: '学生项目 ID', + score_id: '评分 ID', task_id: '任务 ID', rubric_version_id: '量规版本 ID', criterion_id: '评分项 ID', + project_offset: '项目偏移量', criterion_offset: '评分项偏移量', offset: '偏移量', limit: '数量上限', + status: '状态筛选', student_no: '学号', relative_dir: '目录', score: '分数', format: '格式', +}; +function visibleArguments(call: any) { + const args: Record = call.arguments && typeof call.arguments === 'object' && !Array.isArray(call.arguments) + ? { ...call.arguments } : {}; + let missing = Number(args.omitted_text_fields || args.omitted_fields || 0); + delete args.omitted_text_fields; + delete args.omitted_fields; + // Older score calls kept their effective offsets in the result but dropped them from arguments. + if (call.name === 'get_scores') for (const field of ['project_offset', 'criterion_offset']) { + if (!(field in args) && typeof call.result?.[field] === 'number') { + args[field] = call.result[field]; + missing = Math.max(0, missing - 1); + } + } + if (['search_course_material', 'search_project_material'].includes(call.name) && !('query' in args) && missing > 0) { + args.query = '历史调用未保存查询词'; + missing -= 1; + } + if (missing > 0) args['历史未保存参数'] = `${missing} 项`; + return Object.fromEntries(Object.entries(args).map(([field, value]) => [argumentLabels[field] || field, value])); +} function Tools({ calls }: { calls: any[] }) { if (!calls?.length) return null; const active = calls.filter(call => call.status === 'running').length; @@ -115,9 +141,9 @@ function Tools({ calls }: { calls: any[] }) { 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.status === 'failed' ? '失败' : call.status === 'skipped' ? '已跳过' : '成功'} {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)}
+
调用参数
{JSON.stringify(visibleArguments(call), null, 2)}
{call.error ? '错误' : '调用结果'}
{call.error || (typeof call.result === 'string' ? call.result : JSON.stringify(call.result ?? '运行中', null, 2))}
)}
; } diff --git a/frontend/tests/chat.spec.ts b/frontend/tests/chat.spec.ts index 8048357..199bb87 100644 --- a/frontend/tests/chat.spec.ts +++ b/frontend/tests/chat.spec.ts @@ -8,10 +8,14 @@ async function mockApi(page: Page) { content: '# 标题\n\n段落 [安全链接](https://example.com) [危险链接](javascript: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' }, { 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 }, + tool_calls: [{ id: 'call-one', name: 'search_project_material', status: 'completed', arguments: { project_id: 1, omitted_text_fields: 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-two', name: 'get_scores', status: 'completed', arguments: { batch_id: 3, omitted_text_fields: 1 }, + summary: '返回 1 项评分', result: { project_offset: 0, criterion_offset: 6, next_criterion_offset: 12, results: [{ rows: [{ criterion_id: 'C1', final_score: 8 }] }] } }, + { id: 'call-three', name: 'search_project_material', status: 'completed', arguments: { project_id: 1, query: '视觉检测' }, + summary: '找到 1 条引用', result: { found: true, citations: [] } }, + { id: 'call-four', name: 'search_project_material', status: 'skipped', arguments: { project_id: 1, query: '视觉检测' }, + summary: '重复调用已跳过', result: { previous_call_id: 'call-three' } }] }], actions: [], runs: [{ id: 1, status: 'completed', tool_calls: [] }] }, }; let nextId = 8; @@ -103,6 +107,13 @@ test('markdown, citations, tool folding and narrow layout are safe', async ({ pa 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 expect(page.locator('.chat-tool').nth(1).locator('.tool-detail')).toContainText('评分项偏移量'); + await expect(page.locator('.chat-tool').nth(1).locator('.tool-detail')).toContainText('6'); + await expect(page.locator('.chat-tool').first().locator('.tool-detail')).toContainText('历史调用未保存查询词'); + await page.locator('.chat-tool').nth(2).locator('summary').click(); + await expect(page.locator('.chat-tool').nth(2).locator('.tool-detail')).toContainText('查询词 (query)'); + await expect(page.locator('.chat-tool').nth(2).locator('.tool-detail')).toContainText('视觉检测'); + await expect(page.locator('.chat-tool').nth(3).locator('summary')).toContainText('已跳过'); await page.locator('.chat-citation-group > summary').click(); await expect(page.locator('.chat-citation-group')).toHaveAttribute('open', ''); await expect(page.getByText('资料.pdf')).toBeVisible();