fix: show tool parameters and skip duplicate calls
This commit is contained in:
@@ -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):
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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()
|
||||
|
||||
Reference in New Issue
Block a user