436 lines
24 KiB
Python
436 lines
24 KiB
Python
import json
|
|
from datetime import timedelta
|
|
from types import SimpleNamespace
|
|
|
|
import httpx
|
|
import pymupdf
|
|
import pytest
|
|
from fastapi import HTTPException
|
|
from fastapi.testclient import TestClient
|
|
from sqlalchemy import select
|
|
|
|
from wisegrade import agent_tools, chat_api, services
|
|
from wisegrade.actions import confirm, prepare
|
|
from wisegrade.agent_tools import execute
|
|
from wisegrade.db import get_db
|
|
from wisegrade.indexing import run_index_task
|
|
from wisegrade.llm import DeepSeekClient, ModelError, ScoreResult
|
|
from wisegrade.main import app
|
|
from wisegrade.models import (AgentRun, ChatMessage, Conversation, CourseVersion, CriterionScore, IndexTask, PendingAction,
|
|
Project, RubricVersion, ScoreEvidence, ScoringBatch, Student, SubmissionFile,
|
|
TeacherRevision, now)
|
|
from wisegrade.rag import ingest
|
|
from wisegrade.scoring import _score_criterion, run_batch
|
|
from wisegrade.storage import scan
|
|
|
|
|
|
def add_project(db, number, *, version_id=1):
|
|
student = Student(student_no=number, name=f"学生{number[-1]}")
|
|
db.add(student)
|
|
db.flush()
|
|
project = Project(course_version_id=version_id, student_id=student.id, topic="控制系统", source_dir=f"test/{number}")
|
|
db.add(project)
|
|
db.commit()
|
|
return project
|
|
|
|
|
|
def test_tool_scope_snapshots_and_project_citations(db):
|
|
first = add_project(db, "123450001")
|
|
second = add_project(db, "123450002")
|
|
vectors = lambda texts: [[0.1] * 1024 for _ in texts]
|
|
for project, text in ((first, "一号项目电机控制方案"), (second, "二号项目视觉方案")):
|
|
ingest(db, course_version_id=1, project_id=project.id, submission_file_id=None,
|
|
title=f"{project.id}.pdf", category="final_report", source_hash=str(project.id),
|
|
pages=[{"page_number": 2, "section": "方案", "text": text, "tables": []}], embedder=vectors)
|
|
conversation = Conversation(title="范围", course_version_id=1, project_id=first.id)
|
|
db.add(conversation)
|
|
db.commit()
|
|
from wisegrade import agent_tools
|
|
original = agent_tools.retrieve
|
|
agent_tools.retrieve = lambda *args, **kwargs: original(*args, **kwargs, embedder=vectors)
|
|
try:
|
|
found = execute(db, conversation, "search_project_material", {"project_id": first.id, "query": "电机控制"}, "")
|
|
assert found["found"] and len(found["citations"]) == 1
|
|
assert found["citations"][0]["quote"] == "一号项目电机控制方案"
|
|
assert found["citations"][0]["page"] == 2
|
|
assert "/pages/2" in found["citations"][0]["url"]
|
|
with pytest.raises(HTTPException):
|
|
execute(db, conversation, "search_project_material", {"project_id": second.id, "query": "视觉"}, "")
|
|
with pytest.raises(Exception):
|
|
execute(db, conversation, "get_project", {"project_id": first.id, "arbitrary_sql": "DROP TABLE"}, "")
|
|
finally:
|
|
agent_tools.retrieve = original
|
|
batch = services.create_batch(SimpleNamespace(course_version_id=1, rubric_version_id=1), db)
|
|
snapshot = db.get(ScoringBatch, batch["id"])
|
|
before = snapshot.rubric_snapshot["criteria"][0]["name"]
|
|
rubric = db.get(RubricVersion, 1)
|
|
rubric.criteria = [{**rubric.criteria[0], "name": "后来修改"}, *rubric.criteria[1:]]
|
|
db.commit()
|
|
rules = execute(db, conversation, "get_course_rules", {"batch_id": snapshot.id}, "")
|
|
assert rules["rubric"]["criteria"][0]["name"] == before
|
|
assert rules["source"] == "batch_snapshot"
|
|
|
|
|
|
def test_get_scores_pages_projects_and_criteria(db, monkeypatch):
|
|
first = add_project(db, "123450010")
|
|
second = add_project(db, "123450011")
|
|
created = services.create_batch(SimpleNamespace(course_version_id=1, rubric_version_id=1), db)
|
|
conversation = Conversation(title="分页评分", batch_id=created["id"])
|
|
db.add(conversation)
|
|
db.commit()
|
|
|
|
def result(_db, _batch, item):
|
|
return {"student_no": f"{item.id}", "name": "学生", "totals": {"overall": 80},
|
|
"rows": [{"criterion": {"criterion_id": f"C{index}", "name": f"评分项{index}", "max_score": 10},
|
|
"score_id": index, "model_score": 8, "teacher_score": None, "final_score": 8,
|
|
"status": "scored", "error": "", "evidence": [{"page": index + 1,
|
|
"quote": "证据" * 200, "document": "报告.pdf", "document_id": index + 100,
|
|
"image_url": f"/api/assets/{item.id}/1" if index == 0 else None}]}
|
|
for index in range(14)]}
|
|
|
|
monkeypatch.setattr(agent_tools, "project_result", result)
|
|
first_page = execute(db, conversation, "get_scores", {"batch_id": created["id"]}, "")
|
|
assert first_page["count"] == 2 and len(first_page["results"]) == 1
|
|
assert first_page["results"][0]["project_id"] == first.id
|
|
assert len(first_page["results"][0]["rows"]) == 6
|
|
assert first_page["next_criterion_offset"] == 6 and first_page["next_project_offset"] == 1
|
|
assert first_page["citations"][0]["url"] is None
|
|
assert first_page["citations"][0]["image_url"] is None
|
|
assert first_page["citations"][0]["score_url"] == f"/?page=scores&batch_id={created['id']}&project_id={first.id}"
|
|
assert first_page["citations"][1]["url"] == first_page["citations"][0]["url"]
|
|
assert first_page["citations"][1]["image_url"] is None
|
|
assert len(json.dumps(first_page, ensure_ascii=False)) < 12000
|
|
later = execute(db, conversation, "get_scores", {"batch_id": created["id"],
|
|
"project_offset": 1, "criterion_offset": 12}, "")
|
|
assert later["results"][0]["project_id"] == second.id
|
|
assert [row["criterion_id"] for row in later["results"][0]["rows"]] == ["C12", "C13"]
|
|
assert later["next_project_offset"] is None and later["next_criterion_offset"] is None
|
|
|
|
|
|
def test_scoring_uses_the_same_page_image_as_its_saved_evidence(db, environment):
|
|
project = add_project(db, "123450090")
|
|
created = services.create_batch(SimpleNamespace(course_version_id=1, rubric_version_id=1), db)
|
|
batch = db.get(ScoringBatch, created["id"])
|
|
criterion = batch.rubric_snapshot["criteria"][0]
|
|
file = SubmissionFile(project_id=project.id, category=criterion["category"],
|
|
relative_path="test/score/source.pdf", mime_type="application/pdf", size=1,
|
|
mtime=1, sha256="b" * 64, recognition_status="recognized", parse_status="ready")
|
|
db.add(file)
|
|
db.flush()
|
|
image = environment / "processed" / f"file-{file.id}" / "page-2.png"
|
|
image.parent.mkdir(parents=True)
|
|
image.write_bytes(b"\x89PNG\r\n\x1a\n")
|
|
source = {"page_number": 2, "text": "评分所用原文证据", "rendered_image_path": str(image)}
|
|
file.parsed = {"pages": [source]}
|
|
document = ingest(db, course_version_id=1, project_id=project.id, submission_file_id=file.id,
|
|
title="报告.pdf", category=criterion["category"], source_hash=file.sha256,
|
|
pages=[source], embedder=lambda texts: [[0.1] * 1024 for _ in texts])
|
|
from wisegrade.models import KnowledgeChunk
|
|
chunk = db.scalar(select(KnowledgeChunk).where(KnowledgeChunk.document_id == document.id))
|
|
evidence = {"chunk_id": chunk.id, "document_id": document.id, "project_id": project.id,
|
|
"page": 2, "text": chunk.text}
|
|
sent_images = []
|
|
|
|
class FakeClient:
|
|
def json_call(self, _prompt, _payload, _schema, *, images=None):
|
|
sent_images.extend(images or [])
|
|
return ScoreResult(criterion_id=criterion["criterion_id"], score=1,
|
|
max_score=criterion["max_score"], reason="测试评分",
|
|
evidence=[{"chunk_id": chunk.id, "page": 2, "quote": "原文证据"}], confidence=.9)
|
|
|
|
scored = _score_criterion(db, batch, project, criterion, FakeClient(),
|
|
retriever=lambda *_, **kwargs: [] if kwargs.get("public_only") else [evidence])
|
|
saved = db.scalar(select(ScoreEvidence).where(ScoreEvidence.score_id == scored.id))
|
|
assert sent_images == [str(image)]
|
|
assert saved.document_id == document.id and saved.page == 2 and saved.image_path == str(image)
|
|
|
|
|
|
def test_import_confirmation_hash_change_index_and_idempotency(environment, db, monkeypatch, session_factory):
|
|
folder = environment / "inbox" / "batch" / "123450003_张三_机器人"
|
|
folder.mkdir(parents=True)
|
|
file = folder / "阶段报告1.pdf"
|
|
def write_pdf(text):
|
|
pdf = pymupdf.open()
|
|
page = pdf.new_page()
|
|
page.insert_text((72, 72), (text + " with enough searchable report detail for indexing. ") * 4)
|
|
pdf.save(file)
|
|
write_pdf("First report content")
|
|
scanned = scan("batch")["projects"][0]
|
|
payload = {"course_version_id": 1, "projects": [{"relative_dir": scanned["relative_dir"],
|
|
"student_no": scanned["student_no"], "name": scanned["name"], "topic": scanned["topic"],
|
|
"files": [{"relative_path": item["relative_path"], "category": item["category"],
|
|
"sha256": item["sha256"], "status": "recognized"} for item in scanned["files"]]}]}
|
|
conversation = Conversation(title="导入", course_version_id=1)
|
|
db.add(conversation)
|
|
db.commit()
|
|
action = prepare(db, conversation, "import", payload)
|
|
assert db.scalar(select(Project.id)) is None
|
|
write_pdf("Changed report content")
|
|
with pytest.raises(HTTPException) as exc:
|
|
confirm(db, conversation, action.id)
|
|
assert exc.value.status_code == 409 and db.scalar(select(Project.id)) is None
|
|
scanned = scan("batch")["projects"][0]
|
|
payload["projects"][0]["files"][0]["sha256"] = scanned["files"][0]["sha256"]
|
|
monkeypatch.setattr(services, "enqueue_index_task", lambda db, task_id: db.get(IndexTask, task_id))
|
|
action = prepare(db, conversation, "import", payload)
|
|
result = confirm(db, conversation, action.id)
|
|
assert confirm(db, conversation, action.id) == result
|
|
assert len(result["project_ids"]) == len(result["index_task_ids"]) == 1
|
|
task = db.get(IndexTask, result["index_task_ids"][0])
|
|
assert task.status == "queued"
|
|
vectors = lambda texts: [[0.1] * 1024 for _ in texts]
|
|
run_index_task(task.id, session_factory=session_factory, embedder=vectors)
|
|
db.refresh(task)
|
|
assert task.status == "completed" and task.result["pages"] == 1, task.error
|
|
failed = IndexTask(course_version_id=1, submission_file_id=task.submission_file_id,
|
|
kind="project", status="failed", error="temporary")
|
|
db.add(failed)
|
|
db.commit()
|
|
first_retry = services.retry_index_task(db, failed.id)
|
|
assert services.retry_index_task(db, failed.id).id == first_retry.id
|
|
|
|
|
|
def test_duplicate_scan_requires_teacher_file_choice(environment, db):
|
|
folder = environment / "inbox" / "dup" / "123450009_王五_控制"
|
|
folder.mkdir(parents=True)
|
|
for name in ("阶段报告1-A.pdf", "阶段报告1-B.pdf"):
|
|
pdf = pymupdf.open()
|
|
page = pdf.new_page()
|
|
page.insert_text((72, 72), "Long report text for reliable detection. " * 4)
|
|
pdf.save(folder / name)
|
|
scanned = scan("dup")["projects"][0]
|
|
assert all(file["status"] == "duplicate" for file in scanned["files"])
|
|
chosen = scanned["files"][0]
|
|
payload = {"course_version_id": 1, "projects": [{"relative_dir": scanned["relative_dir"],
|
|
"student_no": scanned["student_no"], "name": scanned["name"], "topic": scanned["topic"],
|
|
"files": [{"relative_path": chosen["relative_path"], "category": "stage1",
|
|
"sha256": chosen["sha256"], "status": "recognized"}]}]}
|
|
conversation = Conversation(title="重复文件", course_version_id=1)
|
|
db.add(conversation)
|
|
db.commit()
|
|
generated = execute(db, conversation, "prepare_import", {"course_version_id": 1, "relative_dir": "dup"}, "准备导入")
|
|
generated_action = db.get(PendingAction, generated["pending_action"]["id"])
|
|
assert generated_action.preview["requires_edit"] is True
|
|
assert all(file["status"] == "ignored" for file in generated_action.payload["projects"][0]["files"])
|
|
proposed = prepare(db, conversation, "import", payload)
|
|
assert proposed.preview["requires_edit"] is True
|
|
with pytest.raises(HTTPException):
|
|
confirm(db, conversation, proposed.id)
|
|
assert db.scalar(select(Project.id)) is None
|
|
corrected = prepare(db, conversation, "import", payload, teacher_edited=True)
|
|
assert corrected.id != proposed.id and corrected.preview["requires_edit"] is False
|
|
|
|
|
|
def test_fixed_batch_membership_duplicate_queue_and_review(db, monkeypatch, session_factory):
|
|
first = add_project(db, "123450004")
|
|
created = services.create_batch(SimpleNamespace(course_version_id=1, rubric_version_id=1), db)
|
|
batch = db.get(ScoringBatch, created["id"])
|
|
assert batch.status == "created" and batch.project_ids == [first.id]
|
|
add_project(db, "123450005")
|
|
jobs = []
|
|
monkeypatch.setattr(services, "Queue", lambda *args, **kwargs: SimpleNamespace(enqueue=lambda *a, **k: jobs.append(k)))
|
|
services.start_batch(batch.id, db)
|
|
with pytest.raises(HTTPException) as exc:
|
|
services.start_batch(batch.id, db)
|
|
assert exc.value.status_code == 409 and len(jobs) == 1
|
|
run_batch(batch.id, session_factory=session_factory, client_factory=lambda *_: SimpleNamespace())
|
|
# The fixed list excludes the project imported after creation.
|
|
db.refresh(batch)
|
|
assert batch.progress_total == len(batch.rubric_snapshot["criteria"])
|
|
score = db.scalar(select(CriterionScore).where(CriterionScore.batch_id == batch.id))
|
|
score.model_score, score.status, score.error = None, "failed", "证据不足"
|
|
db.commit()
|
|
conversation = Conversation(title="复核", batch_id=batch.id, project_id=first.id)
|
|
db.add(conversation)
|
|
db.commit()
|
|
with pytest.raises(HTTPException):
|
|
execute(db, conversation, "prepare_review", {"score_id": score.id, "score": 3,
|
|
"comment": "人工核定", "reason": "核查纸质材料"}, "请复核")
|
|
prepared = execute(db, conversation, "prepare_review", {"score_id": score.id, "score": 3,
|
|
"comment": "人工核定", "reason": "核查纸质材料"}, "给 3 分")
|
|
action_id = prepared["pending_action"]["id"]
|
|
assert score.teacher_score is None
|
|
student = db.get(Student, first.student_id)
|
|
student.name = "身份变化"
|
|
db.commit()
|
|
with pytest.raises(HTTPException):
|
|
confirm(db, conversation, action_id)
|
|
assert score.teacher_score is None
|
|
student.name = "学生4"
|
|
db.commit()
|
|
prepared = execute(db, conversation, "prepare_review", {"score_id": score.id, "score": 3,
|
|
"comment": "人工核定", "reason": "核查纸质材料"}, "给 3 分")
|
|
action_id = prepared["pending_action"]["id"]
|
|
confirm(db, conversation, action_id)
|
|
db.refresh(score)
|
|
assert score.teacher_score == 3 and score.model_score is None and score.status == "reviewed"
|
|
assert db.scalar(select(TeacherRevision).where(TeacherRevision.score_id == score.id)).new_score == 3
|
|
summary = execute(db, conversation, "summarize_batch", {"batch_id": batch.id}, "")
|
|
assert summary["project_count"] == 1 and summary["complete_count"] == 0
|
|
assert summary["incomplete_count"] == 1 and not summary["overall_distribution"]
|
|
|
|
|
|
def test_batch_and_export_are_previews_until_confirmed(db):
|
|
project = add_project(db, "123450006")
|
|
conversation = Conversation(title="确认", course_version_id=1)
|
|
db.add(conversation)
|
|
db.commit()
|
|
action = prepare(db, conversation, "create_batch", {"course_version_id": 1, "rubric_version_id": 1})
|
|
assert action.preview["project_ids"] == [project.id]
|
|
assert db.scalar(select(ScoringBatch.id)) is None
|
|
first = confirm(db, conversation, action.id)
|
|
assert confirm(db, conversation, action.id) == first
|
|
assert len(db.scalars(select(ScoringBatch.id)).all()) == 1
|
|
export = prepare(db, conversation, "export", {"batch_id": first["id"], "format": "excel"})
|
|
assert export.preview["incomplete_projects"] == [project.id]
|
|
from wisegrade.models import ExportTask
|
|
assert db.scalar(select(ExportTask.id)) is None
|
|
|
|
|
|
def test_sse_persists_messages_and_tool_failure(session_factory, monkeypatch):
|
|
calls = []
|
|
class FakeClient:
|
|
def tool_call(self, messages, tools):
|
|
calls.append(messages)
|
|
if len(calls) == 1:
|
|
return {"role": "assistant", "content": None, "tool_calls": [
|
|
{"id": f"call{index}", "type": "function",
|
|
"function": {"name": "list_courses", "arguments": "{}"}} for index in range(9)]}
|
|
return {"role": "assistant", "content": "已列出课程版本。", "tool_calls": []}
|
|
monkeypatch.setattr(chat_api, "client_for_chat", lambda db: FakeClient())
|
|
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 body.count("event: tool") == 18 and "event: message" in body and "event: done" in body
|
|
restored = client.get(f"/api/conversations/{created['id']}").json()
|
|
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 [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:
|
|
body = "".join(response.iter_text())
|
|
assert "event: error" in body
|
|
restored = client.get(f"/api/conversations/{created['id']}").json()
|
|
assert len(restored["messages"]) == 3 and restored["runs"][0]["status"] == "failed"
|
|
with session_factory() as db:
|
|
db.add(AgentRun(conversation_id=created["id"], status="running",
|
|
created_at=now() - timedelta(hours=1)))
|
|
db.commit()
|
|
recovered = client.get(f"/api/conversations/{created['id']}").json()
|
|
assert recovered["runs"][0]["status"] == "interrupted"
|
|
finally:
|
|
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):
|
|
return {"role": "assistant", "content": None, "tool_calls": [
|
|
{"id": f"call{len(messages)}", "type": "function",
|
|
"function": {"name": "unknown_tool", "arguments": "{}"}}]}
|
|
|
|
conversation = SimpleNamespace(id=1, course_version_id=None, batch_id=None, project_id=None, updated_at=None)
|
|
run = SimpleNamespace(id=1, status="running", error=None, finished_at=None)
|
|
class FakeSession:
|
|
def __enter__(self):
|
|
return self
|
|
def __exit__(self, *_args):
|
|
pass
|
|
def get(self, model, _id):
|
|
return conversation if model is Conversation else run
|
|
def scalars(self, _query):
|
|
return SimpleNamespace(all=lambda: [])
|
|
def commit(self):
|
|
pass
|
|
def rollback(self):
|
|
pass
|
|
|
|
monkeypatch.setattr(chat_api, "client_for_chat", lambda db: RepeatingClient())
|
|
monkeypatch.setattr(chat_api, "get_settings", lambda: SimpleNamespace(chat_max_tool_calls=9))
|
|
body = "".join(chat_api._run_agent(FakeSession, 1, 1, "循环调用工具"))
|
|
assert body.count("event: tool") == 18
|
|
assert "本次消息工具调用超过上限 9 次" in body
|
|
assert run.status == "failed"
|
|
|
|
|
|
def test_native_tool_calls_unsupported_endpoint():
|
|
transport = httpx.MockTransport(lambda request: httpx.Response(400, json={"error": "unsupported"}))
|
|
client = DeepSeekClient(api_key="test-key", transport=transport)
|
|
with pytest.raises(ModelError, match="tool_calls"):
|
|
client.tool_call([{"role": "user", "content": "hello"}], [])
|