fix: show tool parameters and skip duplicate calls

This commit is contained in:
2026-09-27 14:47:55 +08:00
parent 40fd07a1bc
commit e5bf1cf250
7 changed files with 192 additions and 42 deletions
+68 -1
View File
@@ -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):
+6 -4
View File
@@ -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
+67 -29
View File
@@ -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()