import json from pathlib import Path from fastapi import APIRouter, Depends, HTTPException from fastapi.responses import FileResponse from pydantic import BaseModel, Field, field_validator, model_validator from sqlalchemy import func, select from sqlalchemy.orm import Session from . import services from .services import found, audit from .config import get_settings from .db import get_db from .evidence import CONTENT_ALLOWED from .exports import (BATCH_STATUS_LABELS, batch_projects, export_excel, export_project_pdf, page_type_label, project_result) from .knowledge_pages import page_image_path, page_links from .models import (AuditLog, Course, CourseVersion, CriterionScore, ExportTask, IndexTask, KnowledgeChunk, KnowledgeDocument, KnowledgePage, Project, RubricVersion, ScoringBatch, Student, SubmissionFile, TeacherRevision) from .materialized import sync_course_entities, sync_rubric_entities from .rules import RubricData from .scoring import ATTENTION_STATUSES from .settings_store import key_configured, model_config, save_model_config from .storage import CATEGORIES, directories, scan router = APIRouter(prefix="/api") def version_dict(item): return {key: getattr(item, key) for key in ("id", "course_id", "version", "title", "objectives", "indicators", "directions", "prompts", "templates", "published", "created_at")} def rubric_dict(item): return {key: getattr(item, key) for key in ("id", "course_version_id", "version", "criteria", "weights", "published", "created_at")} class CourseInput(BaseModel): name: str = Field(min_length=1) code: str = Field(min_length=1) class VersionInput(BaseModel): title: str = Field(min_length=1) source_version_id: int | None = None class VersionEdit(BaseModel): title: str = Field(min_length=1) objectives: list[dict] indicators: list[dict] directions: list[dict] prompts: dict[str, str] templates: list[str] class ImportScan(BaseModel): relative_dir: str class FileConfirm(BaseModel): relative_path: str category: str sha256: str status: str = "recognized" class ProjectConfirm(BaseModel): relative_dir: str student_no: str = Field(pattern=r"^[0-9]{5,20}$") name: str = Field(min_length=1) topic: str = Field(min_length=1) direction: str = "" files: list[FileConfirm] = Field(min_length=1) @field_validator("student_no", "name", "topic", "direction", mode="before") @classmethod def trim_identity(cls, value): return value.strip() if isinstance(value, str) else value @model_validator(mode="after") def validate_selection(self): selected = [file for file in self.files if file.status != "ignored"] if not selected: raise ValueError("至少选择1份资料") categories = [file.category for file in selected] if any(category not in CATEGORIES for category in categories): raise ValueError("请选择有效材料类型") if len(set(categories)) != len(categories): raise ValueError("每类材料只允许选择1份,其余请设为忽略") return self class ImportConfirm(BaseModel): course_version_id: int projects: list[ProjectConfirm] = Field(min_length=1) class BatchInput(BaseModel): course_version_id: int rubric_version_id: int project_ids: list[int] | None = Field(default=None, min_length=1) class ReviewInput(BaseModel): score: float = Field(ge=0) comment: str = Field(min_length=1) reason: str = "" class ModelSettingsInput(BaseModel): base_url: str | None = None model: str | None = None timeout_seconds: int | None = Field(default=None, ge=10, le=600) concurrency: int | None = Field(default=None, ge=1, le=16) retries: int | None = Field(default=None, ge=0, le=5) api_key: str | None = None @router.get("/dashboard") def dashboard(db: Session = Depends(get_db)): """Overview counts. Items awaiting teacher review are counted as attention needed. A ``needs_review`` item is not a failure: retrieval ran and found only navigation pages where the rule requires body content, so a teacher decides it. """ counts = dict(db.execute(select(CriterionScore.status, func.count(CriterionScore.id)) .group_by(CriterionScore.status)).all()) return {"courses": db.scalar(select(func.count(Course.id))), "projects": db.scalar(select(func.count(Project.id))), "batches": db.scalar(select(func.count(ScoringBatch.id))), "needs_attention": sum(counts.get(status, 0) for status in ATTENTION_STATUSES), "needs_review": counts.get("needs_review", 0), "unscorable": counts.get("unscorable", 0), "failed": counts.get("failed", 0), "status_counts": counts} @router.get("/courses") def list_courses(db: Session = Depends(get_db)): return [{"id": c.id, "name": c.name, "code": c.code} for c in db.scalars(select(Course).order_by(Course.id))] @router.post("/courses") def create_course(data: CourseInput, db: Session = Depends(get_db)): if db.scalar(select(Course.id).where((Course.code == data.code) | (Course.name == data.name))): raise HTTPException(409, "课程名称或代码已存在") course = Course(**data.model_dump()) db.add(course) db.flush() version = CourseVersion(course_id=course.id, version=1, title="初始草稿", objectives=[], indicators=[], directions=[], prompts={}, templates=[]) db.add(version) db.flush() sync_course_entities(db, version) audit(db, "create", "course", course.id) db.commit() return {"id": course.id} @router.get("/courses/{course_id}/versions") def list_versions(course_id: int, db: Session = Depends(get_db)): found(db, Course, course_id) return [version_dict(v) for v in db.scalars(select(CourseVersion).where(CourseVersion.course_id == course_id).order_by(CourseVersion.version.desc()))] @router.get("/course-versions/{version_id}") def get_version(version_id: int, db: Session = Depends(get_db)): return version_dict(found(db, CourseVersion, version_id)) @router.post("/courses/{course_id}/versions") def copy_version(course_id: int, data: VersionInput, db: Session = Depends(get_db)): found(db, Course, course_id) source = found(db, CourseVersion, data.source_version_id) if data.source_version_id else db.scalar(select(CourseVersion).where(CourseVersion.course_id == course_id).order_by(CourseVersion.version.desc())) if source and source.course_id != course_id: raise HTTPException(400, "源版本不属于课程") next_version = (db.scalar(select(func.max(CourseVersion.version)).where(CourseVersion.course_id == course_id)) or 0) + 1 fields = {key: json.loads(json.dumps(getattr(source, key))) if source else ([] if key != "prompts" else {}) for key in ("objectives", "indicators", "directions", "prompts", "templates")} version = CourseVersion(course_id=course_id, version=next_version, title=data.title, published=False, **fields) db.add(version) db.flush() sync_course_entities(db, version) if source: rubric = db.scalar(select(RubricVersion).where(RubricVersion.course_version_id == source.id).order_by(RubricVersion.version.desc())) if rubric: copied = RubricVersion(course_version_id=version.id, version=1, criteria=rubric.criteria, weights=rubric.weights, published=False) db.add(copied) db.flush() sync_rubric_entities(db, copied) audit(db, "copy", "course_version", version.id, {"source_id": source.id if source else None}) db.commit() return version_dict(version) @router.put("/course-versions/{version_id}") def edit_version(version_id: int, data: VersionEdit, db: Session = Depends(get_db)): version = found(db, CourseVersion, version_id) if version.published: raise HTTPException(409, "已发布版本不可修改,请复制新版本") for key, value in data.model_dump().items(): setattr(version, key, value) sync_course_entities(db, version) audit(db, "edit", "course_version", version.id) db.commit() return version_dict(version) @router.post("/course-versions/{version_id}/publish") def publish_version(version_id: int, db: Session = Depends(get_db)): version = found(db, CourseVersion, version_id) if not version.objectives or not version.indicators or not all(k in version.prompts for k in ("facts", "score", "consistency")): raise HTTPException(400, "课程目标、指标点和三个Prompt不能为空") version.published = True audit(db, "publish", "course_version", version.id) db.commit() return version_dict(version) @router.get("/course-versions/{version_id}/rubrics") def list_rubrics(version_id: int, db: Session = Depends(get_db)): found(db, CourseVersion, version_id) return [rubric_dict(r) for r in db.scalars(select(RubricVersion).where(RubricVersion.course_version_id == version_id).order_by(RubricVersion.version.desc()))] @router.post("/course-versions/{version_id}/rubrics") def create_rubric(version_id: int, data: RubricData, db: Session = Depends(get_db)): found(db, CourseVersion, version_id) next_version = (db.scalar(select(func.max(RubricVersion.version)).where(RubricVersion.course_version_id == version_id)) or 0) + 1 rubric = RubricVersion(course_version_id=version_id, version=next_version, criteria=[c.model_dump() for c in data.criteria], weights=data.weights) db.add(rubric) db.flush() sync_rubric_entities(db, rubric) db.commit() return rubric_dict(rubric) @router.post("/course-versions/{version_id}/rubrics/copy-from/{source_id}") def create_rubric_from_source(version_id: int, source_id: int, db: Session = Depends(get_db)): found(db, CourseVersion, version_id) source = found(db, RubricVersion, source_id) next_version = (db.scalar(select(func.max(RubricVersion.version)).where(RubricVersion.course_version_id == version_id)) or 0) + 1 rubric = RubricVersion(course_version_id=version_id, version=next_version, criteria=json.loads(json.dumps(source.criteria)), weights=dict(source.weights), published=False) db.add(rubric) db.flush() sync_rubric_entities(db, rubric) audit(db, "copy", "rubric", rubric.id, {"source_id": source.id}) db.commit() return rubric_dict(rubric) @router.get("/rubrics/catalog") def rubric_catalog(db: Session = Depends(get_db)): return [{"id": r.id, "course_version_id": r.course_version_id, "version": r.version, "criteria_count": len(r.criteria)} for r in db.scalars(select(RubricVersion).where(RubricVersion.published.is_(True)).order_by(RubricVersion.id))] @router.get("/rubrics/{rubric_id}") def get_rubric(rubric_id: int, db: Session = Depends(get_db)): return rubric_dict(found(db, RubricVersion, rubric_id)) @router.post("/rubrics/{rubric_id}/copy") def copy_rubric(rubric_id: int, db: Session = Depends(get_db)): source = found(db, RubricVersion, rubric_id) next_version = (db.scalar(select(func.max(RubricVersion.version)).where(RubricVersion.course_version_id == source.course_version_id)) or 0) + 1 rubric = RubricVersion(course_version_id=source.course_version_id, version=next_version, criteria=json.loads(json.dumps(source.criteria)), weights=dict(source.weights), published=False) db.add(rubric) db.flush() sync_rubric_entities(db, rubric) audit(db, "copy", "rubric", rubric.id, {"source_id": source.id}) db.commit() return rubric_dict(rubric) @router.put("/rubrics/{rubric_id}") def edit_rubric(rubric_id: int, data: RubricData, db: Session = Depends(get_db)): rubric = found(db, RubricVersion, rubric_id) if rubric.published: raise HTTPException(409, "已发布规则不可修改,请复制新版本") rubric.criteria, rubric.weights = [c.model_dump() for c in data.criteria], data.weights sync_rubric_entities(db, rubric) audit(db, "edit", "rubric", rubric.id) db.commit() return rubric_dict(rubric) @router.post("/rubrics/{rubric_id}/publish") def publish_rubric(rubric_id: int, db: Session = Depends(get_db)): rubric = found(db, RubricVersion, rubric_id) data = RubricData(criteria=rubric.criteria, weights=rubric.weights) version = found(db, CourseVersion, rubric.course_version_id) objective_codes = {str(item.get("code")) for item in version.objectives} indicator_codes = {str(item.get("code")) for item in version.indicators} for criterion in data.criteria: if not set(criterion.linked_objectives) <= objective_codes or not set(criterion.linked_outcomes) <= indicator_codes: raise HTTPException(400, f"{criterion.criterion_id}关联了课程版本不存在的目标或指标点") rubric.published = True audit(db, "publish", "rubric", rubric.id) db.commit() return rubric_dict(rubric) @router.get("/storage/directories") def list_directories(relative: str = "."): return directories(relative) @router.post("/import/scan") def scan_import(data: ImportScan): return scan(data.relative_dir) @router.post("/import/confirm") def confirm_import(data: ImportConfirm, db: Session = Depends(get_db)): return services.confirm_import(data, db) @router.get("/projects") def list_projects(course_version_id: int | None = None, db: Session = Depends(get_db)): query = select(Project).order_by(Project.id.desc()) if course_version_id: query = query.where(Project.course_version_id == course_version_id) result = [] for project in db.scalars(query): student = db.get(Student, project.student_id) files = db.scalars(select(SubmissionFile).where(SubmissionFile.project_id == project.id)).all() result.append({"id": project.id, "student_no": student.student_no, "name": student.name, "topic": project.topic, "course_version_id": project.course_version_id, "status": project.status, "missing": [category for category in CATEGORIES if not any(file.category == category and file.recognition_status == "recognized" for file in files)], "materials": [{"id": file.id, "file_name": Path(file.relative_path).name, "category": file.category, "parse_status": file.parse_status, "parse_error": file.parse_error} for file in files if file.recognition_status == "recognized"], "parse_errors": [file.parse_error for file in files if file.parse_error]}) return result @router.get("/projects/{project_id}") def get_project(project_id: int, db: Session = Depends(get_db)): project = found(db, Project, project_id) student = db.get(Student, project.student_id) files = db.scalars(select(SubmissionFile).where(SubmissionFile.project_id == project.id)).all() return {"id": project.id, "student_no": student.student_no, "name": student.name, "topic": project.topic, "course_version_id": project.course_version_id, "direction": project.direction, "facts": project.facts, "files": [{**{key: getattr(file, key) for key in ("id", "category", "relative_path", "sha256", "parse_status", "parse_error", "recognition_status")}, "warnings": [{"page": page["page_number"], "message": message} for page in (file.parsed or {}).get("pages", []) for message in ([page.get("text_quality", {}).get("warning")] + [table.get("warning") for table in page.get("tables", [])]) if message]} for file in files]} @router.get("/knowledge") def knowledge(course_version_id: int, project_id: int | None = None, db: Session = Depends(get_db)): query = select(KnowledgeDocument).where(KnowledgeDocument.course_version_id == course_version_id, KnowledgeDocument.project_id == project_id) documents = db.scalars(query).all() counts = dict(db.execute(select(KnowledgeChunk.document_id, func.count(KnowledgeChunk.id)) .where(KnowledgeChunk.document_id.in_(query.with_only_columns(KnowledgeDocument.id))) .group_by(KnowledgeChunk.document_id)).all()) if documents else {} return [{"id": doc.id, "title": doc.title, "type": doc.document_type, "status": doc.status, "index_version": doc.index_version, "revision": doc.revision, "requires_reindex": doc.status != "superseded" and (doc.index_version != services.INDEX_VERSION or doc.status != "ready"), "chunks": counts.get(doc.id, 0)} for doc in documents] @router.get("/knowledge/documents/{document_id}/pages/{page}") def knowledge_page(document_id: int, page: int, db: Session = Depends(get_db)): doc = found(db, KnowledgeDocument, document_id) record = db.get(KnowledgePage, (document_id, page)) if not record: raise HTTPException(404, "资料页面不存在") return {"document_id": doc.id, "title": doc.title, "course_version_id": doc.course_version_id, "project_id": doc.project_id, "page": page, "text": record.text, "page_type": record.page_type or "unknown", "page_type_label": page_type_label(record.page_type), "mixed": bool(record.mixed), "section": record.section or "", "chapter": record.chapter or "", "confidence": record.confidence, "classification": record.classification or {}, "content_allowed": (record.page_type or "unknown") in CONTENT_ALLOWED, "image_url": page_links(db, document_id, page)["image_url"]} @router.get("/knowledge/documents/{document_id}/pages") def knowledge_document_pages(document_id: int, db: Session = Depends(get_db)): """Page inventory with types, so the teacher can see how a document was classified.""" doc = found(db, KnowledgeDocument, document_id) records = db.scalars(select(KnowledgePage).where(KnowledgePage.document_id == document_id) .order_by(KnowledgePage.page)).all() return {"document_id": doc.id, "title": doc.title, "index_version": doc.index_version, "pages": [{"page": item.page, "page_type": item.page_type or "unknown", "page_type_label": page_type_label(item.page_type), "mixed": bool(item.mixed), "section": item.section or "", "chapter": item.chapter or "", "confidence": item.confidence, "classification": item.classification or {}, "content_allowed": (item.page_type or "unknown") in CONTENT_ALLOWED} for item in records]} @router.get("/knowledge/documents/{document_id}/pages/{page}/image") def knowledge_page_image(document_id: int, page: int, db: Session = Depends(get_db)): doc = found(db, KnowledgeDocument, document_id) path = page_image_path(db, doc, page) if not path: raise HTTPException(404, "页面图像不存在") return FileResponse(path, media_type="image/png") @router.post("/knowledge/ingest-course/{course_version_id}") def ingest_course(course_version_id: int, db: Session = Depends(get_db)): return services.ingest_course(course_version_id, db) @router.get("/knowledge/stale/{course_version_id}") def stale_knowledge(course_version_id: int, db: Session = Depends(get_db)): """Documents still on an older index; they are excluded from retrieval until rebuilt.""" from .indexing import stale_documents from .rag import INDEX_VERSION found(db, CourseVersion, course_version_id) documents = stale_documents(db, course_version_id) return {"index_version": INDEX_VERSION, "documents": [{"id": item.id, "title": item.title, "type": item.document_type, "project_id": item.project_id, "index_version": item.index_version, "status": item.status} for item in documents]} @router.post("/knowledge/reindex/{course_version_id}") def reindex_knowledge(course_version_id: int, db: Session = Depends(get_db)): """Queue a rebuild of every stale index for this course version.""" from .indexing import create_reindex_task found(db, CourseVersion, course_version_id) task = create_reindex_task(db, course_version_id) return get_index_task(task.id, db) @router.get("/index-tasks") def list_index_tasks(project_id: int | None = None, db: Session = Depends(get_db)): query = select(IndexTask).order_by(IndexTask.id.desc()).limit(100) if project_id is not None: file_ids = select(SubmissionFile.id).where(SubmissionFile.project_id == project_id) query = query.where(IndexTask.submission_file_id.in_(file_ids)) return [{key: getattr(task, key) for key in ("id", "course_version_id", "submission_file_id", "kind", "status", "result", "error", "created_at", "updated_at")} for task in db.scalars(query)] @router.get("/index-tasks/{task_id}") def get_index_task(task_id: int, db: Session = Depends(get_db)): task = found(db, IndexTask, task_id) return {key: getattr(task, key) for key in ("id", "course_version_id", "submission_file_id", "kind", "status", "result", "error", "created_at", "updated_at")} @router.post("/index-tasks/{task_id}/retry") def retry_index_task(task_id: int, db: Session = Depends(get_db)): retry = services.retry_index_task(db, task_id) return get_index_task(retry.id, db) @router.post("/scoring-batches") def create_batch(data: BatchInput, db: Session = Depends(get_db)): return services.create_batch(data, db) def batch_status_counts(db, batch_id: int) -> dict: rows = db.execute(select(CriterionScore.status, func.count(CriterionScore.id)) .where(CriterionScore.batch_id == batch_id).group_by(CriterionScore.status)).all() counts = {status: count for status, count in rows} return {"status_counts": counts, "needs_review": counts.get("needs_review", 0), "unscorable": counts.get("unscorable", 0), "failed": counts.get("failed", 0)} def batch_version_metadata(batch: ScoringBatch) -> dict: """Display the immutable rule/course versions, not their database IDs.""" return {"course_version": batch.course_snapshot.get("version"), "course_title": batch.course_snapshot.get("title", ""), "rubric_version": batch.rubric_snapshot.get("version"), "criterion_count": len(batch.rubric_snapshot.get("criteria", []))} @router.get("/scoring-batches") def list_batches(db: Session = Depends(get_db)): return [{**{key: getattr(batch, key) for key in ("id", "course_version_id", "rubric_version_id", "status", "progress_done", "progress_total", "error", "created_at", "finished_at")}, "status_label": BATCH_STATUS_LABELS.get(batch.status, batch.status), "project_ids": batch.project_ids, "legacy_membership": batch.project_ids is None, **batch_version_metadata(batch), **batch_status_counts(db, batch.id)} for batch in db.scalars(select(ScoringBatch).order_by(ScoringBatch.id.desc()))] @router.get("/scoring-batches/{batch_id}/progress") def batch_progress(batch_id: int, db: Session = Depends(get_db)): batch = found(db, ScoringBatch, batch_id) return {**{key: getattr(batch, key) for key in ("id", "status", "progress_done", "progress_total", "error", "created_at", "finished_at")}, "status_label": BATCH_STATUS_LABELS.get(batch.status, batch.status), "project_ids": batch.project_ids, "legacy_membership": batch.project_ids is None, **batch_version_metadata(batch), **batch_status_counts(db, batch_id)} @router.post("/scoring-batches/{batch_id}/start") def start_batch(batch_id: int, db: Session = Depends(get_db)): return services.start_batch(batch_id, db) @router.get("/scoring-batches/{batch_id}/scores") def batch_scores(batch_id: int, db: Session = Depends(get_db)): batch = found(db, ScoringBatch, batch_id) projects = batch_projects(db, batch) return [project_result(db, batch, project) for project in projects] @router.get("/projects/{project_id}/scores") def project_scores(project_id: int, batch_id: int, db: Session = Depends(get_db)): batch, project = found(db, ScoringBatch, batch_id), found(db, Project, project_id) if project.course_version_id != batch.course_version_id: raise HTTPException(400, "项目不属于评分批次课程版本") if batch.project_ids is not None and project.id not in batch.project_ids: raise HTTPException(400, "项目不属于该批次固定项目清单") return project_result(db, batch, project) @router.put("/criterion-scores/{score_id}/review") def review_score(score_id: int, data: ReviewInput, db: Session = Depends(get_db)): return services.review_score(score_id, data, db) @router.get("/criterion-scores/{score_id}/revisions") def revisions(score_id: int, db: Session = Depends(get_db)): found(db, CriterionScore, score_id) return [{key: getattr(item, key) for key in ("id", "old_score", "new_score", "old_comment", "new_comment", "reason", "created_at")} for item in db.scalars(select(TeacherRevision).where(TeacherRevision.score_id == score_id).order_by(TeacherRevision.id))] @router.post("/exports/excel") def create_excel(data: dict, db: Session = Depends(get_db)): task = export_excel(db, int(data["batch_id"])) audit(db, "export", "scoring_batch", task.batch_id, {"kind": "excel", "task_id": task.id}) db.commit() return {"id": task.id} @router.post("/exports/project-pdf") def create_pdf(data: dict, db: Session = Depends(get_db)): task = export_project_pdf(db, int(data["batch_id"]), int(data["project_id"])) audit(db, "export", "project", task.project_id, {"kind": "pdf", "task_id": task.id}) db.commit() return {"id": task.id} @router.get("/exports") def list_exports(db: Session = Depends(get_db)): return [{key: getattr(task, key) for key in ("id", "batch_id", "project_id", "kind", "status", "error", "created_at")} for task in db.scalars(select(ExportTask).order_by(ExportTask.id.desc()))] @router.get("/exports/{task_id}") def get_export(task_id: int, db: Session = Depends(get_db)): task = found(db, ExportTask, task_id) return {key: getattr(task, key) for key in ("id", "batch_id", "project_id", "kind", "status", "error", "created_at")} @router.get("/exports/{task_id}/download") def download_export(task_id: int, db: Session = Depends(get_db)): task = found(db, ExportTask, task_id) root = get_settings().exports_path.resolve() path = Path(task.path or "").resolve() if task.status != "completed" or not path.is_file() or not path.is_relative_to(root): raise HTTPException(404, "导出文件不存在") return FileResponse(path, filename=path.name) @router.get("/assets/{file_id}/{page}") def page_asset(file_id: int, page: int, db: Session = Depends(get_db)): file = found(db, SubmissionFile, file_id) pages = file.parsed.get("pages", []) if file.parsed else [] item = next((item for item in pages if item["page_number"] == page), None) path = Path(item["rendered_image_path"]).resolve() if item and item.get("rendered_image_path") else None root = get_settings().processed_path.resolve() if not path or not path.is_file() or not path.is_relative_to(root): raise HTTPException(404, "页面图像不存在") return FileResponse(path, media_type="image/png") @router.get("/settings/model") def get_model_settings(db: Session = Depends(get_db)): return {**model_config(db), "api_key_configured": key_configured(db), "embedding_model_name": get_settings().embedding_model_name, "embedding_model_path": str(get_settings().embedding_model_path), "embedding_model_version": get_settings().embedding_model_version} @router.put("/settings/model") def put_model_settings(data: ModelSettingsInput, db: Session = Depends(get_db)): result = save_model_config(db, data.model_dump(exclude_none=True)) audit(db, "edit", "model_settings", None, {"fields": [key for key in data.model_fields_set if key != "api_key"]}) db.commit() return result