Files
WiseGrade/backend/wisegrade/agent_tools.py
T

437 lines
22 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 .exports import batch_projects, project_result
from .models import (Conversation, Course, CourseVersion, IndexTask, KnowledgeDocument,
Project, RubricVersion, ScoringBatch, Student, SubmissionFile)
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 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 ScoresArgs(BatchId):
project_id: int | None = Field(default=None, gt=0)
status: Literal["pending", "scored", "reviewed", "failed", "unscorable"] | None = None
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):
chunks = retrieve(db, course_version_id=version_id, project_id=project_id or 0, query=query,
limit=6, include_public=False, public_only=public_only)
citations = []
for chunk in chunks:
doc = db.get(KnowledgeDocument, chunk["document_id"])
file_id = doc.submission_file_id if doc else None
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"],
"url": f"/api/knowledge/documents/{chunk['document_id']}/pages/{chunk['page']}",
"image_url": f"/api/assets/{file_id}/{chunk['page']}" if file_id else None})
return {"citations": citations, "found": bool(citations)}
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 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 = []
citations = []
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"]:
result["url"] = f"/?page=scores&batch_id={batch.id}&project_id={item.id}"
results.append({"project_id": item.id, "student_no": result["student_no"], "name": result["name"],
"totals": result["totals"], "url": result["url"],
"rows": [{"criterion_id": row["criterion"]["criterion_id"],
"criterion_name": row["criterion"]["name"],
"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"],
"error": row["error"], "evidence": [{"page": ev["page"],
"quote": ev["quote"][:240], "document": ev["document"]}
for ev in row["evidence"][:2]]} for row in result["rows"]]})
for row in result["rows"]:
for evidence in row["evidence"][:2]:
citations.append({"title": evidence["document"], "page": evidence["page"],
"quote": evidence["quote"][:500],
"url": evidence["image_url"] or result["url"]})
return {"batch_id": batch.id, "status_filter": args.status, "count": len(results),
"results": results[:2], "citations": citations[:12], "truncated": len(results) > 2}
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),
"list_batches": Tool("列出可访问的评分批次。", Empty, batch_list),
"get_batch_progress": Tool("查询评分批次进度、状态和固定项目范围。", BatchId, progress),
"get_scores": Tool("查询单学生或单批次评分详情,可按评分项状态筛选。", ScoresArgs, scores),
"summarize_batch": Tool("用数据库计算批次或单学生完成数、未完成数和最终分布,可按评分项状态筛选,不补零。", ScoresArgs, 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)