Files
aerospaceboy ebe33392ca fix: improve import, evidence indexing and material-scoped grading
Preserve document revisions and evidence, harden indexing and export behavior, add explicit material selection and direct-folder import, validate model JSON schemas with safe error messages, and retain student selection while loading scoring data.
2026-10-14 13:52:56 +08:00

582 lines
31 KiB
Python

"""Fixed, typed tool registry for teacher conversations."""
import re
from dataclasses import dataclass
from typing import Callable, Literal
from fastapi import HTTPException
from pydantic import BaseModel, ConfigDict, Field
from sqlalchemy import select
from .actions import prepare, require_scope
from .evidence import CONTENT_ALLOWED, STRUCTURE_ALLOWED
from .exports import STATUS_LABELS, batch_projects, page_type_label, project_result
from .knowledge_pages import page_links
from .models import (Conversation, Course, CourseVersion, IndexTask, KnowledgeChunk, KnowledgeDocument, KnowledgePage,
Project, RubricVersion, ScoringBatch, Student, SubmissionFile)
from .pages import toc_entries
from .rag import retrieve
from .rules import totals
from .storage import directories, scan
class Args(BaseModel):
model_config = ConfigDict(extra="forbid")
class Empty(Args):
pass
class VersionId(Args):
course_version_id: int = Field(gt=0)
class RulesArgs(Args):
course_version_id: int | None = Field(default=None, gt=0)
batch_id: int | None = Field(default=None, gt=0)
criterion_id: str | None = None
class ProjectId(Args):
project_id: int = Field(gt=0)
class ProjectList(Args):
course_version_id: int = Field(gt=0)
student_no: str | None = None
class SearchCourse(VersionId):
query: str = Field(min_length=2, max_length=300)
class SearchProject(ProjectId):
query: str = Field(min_length=2, max_length=300)
class DocumentId(Args):
document_id: int = Field(gt=0)
class SearchDocument(DocumentId):
query: str = Field(min_length=2, max_length=300)
class Directory(Args):
relative_dir: str = Field(min_length=1, max_length=500)
class ScanArgs(Directory):
offset: int = Field(default=0, ge=0)
limit: int = Field(default=10, ge=1, le=20)
class BatchId(Args):
batch_id: int = Field(gt=0)
class ScoresFilterArgs(BatchId):
project_id: int | None = Field(default=None, gt=0)
status: Literal["pending", "scored", "reviewed", "needs_review", "failed", "unscorable"] | None = None
class ScoresArgs(ScoresFilterArgs):
project_offset: int = Field(default=0, ge=0)
criterion_offset: int = Field(default=0, ge=0)
class CreateBatch(VersionId):
rubric_version_id: int = Field(gt=0)
class Review(Args):
score_id: int = Field(gt=0)
score: float = Field(ge=0)
comment: str = Field(min_length=1, max_length=1000)
reason: str = Field(min_length=1, max_length=500)
class Export(BatchId):
format: Literal["excel", "pdf"]
project_id: int | None = Field(default=None, gt=0)
class PrepareImport(VersionId):
relative_dir: str = Field(min_length=1, max_length=500)
offset: int = Field(default=0, ge=0)
limit: int = Field(default=10, ge=1, le=20)
class IndexRetry(Args):
task_id: int = Field(gt=0)
@dataclass(frozen=True)
class Tool:
description: str
schema: type[Args]
handler: Callable
def _version(db, conversation, version_id):
require_scope(db, conversation, version_id=version_id)
version = db.get(CourseVersion, version_id)
if not version:
raise HTTPException(404, "课程版本不存在")
return version
def _batch(db, conversation, batch_id):
batch, _ = require_scope(db, conversation, batch_id=batch_id)
if not batch:
raise HTTPException(404, "批次不存在")
return batch
def _project(db, conversation, project_id):
project = db.get(Project, project_id)
if not project:
raise HTTPException(404, "项目不存在")
require_scope(db, conversation, project_id=project_id, version_id=project.course_version_id)
return project
def courses(db, convo, args, _user):
rows = []
for course in db.scalars(select(Course).order_by(Course.id)):
if convo.course_version_id and db.get(CourseVersion, convo.course_version_id).course_id != course.id:
continue
versions = db.scalars(select(CourseVersion).where(CourseVersion.course_id == course.id).order_by(CourseVersion.version.desc())).all()
rows.append({"id": course.id, "name": course.name, "code": course.code,
"versions": [{"id": v.id, "version": v.version, "title": v.title, "published": v.published}
for v in versions if not convo.course_version_id or v.id == convo.course_version_id]})
return rows[:30]
def rules(db, convo, args, _user):
def compact_rubric(source):
criteria = source["criteria"]
if args.criterion_id:
criteria = [item for item in criteria if item["criterion_id"] == args.criterion_id]
else:
criteria = [{key: item[key] for key in ("criterion_id", "category", "name", "max_score")}
for item in criteria]
return {"version": source["version"], "criteria": criteria, "weights": source["weights"]}
batch_id = args.batch_id or convo.batch_id
if batch_id:
batch = _batch(db, convo, batch_id)
if args.course_version_id and args.course_version_id != batch.course_version_id:
raise HTTPException(400, "指定课程版本与批次快照不一致")
return {"batch_id": batch.id, "course_version_id": batch.course_version_id,
"course": {key: batch.course_snapshot.get(key) for key in ("version", "title", "objectives", "indicators", "directions")},
"rubric": compact_rubric(batch.rubric_snapshot),
"source": "batch_snapshot", "url": f"/?page=batches&batch_id={batch.id}"}
version_id = args.course_version_id or convo.course_version_id
if not version_id:
raise HTTPException(400, "请指定课程版本;有多个版本时不能猜测")
version = _version(db, convo, version_id)
rubrics = db.scalars(select(RubricVersion).where(RubricVersion.course_version_id == version.id).order_by(RubricVersion.version.desc())).all()
return {"course_version_id": version.id, "title": version.title, "version": version.version,
"objectives": version.objectives, "indicators": version.indicators,
"directions": version.directions, "published": version.published,
"rubrics": [{"id": r.id, "published": r.published,
**compact_rubric({"version": r.version, "criteria": r.criteria, "weights": r.weights})} for r in rubrics[:5]],
"source": "current_version", "url": f"/?page=courses&course_version_id={version.id}"}
def projects(db, convo, args, _user):
_version(db, convo, args.course_version_id)
query = select(Project).where(Project.course_version_id == args.course_version_id).order_by(Project.id)
rows = []
for project in db.scalars(query):
if convo.project_id and project.id != convo.project_id:
continue
student = db.get(Student, project.student_id)
if args.student_no and student.student_no != args.student_no:
continue
rows.append({"id": project.id, "student_no": student.student_no, "name": student.name,
"topic": project.topic, "status": project.status,
"url": f"/?page=projects&project_id={project.id}"})
return {"count": len(rows), "projects": rows[:30], "truncated": len(rows) > 30}
def project(db, convo, args, _user):
item = _project(db, convo, args.project_id)
student = db.get(Student, item.student_id)
files = db.scalars(select(SubmissionFile).where(SubmissionFile.project_id == item.id)).all()
return {"id": item.id, "student_no": student.student_no, "name": student.name,
"topic": item.topic, "direction": item.direction, "course_version_id": item.course_version_id,
"facts": item.facts, "files": [{"id": f.id, "name": f.relative_path.rsplit("/", 1)[-1],
"category": f.category, "parse_status": f.parse_status, "parse_error": f.parse_error} for f in files],
"url": f"/?page=projects&project_id={item.id}"}
def _search(db, version_id, project_id, query, *, public_only=False, scopes=None, limit=6):
"""Content search under the evidence policy: navigation pages are never body text."""
scopes = scopes or ("content",)
allowed = sorted({page_type for scope in scopes for page_type in STRUCTURE_ALLOWED} if "structure" in scopes
else {page_type for scope in scopes for page_type in CONTENT_ALLOWED})
chunks = retrieve(db, course_version_id=version_id, project_id=project_id or 0, query=query,
limit=limit, include_public=False, public_only=public_only,
allowed_page_types=allowed, substantive_only="content" in scopes)
citations = []
for chunk in chunks:
doc = db.get(KnowledgeDocument, chunk["document_id"])
links = page_links(db, chunk["document_id"], chunk["page"])
citations.append({"chunk_id": chunk["chunk_id"], "document_id": chunk["document_id"],
"title": doc.title if doc else "资料", "page": chunk["page"],
"quote": chunk["text"][:500], "project_id": chunk["project_id"],
"page_type": chunk.get("page_type") or links.get("page_type") or "unknown",
"page_type_label": page_type_label(chunk.get("page_type")),
"section": chunk.get("section") or "",
"block_type": chunk.get("block_type") or "body",
"source_role": "structure" if "structure" in scopes else "content",
# Stable identity so an answer can cite exactly what it used.
"citation_id": f"chunk:{chunk['chunk_id']}",
"answer_url": f"{links['url']}#chunk-{chunk['chunk_id']}",
"url": links["url"], "image_url": links["image_url"]})
return {"citations": citations, "found": bool(citations), "scopes": list(scopes)}
def search_course(db, convo, args, _user):
_version(db, convo, args.course_version_id)
return _search(db, args.course_version_id, None, args.query, public_only=True)
def search_project(db, convo, args, _user):
item = _project(db, convo, args.project_id)
return _search(db, item.course_version_id, item.id, args.query)
def search_structure(db, convo, args, _user):
"""Structure and contents lookup: navigation pages plus body headings.
This tool answers questions about chapters, sections and report layout. Its results
are explicitly labelled as structure, so they can never be mistaken for content
evidence about what the student implemented or measured.
"""
item = _project(db, convo, args.project_id)
chunks = retrieve(db, course_version_id=item.course_version_id, project_id=item.id, query=args.query,
limit=8, include_public=False, allowed_page_types=sorted(STRUCTURE_ALLOWED),
substantive_only=False)
citations = []
for chunk in chunks:
doc = db.get(KnowledgeDocument, chunk["document_id"])
links = page_links(db, chunk["document_id"], chunk["page"])
page_type = chunk.get("page_type") or links.get("page_type") or "unknown"
citations.append({
"chunk_id": chunk["chunk_id"], "document_id": chunk["document_id"],
"title": doc.title if doc else "资料", "page": chunk["page"],
"quote": chunk["text"][:500], "project_id": chunk["project_id"],
"page_type": page_type, "page_type_label": page_type_label(page_type),
"section": chunk.get("section") or "", "block_type": chunk.get("block_type") or "body",
"source_role": "structure",
"citation_id": f"chunk:{chunk['chunk_id']}",
"answer_url": f"{links['url']}#chunk-{chunk['chunk_id']}",
"toc_entries": toc_entries([{"kind": "toc", "text": chunk["text"]}])[:40]
if page_type == "toc" else [],
"url": links["url"], "image_url": links["image_url"]})
return {"citations": citations, "found": bool(citations), "scopes": ["structure"],
"note": "这些是结构或目录信息,只能说明章节安排,不能证明章节内容。"}
def get_document_pages(db, convo, args, _user):
"""List a document's pages with page type, so a caller can navigate precisely."""
_document(db, convo, args.document_id)
rows = db.scalars(select(KnowledgePage).where(KnowledgePage.document_id == args.document_id)
.order_by(KnowledgePage.page)).all()
pages: dict[int, dict] = {}
for chunk in rows:
entry = pages.setdefault(chunk.page, {"page": chunk.page, "page_type": chunk.page_type,
"page_type_label": page_type_label(chunk.page_type),
"chapter": chunk.chapter, "section": chunk.section,
"block_types": []})
for block in (chunk.classification or {}).get("blocks", []):
kind = block.get("kind")
if kind and kind not in entry["block_types"]:
entry["block_types"].append(kind)
if chunk.section:
entry["section"] = chunk.section
return {"document_id": args.document_id, "count": len(pages),
"pages": [pages[key] for key in sorted(pages)]}
def search_document(db, convo, args, _user):
"""Content search restricted to one document, under the same page-type policy."""
document = _document(db, convo, args.document_id)
chunks = retrieve(db, course_version_id=document.course_version_id, project_id=document.project_id,
query=args.query, limit=6, include_public=False,
allowed_page_types=sorted(CONTENT_ALLOWED), substantive_only=True, document_id=document.id)
citations = []
for chunk in chunks:
if chunk["document_id"] != document.id:
continue
links = page_links(db, chunk["document_id"], chunk["page"])
citations.append({"chunk_id": chunk["chunk_id"], "document_id": chunk["document_id"],
"title": document.title, "page": chunk["page"], "quote": chunk["text"][:500],
"project_id": chunk["project_id"], "page_type": chunk.get("page_type"),
"page_type_label": page_type_label(chunk.get("page_type")),
"section": chunk.get("section") or "", "source_role": "content",
"citation_id": f"chunk:{chunk['chunk_id']}",
"answer_url": f"{links['url']}#chunk-{chunk['chunk_id']}",
"url": links["url"], "image_url": links["image_url"]})
return {"citations": citations, "found": bool(citations), "scopes": ["content"]}
def _document(db, convo, document_id):
document = db.get(KnowledgeDocument, document_id)
if not document:
raise HTTPException(404, "资料不存在")
require_scope(db, convo, version_id=document.course_version_id, project_id=document.project_id)
return document
def batch_list(db, convo, args, _user):
query = select(ScoringBatch).order_by(ScoringBatch.id.desc())
rows = []
for batch in db.scalars(query):
if convo.batch_id and batch.id != convo.batch_id or convo.course_version_id and batch.course_version_id != convo.course_version_id:
continue
if convo.project_id and batch.project_ids is not None and convo.project_id not in batch.project_ids:
continue
rows.append({"id": batch.id, "course_version_id": batch.course_version_id,
"rubric_version_id": batch.rubric_version_id, "status": batch.status,
"done": batch.progress_done, "total": batch.progress_total,
"project_count": len(batch.project_ids) if batch.project_ids is not None else None,
"legacy_membership": batch.project_ids is None})
return rows[:30]
def progress(db, convo, args, _user):
batch = _batch(db, convo, args.batch_id)
project_ids = [convo.project_id] if convo.project_id else batch.project_ids
return {"id": batch.id, "status": batch.status, "done": batch.progress_done,
"total": batch.progress_total, "error": batch.error,
"project_ids": project_ids, "legacy_membership": batch.project_ids is None,
"url": f"/?page=batches&batch_id={batch.id}"}
def scores(db, convo, args, _user):
batch = _batch(db, convo, args.batch_id)
selected = batch_projects(db, batch)
if args.project_id:
require_scope(db, convo, batch_id=batch.id, project_id=args.project_id)
selected = [p for p in selected if p.id == args.project_id]
if convo.project_id:
selected = [p for p in selected if p.id == convo.project_id]
results = []
for item in selected:
result = project_result(db, batch, item)
if args.status:
result["rows"] = [row for row in result["rows"] if row["status"] == args.status]
if result["rows"]:
results.append((item, result))
page = results[args.project_offset:args.project_offset + 1]
output = []
citations = []
for item, result in page:
url = f"/?page=scores&batch_id={batch.id}&project_id={item.id}"
rows = result["rows"][args.criterion_offset:args.criterion_offset + 6]
output.append({"project_id": item.id, "student_no": str(result["student_no"])[:50], "name": str(result["name"])[:100],
"totals": result["totals"], "url": url, "total_rows": len(result["rows"]),
"rows": [{"criterion_id": str(row["criterion"]["criterion_id"])[:100],
"criterion_name": str(row["criterion"]["name"])[:150],
"max_score": row["criterion"]["max_score"], "score_id": row["score_id"],
"model_score": row["model_score"], "teacher_score": row["teacher_score"],
"final_score": row["final_score"], "status": row["status"],
"status_label": STATUS_LABELS.get(row["status"], row["status"]),
"error": str(row["error"] or "")[:300],
# Which body targets still lack evidence, so the answer can
# explain a review item instead of reporting a score.
"evidence_gaps": [{"target_label": str(gap.get("target_label") or "")[:120],
"searched_page_types": gap.get("searched_page_types") or []}
for gap in (row.get("evidence_gaps") or [])][:6],
"evidence": [{"page": ev["page"],
"quote": ev["quote"][:160], "document": str(ev["document"])[:200],
"page_type": ev.get("page_type") or "unknown",
"page_type_label": page_type_label(ev.get("page_type")),
"target_label": str(ev.get("target_label") or "")[:120],
"historical": bool(ev.get("historical"))}
for ev in row["evidence"][:2]]} for row in rows]})
for row in rows:
for evidence in row["evidence"][:2]:
links = page_links(db, evidence.get("document_id"), evidence["page"])
citations.append({"title": str(evidence["document"])[:200], "page": evidence["page"],
"quote": evidence["quote"][:320], "document_id": evidence.get("document_id"),
"page_type": evidence.get("page_type") or links.get("page_type") or "unknown",
"page_type_label": page_type_label(evidence.get("page_type")),
"target_label": str(evidence.get("target_label") or "")[:120],
"historical": bool(evidence.get("historical")),
"source": "historical_score_evidence" if evidence.get("historical") else "score_evidence",
"url": links["url"], "image_url": links["image_url"], "score_url": url})
if citations:
# Scoring quotations are never presented as report body evidence in an answer.
for citation in citations:
citation["usage"] = ("历史评分引用" if citation.get("historical")
else f"评分证据 · {citation.get('page_type_label')}")
next_criterion_offset = args.criterion_offset + 6 if page and args.criterion_offset + 6 < len(page[0][1]["rows"]) else None
next_project_offset = args.project_offset + 1 if args.project_offset + 1 < len(results) else None
return {"batch_id": batch.id, "status_filter": args.status, "count": len(results),
"project_offset": args.project_offset, "criterion_offset": args.criterion_offset,
"next_project_offset": next_project_offset, "next_criterion_offset": next_criterion_offset,
"results": output, "citations": citations}
def summary(db, convo, args, _user):
batch = _batch(db, convo, args.batch_id)
if args.project_id:
require_scope(db, convo, batch_id=batch.id, project_id=args.project_id)
result = {"batch_id": batch.id, "status": batch.status, "project_count": 0, "complete_count": 0,
"incomplete_count": 0, "criteria_status_counts": {}, "overall_distribution": {},
"model_complete_count": 0, "model_average": None, "final_average": None,
"status_filter": args.status, "matched_criteria_count": 0,
"legacy_membership": batch.project_ids is None,
"url": f"/?page=scores&batch_id={batch.id}"}
model_sum = 0.0
final_sum = 0.0
selected = batch_projects(db, batch)
if args.project_id:
selected = [item for item in selected if item.id == args.project_id]
if convo.project_id:
selected = [item for item in selected if item.id == convo.project_id]
for item in selected:
row = project_result(db, batch, item)
matched = [criterion for criterion in row["rows"] if not args.status or criterion["status"] == args.status]
if not matched:
continue
result["project_count"] += 1
for criterion in matched:
status = criterion["status"]
result["criteria_status_counts"][status] = result["criteria_status_counts"].get(status, 0) + 1
result["matched_criteria_count"] += 1
model_scores = {criterion["criterion"]["criterion_id"]: criterion["model_score"] for criterion in row["rows"]}
model_overall = totals(model_scores, batch.rubric_snapshot["criteria"], batch.rubric_snapshot["weights"])["overall"]
if model_overall is not None:
result["model_complete_count"] += 1
model_sum += model_overall
overall = row["totals"]["overall"]
if overall is None:
result["incomplete_count"] += 1
else:
result["complete_count"] += 1
bucket = f"{int(overall // 10) * 10}-{min(100, int(overall // 10) * 10 + 9)}"
result["overall_distribution"][bucket] = result["overall_distribution"].get(bucket, 0) + 1
final_sum += overall
if result["model_complete_count"]:
result["model_average"] = round(model_sum / result["model_complete_count"], 2)
if result["complete_count"]:
result["final_average"] = round(final_sum / result["complete_count"], 2)
return result
def index_tasks(db, convo, args, _user):
_version(db, convo, args.course_version_id)
tasks = db.scalars(select(IndexTask).where(IndexTask.course_version_id == args.course_version_id).order_by(IndexTask.id.desc()).limit(30)).all()
rows = []
for task in tasks:
if convo.project_id and task.submission_file_id:
file = db.get(SubmissionFile, task.submission_file_id)
if not file or file.project_id != convo.project_id:
continue
rows.append({"id": task.id, "kind": task.kind, "file_id": task.submission_file_id,
"status": task.status, "error": task.error, "result": task.result})
return rows
def browse_directories(db, convo, args, _user):
if convo.project_id or convo.batch_id:
raise HTTPException(400, "请在未限定学生或批次的会话中扫描目录")
return directories(args.relative_dir)
def scan_directories(db, convo, args, _user):
if convo.project_id or convo.batch_id:
raise HTTPException(400, "请在未限定学生或批次的会话中扫描目录")
data = scan(args.relative_dir)
return {**data, "projects": data["projects"][args.offset:args.offset + args.limit],
"count": len(data["projects"]), "offset": args.offset, "limit": args.limit}
def prepare_kind(kind):
def handler(db, convo, args, user_text):
payload = args.model_dump(exclude_none=True)
if kind == "review":
explicit = [float(value) for value in re.findall(r"(?<!\d)(\d+(?:\.\d+)?)\s*分", user_text)]
digits = {char: index for index, char in enumerate("零一二三四五六七八九")}
for value in re.findall(r"([零一二三四五六七八九十]+)\s*分", user_text):
if "十" in value:
left, right = value.split("十", 1)
explicit.append(float((digits[left] if left else 1) * 10 + (digits[right] if right else 0)))
elif len(value) == 1:
explicit.append(float(digits[value]))
if args.score not in explicit:
raise HTTPException(400, "教师须在当前消息中明确给出分数,例如“给 8 分”")
if kind == "export" and args.format == "pdf" and not args.project_id:
raise HTTPException(400, "PDF 导出须指定学生项目")
action = prepare(db, convo, kind, payload)
return {"pending_action": {"id": action.id, "kind": kind, "status": action.status,
"requires_edit": action.preview.get("requires_edit", False),
"expires_at": action.expires_at.isoformat()}}
return handler
def prepare_import(db, convo, args, _user):
data = scan(args.relative_dir)
candidates = data["projects"][args.offset:args.offset + args.limit]
if not candidates:
raise HTTPException(400, "扫描范围内没有学生项目")
projects = []
for candidate in candidates:
unique = {category for category in [file["category"] for file in candidate["files"]]
if category != "unknown" and sum(file["category"] == category for file in candidate["files"]) == 1}
projects.append({"relative_dir": candidate["relative_dir"],
"student_no": candidate["student_no"] or "00000", "name": candidate["name"] or "待确认",
"topic": candidate["topic"] or "待确认", "direction": "",
"files": [{"relative_path": file["relative_path"], "category": file["category"],
"sha256": file["sha256"], "status": "recognized" if file["category"] in unique and file["status"] == "recognized" else "ignored"}
for file in candidate["files"]]})
action = prepare(db, convo, "import", {"course_version_id": args.course_version_id, "projects": projects})
return {"pending_action": {"id": action.id, "kind": "import", "status": action.status,
"requires_edit": action.preview.get("requires_edit", False),
"project_count": len(projects), "total_scanned": len(data["projects"]),
"offset": args.offset, "expires_at": action.expires_at.isoformat()}}
TOOLS: dict[str, Tool] = {
"list_courses": Tool("列出课程、版本和发布状态;名称或版本不清楚时先调用此工具。", Empty, courses),
"get_course_rules": Tool("查询课程目标和规则。指定批次时只返回批次保存的快照。", RulesArgs, rules),
"search_course_material": Tool("检索指定课程版本的课程资料正文,返回原文片段和引用;不含目录、摘要和参考文献页。", SearchCourse, search_course),
"list_directories": Tool("只读浏览 /data/inbox 下的相对目录。", Directory, browse_directories),
"scan_directory": Tool("只读分页扫描学生总目录,返回材料候选、缺失、重复和识别异常。", ScanArgs, scan_directories),
"list_projects": Tool("按课程版本及可选学号查询学生项目。", ProjectList, projects),
"get_project": Tool("读取单个学生项目的元数据、材料和解析状态。", ProjectId, project),
"search_project_material": Tool("只检索指定学生项目的报告正文,返回带页面类型和稳定引用标识的原文片段;不含目录、摘要和参考文献页。", SearchProject, search_project),
"search_project_structure": Tool("查询学生报告的章节结构与目录,返回目录条目和页面类型;只能说明章节安排,不能证明章节内容。", SearchProject, search_structure),
"get_document_pages": Tool("列出某份资料各页的页码、页面类型和章节,用于判断正文页位置。", DocumentId, get_document_pages),
"search_document": Tool("在指定的一份资料内检索正文段落,返回带页面类型和引用标识的片段。", SearchDocument, search_document),
"list_batches": Tool("列出可访问的评分批次。", Empty, batch_list),
"get_batch_progress": Tool("查询评分批次进度、状态和固定项目范围。", BatchId, progress),
"get_scores": Tool("分页查询单学生或单批次评分详情,可按状态筛选。每次返回一个项目、最多六项评分;如需其余评分项,用 next_criterion_offset 作为 criterion_offset 再查;如需下个项目,用 next_project_offset 作为 project_offset,并将 criterion_offset 设为 0。", ScoresArgs, scores),
"summarize_batch": Tool("用数据库计算批次或单学生完成数、未完成数和最终分布,可按评分项状态筛选,不补零。", ScoresFilterArgs, summary),
"list_index_tasks": Tool("查询课程版本的材料解析与索引任务进度。", VersionId, index_tasks),
"prepare_import": Tool("从只读扫描结果准备导入确认卡片;每页最多20个项目,文件有歧义时要求教师在卡片中修正。", PrepareImport, prepare_import),
"prepare_course_ingest": Tool("准备课程资料入库确认卡片。", VersionId, prepare_kind("course_ingest")),
"prepare_create_batch": Tool("准备评分批次创建确认卡片,固定项目清单。", CreateBatch, prepare_kind("create_batch")),
"prepare_start_batch": Tool("准备启动或重试评分批次确认卡片。", BatchId, prepare_kind("start_batch")),
"prepare_review": Tool("准备教师复核确认卡片;教师必须在当前消息明确给出分数。", Review, prepare_kind("review")),
"prepare_export": Tool("准备 Excel 成绩表或逐生 PDF 反馈导出确认卡片。", Export, prepare_kind("export")),
"prepare_index_retry": Tool("准备失败索引任务重试确认卡片。", IndexRetry, prepare_kind("index_retry")),
}
def tool_specs():
return [{"type": "function", "function": {"name": name, "description": tool.description,
"parameters": tool.schema.model_json_schema()}} for name, tool in TOOLS.items()]
def execute(db, conversation, name: str, arguments: dict, user_text: str):
tool = TOOLS.get(name)
if not tool:
raise HTTPException(400, "未注册的工具")
return tool.handler(db, conversation, tool.schema.model_validate(arguments), user_text)