295 lines
19 KiB
Python
295 lines
19 KiB
Python
"""Teacher-only confirmation boundary for conversation mutations."""
|
|
import hashlib
|
|
import json
|
|
from datetime import timedelta, timezone
|
|
from pathlib import Path
|
|
from uuid import uuid4
|
|
|
|
from fastapi import HTTPException
|
|
from sqlalchemy import select
|
|
|
|
from . import services
|
|
from .exports import batch_projects, export_excel, export_project_pdf, project_result
|
|
from .models import (AuditLog, Conversation, CourseVersion, CriterionScore, IndexTask,
|
|
PendingAction, Project, RubricVersion, ScoreEvidence, ScoringBatch, Student, SubmissionFile, now)
|
|
from .storage import CATEGORIES, scan, secure_path, sha256
|
|
from .settings_store import model_config
|
|
|
|
|
|
def digest(value):
|
|
return hashlib.sha256(json.dumps(value, ensure_ascii=False, sort_keys=True, default=str).encode()).hexdigest()
|
|
|
|
|
|
def require_scope(db, conversation: Conversation, *, version_id=None, batch_id=None, project_id=None):
|
|
if version_id and not db.get(CourseVersion, version_id):
|
|
raise HTTPException(404, "课程版本不存在")
|
|
batch = db.get(ScoringBatch, batch_id) if batch_id else None
|
|
project = db.get(Project, project_id) if project_id else None
|
|
if batch_id and not batch or project_id and not project:
|
|
raise HTTPException(404, "批次或项目不存在")
|
|
effective_version = version_id or (batch.course_version_id if batch else None) or (project.course_version_id if project else None)
|
|
context_batch = db.get(ScoringBatch, conversation.batch_id) if conversation.batch_id else None
|
|
context_project = db.get(Project, conversation.project_id) if conversation.project_id else None
|
|
if context_batch and effective_version and context_batch.course_version_id != effective_version:
|
|
raise HTTPException(400, "目标超出会话批次课程范围")
|
|
if context_project and effective_version and context_project.course_version_id != effective_version:
|
|
raise HTTPException(400, "目标超出会话项目课程范围")
|
|
if conversation.course_version_id and effective_version and conversation.course_version_id != effective_version:
|
|
raise HTTPException(400, "目标超出会话课程版本范围")
|
|
if conversation.batch_id and batch_id and conversation.batch_id != batch_id:
|
|
raise HTTPException(400, "目标超出会话批次范围")
|
|
if conversation.project_id and project_id and conversation.project_id != project_id:
|
|
raise HTTPException(400, "目标超出会话项目范围")
|
|
if context_batch and project and (project.course_version_id != context_batch.course_version_id or
|
|
context_batch.project_ids is not None and project.id not in context_batch.project_ids):
|
|
raise HTTPException(400, "项目超出会话批次范围")
|
|
if context_project and batch and (context_project.course_version_id != batch.course_version_id or
|
|
batch.project_ids is not None and context_project.id not in batch.project_ids):
|
|
raise HTTPException(400, "批次不包含会话项目")
|
|
if batch and project and (batch.course_version_id != project.course_version_id or
|
|
batch.project_ids is not None and project.id not in batch.project_ids):
|
|
raise HTTPException(400, "项目不属于批次")
|
|
if project and version_id and project.course_version_id != version_id:
|
|
raise HTTPException(400, "项目不属于课程版本")
|
|
if batch and version_id and batch.course_version_id != version_id:
|
|
raise HTTPException(400, "批次不属于课程版本")
|
|
return batch, project
|
|
|
|
|
|
def _import_snapshot(db, payload):
|
|
version = db.get(CourseVersion, payload["course_version_id"])
|
|
if not version or not version.published:
|
|
raise HTTPException(409, "课程版本未发布,请重新预览")
|
|
files = []
|
|
for project in payload["projects"]:
|
|
folder = secure_path(project["relative_dir"])
|
|
if not folder.is_dir():
|
|
raise HTTPException(409, "学生目录已变化,请重新预览")
|
|
seen = set()
|
|
for file in project["files"]:
|
|
path = secure_path(file["relative_path"])
|
|
if path.parent != folder or not path.is_file():
|
|
raise HTTPException(409, "文件选择已变化,请重新预览")
|
|
if file["status"] != "ignored":
|
|
if file["category"] not in CATEGORIES or file["category"] in seen:
|
|
raise HTTPException(400, "每类材料只能选择一个文件")
|
|
seen.add(file["category"])
|
|
files.append([file["relative_path"], sha256(path)])
|
|
return {"version": digest({"published": version.published, "templates": version.templates}), "files": sorted(files)}
|
|
|
|
|
|
def _state(db, kind, payload):
|
|
if kind == "import":
|
|
return _import_snapshot(db, payload)
|
|
if kind == "course_ingest":
|
|
version = services.found(db, CourseVersion, payload["course_version_id"])
|
|
if not version.published:
|
|
raise HTTPException(409, "课程版本未发布,请重新预览")
|
|
files = []
|
|
for name in version.templates:
|
|
if Path(name).name != name:
|
|
raise HTTPException(400, "课程资料路径非法")
|
|
path = Path("/app/course-docs") / name
|
|
files.append([name, sha256(path) if path.is_file() else None])
|
|
return {"version": digest({"published": version.published, "templates": version.templates}), "files": files}
|
|
if kind == "create_batch":
|
|
version = services.found(db, CourseVersion, payload["course_version_id"])
|
|
rubric = services.found(db, RubricVersion, payload["rubric_version_id"])
|
|
if not version.published or not rubric.published or rubric.course_version_id != version.id:
|
|
raise HTTPException(409, "课程或规则发布状态已变化,请重新预览")
|
|
projects = db.scalars(select(Project.id).where(Project.course_version_id == version.id).order_by(Project.id)).all()
|
|
return {"course": digest({"title": version.title, "objectives": version.objectives,
|
|
"indicators": version.indicators, "prompts": version.prompts}),
|
|
"rubric": digest({"criteria": rubric.criteria, "weights": rubric.weights}), "project_ids": projects}
|
|
if kind == "start_batch":
|
|
batch = services.found(db, ScoringBatch, payload["batch_id"])
|
|
version = services.found(db, CourseVersion, batch.course_version_id)
|
|
rubric = services.found(db, RubricVersion, batch.rubric_version_id)
|
|
if not version.published or not rubric.published or rubric.course_version_id != version.id:
|
|
raise HTTPException(409, "课程或规则发布状态已变化,请重新预览")
|
|
return {"status": batch.status, "project_ids": batch.project_ids,
|
|
"snapshot": digest(batch.rubric_snapshot)}
|
|
if kind == "review":
|
|
score = services.found(db, CriterionScore, payload["score_id"])
|
|
batch = services.found(db, ScoringBatch, score.batch_id)
|
|
project = services.found(db, Project, score.project_id)
|
|
if project.course_version_id != batch.course_version_id or batch.project_ids is not None and project.id not in batch.project_ids:
|
|
raise HTTPException(409, "评分项与批次项目关系已变化,请重新预览")
|
|
if not any(item["criterion_id"] == score.criterion_id for item in batch.rubric_snapshot["criteria"]):
|
|
raise HTTPException(409, "评分项不属于批次规则快照")
|
|
student = services.found(db, Student, project.student_id)
|
|
evidence = db.scalars(select(ScoreEvidence).where(ScoreEvidence.score_id == score.id).order_by(ScoreEvidence.id)).all()
|
|
return {"status": score.status, "model_score": score.model_score,
|
|
"teacher_score": score.teacher_score, "teacher_comment": score.teacher_comment,
|
|
"error": score.error, "batch_id": score.batch_id, "project_id": score.project_id,
|
|
"student_id": student.id, "student_no": student.student_no, "student_name": student.name,
|
|
"criterion_id": score.criterion_id, "rubric_snapshot": digest(batch.rubric_snapshot),
|
|
"evidence": digest([[item.id, item.page, item.quote] for item in evidence])}
|
|
if kind == "export":
|
|
batch = services.found(db, ScoringBatch, payload["batch_id"])
|
|
projects = batch_projects(db, batch)
|
|
if payload["format"] == "pdf":
|
|
projects = [project for project in projects if project.id == payload["project_id"]]
|
|
if not projects:
|
|
raise HTTPException(400, "学生不属于该批次")
|
|
return {"batch_status": batch.status, "project_ids": [p.id for p in projects],
|
|
"scores": digest([[row["score_id"], row["status"], row["final_score"]]
|
|
for project in projects for row in project_result(db, batch, project)["rows"]])}
|
|
if kind == "index_retry":
|
|
task = services.found(db, IndexTask, payload["task_id"])
|
|
return {"status": task.status, "file_id": task.submission_file_id, "kind": task.kind}
|
|
raise HTTPException(400, "不支持的操作")
|
|
|
|
|
|
def prepare(db, conversation: Conversation, kind: str, payload: dict, *, teacher_edited=False):
|
|
if conversation.project_id and kind in ("import", "create_batch", "start_batch"):
|
|
raise HTTPException(400, "该操作涉及多个项目,请在未限定学生项目的会话中准备")
|
|
if conversation.project_id and kind == "export" and payload.get("format") == "excel":
|
|
raise HTTPException(400, "整批 Excel 导出须在未限定学生项目的会话中准备")
|
|
if kind == "import":
|
|
from .api import ImportConfirm
|
|
data = ImportConfirm.model_validate(payload)
|
|
payload = data.model_dump()
|
|
require_scope(db, conversation, version_id=data.course_version_id)
|
|
if teacher_edited and any(item.student_no == "00000" or item.name == "待确认" or item.topic == "待确认" for item in data.projects):
|
|
raise HTTPException(400, "请先修正无法识别的学号、姓名和选题")
|
|
scanned = []
|
|
ambiguous = []
|
|
for item in data.projects:
|
|
parent = item.relative_dir.rsplit("/", 1)[0] if "/" in item.relative_dir else "."
|
|
match = next((p for p in scan(parent)["projects"] if p["relative_dir"] == item.relative_dir), None)
|
|
if not match:
|
|
raise HTTPException(409, "扫描结果已变化,请重新预览")
|
|
scanned.append(match)
|
|
if match["status"] != "recognized" or match["warnings"] or any(f["status"] != "recognized" for f in match["files"]):
|
|
ambiguous.append(item.relative_dir)
|
|
preview = {"course_version_id": data.course_version_id, "projects": scanned,
|
|
"selection": payload["projects"], "ambiguous": ambiguous,
|
|
"requires_edit": bool(ambiguous) and not teacher_edited}
|
|
elif kind == "course_ingest":
|
|
require_scope(db, conversation, version_id=payload["course_version_id"])
|
|
preview = {"course_version_id": payload["course_version_id"], "templates":
|
|
services.found(db, CourseVersion, payload["course_version_id"]).templates}
|
|
elif kind == "create_batch":
|
|
require_scope(db, conversation, version_id=payload["course_version_id"])
|
|
rubric = services.found(db, RubricVersion, payload["rubric_version_id"])
|
|
preview = {"course_version_id": payload["course_version_id"], "rubric_version_id": rubric.id,
|
|
"criteria_count": len(rubric.criteria), "model_name": model_config(db)["model"]}
|
|
elif kind == "start_batch":
|
|
batch, _ = require_scope(db, conversation, batch_id=payload["batch_id"])
|
|
preview = {"batch_id": batch.id, "status": batch.status,
|
|
"project_ids": batch.project_ids, "legacy_membership": batch.project_ids is None}
|
|
elif kind == "review":
|
|
score = services.found(db, CriterionScore, payload["score_id"])
|
|
batch, project = require_scope(db, conversation, batch_id=score.batch_id, project_id=score.project_id)
|
|
student = db.get(Student, project.student_id)
|
|
criterion = next((c for c in batch.rubric_snapshot["criteria"] if c["criterion_id"] == score.criterion_id), None)
|
|
if not criterion or not 0 <= payload["score"] <= criterion["max_score"]:
|
|
raise HTTPException(400, "教师分数超出规则范围")
|
|
evidence = db.scalars(select(ScoreEvidence).where(ScoreEvidence.score_id == score.id)).all()
|
|
preview = {"batch_id": batch.id, "project_id": project.id,
|
|
"student_no": student.student_no, "student_name": student.name,
|
|
"criterion_id": score.criterion_id, "criterion_name": criterion["name"], "criterion": criterion,
|
|
"old_model_score": score.model_score, "old_teacher_score": score.teacher_score,
|
|
"old_status": score.status, "evidence_warning": score.error or score.missing_evidence,
|
|
"evidence": [{"document": db.get(SubmissionFile, item.submission_file_id).relative_path if item.submission_file_id else "课程资料",
|
|
"page": item.page, "quote": item.quote,
|
|
"url": f"/api/assets/{item.submission_file_id}/{item.page}" if item.image_path else None}
|
|
for item in evidence],
|
|
"new_score": payload["score"], "comment": payload["comment"], "reason": payload["reason"]}
|
|
elif kind == "export":
|
|
batch, project = require_scope(db, conversation, batch_id=payload["batch_id"], project_id=payload.get("project_id"))
|
|
preview = {"batch_id": batch.id, "project_id": project.id if project else None, "format": payload["format"],
|
|
"status": batch.status, "incomplete_projects":
|
|
[p.id for p in batch_projects(db, batch) if project_result(db, batch, p)["totals"]["overall"] is None]}
|
|
elif kind == "index_retry":
|
|
task = services.found(db, IndexTask, payload["task_id"])
|
|
require_scope(db, conversation, version_id=task.course_version_id)
|
|
preview = {"task_id": task.id, "status": task.status, "error": task.error}
|
|
else:
|
|
raise HTTPException(400, "不支持的操作")
|
|
expected = _state(db, kind, payload)
|
|
if kind == "start_batch" and expected["status"] not in ("created", "failed", "partial"):
|
|
raise HTTPException(409, "批次不可启动或重试")
|
|
if kind == "index_retry" and expected["status"] != "failed":
|
|
raise HTTPException(409, "仅失败任务可重试")
|
|
if kind == "create_batch":
|
|
preview["project_ids"] = expected["project_ids"]
|
|
if kind == "course_ingest":
|
|
preview["files"] = expected["files"]
|
|
preview["missing_files"] = [name for name, file_hash in expected["files"] if file_hash is None]
|
|
existing = db.scalar(select(PendingAction).where(PendingAction.conversation_id == conversation.id,
|
|
PendingAction.kind == kind, PendingAction.status == "pending").order_by(PendingAction.id.desc()))
|
|
if existing and existing.payload == payload and existing.expected == expected and existing.preview.get("requires_edit") == preview.get("requires_edit"):
|
|
expires = existing.expires_at.replace(tzinfo=timezone.utc) if existing.expires_at.tzinfo is None else existing.expires_at
|
|
if expires > now():
|
|
return existing
|
|
action = PendingAction(conversation_id=conversation.id, kind=kind, payload=payload,
|
|
preview=preview, expected=expected, idempotency_key=uuid4().hex,
|
|
expires_at=now() + timedelta(minutes=20), status="pending")
|
|
db.add(action)
|
|
db.commit()
|
|
return action
|
|
|
|
|
|
def action_dict(action):
|
|
return {key: getattr(action, key) for key in ("id", "conversation_id", "kind", "payload", "preview",
|
|
"status", "result", "created_at", "expires_at")}
|
|
|
|
|
|
def confirm(db, conversation: Conversation, action_id: int):
|
|
action = db.scalar(select(PendingAction).where(PendingAction.id == action_id,
|
|
PendingAction.conversation_id == conversation.id).with_for_update())
|
|
if not action:
|
|
raise HTTPException(404, "待确认操作不存在")
|
|
if action.status == "completed":
|
|
return action.result
|
|
if action.status != "pending":
|
|
raise HTTPException(409, "操作已取消、失败或正在执行")
|
|
if action.kind == "import" and action.preview.get("requires_edit"):
|
|
raise HTTPException(409, "导入候选存在歧义,请教师修正文件选择并更新预览")
|
|
expires = action.expires_at.replace(tzinfo=timezone.utc) if action.expires_at.tzinfo is None else action.expires_at
|
|
try:
|
|
current = _state(db, action.kind, action.payload) if expires > now() else None
|
|
except (HTTPException, ValueError):
|
|
current = None
|
|
if current != action.expected:
|
|
action.status = "stale"
|
|
db.commit()
|
|
raise HTTPException(409, "目标或文件已变化,请重新预览")
|
|
action.status = "executing"
|
|
db.commit()
|
|
try:
|
|
if action.kind == "import":
|
|
from .api import ImportConfirm
|
|
result = services.confirm_import(ImportConfirm.model_validate(action.payload), db)
|
|
elif action.kind == "course_ingest":
|
|
from .indexing import create_course_index_task
|
|
result = {"index_task_id": create_course_index_task(db, action.payload["course_version_id"]).id}
|
|
elif action.kind == "create_batch":
|
|
from .api import BatchInput
|
|
result = services.create_batch(BatchInput.model_validate(action.payload), db)
|
|
elif action.kind == "start_batch":
|
|
result = services.start_batch(action.payload["batch_id"], db)
|
|
elif action.kind == "review":
|
|
from .api import ReviewInput
|
|
result = services.review_score(action.payload["score_id"], ReviewInput.model_validate(action.payload), db)
|
|
elif action.kind == "export":
|
|
task = (export_excel(db, action.payload["batch_id"]) if action.payload["format"] == "excel" else
|
|
export_project_pdf(db, action.payload["batch_id"], action.payload["project_id"]))
|
|
result = {"export_task_id": task.id, "download_url": f"/api/exports/{task.id}/download"}
|
|
elif action.kind == "index_retry":
|
|
retry = services.retry_index_task(db, action.payload["task_id"])
|
|
result = {"index_task_id": retry.id}
|
|
else:
|
|
raise ValueError("不支持的操作")
|
|
action.status, action.result = "completed", result
|
|
db.add(AuditLog(action=f"chat_{action.kind}", entity_type="conversation", entity_id=conversation.id,
|
|
details={"action_id": action.id, "result": result}))
|
|
db.commit()
|
|
return result
|
|
except Exception as exc:
|
|
action.status, action.result = "failed", {"error": str(exc)[:300]}
|
|
db.commit()
|
|
raise
|