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"}], [])