Files
WiseGrade/backend/wisegrade/actions.py
T

296 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 .knowledge_pages import evidence_links
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,
**evidence_links(db, item)}
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", "review_required"):
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