"""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"(?