Files
WiseGrade/backend/wisegrade/api.py
T

567 lines
26 KiB
Python

import json
from pathlib import Path
from fastapi import APIRouter, Depends, HTTPException
from fastapi.responses import FileResponse
from pydantic import BaseModel, Field
from sqlalchemy import func, select
from sqlalchemy.orm import Session
from . import services
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 found(db, cls, entity_id):
item = db.get(cls, entity_id)
if not item:
raise HTTPException(404, f"{cls.__name__}不存在")
return item
def audit(db, action, entity_type, entity_id, details=None):
db.add(AuditLog(action=action, entity_type=entity_type, entity_id=entity_id, details=details or {}))
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"^\d{5,20}$")
name: str = Field(min_length=1)
topic: str = Field(min_length=1)
direction: str = ""
files: list[FileConfirm]
class ImportConfirm(BaseModel):
course_version_id: int
projects: list[ProjectConfirm]
class BatchInput(BaseModel):
course_version_id: int
rubric_version_id: int
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)],
"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")} 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)
return [{"id": doc.id, "title": doc.title, "type": doc.document_type, "status": doc.status,
"chunks": db.scalar(select(func.count(KnowledgeChunk.id)).where(KnowledgeChunk.document_id == doc.id))} for doc in db.scalars(query)]
@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)}
@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_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_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