277 lines
15 KiB
Python
277 lines
15 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 delete, func, 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,
|
|
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 .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():
|
|
db.execute(delete(KnowledgeChunk).where(KnowledgeChunk.document_id == document.id))
|
|
db.delete(document)
|
|
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"
|
|
if old.parse_status == "ready":
|
|
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 = []
|
|
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()
|
|
tasks.append(current.id)
|
|
db.commit()
|
|
for task_id in tasks:
|
|
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"
|
|
parsed = parse_document(path, category, processed / hashlib.sha256(filename.encode()).hexdigest()[: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"])
|
|
completed.append(filename)
|
|
except Exception as exc:
|
|
errors[filename] = str(exc)[:500]
|
|
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]
|
|
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, "请选择同一课程版本下已发布的规则")
|
|
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=db.scalars(select(Project.id).where(
|
|
Project.course_version_id == version.id).order_by(Project.id)).all())
|
|
db.add(batch)
|
|
db.flush()
|
|
audit(db, "create", "scoring_batch", batch.id)
|
|
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, "批次已入队、运行中或已完成")
|
|
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 = found(db, CriterionScore, score_id)
|
|
batch = found(db, ScoringBatch, score.batch_id)
|
|
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.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 []}
|