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
+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()