fix: show tool parameters and skip duplicate calls
This commit is contained in:
@@ -98,3 +98,9 @@ uv run pytest -q
|
|||||||
后续排查确认“旧结果未保存”来自 6 号会话三条已持久化的工具摘要,而非前端旧版本;这些摘要现已改为简短的评分项数量,会话本身仍在。
|
后续排查确认“旧结果未保存”来自 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 大块警告。
|
隔离 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,旧检索调用显示未保存查询词提示。
|
||||||
|
|||||||
@@ -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 [m["role"] for m in restored["messages"]] == ["user", "assistant"]
|
||||||
assert len(restored["messages"][1]["tool_calls"]) == 9
|
assert len(restored["messages"][1]["tool_calls"]) == 9
|
||||||
assert len({call["id"] for call in 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"
|
assert restored["runs"][0]["status"] == "completed"
|
||||||
monkeypatch.setattr(chat_api, "client_for_chat", lambda db: (_ for _ in ()).throw(ModelError("不支持工具调用")))
|
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:
|
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()
|
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):
|
def test_failed_tool_calls_count_toward_limit(monkeypatch):
|
||||||
class RepeatingClient:
|
class RepeatingClient:
|
||||||
def tool_call(self, messages, tools):
|
def tool_call(self, messages, tools):
|
||||||
|
|||||||
@@ -162,10 +162,12 @@ def test_background_runs_survive_no_stream_and_isolate_parallel_events(session_f
|
|||||||
app.dependency_overrides.clear()
|
app.dependency_overrides.clear()
|
||||||
|
|
||||||
|
|
||||||
def test_tool_record_redacts_text_and_secrets():
|
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": "敏感评语"})
|
saved = chat_api._safe_arguments({"project_id": 2, "query": "电机控制", "api_key": "secret", "comment": "敏感评语"})
|
||||||
assert saved["project_id"] == 2
|
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 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,
|
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):
|
if not any(message['role'] == 'tool' for message in messages):
|
||||||
return {"role": "assistant", "content": None, "tool_calls": [
|
return {"role": "assistant", "content": None, "tool_calls": [
|
||||||
{"id": f"find-{index}", "type": "function", "function": {"name": "search_project_material",
|
{"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": []}
|
return {"role": "assistant", "content": "综合报告第48页给出改进方向。", "tool_calls": []}
|
||||||
|
|
||||||
count = 0
|
count = 0
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
"""Persistent teacher conversations and SSE agent orchestration."""
|
"""Persistent teacher conversations and SSE agent orchestration."""
|
||||||
import json
|
import json
|
||||||
import re
|
|
||||||
import time
|
import time
|
||||||
from concurrent.futures import ThreadPoolExecutor
|
from concurrent.futures import ThreadPoolExecutor
|
||||||
from datetime import timedelta
|
from datetime import timedelta
|
||||||
@@ -9,13 +8,13 @@ from uuid import UUID
|
|||||||
|
|
||||||
from fastapi import APIRouter, Depends, HTTPException
|
from fastapi import APIRouter, Depends, HTTPException
|
||||||
from fastapi.responses import StreamingResponse
|
from fastapi.responses import StreamingResponse
|
||||||
from pydantic import BaseModel, Field
|
from pydantic import BaseModel, Field, ValidationError
|
||||||
from sqlalchemy import delete, select
|
from sqlalchemy import delete, select
|
||||||
from sqlalchemy.exc import IntegrityError
|
from sqlalchemy.exc import IntegrityError
|
||||||
from sqlalchemy.orm import Session, sessionmaker
|
from sqlalchemy.orm import Session, sessionmaker
|
||||||
|
|
||||||
from .actions import action_dict, confirm, prepare, require_scope
|
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 .config import get_settings
|
||||||
from .db import get_db
|
from .db import get_db
|
||||||
from .llm import ModelError
|
from .llm import ModelError
|
||||||
@@ -31,7 +30,8 @@ SYSTEM = """你是 WiseGrade 教师助手。仅使用提供的工具查询或准
|
|||||||
课程与评分规则以结构化数据库为准;指定批次时必须使用其快照。名称或版本有歧义时请先列出候选并请教师澄清。
|
课程与评分规则以结构化数据库为准;指定批次时必须使用其快照。名称或版本有歧义时请先列出候选并请教师澄清。
|
||||||
报告内容只能根据检索证据回答,引用资料名、页码或幻灯片编号和原文;无证据时说明未找到。
|
报告内容只能根据检索证据回答,引用资料名、页码或幻灯片编号和原文;无证据时说明未找到。
|
||||||
不要把未完成、失败或无法评分算零分或完整成绩。教师复核分数必须由教师当前消息明确给出。
|
不要把未完成、失败或无法评分算零分或完整成绩。教师复核分数必须由教师当前消息明确给出。
|
||||||
所有写入操作仅能准备确认卡片,绝不能声称已经执行。确认由教师点击界面按钮完成。不要要求模型调用确认工具。"""
|
所有写入操作仅能准备确认卡片,绝不能声称已经执行。确认由教师点击界面按钮完成。不要要求模型调用确认工具。
|
||||||
|
同一轮已取得工具结果后不要以相同参数重复调用;分页查询须使用结果中的 next offset。"""
|
||||||
|
|
||||||
|
|
||||||
class CreateConversation(BaseModel):
|
class CreateConversation(BaseModel):
|
||||||
@@ -165,27 +165,39 @@ def _event(name, payload):
|
|||||||
|
|
||||||
|
|
||||||
def _safe_arguments(arguments):
|
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):
|
if not isinstance(arguments, dict):
|
||||||
return {}
|
return {}
|
||||||
allowed = {"course_version_id", "batch_id", "project_id", "score_id", "task_id",
|
numeric = {"course_version_id", "batch_id", "project_id", "score_id", "task_id",
|
||||||
"criterion_id", "format", "status", "offset", "limit"}
|
"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 = {}
|
result = {}
|
||||||
for key, value in arguments.items():
|
for key, value in arguments.items():
|
||||||
if key not in allowed:
|
if key in numeric and isinstance(value, (int, float)) and not isinstance(value, bool):
|
||||||
continue
|
|
||||||
if isinstance(value, (int, float)) and not isinstance(value, bool):
|
|
||||||
result[key] = value
|
result[key] = value
|
||||||
elif isinstance(value, str) and re.fullmatch(r"[A-Za-z0-9_.-]{1,80}", value):
|
elif key in text_limits and isinstance(value, str):
|
||||||
result[key] = value
|
result[key] = value[:text_limits[key]]
|
||||||
else:
|
elif key in {"comment", "reason"}:
|
||||||
result[key] = "[文本已省略]"
|
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:
|
if omitted:
|
||||||
result["omitted_text_fields"] = omitted
|
result["omitted_fields"] = omitted
|
||||||
return result
|
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):
|
def _result_summary(result):
|
||||||
if not isinstance(result, dict):
|
if not isinstance(result, dict):
|
||||||
return "工具已返回结果"
|
return "工具已返回结果"
|
||||||
@@ -291,6 +303,7 @@ def _run_agent(factory, conversation_id, run_id, user_text):
|
|||||||
run = db.get(AgentRun, run_id)
|
run = db.get(AgentRun, run_id)
|
||||||
citations = []
|
citations = []
|
||||||
used = []
|
used = []
|
||||||
|
successful_calls = {}
|
||||||
try:
|
try:
|
||||||
client = client_for_chat(db)
|
client = client_for_chat(db)
|
||||||
if hasattr(client, "verify_tool_support"):
|
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}"
|
call_id = call.get("id") or f"run-{run.id}-call-{ordinal}"
|
||||||
entry = {"id": f"{run.id}:{ordinal}:{call_id}", "name": name[:100],
|
entry = {"id": f"{run.id}:{ordinal}:{call_id}", "name": name[:100],
|
||||||
"status": "running", "arguments": {}}
|
"status": "running", "arguments": {}}
|
||||||
used.append(entry)
|
|
||||||
run.tool_calls = list(used)
|
|
||||||
yield _record(db, run, "tool", entry)
|
|
||||||
try:
|
try:
|
||||||
arguments = json.loads(call.get("function", {}).get("arguments", "{}"))
|
arguments = json.loads(call.get("function", {}).get("arguments", "{}"))
|
||||||
if not isinstance(arguments, dict):
|
if not isinstance(arguments, dict):
|
||||||
raise ValueError("工具参数须为对象")
|
raise ValueError("工具参数须为对象")
|
||||||
entry = {**entry, "arguments": _safe_arguments(arguments)}
|
visible_arguments = arguments
|
||||||
result = execute(db, conversation, name, arguments, user_text)
|
if name in TOOLS:
|
||||||
if isinstance(result, dict):
|
try:
|
||||||
citations.extend(result.get("citations", []))
|
visible_arguments = TOOLS[name].schema.model_validate(arguments).model_dump(exclude_none=True)
|
||||||
output = json.dumps(result, ensure_ascii=False, default=str)
|
except ValidationError:
|
||||||
if len(output) > 12000:
|
pass
|
||||||
output = json.dumps({"notice": "结果已按结构压缩;可使用工具分页参数继续查询",
|
entry["arguments"] = _safe_arguments(visible_arguments)
|
||||||
**_safe_result(result, name)}, ensure_ascii=False, default=str)
|
parse_error = None
|
||||||
messages.append({"role": "tool", "tool_call_id": call_id, "content": output})
|
except (TypeError, ValueError) as exc:
|
||||||
entry = {**entry, "status": "completed", "summary": _result_summary(result),
|
arguments = {}
|
||||||
"result": _safe_result(result, name)}
|
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:
|
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()
|
||||||
|
|||||||
+2
-2
@@ -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 容器。`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 与故障恢复
|
## 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 缺失,任务保留具体失败原因;补齐离线模型后重试。若课程资料缺失、原件哈希变化、课程或批次状态变化,应重新预览。
|
||||||
|
|||||||
+29
-3
@@ -7,7 +7,7 @@ import { api, post, put, subscribeRun } from './api';
|
|||||||
const { Title, Text, Paragraph } = Typography;
|
const { Title, Text, Paragraph } = Typography;
|
||||||
const kinds: Record<string, string> = { import: '导入学生材料', course_ingest: '课程资料入库', create_batch: '创建评分批次', start_batch: '启动/重试评分批次', review: '教师复核', export: '导出成绩或反馈', index_retry: '重试索引任务' };
|
const kinds: Record<string, string> = { import: '导入学生材料', course_ingest: '课程资料入库', create_batch: '创建评分批次', start_batch: '启动/重试评分批次', review: '教师复核', export: '导出成绩或反馈', index_retry: '重试索引任务' };
|
||||||
const categories: Record<string, string> = { stage1: '阶段报告1', stage2: '阶段报告2', stage3: '阶段报告3', final_report: '最终报告', presentation: '汇报PPT/PDF' };
|
const categories: Record<string, string> = { 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));
|
const reportError = (error: unknown) => message.error(error instanceof Error ? error.message : String(error));
|
||||||
|
|
||||||
function ImportEditor({ action, conversationId, reload }: { action: any; conversationId: number; reload: () => void }) {
|
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;
|
if (!unique.length) return null;
|
||||||
return <section className="chat-citations" aria-label="引用资料"><details className="chat-citation-group"><summary><Text strong>引用 · {unique.length}</Text></summary><CitationCards items={unique} /></details></section>;
|
return <section className="chat-citations" aria-label="引用资料"><details className="chat-citation-group"><summary><Text strong>引用 · {unique.length}</Text></summary><CitationCards items={unique} /></details></section>;
|
||||||
}
|
}
|
||||||
|
const argumentLabels: Record<string, string> = {
|
||||||
|
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<string, unknown> = 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[] }) {
|
function Tools({ calls }: { calls: any[] }) {
|
||||||
if (!calls?.length) return null;
|
if (!calls?.length) return null;
|
||||||
const active = calls.filter(call => call.status === 'running').length;
|
const active = calls.filter(call => call.status === 'running').length;
|
||||||
@@ -115,9 +141,9 @@ function Tools({ calls }: { calls: any[] }) {
|
|||||||
return <section className="chat-tools" aria-label="工具调用"><details className="chat-tool-group"><summary><Text strong>工具调用 · {calls.length}</Text>
|
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>
|
{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>
|
<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' ? '失败' : call.status === 'skipped' ? '已跳过' : '成功'}</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(visibleArguments(call), 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></div>
|
||||||
</details>)}</div></details></section>;
|
</details>)}</div></details></section>;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,10 +8,14 @@ async function mockApi(page: Page) {
|
|||||||
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' },
|
||||||
{ chunk_id: 2977, title: '综合报告.pdf', page: 48, quote: '第48页原文片段', url: '/api/pages/48' }],
|
{ 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页原文片段' }] } },
|
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 },
|
{ id: 'call-two', name: 'get_scores', status: 'completed', arguments: { batch_id: 3, omitted_text_fields: 1 },
|
||||||
summary: '返回 1 项评分', result: { next_criterion_offset: 6, results: [{ rows: [{ criterion_id: 'C1', final_score: 8 }] }] } }] }],
|
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: [] }] },
|
actions: [], runs: [{ id: 1, status: 'completed', tool_calls: [] }] },
|
||||||
};
|
};
|
||||||
let nextId = 8;
|
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 expect(page.locator('.tool-detail').first()).toContainText('第48页原文片段');
|
||||||
await page.locator('.chat-tool').nth(1).locator('summary').click();
|
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('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 page.locator('.chat-citation-group > summary').click();
|
||||||
await expect(page.locator('.chat-citation-group')).toHaveAttribute('open', '');
|
await expect(page.locator('.chat-citation-group')).toHaveAttribute('open', '');
|
||||||
await expect(page.getByText('资料.pdf')).toBeVisible();
|
await expect(page.getByText('资料.pdf')).toBeVisible();
|
||||||
|
|||||||
Reference in New Issue
Block a user