Files
WiseGrade/backend/wisegrade/services.py
T
aerospaceboy ebe33392ca 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.
2026-10-14 13:52:56 +08:00

311 lines
17 KiB
Python

"""Shared business operations used by REST routes and the conversation agent."""
import hashlib
import json
import mimetypes
from pathlib import Path
from uuid import uuid4
from fastapi import HTTPException
from redis import Redis
from rq import Queue, Retry
from sqlalchemy import select
from .config import get_settings
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 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
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 remove_file_index(db, file):
for document in db.scalars(select(KnowledgeDocument).where(KnowledgeDocument.submission_file_id == file.id)).all():
# 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):
version = found(db, CourseVersion, data.course_version_id)
if not version.published:
raise HTTPException(400, "请先发布课程版本")
imported = []
files_to_index = []
for item in data.projects:
folder = secure_path(item.relative_dir)
if not folder.is_dir():
raise ValueError("学生目录不存在")
student = db.scalar(select(Student).where(Student.student_no == item.student_no))
if not student:
student = Student(student_no=item.student_no, name=item.name)
db.add(student)
db.flush()
else:
student.name = item.name
project = db.scalar(select(Project).where(Project.course_version_id == version.id, Project.source_dir == item.relative_dir))
if not project:
project = Project(course_version_id=version.id, student_id=student.id, source_dir=item.relative_dir,
topic=item.topic, direction=item.direction)
db.add(project)
db.flush()
else:
project.student_id, project.topic, project.direction = student.id, item.topic, item.direction
selected = set()
selected_paths = set()
for file in item.files:
path = secure_path(file.relative_path)
if path.parent != folder or not path.is_file() or path.suffix.lower() not in SUPPORTED:
raise ValueError("材料必须是学生目录内的PDF、DOCX或PPTX文件")
if sha256(path) != file.sha256:
raise ValueError("文件已变化,请重新扫描")
if file.status == "ignored":
continue
if file.category not in CATEGORIES or file.category in selected or file.relative_path in selected_paths:
raise ValueError("请为每类材料只确认一个文件")
selected.add(file.category)
selected_paths.add(file.relative_path)
stat = path.stat()
record = db.scalar(select(SubmissionFile).where(SubmissionFile.project_id == project.id, SubmissionFile.relative_path == file.relative_path))
if not record:
record = SubmissionFile(project_id=project.id, relative_path=file.relative_path, category=file.category,
mime_type=mimetypes.guess_type(path.name)[0] or "application/octet-stream",
size=stat.st_size, mtime=stat.st_mtime, sha256=file.sha256, recognition_status="recognized")
db.add(record)
db.flush()
else:
if record.sha256 != file.sha256 or record.category != file.category:
remove_file_index(db, record)
record.category, record.sha256, record.size, record.mtime, record.recognition_status = file.category, file.sha256, stat.st_size, stat.st_mtime, "recognized"
doc = db.scalar(select(KnowledgeDocument).where(KnowledgeDocument.submission_file_id == record.id,
KnowledgeDocument.source_hash == record.sha256, KnowledgeDocument.index_version == INDEX_VERSION,
KnowledgeDocument.status == "ready"))
if record.parse_status != "ready" or not doc:
files_to_index.append(record.id)
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"
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:
submission = Submission(project_id=project.id, category=category, status="missing")
db.add(submission)
db.flush()
submission.status = "submitted" if category in selected else "missing"
for file in db.scalars(select(SubmissionFile).where(SubmissionFile.project_id == project.id,
SubmissionFile.category == category, SubmissionFile.recognition_status == "recognized")).all():
file.submission_id = submission.id
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()))
if not current:
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 newly_created:
enqueue_index_task(db, task_id)
return {"project_ids": imported, "index_task_ids": tasks}
def enqueue_index_task(db, task_id: int):
from .indexing import run_index_task
task = found(db, IndexTask, task_id)
if task.status not in ("queued", "failed"):
raise HTTPException(409, "索引任务不可重试")
try:
Queue("wisegrade", connection=Redis.from_url(get_settings().redis_url)).enqueue(
run_index_task, task.id, job_id=f"wisegrade-index-{task.id}", job_timeout="3h")
task.status, task.error = "queued", ""
except Exception as exc:
task.status, task.error = "failed", f"无法提交索引任务: {type(exc).__name__}"
db.commit()
return task
def retry_index_task(db, source_id: int):
source = db.scalar(select(IndexTask).where(IndexTask.id == source_id).with_for_update())
if not source:
raise HTTPException(404, "索引任务不存在")
if source.status != "failed":
db.rollback()
raise HTTPException(409, "仅失败的索引任务可重试")
existing = db.scalar(select(IndexTask).where(IndexTask.id > source.id,
IndexTask.course_version_id == source.course_version_id,
IndexTask.submission_file_id == source.submission_file_id,
IndexTask.kind == source.kind, IndexTask.status.in_(["queued", "running", "completed"]))
.order_by(IndexTask.id.desc()))
if existing:
db.commit()
return existing
retry = IndexTask(course_version_id=source.course_version_id, submission_file_id=source.submission_file_id,
kind=source.kind, status="queued")
db.add(retry)
db.commit()
return enqueue_index_task(db, retry.id)
def ingest_course(course_version_id: int, db):
with file_index_lock(-course_version_id):
return _ingest_course_locked(course_version_id, db)
def _ingest_course_locked(course_version_id: int, db):
version = found(db, CourseVersion, course_version_id)
root = Path("/app/course-docs")
processed = get_settings().processed_path / f"course-{course_version_id}"
completed, errors = [], {}
for filename in version.templates:
if Path(filename).name != filename:
errors[filename] = "非法课程资料路径"
continue
path = root / filename
if not path.is_file():
errors[filename] = "课程资料文件不存在"
continue
try:
category = "stage1" if "阶段报告1" in filename else "stage2" if "阶段报告2" in filename else "stage3" if "阶段报告3" in filename else "course"
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=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 = [
("课程目标、毕业要求与选题方向", {"objectives": version.objectives, "indicators": version.indicators,
"directions": version.directions}),
("已发布评分规则", {"criteria": rubric.criteria, "weights": rubric.weights} if rubric else {}),
]
for title, payload in virtual:
if not payload:
continue
try:
content = json.dumps(payload, ensure_ascii=False, indent=2)
ingest(db, course_version_id=version.id, project_id=None, submission_file_id=None, title=title,
category="course", source_hash=hashlib.sha256(content.encode()).hexdigest(),
pages=[{"page_number": 1, "section": title, "text": content, "tables": []}])
completed.append(title)
except Exception as exc:
errors[title] = str(exc)[:500]
db.rollback()
return {"completed": completed, "errors": errors}
def create_batch(data, db):
version = found(db, CourseVersion, data.course_version_id)
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.
batch = ScoringBatch(course_version_id=version.id, rubric_version_id=rubric.id,
course_snapshot={key: getattr(version, key) for key in ("version", "title", "objectives", "indicators", "directions", "prompts", "templates")},
rubric_snapshot={"version": rubric.version, "criteria": rubric.criteria,
"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=project_ids)
db.add(batch)
db.flush()
audit(db, "create", "scoring_batch", batch.id, {"project_ids": project_ids})
db.commit()
return {"id": batch.id, "status": batch.status}
def start_batch(batch_id: int, db):
batch = db.scalar(select(ScoringBatch).where(ScoringBatch.id == batch_id).with_for_update())
if not batch:
raise HTTPException(404, "评分批次不存在")
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}",
job_timeout="12h", retry=Retry(max=1, interval=[60]))
except Exception as exc:
db.rollback()
batch = found(db, ScoringBatch, batch_id)
batch.status, batch.error = "failed", f"无法提交后台任务: {type(exc).__name__}"
db.commit()
raise HTTPException(503, f"无法提交后台任务: {type(exc).__name__}") from exc
batch.status, batch.error, batch.finished_at = "queued", "", None
db.commit()
return {"id": batch.id, "status": batch.status}
def review_score(score_id: int, data, db):
"""Record a teacher decision. Model scores, retrieval gaps and history are kept.
A teacher may review any criterion in any state, including one awaiting review or
one whose material is missing: the teacher is the authority on a criterion the
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 = 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, "教师分数超出规则满分")
from_status = score.status
db.add(TeacherRevision(score_id=score.id, old_score=score.teacher_score, new_score=data.score,
old_comment=score.teacher_comment, new_comment=data.comment, reason=data.reason))
score.teacher_score, score.teacher_comment = data.score, data.comment
# ``evidence_gaps`` is intentionally left untouched: it records why the criterion
# reached the teacher, independently of the score the teacher then entered.
score.status = "reviewed"
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,
"status": score.status, "evidence_gaps": score.evidence_gaps or []}