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.
This commit is contained in:
2026-10-14 13:52:56 +08:00
parent 29c95ce5ad
commit ebe33392ca
42 changed files with 2332 additions and 304 deletions
+51 -17
View File
@@ -7,15 +7,15 @@ from uuid import uuid4
from fastapi import HTTPException
from redis import Redis
from rq import Queue, Retry
from sqlalchemy import delete, func, select
from sqlalchemy import select
from .config import get_settings
from .evidence import plan_snapshot
from .exports import export_excel, export_project_pdf
from .models import (AuditLog, CourseVersion, CriterionScore, IndexTask, KnowledgeChunk, KnowledgeDocument,
from .evidence import POLICY_VERSION, plan_snapshot
from .models import (AuditLog, CourseVersion, CriterionScore, IndexTask, KnowledgeDocument,
Project, RubricVersion, ScoringBatch, Student, Submission, SubmissionFile, TeacherRevision)
from .parsing import parse_document
from .rag import INDEX_VERSION, ingest
from .scoring import file_index_lock, run_batch
from .scoring import run_batch, refresh_batch_status
from .submissions import file_index_lock
from .settings_store import model_config
from .storage import CATEGORIES, SUPPORTED, secure_path, sha256
@@ -30,8 +30,9 @@ def audit(db, action, entity_type, entity_id, details=None):
def remove_file_index(db, file):
for document in db.scalars(select(KnowledgeDocument).where(KnowledgeDocument.submission_file_id == file.id)).all():
db.execute(delete(KnowledgeChunk).where(KnowledgeChunk.document_id == document.id))
db.delete(document)
# Historical score/chat citations still point at these pages. Retire the
# index from new retrieval without deleting its source or breaking FKs.
document.status = "superseded"
file.parsed, file.parse_status = {}, "pending"
def confirm_import(data, db):
@@ -93,8 +94,7 @@ def confirm_import(data, db):
for old in db.scalars(select(SubmissionFile).where(SubmissionFile.project_id == project.id)).all():
if old.relative_path not in selected_paths:
old.recognition_status = "ignored"
if old.parse_status == "ready":
remove_file_index(db, old)
remove_file_index(db, old)
for category in CATEGORIES:
submission = db.scalar(select(Submission).where(Submission.project_id == project.id, Submission.category == category))
if not submission:
@@ -108,6 +108,7 @@ def confirm_import(data, db):
audit(db, "import", "project", project.id, {"files": len(selected)})
imported.append(project.id)
tasks = []
newly_created = []
for file_id in sorted(set(files_to_index)):
current = db.scalar(select(IndexTask).where(IndexTask.submission_file_id == file_id,
IndexTask.status.in_(["queued", "running"])).order_by(IndexTask.id.desc()))
@@ -115,9 +116,10 @@ def confirm_import(data, db):
current = IndexTask(course_version_id=version.id, submission_file_id=file_id, kind="project")
db.add(current)
db.flush()
newly_created.append(current.id)
tasks.append(current.id)
db.commit()
for task_id in tasks:
for task_id in newly_created:
enqueue_index_task(db, task_id)
return {"project_ids": imported, "index_task_ids": tasks}
@@ -178,12 +180,21 @@ def _ingest_course_locked(course_version_id: int, db):
continue
try:
category = "stage1" if "阶段报告1" in filename else "stage2" if "阶段报告2" in filename else "stage3" if "阶段报告3" in filename else "course"
parsed = parse_document(path, category, processed / hashlib.sha256(filename.encode()).hexdigest()[:16])
source_hash = sha256(path)
cached = db.scalar(select(KnowledgeDocument).where(KnowledgeDocument.course_version_id == version.id,
KnowledgeDocument.project_id.is_(None), KnowledgeDocument.title == filename,
KnowledgeDocument.source_hash == source_hash, KnowledgeDocument.index_version == INDEX_VERSION,
KnowledgeDocument.status == "ready"))
if cached:
completed.append(filename)
continue
parsed = parse_document(path, category, processed / hashlib.sha256(filename.encode()).hexdigest()[:16] / source_hash[:16])
ingest(db, course_version_id=version.id, project_id=None, submission_file_id=None,
title=filename, category=category, source_hash=sha256(path), pages=parsed["pages"])
title=filename, category=category, source_hash=source_hash, pages=parsed["pages"])
completed.append(filename)
except Exception as exc:
errors[filename] = str(exc)[:500]
db.rollback()
rubric = db.scalar(select(RubricVersion).where(RubricVersion.course_version_id == version.id,
RubricVersion.published.is_(True)).order_by(RubricVersion.version.desc()))
virtual = [
@@ -202,6 +213,7 @@ def _ingest_course_locked(course_version_id: int, db):
completed.append(title)
except Exception as exc:
errors[title] = str(exc)[:500]
db.rollback()
return {"completed": completed, "errors": errors}
def create_batch(data, db):
@@ -209,6 +221,19 @@ def create_batch(data, db):
rubric = found(db, RubricVersion, data.rubric_version_id)
if not version.published or not rubric.published or rubric.course_version_id != version.id:
raise HTTPException(400, "请选择同一课程版本下已发布的规则")
available_ids = db.scalars(select(Project.id).where(
Project.course_version_id == version.id).order_by(Project.id)).all()
requested_ids = getattr(data, "project_ids", None)
if requested_ids is None:
project_ids = available_ids # Preserve older callers' explicit all-course default.
else:
if not requested_ids:
raise HTTPException(400, "请至少选择一个要评分的学生项目")
if len(requested_ids) != len(set(requested_ids)):
raise HTTPException(400, "评分项目清单包含重复项目")
if any(project_id not in available_ids for project_id in requested_ids):
raise HTTPException(400, "所选项目不存在或不属于该课程版本")
project_ids = list(requested_ids)
settings = get_settings()
# The evidence policy travels with the batch snapshot, so a batch can always be read
# back against the rules and page permissions it was actually scored with.
@@ -218,11 +243,10 @@ def create_batch(data, db):
"weights": rubric.weights, "evidence_plan": plan_snapshot(rubric.criteria)},
model_config_snapshot=model_config(db), embedding_model_name=settings.embedding_model_name,
embedding_model_version=settings.embedding_model_version,
status="created", project_ids=db.scalars(select(Project.id).where(
Project.course_version_id == version.id).order_by(Project.id)).all())
status="created", project_ids=project_ids)
db.add(batch)
db.flush()
audit(db, "create", "scoring_batch", batch.id)
audit(db, "create", "scoring_batch", batch.id, {"project_ids": project_ids})
db.commit()
return {"id": batch.id, "status": batch.status}
@@ -233,6 +257,10 @@ def start_batch(batch_id: int, db):
if batch.status not in ("created", "failed", "partial", "review_required"):
db.rollback()
raise HTTPException(409, "批次已入队、运行中或已完成")
saved_policy = batch.rubric_snapshot.get("evidence_plan", {}).get("version")
if saved_policy != POLICY_VERSION:
db.rollback()
raise HTTPException(409, "该批次使用旧证据政策,请保留历史结果并创建新批次")
try:
Queue("wisegrade", connection=Redis.from_url(get_settings().redis_url)).enqueue(run_batch, batch_id,
job_id=f"wisegrade-batch-{batch_id}-{uuid4().hex}",
@@ -255,8 +283,12 @@ def review_score(score_id: int, data, db):
retrieval could not support. The gaps from the original retrieval stay on the record
after the review, so the reason it reached the teacher remains auditable.
"""
score = found(db, CriterionScore, score_id)
batch = found(db, ScoringBatch, score.batch_id)
score = db.scalar(select(CriterionScore).where(CriterionScore.id == score_id)
.with_for_update().execution_options(populate_existing=True))
if not score:
raise HTTPException(404, "评分项不存在")
batch = db.scalar(select(ScoringBatch).where(ScoringBatch.id == score.batch_id)
.with_for_update().execution_options(populate_existing=True))
criterion = next((item for item in batch.rubric_snapshot["criteria"] if item["criterion_id"] == score.criterion_id), None)
if not criterion or data.score > criterion["max_score"]:
raise HTTPException(400, "教师分数超出规则满分")
@@ -270,6 +302,8 @@ def review_score(score_id: int, data, db):
audit(db, "review", "criterion_score", score.id,
{"reason": data.reason, "from_status": from_status,
"came_from_review": bool(score.evidence_gaps)})
db.flush()
refresh_batch_status(db, batch)
db.commit()
return {"id": score.id, "model_score": score.model_score, "teacher_score": score.teacher_score,
"final_score": score.final_score, "teacher_comment": score.teacher_comment,