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:
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user