feat: add versioned backend scoring workflow

This commit is contained in:
2026-09-25 11:11:18 +08:00
parent 146d5566cf
commit eece55d08d
20 changed files with 2063 additions and 0 deletions
+1
View File
@@ -0,0 +1 @@
"""WiseGrade backend."""
+594
View File
@@ -0,0 +1,594 @@
import hashlib
import json
import mimetypes
from pathlib import Path
from fastapi import APIRouter, Depends, HTTPException
from fastapi.responses import FileResponse
from pydantic import BaseModel, Field
from redis import Redis
from rq import Queue, Retry
from sqlalchemy import delete, func, select
from sqlalchemy.orm import Session
from .config import get_settings
from .db import get_db
from .exports import export_excel, export_project_pdf, project_result
from .models import (AppSetting, AuditLog, Course, CourseVersion, CriterionScore, ExportTask,
KnowledgeChunk, KnowledgeDocument, Project, RubricVersion, ScoringBatch,
Student, Submission, SubmissionFile, TeacherRevision)
from .materialized import sync_course_entities, sync_rubric_entities
from .parsing import parse_document
from .rag import ingest
from .rules import RubricData
from .scoring import run_batch
from .settings_store import key_configured, model_config, save_model_config
from .storage import CATEGORIES, SUPPORTED, directories, scan, secure_path, sha256
router = APIRouter(prefix="/api")
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 version_dict(item):
return {key: getattr(item, key) for key in ("id", "course_id", "version", "title", "objectives", "indicators", "directions", "prompts", "templates", "published", "created_at")}
def rubric_dict(item):
return {key: getattr(item, key) for key in ("id", "course_version_id", "version", "criteria", "weights", "published", "created_at")}
class CourseInput(BaseModel):
name: str = Field(min_length=1)
code: str = Field(min_length=1)
class VersionInput(BaseModel):
title: str = Field(min_length=1)
source_version_id: int | None = None
class VersionEdit(BaseModel):
title: str = Field(min_length=1)
objectives: list[dict]
indicators: list[dict]
directions: list[dict]
prompts: dict[str, str]
templates: list[str]
class ImportScan(BaseModel):
relative_dir: str
class FileConfirm(BaseModel):
relative_path: str
category: str
sha256: str
status: str = "recognized"
class ProjectConfirm(BaseModel):
relative_dir: str
student_no: str = Field(pattern=r"^\d{5,20}$")
name: str = Field(min_length=1)
topic: str = Field(min_length=1)
direction: str = ""
files: list[FileConfirm]
class ImportConfirm(BaseModel):
course_version_id: int
projects: list[ProjectConfirm]
class BatchInput(BaseModel):
course_version_id: int
rubric_version_id: int
class ReviewInput(BaseModel):
score: float = Field(ge=0)
comment: str = Field(min_length=1)
reason: str = ""
class ModelSettingsInput(BaseModel):
base_url: str | None = None
model: str | None = None
timeout_seconds: int | None = Field(default=None, ge=10, le=600)
concurrency: int | None = Field(default=None, ge=1, le=16)
retries: int | None = Field(default=None, ge=0, le=5)
api_key: str | None = None
@router.get("/dashboard")
def dashboard(db: Session = Depends(get_db)):
return {"courses": db.scalar(select(func.count(Course.id))), "projects": db.scalar(select(func.count(Project.id))),
"batches": db.scalar(select(func.count(ScoringBatch.id))),
"needs_attention": db.scalar(select(func.count(CriterionScore.id)).where(CriterionScore.status.in_(["failed", "unscorable"]))) }
@router.get("/courses")
def list_courses(db: Session = Depends(get_db)):
return [{"id": c.id, "name": c.name, "code": c.code} for c in db.scalars(select(Course).order_by(Course.id))]
@router.post("/courses")
def create_course(data: CourseInput, db: Session = Depends(get_db)):
if db.scalar(select(Course.id).where((Course.code == data.code) | (Course.name == data.name))):
raise HTTPException(409, "课程名称或代码已存在")
course = Course(**data.model_dump())
db.add(course)
db.flush()
version = CourseVersion(course_id=course.id, version=1, title="初始草稿", objectives=[], indicators=[], directions=[], prompts={}, templates=[])
db.add(version)
db.flush()
sync_course_entities(db, version)
audit(db, "create", "course", course.id)
db.commit()
return {"id": course.id}
@router.get("/courses/{course_id}/versions")
def list_versions(course_id: int, db: Session = Depends(get_db)):
found(db, Course, course_id)
return [version_dict(v) for v in db.scalars(select(CourseVersion).where(CourseVersion.course_id == course_id).order_by(CourseVersion.version.desc()))]
@router.post("/courses/{course_id}/versions")
def copy_version(course_id: int, data: VersionInput, db: Session = Depends(get_db)):
found(db, Course, course_id)
source = found(db, CourseVersion, data.source_version_id) if data.source_version_id else db.scalar(select(CourseVersion).where(CourseVersion.course_id == course_id).order_by(CourseVersion.version.desc()))
if source and source.course_id != course_id:
raise HTTPException(400, "源版本不属于课程")
next_version = (db.scalar(select(func.max(CourseVersion.version)).where(CourseVersion.course_id == course_id)) or 0) + 1
fields = {key: json.loads(json.dumps(getattr(source, key))) if source else ([] if key != "prompts" else {}) for key in ("objectives", "indicators", "directions", "prompts", "templates")}
version = CourseVersion(course_id=course_id, version=next_version, title=data.title, published=False, **fields)
db.add(version)
db.flush()
sync_course_entities(db, version)
if source:
rubric = db.scalar(select(RubricVersion).where(RubricVersion.course_version_id == source.id).order_by(RubricVersion.version.desc()))
if rubric:
copied = RubricVersion(course_version_id=version.id, version=1, criteria=rubric.criteria, weights=rubric.weights, published=False)
db.add(copied)
db.flush()
sync_rubric_entities(db, copied)
audit(db, "copy", "course_version", version.id, {"source_id": source.id if source else None})
db.commit()
return version_dict(version)
@router.put("/course-versions/{version_id}")
def edit_version(version_id: int, data: VersionEdit, db: Session = Depends(get_db)):
version = found(db, CourseVersion, version_id)
if version.published:
raise HTTPException(409, "已发布版本不可修改,请复制新版本")
for key, value in data.model_dump().items():
setattr(version, key, value)
sync_course_entities(db, version)
audit(db, "edit", "course_version", version.id)
db.commit()
return version_dict(version)
@router.post("/course-versions/{version_id}/publish")
def publish_version(version_id: int, db: Session = Depends(get_db)):
version = found(db, CourseVersion, version_id)
if not version.objectives or not version.indicators or not all(k in version.prompts for k in ("facts", "score", "consistency")):
raise HTTPException(400, "课程目标、指标点和三个Prompt不能为空")
version.published = True
audit(db, "publish", "course_version", version.id)
db.commit()
return version_dict(version)
@router.get("/course-versions/{version_id}/rubrics")
def list_rubrics(version_id: int, db: Session = Depends(get_db)):
found(db, CourseVersion, version_id)
return [rubric_dict(r) for r in db.scalars(select(RubricVersion).where(RubricVersion.course_version_id == version_id).order_by(RubricVersion.version.desc()))]
@router.post("/course-versions/{version_id}/rubrics")
def create_rubric(version_id: int, data: RubricData, db: Session = Depends(get_db)):
found(db, CourseVersion, version_id)
next_version = (db.scalar(select(func.max(RubricVersion.version)).where(RubricVersion.course_version_id == version_id)) or 0) + 1
rubric = RubricVersion(course_version_id=version_id, version=next_version, criteria=[c.model_dump() for c in data.criteria], weights=data.weights)
db.add(rubric)
db.flush()
sync_rubric_entities(db, rubric)
db.commit()
return rubric_dict(rubric)
@router.post("/course-versions/{version_id}/rubrics/copy-from/{source_id}")
def create_rubric_from_source(version_id: int, source_id: int, db: Session = Depends(get_db)):
found(db, CourseVersion, version_id)
source = found(db, RubricVersion, source_id)
next_version = (db.scalar(select(func.max(RubricVersion.version)).where(RubricVersion.course_version_id == version_id)) or 0) + 1
rubric = RubricVersion(course_version_id=version_id, version=next_version,
criteria=json.loads(json.dumps(source.criteria)), weights=dict(source.weights), published=False)
db.add(rubric)
db.flush()
sync_rubric_entities(db, rubric)
audit(db, "copy", "rubric", rubric.id, {"source_id": source.id})
db.commit()
return rubric_dict(rubric)
@router.get("/rubrics/catalog")
def rubric_catalog(db: Session = Depends(get_db)):
return [{"id": r.id, "course_version_id": r.course_version_id, "version": r.version,
"criteria_count": len(r.criteria)} for r in db.scalars(select(RubricVersion).where(RubricVersion.published.is_(True)).order_by(RubricVersion.id))]
@router.get("/rubrics/{rubric_id}")
def get_rubric(rubric_id: int, db: Session = Depends(get_db)):
return rubric_dict(found(db, RubricVersion, rubric_id))
@router.post("/rubrics/{rubric_id}/copy")
def copy_rubric(rubric_id: int, db: Session = Depends(get_db)):
source = found(db, RubricVersion, rubric_id)
next_version = (db.scalar(select(func.max(RubricVersion.version)).where(RubricVersion.course_version_id == source.course_version_id)) or 0) + 1
rubric = RubricVersion(course_version_id=source.course_version_id, version=next_version,
criteria=json.loads(json.dumps(source.criteria)), weights=dict(source.weights), published=False)
db.add(rubric)
db.flush()
sync_rubric_entities(db, rubric)
audit(db, "copy", "rubric", rubric.id, {"source_id": source.id})
db.commit()
return rubric_dict(rubric)
@router.put("/rubrics/{rubric_id}")
def edit_rubric(rubric_id: int, data: RubricData, db: Session = Depends(get_db)):
rubric = found(db, RubricVersion, rubric_id)
if rubric.published:
raise HTTPException(409, "已发布规则不可修改,请复制新版本")
rubric.criteria, rubric.weights = [c.model_dump() for c in data.criteria], data.weights
sync_rubric_entities(db, rubric)
audit(db, "edit", "rubric", rubric.id)
db.commit()
return rubric_dict(rubric)
@router.post("/rubrics/{rubric_id}/publish")
def publish_rubric(rubric_id: int, db: Session = Depends(get_db)):
rubric = found(db, RubricVersion, rubric_id)
data = RubricData(criteria=rubric.criteria, weights=rubric.weights)
version = found(db, CourseVersion, rubric.course_version_id)
objective_codes = {str(item.get("code")) for item in version.objectives}
indicator_codes = {str(item.get("code")) for item in version.indicators}
for criterion in data.criteria:
if not set(criterion.linked_objectives) <= objective_codes or not set(criterion.linked_outcomes) <= indicator_codes:
raise HTTPException(400, f"{criterion.criterion_id}关联了课程版本不存在的目标或指标点")
rubric.published = True
audit(db, "publish", "rubric", rubric.id)
db.commit()
return rubric_dict(rubric)
@router.get("/storage/directories")
def list_directories(relative: str = "."):
return directories(relative)
@router.post("/import/scan")
def scan_import(data: ImportScan):
return scan(data.relative_dir)
@router.post("/import/confirm")
def confirm_import(data: ImportConfirm, db: Session = Depends(get_db)):
version = found(db, CourseVersion, data.course_version_id)
if not version.published:
raise HTTPException(400, "请先发布课程版本")
imported = []
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)
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"
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)
db.commit()
return {"project_ids": imported}
@router.get("/projects")
def list_projects(course_version_id: int | None = None, db: Session = Depends(get_db)):
query = select(Project).order_by(Project.id.desc())
if course_version_id:
query = query.where(Project.course_version_id == course_version_id)
result = []
for project in db.scalars(query):
student = db.get(Student, project.student_id)
files = db.scalars(select(SubmissionFile).where(SubmissionFile.project_id == project.id)).all()
result.append({"id": project.id, "student_no": student.student_no, "name": student.name, "topic": project.topic,
"course_version_id": project.course_version_id, "status": project.status,
"missing": [category for category in CATEGORIES if not any(file.category == category and file.recognition_status == "recognized" for file in files)],
"parse_errors": [file.parse_error for file in files if file.parse_error]})
return result
@router.get("/projects/{project_id}")
def get_project(project_id: int, db: Session = Depends(get_db)):
project = found(db, Project, project_id)
student = db.get(Student, project.student_id)
files = db.scalars(select(SubmissionFile).where(SubmissionFile.project_id == project.id)).all()
return {"id": project.id, "student_no": student.student_no, "name": student.name, "topic": project.topic,
"course_version_id": project.course_version_id, "direction": project.direction, "facts": project.facts,
"files": [{key: getattr(file, key) for key in ("id", "category", "relative_path", "sha256", "parse_status", "parse_error", "recognition_status")} for file in files]}
@router.get("/knowledge")
def knowledge(course_version_id: int, project_id: int | None = None, db: Session = Depends(get_db)):
query = select(KnowledgeDocument).where(KnowledgeDocument.course_version_id == course_version_id,
KnowledgeDocument.project_id == project_id)
return [{"id": doc.id, "title": doc.title, "type": doc.document_type, "status": doc.status,
"chunks": db.scalar(select(func.count(KnowledgeChunk.id)).where(KnowledgeChunk.document_id == doc.id))} for doc in db.scalars(query)]
@router.post("/knowledge/ingest-course/{course_version_id}")
def ingest_course(course_version_id: int, db: Session = Depends(get_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}
@router.post("/scoring-batches")
def create_batch(data: BatchInput, db: Session = Depends(get_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()
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},
model_config_snapshot=model_config(db), embedding_model_name=settings.embedding_model_name,
embedding_model_version=settings.embedding_model_version)
db.add(batch)
db.flush()
audit(db, "create", "scoring_batch", batch.id)
db.commit()
return {"id": batch.id, "status": batch.status}
@router.get("/scoring-batches")
def list_batches(db: Session = Depends(get_db)):
return [{key: getattr(batch, key) for key in ("id", "course_version_id", "rubric_version_id", "status", "progress_done", "progress_total", "error", "created_at", "finished_at")}
for batch in db.scalars(select(ScoringBatch).order_by(ScoringBatch.id.desc()))]
@router.get("/scoring-batches/{batch_id}/progress")
def batch_progress(batch_id: int, db: Session = Depends(get_db)):
batch = found(db, ScoringBatch, batch_id)
return {key: getattr(batch, key) for key in ("id", "status", "progress_done", "progress_total", "error", "created_at", "finished_at")}
@router.post("/scoring-batches/{batch_id}/start")
def start_batch(batch_id: int, db: Session = Depends(get_db)):
batch = found(db, ScoringBatch, batch_id)
if batch.status == "running":
raise HTTPException(409, "批次正在评分")
try:
Queue("wisegrade", connection=Redis.from_url(get_settings().redis_url)).enqueue(run_batch, batch_id,
job_timeout="12h", retry=Retry(max=1, interval=[60]))
except Exception as exc:
raise HTTPException(503, f"无法提交后台任务: {type(exc).__name__}") from exc
batch.status, batch.error = "queued", ""
db.commit()
return {"id": batch.id, "status": batch.status}
@router.get("/scoring-batches/{batch_id}/scores")
def batch_scores(batch_id: int, db: Session = Depends(get_db)):
batch = found(db, ScoringBatch, batch_id)
projects = db.scalars(select(Project).where(Project.course_version_id == batch.course_version_id).order_by(Project.id)).all()
return [project_result(db, batch, project) for project in projects]
@router.get("/projects/{project_id}/scores")
def project_scores(project_id: int, batch_id: int, db: Session = Depends(get_db)):
batch, project = found(db, ScoringBatch, batch_id), found(db, Project, project_id)
if project.course_version_id != batch.course_version_id:
raise HTTPException(400, "项目不属于评分批次课程版本")
return project_result(db, batch, project)
@router.put("/criterion-scores/{score_id}/review")
def review_score(score_id: int, data: ReviewInput, db: Session = Depends(get_db)):
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, "教师分数超出规则满分")
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, score.status = data.score, data.comment, "reviewed"
audit(db, "review", "criterion_score", score.id, {"reason": data.reason})
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}
@router.get("/criterion-scores/{score_id}/revisions")
def revisions(score_id: int, db: Session = Depends(get_db)):
found(db, CriterionScore, score_id)
return [{key: getattr(item, key) for key in ("id", "old_score", "new_score", "old_comment", "new_comment", "reason", "created_at")}
for item in db.scalars(select(TeacherRevision).where(TeacherRevision.score_id == score_id).order_by(TeacherRevision.id))]
@router.post("/exports/excel")
def create_excel(data: dict, db: Session = Depends(get_db)):
task = export_excel(db, int(data["batch_id"]))
audit(db, "export", "scoring_batch", task.batch_id, {"kind": "excel", "task_id": task.id})
db.commit()
return {"id": task.id}
@router.post("/exports/project-pdf")
def create_pdf(data: dict, db: Session = Depends(get_db)):
task = export_project_pdf(db, int(data["batch_id"]), int(data["project_id"]))
audit(db, "export", "project", task.project_id, {"kind": "pdf", "task_id": task.id})
db.commit()
return {"id": task.id}
@router.get("/exports")
def list_exports(db: Session = Depends(get_db)):
return [{key: getattr(task, key) for key in ("id", "batch_id", "project_id", "kind", "status", "error", "created_at")}
for task in db.scalars(select(ExportTask).order_by(ExportTask.id.desc()))]
@router.get("/exports/{task_id}")
def get_export(task_id: int, db: Session = Depends(get_db)):
task = found(db, ExportTask, task_id)
return {key: getattr(task, key) for key in ("id", "batch_id", "project_id", "kind", "status", "error", "created_at")}
@router.get("/exports/{task_id}/download")
def download_export(task_id: int, db: Session = Depends(get_db)):
task = found(db, ExportTask, task_id)
root = get_settings().exports_path.resolve()
path = Path(task.path or "").resolve()
if task.status != "completed" or not path.is_file() or not path.is_relative_to(root):
raise HTTPException(404, "导出文件不存在")
return FileResponse(path, filename=path.name)
@router.get("/assets/{file_id}/{page}")
def page_asset(file_id: int, page: int, db: Session = Depends(get_db)):
file = found(db, SubmissionFile, file_id)
pages = file.parsed.get("pages", []) if file.parsed else []
item = next((item for item in pages if item["page_number"] == page), None)
path = Path(item["rendered_image_path"]).resolve() if item and item.get("rendered_image_path") else None
root = get_settings().processed_path.resolve()
if not path or not path.is_file() or not path.is_relative_to(root):
raise HTTPException(404, "页面图像不存在")
return FileResponse(path, media_type="image/png")
@router.get("/settings/model")
def get_model_settings(db: Session = Depends(get_db)):
return {**model_config(db), "api_key_configured": key_configured(db),
"embedding_model_name": get_settings().embedding_model_name,
"embedding_model_path": str(get_settings().embedding_model_path),
"embedding_model_version": get_settings().embedding_model_version}
@router.put("/settings/model")
def put_model_settings(data: ModelSettingsInput, db: Session = Depends(get_db)):
result = save_model_config(db, data.model_dump(exclude_none=True))
audit(db, "edit", "model_settings", None, {"fields": [key for key in data.model_fields_set if key != "api_key"]})
db.commit()
return result
+29
View File
@@ -0,0 +1,29 @@
from functools import lru_cache
from pathlib import Path
from pydantic_settings import BaseSettings, SettingsConfigDict
class Settings(BaseSettings):
model_config = SettingsConfigDict(env_file=".env", extra="ignore")
database_url: str = "sqlite:///./wisegrade.db"
redis_url: str = "redis://localhost:6379/0"
inbox_path: Path = Path("/data/inbox")
processed_path: Path = Path("/data/processed")
exports_path: Path = Path("/data/exports")
deepseek_base_url: str = "https://api.deepseek.com"
deepseek_model: str = "deepseek-flash"
deepseek_api_key: str = ""
wisegrade_secret_key: str = ""
embedding_model_name: str = "BAAI/bge-m3"
embedding_model_path: Path = Path("/models/bge-m3")
embedding_model_version: str = "local"
model_timeout_seconds: int = 120
model_concurrency: int = 2
model_retries: int = 2
@lru_cache
def get_settings() -> Settings:
return Settings()
+22
View File
@@ -0,0 +1,22 @@
from sqlalchemy import create_engine
from sqlalchemy.orm import DeclarativeBase, sessionmaker
from .config import get_settings
class Base(DeclarativeBase):
pass
def engine_for(url: str | None = None):
url = url or get_settings().database_url
return create_engine(url, connect_args={"check_same_thread": False} if url.startswith("sqlite") else {}, pool_pre_ping=True)
engine = engine_for()
SessionLocal = sessionmaker(bind=engine, autoflush=False, expire_on_commit=False)
def get_db():
with SessionLocal() as session:
yield session
+124
View File
@@ -0,0 +1,124 @@
from html import escape
from pathlib import Path
from openpyxl import Workbook
from openpyxl.styles import Font, PatternFill
from sqlalchemy import select
from weasyprint import HTML
from .config import get_settings
from .models import (CriterionScore, ExportTask, Project, ScoreEvidence, ScoringBatch, Student, SubmissionFile)
from .rules import totals
def project_result(db, batch: ScoringBatch, project: Project):
scores = db.scalars(select(CriterionScore).where(CriterionScore.batch_id == batch.id,
CriterionScore.project_id == project.id)).all()
by_id = {score.criterion_id: score for score in scores}
criteria = batch.rubric_snapshot["criteria"]
totals_ = totals({key: score.final_score for key, score in by_id.items()}, criteria, batch.rubric_snapshot["weights"])
rows = []
for criterion in criteria:
score = by_id.get(criterion["criterion_id"])
evidence = db.scalars(select(ScoreEvidence).where(ScoreEvidence.score_id == score.id)).all() if score else []
rows.append({"criterion": criterion, "score_id": score.id if score else None,
"model_score": score.model_score if score else None, "teacher_score": score.teacher_score if score else None,
"final_score": score.final_score if score else None,
"confidence": score.confidence if score else None,
"model_comment": score.model_comment if score else "", "teacher_comment": score.teacher_comment if score else None,
"missing_evidence": score.missing_evidence if score else [], "suggestion": score.suggestion if score else "",
"status": score.status if score else "pending", "error": score.error if score else "",
"evidence": [{"page": item.page, "quote": item.quote, "file_id": item.submission_file_id,
"document": db.get(SubmissionFile, item.submission_file_id).relative_path if item.submission_file_id else "课程资料",
"image_url": f"/api/assets/{item.submission_file_id}/{item.page}" if item.image_path else None} for item in evidence]})
student = db.get(Student, project.student_id)
return {"project_id": project.id, "student_no": student.student_no, "name": student.name, "topic": project.topic,
"totals": totals_, "rows": rows, "facts": project.facts}
def export_excel(db, batch_id: int):
batch = db.get(ScoringBatch, batch_id)
if not batch:
raise ValueError("评分批次不存在")
root = get_settings().exports_path
root.mkdir(parents=True, exist_ok=True)
task = ExportTask(batch_id=batch_id, kind="excel", status="running")
db.add(task)
db.flush()
path = root / f"batch-{batch_id}-scores-{task.id}.xlsx"
try:
book = Workbook()
sheet = book.active
sheet.title = "成绩"
final_criteria = [c for c in batch.rubric_snapshot["criteria"] if c["category"] == "final"]
sheet.append(["学号", "姓名", "选题", "阶段报告1", "阶段报告2", "阶段报告3", *[c["name"] for c in final_criteria], "期末成绩", "总评", "状态", "批次", "规则版本"])
for project in db.scalars(select(Project).where(Project.course_version_id == batch.course_version_id).order_by(Project.id)):
result = project_result(db, batch, project)
scores = {row["criterion"]["criterion_id"]: row["final_score"] for row in result["rows"]}
totals_ = result["totals"]
sheet.append([result["student_no"], result["name"], result["topic"], *[totals_[key] for key in ("stage1", "stage2", "stage3")],
*[scores.get(c["criterion_id"]) for c in final_criteria], totals_["final"], totals_["overall"],
"完成" if totals_["overall"] is not None else "未完成/待人工处理", batch.id, batch.rubric_version_id])
sheet.freeze_panes = "D2"
sheet.auto_filter.ref = sheet.dimensions
for cell in sheet[1]:
cell.font = Font(bold=True, color="FFFFFF")
cell.fill = PatternFill("solid", fgColor="153C65")
for column in sheet.columns:
letter = column[0].column_letter
sheet.column_dimensions[letter].width = min(50, max(12, max(len(str(cell.value or "")) for cell in column) + 2))
book.save(path)
task.path, task.status = str(path), "completed"
except Exception as exc:
task.status, task.error = "failed", str(exc)[:1000]
db.commit()
raise
db.commit()
return task
def _html_report(result, batch):
esc = lambda value: escape(str(value or ""))
rows = []
for row in result["rows"]:
criterion = row["criterion"]
evidences = "".join(f"<li>{esc(e['document'])} · 第{e['page']}页/张:{esc(e['quote'])}</li>" for e in row["evidence"])
missing = "、".join(row["missing_evidence"] or [])
rows.append(f"<section><h3>{esc(criterion['name'])}({esc(row['final_score'])} / {criterion['max_score']})</h3>"
f"<p>模型原始分:{esc(row['model_score'])};教师修改分:{esc(row['teacher_score'])};状态:{esc(row['status'])}</p>"
f"<p>模型评语:{esc(row['model_comment'])}</p><p>教师评语:{esc(row['teacher_comment'])}</p>"
f"<p>缺失证据:{esc(missing)}</p><p>建议:{esc(row['suggestion'])}</p><p>异常:{esc(row['error'])}</p>"
f"<ul>{evidences}</ul></section>")
totals_ = result["totals"]
return f"""<!doctype html><html lang="zh"><meta charset="utf-8"><style>
body {{ font-family: 'Noto Sans CJK SC', sans-serif; font-size: 11px; color: #172b4d; }}
h1 {{ color: #153c65; }} section {{ border-bottom: 1px solid #ccd7e0; break-inside: avoid; }}
h3 {{ margin-bottom: 3px; }} p {{ margin: 3px 0; }} li {{ margin: 3px 0; }}
</style><body><h1>WiseGrade 智评 · 学生反馈</h1>
<p>学号:{esc(result['student_no'])} 姓名:{esc(result['name'])} 选题:{esc(result['topic'])}</p>
<p>评分批次:{batch.id} 课程版本:{batch.course_version_id} 规则版本:{batch.rubric_version_id}</p>
<p>模型:{esc(batch.model_config_snapshot.get('model'))} 嵌入模型:{esc(batch.embedding_model_name)} ({esc(batch.embedding_model_version)})</p>
<h2>成绩</h2><p>阶段1:{esc(totals_['stage1'])} 阶段2:{esc(totals_['stage2'])} 阶段3:{esc(totals_['stage3'])} 期末:{esc(totals_['final'])} 总评:{esc(totals_['overall'])}</p>
{''.join(rows)}</body></html>"""
def export_project_pdf(db, batch_id: int, project_id: int):
batch = db.get(ScoringBatch, batch_id)
project = db.get(Project, project_id)
if not batch or not project or project.course_version_id != batch.course_version_id:
raise ValueError("批次或项目不存在")
root = get_settings().exports_path
root.mkdir(parents=True, exist_ok=True)
task = ExportTask(batch_id=batch_id, project_id=project_id, kind="pdf", status="running")
db.add(task)
db.flush()
path = root / f"batch-{batch_id}-project-{project_id}-{task.id}.pdf"
try:
HTML(string=_html_report(project_result(db, batch, project), batch)).write_pdf(path)
task.path, task.status = str(path), "completed"
except Exception as exc:
task.status, task.error = "failed", str(exc)[:1000]
db.commit()
raise
db.commit()
return task
+112
View File
@@ -0,0 +1,112 @@
import base64
import json
import time
from pathlib import Path
from typing import Any
import httpx
from pydantic import BaseModel, Field, ValidationError, model_validator
from .config import get_settings
class EvidenceResult(BaseModel):
chunk_id: int
page: int = Field(ge=1)
quote: str = Field(min_length=1)
class ScoreResult(BaseModel):
criterion_id: str
score: float = Field(ge=0)
max_score: float = Field(gt=0)
reason: str = Field(min_length=1)
evidence: list[EvidenceResult]
missing_evidence: list[str] = Field(default_factory=list)
suggestion: str = ""
confidence: float = Field(ge=0, le=1)
@model_validator(mode="after")
def valid_range(self):
if self.score > self.max_score or (self.score > 0 and not self.evidence):
raise ValueError("score exceeds maximum or lacks evidence")
return self
class FactResult(BaseModel):
student_no: str = ""
name: str = ""
topic: str = ""
direction: str = ""
goals: list[str] = Field(default_factory=list)
metrics: list[str] = Field(default_factory=list)
hardware: list[str] = Field(default_factory=list)
software: list[str] = Field(default_factory=list)
experiments: list[str] = Field(default_factory=list)
data: list[str] = Field(default_factory=list)
schedule: list[str] = Field(default_factory=list)
cost: list[str] = Field(default_factory=list)
conclusions: list[str] = Field(default_factory=list)
class ConsistencyIssue(BaseModel):
description: str
chunk_ids: list[int] = Field(min_length=2)
class ConsistencyResult(BaseModel):
issues: list[ConsistencyIssue] = Field(default_factory=list)
class ModelError(RuntimeError):
pass
class DeepSeekClient:
def __init__(self, *, base_url=None, model=None, api_key=None, timeout=None, retries=None, transport=None):
settings = get_settings()
self.base_url = (base_url or settings.deepseek_base_url).rstrip("/")
self.model = model or settings.deepseek_model
self.api_key = api_key or settings.deepseek_api_key
self.timeout = timeout or settings.model_timeout_seconds
self.retries = settings.model_retries if retries is None else retries
self.transport = transport
if not self.api_key:
raise ModelError("未配置DeepSeek API Key")
def json_call(self, prompt: str, payload: dict[str, Any], schema: type[BaseModel], *, images: list[str] | None = None):
content: list[dict] = [{"type": "text", "text": json.dumps(payload, ensure_ascii=False)}]
for image in images or []:
path = Path(image)
if not path.is_file():
raise ModelError(f"页面图像不存在: {path.name}")
content.append({"type": "image_url", "image_url": {"url": "data:image/png;base64," + base64.b64encode(path.read_bytes()).decode()}})
body = {"model": self.model, "response_format": {"type": "json_object"},
"messages": [{"role": "system", "content": prompt + " 严格输出一个JSON对象。"}, {"role": "user", "content": content}],
"temperature": 0}
last_error = None
for attempt in range(self.retries + 1):
try:
with httpx.Client(timeout=self.timeout, transport=self.transport) as client:
response = client.post(self.base_url + "/chat/completions", json=body,
headers={"Authorization": f"Bearer {self.api_key}"})
response.raise_for_status()
raw = response.json()["choices"][0]["message"]["content"]
return schema.model_validate(json.loads(raw))
except (httpx.HTTPError, ValueError, KeyError, IndexError, ValidationError) as exc:
# Never include response bodies or headers: providers sometimes echo credentials.
last_error = type(exc).__name__
if attempt < self.retries:
time.sleep(min(2 ** attempt, 5))
raise ModelError(f"模型请求或JSON校验失败({last_error}),已重试{self.retries}次")
def validate_score(result: ScoreResult, criterion: dict, chunks: list[dict]):
if result.criterion_id != criterion["criterion_id"] or result.max_score != criterion["max_score"]:
raise ModelError("模型评分项或满分与规则版本不一致")
by_id = {chunk["chunk_id"]: chunk for chunk in chunks}
for evidence in result.evidence:
chunk = by_id.get(evidence.chunk_id)
if not chunk or evidence.page != chunk["page"] or evidence.quote not in chunk["text"]:
raise ModelError("模型证据未能匹配检索原文")
return result
+31
View File
@@ -0,0 +1,31 @@
import logging
from contextlib import asynccontextmanager
from fastapi import FastAPI, Request
from fastapi.responses import JSONResponse
from .api import router
from .db import SessionLocal
from .seed import seed_initial
logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(name)s %(message)s")
@asynccontextmanager
async def lifespan(_app: FastAPI):
with SessionLocal() as db:
seed_initial(db)
yield
app = FastAPI(title="WiseGrade 智评", version="0.1.0", lifespan=lifespan)
app.include_router(router)
@app.exception_handler(ValueError)
async def value_error_handler(_request: Request, exc: ValueError):
return JSONResponse(status_code=400, content={"detail": str(exc)})
@app.get("/api/health")
def health():
return {"status": "ok"}
+36
View File
@@ -0,0 +1,36 @@
from sqlalchemy import delete, select
from .models import (CourseObjective, GraduationIndicator, RubricCriterion,
RubricScoreLevel, TopicDirection)
def sync_course_entities(db, version):
db.execute(delete(CourseObjective).where(CourseObjective.course_version_id == version.id))
db.execute(delete(GraduationIndicator).where(GraduationIndicator.course_version_id == version.id))
db.execute(delete(TopicDirection).where(TopicDirection.course_version_id == version.id))
for value in version.objectives:
db.add(CourseObjective(course_version_id=version.id, code=str(value["code"]), name=value["name"],
description=value.get("description", ""), indicator_codes=value.get("indicators", [])))
for value in version.indicators:
db.add(GraduationIndicator(course_version_id=version.id, code=str(value["code"]), description=value["description"]))
for value in version.directions:
db.add(TopicDirection(course_version_id=version.id, name=value["name"], examples=value.get("examples", [])))
db.flush()
def sync_rubric_entities(db, rubric):
ids = db.scalars(select(RubricCriterion.id).where(RubricCriterion.rubric_version_id == rubric.id)).all()
if ids:
db.execute(delete(RubricScoreLevel).where(RubricScoreLevel.rubric_criterion_id.in_(ids)))
db.execute(delete(RubricCriterion).where(RubricCriterion.rubric_version_id == rubric.id))
for value in rubric.criteria:
item = RubricCriterion(rubric_version_id=rubric.id, criterion_id=value["criterion_id"],
category=value["category"], name=value["name"], description=value["description"],
max_score=value["max_score"], linked_objectives=value.get("linked_objectives", []),
linked_outcomes=value.get("linked_outcomes", []), required_evidence=value.get("required_evidence", []))
db.add(item)
db.flush()
for level in value.get("score_levels", []):
db.add(RubricScoreLevel(rubric_criterion_id=item.id, min_score=level["min"],
max_score=level["max"], description=level["description"]))
db.flush()
+274
View File
@@ -0,0 +1,274 @@
from datetime import datetime, timezone
from pgvector.sqlalchemy import Vector
from sqlalchemy import JSON, Boolean, Column, DateTime, Float, ForeignKey, Integer, LargeBinary, String, Text, UniqueConstraint
from sqlalchemy.types import TypeDecorator
from .db import Base
def now():
return datetime.now(timezone.utc)
class Embedding(TypeDecorator):
impl = JSON
cache_ok = True
def load_dialect_impl(self, dialect):
return dialect.type_descriptor(Vector(1024) if dialect.name == "postgresql" else JSON())
class Course(Base):
__tablename__ = "courses"
id = Column(Integer, primary_key=True)
name = Column(String(200), unique=True, nullable=False)
code = Column(String(100), unique=True, nullable=False)
created_at = Column(DateTime(timezone=True), default=now)
class CourseVersion(Base):
__tablename__ = "course_versions"
id = Column(Integer, primary_key=True)
course_id = Column(Integer, ForeignKey("courses.id"), nullable=False)
version = Column(Integer, nullable=False)
title = Column(String(200), nullable=False)
objectives = Column(JSON, default=list, nullable=False)
indicators = Column(JSON, default=list, nullable=False)
directions = Column(JSON, default=list, nullable=False)
prompts = Column(JSON, default=dict, nullable=False)
templates = Column(JSON, default=list, nullable=False)
published = Column(Boolean, default=False, nullable=False)
created_at = Column(DateTime(timezone=True), default=now)
__table_args__ = (UniqueConstraint("course_id", "version"),)
class CourseObjective(Base):
__tablename__ = "course_objectives"
id = Column(Integer, primary_key=True)
course_version_id = Column(Integer, ForeignKey("course_versions.id"), nullable=False)
code = Column(String(50), nullable=False)
name = Column(String(300), nullable=False)
description = Column(Text, default="")
indicator_codes = Column(JSON, default=list)
__table_args__ = (UniqueConstraint("course_version_id", "code"),)
class GraduationIndicator(Base):
__tablename__ = "graduation_indicators"
id = Column(Integer, primary_key=True)
course_version_id = Column(Integer, ForeignKey("course_versions.id"), nullable=False)
code = Column(String(50), nullable=False)
description = Column(Text, nullable=False)
__table_args__ = (UniqueConstraint("course_version_id", "code"),)
class TopicDirection(Base):
__tablename__ = "topic_directions"
id = Column(Integer, primary_key=True)
course_version_id = Column(Integer, ForeignKey("course_versions.id"), nullable=False)
name = Column(String(200), nullable=False)
examples = Column(JSON, default=list)
__table_args__ = (UniqueConstraint("course_version_id", "name"),)
class RubricVersion(Base):
__tablename__ = "rubric_versions"
id = Column(Integer, primary_key=True)
course_version_id = Column(Integer, ForeignKey("course_versions.id"), nullable=False)
version = Column(Integer, nullable=False)
criteria = Column(JSON, default=list, nullable=False)
weights = Column(JSON, default=dict, nullable=False)
published = Column(Boolean, default=False, nullable=False)
created_at = Column(DateTime(timezone=True), default=now)
__table_args__ = (UniqueConstraint("course_version_id", "version"),)
class RubricCriterion(Base):
__tablename__ = "rubric_criteria"
id = Column(Integer, primary_key=True)
rubric_version_id = Column(Integer, ForeignKey("rubric_versions.id"), nullable=False)
criterion_id = Column(String(100), nullable=False)
category = Column(String(50), nullable=False)
name = Column(String(300), nullable=False)
description = Column(Text, nullable=False)
max_score = Column(Float, nullable=False)
linked_objectives = Column(JSON, default=list)
linked_outcomes = Column(JSON, default=list)
required_evidence = Column(JSON, default=list)
__table_args__ = (UniqueConstraint("rubric_version_id", "criterion_id"),)
class RubricScoreLevel(Base):
__tablename__ = "rubric_score_levels"
id = Column(Integer, primary_key=True)
rubric_criterion_id = Column(Integer, ForeignKey("rubric_criteria.id"), nullable=False)
min_score = Column(Float, nullable=False)
max_score = Column(Float, nullable=False)
description = Column(Text, nullable=False)
class Student(Base):
__tablename__ = "students"
id = Column(Integer, primary_key=True)
student_no = Column(String(80), unique=True, nullable=False)
name = Column(String(100), nullable=False)
class Project(Base):
__tablename__ = "projects"
id = Column(Integer, primary_key=True)
course_version_id = Column(Integer, ForeignKey("course_versions.id"), nullable=False)
student_id = Column(Integer, ForeignKey("students.id"), nullable=False)
topic = Column(String(500), nullable=False)
direction = Column(String(200), default="")
source_dir = Column(String(1000), nullable=False)
status = Column(String(40), default="imported")
facts = Column(JSON, default=dict, nullable=False)
created_at = Column(DateTime(timezone=True), default=now)
__table_args__ = (UniqueConstraint("course_version_id", "source_dir"),)
class Submission(Base):
__tablename__ = "submissions"
id = Column(Integer, primary_key=True)
project_id = Column(Integer, ForeignKey("projects.id"), nullable=False)
category = Column(String(50), nullable=False)
status = Column(String(40), nullable=False)
__table_args__ = (UniqueConstraint("project_id", "category"),)
class SubmissionFile(Base):
__tablename__ = "submission_files"
id = Column(Integer, primary_key=True)
project_id = Column(Integer, ForeignKey("projects.id"), nullable=False)
submission_id = Column(Integer, ForeignKey("submissions.id"), nullable=True)
category = Column(String(40), nullable=False)
relative_path = Column(String(1200), nullable=False)
mime_type = Column(String(100), nullable=False)
size = Column(Integer, nullable=False)
mtime = Column(Float, nullable=False)
sha256 = Column(String(64), nullable=False)
recognition_status = Column(String(40), nullable=False)
parse_status = Column(String(40), default="pending")
parse_error = Column(Text, default="")
parsed = Column(JSON, default=dict, nullable=False)
__table_args__ = (UniqueConstraint("project_id", "relative_path"),)
class KnowledgeDocument(Base):
__tablename__ = "knowledge_documents"
id = Column(Integer, primary_key=True)
course_version_id = Column(Integer, ForeignKey("course_versions.id"), nullable=False)
project_id = Column(Integer, ForeignKey("projects.id"), nullable=True)
submission_file_id = Column(Integer, ForeignKey("submission_files.id"), nullable=True)
title = Column(String(500), nullable=False)
document_type = Column(String(80), nullable=False)
source_hash = Column(String(64), nullable=False)
status = Column(String(40), default="ready")
__table_args__ = (UniqueConstraint("course_version_id", "project_id", "submission_file_id", "source_hash"),)
class KnowledgeChunk(Base):
__tablename__ = "knowledge_chunks"
id = Column(Integer, primary_key=True)
document_id = Column(Integer, ForeignKey("knowledge_documents.id"), nullable=False)
course_version_id = Column(Integer, nullable=False)
project_id = Column(Integer, nullable=True)
document_type = Column(String(80), nullable=False)
page = Column(Integer, nullable=False)
section = Column(String(500), default="")
text = Column(Text, nullable=False)
embedding = Column(Embedding, nullable=True)
class ScoringBatch(Base):
__tablename__ = "scoring_batches"
id = Column(Integer, primary_key=True)
course_version_id = Column(Integer, ForeignKey("course_versions.id"), nullable=False)
rubric_version_id = Column(Integer, ForeignKey("rubric_versions.id"), nullable=False)
course_snapshot = Column(JSON, nullable=False)
rubric_snapshot = Column(JSON, nullable=False)
model_config_snapshot = Column(JSON, nullable=False)
embedding_model_name = Column(String(200), nullable=False)
embedding_model_version = Column(String(100), nullable=False)
status = Column(String(40), default="queued")
progress_done = Column(Integer, default=0)
progress_total = Column(Integer, default=0)
error = Column(Text, default="")
created_at = Column(DateTime(timezone=True), default=now)
finished_at = Column(DateTime(timezone=True), nullable=True)
class CriterionScore(Base):
__tablename__ = "criterion_scores"
id = Column(Integer, primary_key=True)
batch_id = Column(Integer, ForeignKey("scoring_batches.id"), nullable=False)
project_id = Column(Integer, ForeignKey("projects.id"), nullable=False)
criterion_id = Column(String(100), nullable=False)
model_score = Column(Float, nullable=True)
teacher_score = Column(Float, nullable=True)
model_comment = Column(Text, default="")
teacher_comment = Column(Text, nullable=True)
confidence = Column(Float, nullable=True)
missing_evidence = Column(JSON, default=list)
suggestion = Column(Text, default="")
status = Column(String(40), default="pending")
error = Column(Text, default="")
__table_args__ = (UniqueConstraint("batch_id", "project_id", "criterion_id"),)
@property
def final_score(self):
return self.teacher_score if self.teacher_score is not None else self.model_score
class ScoreEvidence(Base):
__tablename__ = "score_evidences"
id = Column(Integer, primary_key=True)
score_id = Column(Integer, ForeignKey("criterion_scores.id"), nullable=False)
document_id = Column(Integer, ForeignKey("knowledge_documents.id"), nullable=True)
submission_file_id = Column(Integer, ForeignKey("submission_files.id"), nullable=True)
page = Column(Integer, nullable=False)
quote = Column(Text, nullable=False)
image_path = Column(String(1200), nullable=True)
class TeacherRevision(Base):
__tablename__ = "teacher_revisions"
id = Column(Integer, primary_key=True)
score_id = Column(Integer, ForeignKey("criterion_scores.id"), nullable=False)
old_score = Column(Float, nullable=True)
new_score = Column(Float, nullable=True)
old_comment = Column(Text, nullable=True)
new_comment = Column(Text, nullable=True)
reason = Column(Text, default="")
created_at = Column(DateTime(timezone=True), default=now)
class ExportTask(Base):
__tablename__ = "export_tasks"
id = Column(Integer, primary_key=True)
batch_id = Column(Integer, ForeignKey("scoring_batches.id"), nullable=False)
project_id = Column(Integer, ForeignKey("projects.id"), nullable=True)
kind = Column(String(20), nullable=False)
path = Column(String(1200), nullable=True)
status = Column(String(40), default="queued")
error = Column(Text, default="")
created_at = Column(DateTime(timezone=True), default=now)
class AppSetting(Base):
__tablename__ = "app_settings"
key = Column(String(100), primary_key=True)
encrypted_value = Column(LargeBinary, nullable=False)
updated_at = Column(DateTime(timezone=True), default=now)
class AuditLog(Base):
__tablename__ = "audit_logs"
id = Column(Integer, primary_key=True)
action = Column(String(100), nullable=False)
entity_type = Column(String(100), nullable=False)
entity_id = Column(Integer, nullable=True)
details = Column(JSON, default=dict)
created_at = Column(DateTime(timezone=True), default=now)
+125
View File
@@ -0,0 +1,125 @@
import subprocess
import tempfile
from pathlib import Path
import pymupdf as fitz
from docx import Document
from pptx import Presentation
def _render(page, output: Path):
output.parent.mkdir(parents=True, exist_ok=True)
page.get_pixmap(matrix=fitz.Matrix(1.5, 1.5), alpha=False).save(output)
return str(output)
def _ocr(image_path: str):
try:
from paddleocr import PaddleOCR
except ImportError as exc:
raise RuntimeError("扫描PDF需要PaddleOCR;请安装OCR依赖或交由教师处理") from exc
try:
engine = PaddleOCR(lang="ch", use_textline_orientation=True)
except TypeError:
engine = PaddleOCR(use_angle_cls=True, lang="ch", show_log=False)
if hasattr(engine, "predict"):
result = engine.predict(image_path)
lines = []
for page in result:
payload = page.json if hasattr(page, "json") else page
payload = payload() if callable(payload) else payload
payload = payload.get("res", payload) if isinstance(payload, dict) else {}
lines.extend(payload.get("rec_texts", []))
return "\n".join(str(line) for line in lines)
result = engine.ocr(image_path, cls=True)
return "\n".join(line[1][0] for page in result for line in (page or []))
def parse_pdf(path: Path, asset_dir: Path, *, ocr=True):
pages = []
with fitz.open(path) as pdf:
if not len(pdf):
raise ValueError("PDF没有页面")
for index, page in enumerate(pdf):
image = _render(page, asset_dir / f"page-{index + 1}.png")
text = page.get_text(sort=True).strip()
if ocr and len(text) < 30:
text = _ocr(image)
tables = []
try:
tables = [{"rows": table.extract(), "bbox": list(table.bbox)} for table in page.find_tables().tables]
except Exception:
pass
images = [{"xref": item[0], "width": item[2], "height": item[3]} for item in page.get_images(full=True)]
pages.append({"page_number": index + 1, "text": text, "tables": tables, "images": images,
"section": next((line.strip() for line in text.splitlines() if line.strip()), ""),
"rendered_image_path": image})
return pages
def _convert_to_pdf(path: Path, output_dir: Path):
with tempfile.TemporaryDirectory() as profile:
command = ["libreoffice", f"-env:UserInstallation=file://{profile}", "--headless", "--convert-to", "pdf", "--outdir", str(output_dir), str(path)]
result = subprocess.run(command, capture_output=True, text=True, timeout=180, check=False)
pdf = output_dir / f"{path.stem}.pdf"
if result.returncode or not pdf.exists():
raise RuntimeError(f"LibreOffice转换失败: {(result.stderr or result.stdout).strip()[:500]}")
return pdf
def _docx_structure(path: Path):
doc = Document(path)
headings = [{"text": p.text, "style": p.style.name} for p in doc.paragraphs if p.style and p.style.name.startswith("Heading")]
tables = [[list(cell.text for cell in row.cells) for row in table.rows] for table in doc.tables]
images = len(doc.inline_shapes)
return {"headings": headings, "tables": tables, "image_count": images}
def _pptx_structure(path: Path):
slides = []
deck = Presentation(path)
for number, slide in enumerate(deck.slides, start=1):
texts, tables, images = [], [], 0
for shape in slide.shapes:
if shape.has_text_frame:
texts.append(shape.text)
if shape.has_table:
tables.append([[cell.text for cell in row.cells] for row in shape.table.rows])
if shape.shape_type == 13:
images += 1
try:
notes = slide.notes_slide.notes_text_frame.text if slide.has_notes_slide else ""
except Exception:
notes = ""
slides.append({"page_number": number, "text": "\n".join(texts), "tables": tables, "images": [{"count": images}],
"notes": notes, "section": texts[0][:200] if texts else ""})
if not slides:
raise ValueError("PPTX没有幻灯片")
return slides
def parse_document(path: Path, category: str, asset_dir: Path, *, ocr=True):
suffix = path.suffix.lower()
asset_dir.mkdir(parents=True, exist_ok=True)
if suffix == ".pdf":
pages = parse_pdf(path, asset_dir, ocr=ocr)
extra = {}
elif suffix in {".docx", ".doc"}:
extra = _docx_structure(path) if suffix == ".docx" else {}
pdf = _convert_to_pdf(path, asset_dir)
pages = parse_pdf(pdf, asset_dir, ocr=ocr)
pdf.unlink(missing_ok=True)
elif suffix == ".pptx":
slides = _pptx_structure(path)
pdf = _convert_to_pdf(path, asset_dir)
rendered = parse_pdf(pdf, asset_dir, ocr=False)
pdf.unlink(missing_ok=True)
if len(rendered) != len(slides):
raise ValueError("PPTX渲染页数与幻灯片数不一致")
pages = [{**slide, "rendered_image_path": rendered[index]["rendered_image_path"]} for index, slide in enumerate(slides)]
extra = {}
else:
raise ValueError("仅支持PDF、DOCX和PPTX")
if not any(page["text"].strip() for page in pages):
raise ValueError("未能提取文档内容")
return {"document_type": category, "source_path": str(path), "pages": pages, **extra}
+99
View File
@@ -0,0 +1,99 @@
from functools import lru_cache
from pathlib import Path
from sqlalchemy import cast, delete, func, or_, select
from .config import get_settings
from .models import KnowledgeChunk, KnowledgeDocument
def split_pages(pages: list[dict], *, size=650, overlap=100):
for page in pages:
blocks = [block.strip() for block in page.get("text", "").split("\n\n") if block.strip()]
for table in page.get("tables", []):
blocks.append("表格: " + str(table.get("rows", table)))
for block in blocks:
start = 0
while start < len(block):
yield {"page": page["page_number"], "section": page.get("section", ""), "text": block[start:start + size]}
if start + size >= len(block):
break
start += size - overlap
@lru_cache(maxsize=2)
def local_encoder(model_path: str):
path = Path(model_path)
if not path.is_dir() or not any(path.iterdir()):
raise RuntimeError(f"离线嵌入模型不存在: {path};请挂载本地模型包")
from sentence_transformers import SentenceTransformer
return SentenceTransformer(str(path), local_files_only=True, trust_remote_code=False)
def embed(texts: list[str], *, model_path: str | None = None):
if not texts:
return []
model = local_encoder(model_path or str(get_settings().embedding_model_path))
vectors = model.encode(texts, normalize_embeddings=True)
output = []
for vector in vectors:
values = vector.tolist()
if len(values) > 1024:
raise RuntimeError("嵌入维度超过pgvector列容量1024")
output.append(values + [0.0] * (1024 - len(values)))
return output
def ingest(db, *, course_version_id: int, project_id: int | None, submission_file_id: int | None,
title: str, category: str, source_hash: str, pages: list[dict], embedder=embed):
existing = db.scalar(select(KnowledgeDocument).where(KnowledgeDocument.course_version_id == course_version_id,
KnowledgeDocument.project_id == project_id, KnowledgeDocument.submission_file_id == submission_file_id,
KnowledgeDocument.title == title))
if existing and existing.source_hash == source_hash and existing.status == "ready":
return existing
if existing:
db.execute(delete(KnowledgeChunk).where(KnowledgeChunk.document_id == existing.id))
db.delete(existing)
db.flush()
doc = KnowledgeDocument(course_version_id=course_version_id, project_id=project_id, submission_file_id=submission_file_id,
title=title, document_type=category, source_hash=source_hash, status="indexing")
db.add(doc)
db.flush()
chunks = list(split_pages(pages))
vectors = embedder([chunk["text"] for chunk in chunks])
for chunk, vector in zip(chunks, vectors):
db.add(KnowledgeChunk(document_id=doc.id, course_version_id=course_version_id, project_id=project_id,
document_type=category, page=chunk["page"], section=chunk["section"], text=chunk["text"], embedding=vector))
doc.status = "ready"
db.commit()
return doc
def retrieve(db, *, course_version_id: int, project_id: int, query: str, category: str | None = None,
limit=10, embedder=embed, include_public=True, public_only=False):
"""Hybrid retrieval with project isolation and an optional public course namespace."""
scope = (KnowledgeChunk.project_id.is_(None) if public_only else
or_(KnowledgeChunk.project_id == project_id, KnowledgeChunk.project_id.is_(None)) if include_public else
KnowledgeChunk.project_id == project_id)
filters = [KnowledgeChunk.course_version_id == course_version_id, scope]
if category:
filters.append(KnowledgeChunk.document_type == category)
vector = embedder([query])[0]
if db.bind.dialect.name == "postgresql":
from pgvector.sqlalchemy import Vector
semantic = (1 - cast(KnowledgeChunk.embedding, Vector(1024)).cosine_distance(vector)).label("semantic")
lexical = func.ts_rank(func.to_tsvector("simple", KnowledgeChunk.text), func.plainto_tsquery("simple", query)).label("lexical")
rows = db.execute(select(KnowledgeChunk, semantic, lexical).where(*filters).order_by(semantic.desc(), lexical.desc()).limit(limit * 3)).all()
ranked = [(row[0], float(row.semantic or 0) * .7 + float(row.lexical or 0) * .3) for row in rows]
else:
chunks = db.scalars(select(KnowledgeChunk).where(*filters)).all()
terms = [query[i:i+2] for i in range(max(0, len(query)-1)) if query[i:i+2].strip()]
ranked = []
for chunk in chunks:
semantic = sum(a*b for a, b in zip(chunk.embedding or [], vector))
lexical = sum(term in chunk.text for term in terms) / max(len(terms), 1)
ranked.append((chunk, semantic * .7 + lexical * .3))
ranked.sort(key=lambda pair: pair[1], reverse=True)
return [{"chunk_id": chunk.id, "document_id": chunk.document_id, "project_id": chunk.project_id,
"document_type": chunk.document_type, "page": chunk.page, "section": chunk.section,
"text": chunk.text, "rank": float(rank)} for chunk, rank in ranked[:limit]]
+59
View File
@@ -0,0 +1,59 @@
from pydantic import BaseModel, Field, model_validator
class ScoreLevel(BaseModel):
min: float = Field(ge=0)
max: float = Field(ge=0)
description: str = Field(min_length=1)
class Criterion(BaseModel):
criterion_id: str = Field(min_length=1)
category: str
name: str = Field(min_length=1)
max_score: float = Field(gt=0)
description: str = Field(min_length=1)
linked_objectives: list[str] = Field(default_factory=list)
linked_outcomes: list[str] = Field(default_factory=list)
required_evidence: list[str] = Field(default_factory=list)
score_levels: list[ScoreLevel] = Field(default_factory=list)
@model_validator(mode="after")
def check_levels(self):
if not self.score_levels:
raise ValueError(f"{self.criterion_id}: score levels are required")
ranges = sorted(self.score_levels, key=lambda x: x.min)
if ranges[0].min != 0 or ranges[-1].max != self.max_score:
raise ValueError(f"{self.criterion_id}: score levels must cover 0 through max_score")
for previous, current in zip(ranges, ranges[1:]):
if current.min > previous.max + 1 or current.min <= previous.max:
raise ValueError(f"{self.criterion_id}: score levels overlap or have gaps")
return self
class RubricData(BaseModel):
criteria: list[Criterion]
weights: dict[str, float]
@model_validator(mode="after")
def check_weights(self):
expected = {c.category for c in self.criteria}
if not expected or set(self.weights) != expected or abs(sum(self.weights.values()) - 1) > 1e-8 or any(v < 0 for v in self.weights.values()):
raise ValueError("weights must cover all rubric categories and sum to 1")
for category in expected:
if abs(sum(c.max_score for c in self.criteria if c.category == category) - 100) > 1e-8:
raise ValueError(f"{category} criteria must total 100")
ids = [c.criterion_id for c in self.criteria]
if len(set(ids)) != len(ids):
raise ValueError("duplicate criterion_id")
return self
def totals(scores: dict[str, float | None], criteria: list[dict], weights: dict[str, float]):
grouped = {}
for category in weights:
group = [c for c in criteria if c["category"] == category]
values = [scores.get(c["criterion_id"]) for c in group]
grouped[category] = round(sum(values), 2) if values and all(v is not None for v in values) else None
overall = round(sum(grouped[k] * weights[k] for k in weights), 2) if all(v is not None for v in grouped.values()) else None
return {**grouped, "overall": overall}
+180
View File
@@ -0,0 +1,180 @@
from concurrent.futures import ThreadPoolExecutor, as_completed
from sqlalchemy import select, update
from .config import get_settings
from .db import SessionLocal
from .llm import ConsistencyResult, FactResult, ModelError, ScoreResult, validate_score
from .models import (CriterionScore, KnowledgeDocument, Project, ScoreEvidence, ScoringBatch,
SubmissionFile, now)
from .parsing import parse_document
from .rag import ingest, retrieve
from .storage import secure_path, sha256
from .settings_store import client_for_batch
def parse_submission(db, file: SubmissionFile, *, embedder=None):
path = secure_path(file.relative_path)
if sha256(path) != file.sha256:
file.parse_status, file.parse_error = "changed", "源文件已变化,请重新扫描并确认导入"
db.commit()
raise ValueError(file.parse_error)
if file.parse_status == "ready" and file.parsed:
return file.parsed
settings = get_settings()
try:
parsed = parse_document(path, file.category, settings.processed_path / f"file-{file.id}")
project = db.get(Project, file.project_id)
kwargs = {"embedder": embedder} if embedder else {}
ingest(db, course_version_id=project.course_version_id, project_id=project.id,
submission_file_id=file.id, title=path.name, category=file.category,
source_hash=file.sha256, pages=parsed["pages"], **kwargs)
file.parsed, file.parse_status, file.parse_error = parsed, "ready", ""
db.commit()
return parsed
except Exception as exc:
file.parse_status, file.parse_error = "failed", str(exc)[:1000]
db.commit()
raise
def _project_facts(db, project, course_snapshot, client, *, retriever):
prompts = course_snapshot["prompts"]
categories = ("stage1", "stage2", "stage3", "final_report", "presentation")
snippets = []
for category in categories:
snippets.extend(retriever(db, course_version_id=project.course_version_id, project_id=project.id,
category=category, query="项目名称 目标 指标 硬件 软件 实验 数据 成本 结论", limit=3, include_public=False))
if not snippets:
return {}
facts = client.json_call(prompts["facts"], {"evidence": snippets}, FactResult).model_dump()
from .models import Student
student = db.get(Student, project.student_id)
warnings = []
for key, expected in (("student_no", student.student_no), ("name", student.name), ("topic", project.topic)):
if facts.get(key) and facts[key] != expected:
warnings.append(f"模型抽取的{key}与教师确认信息不一致")
consistency = client.json_call(prompts["consistency"], {"facts": facts, "evidence": snippets}, ConsistencyResult)
known = {snippet["chunk_id"] for snippet in snippets}
for issue in consistency.issues:
if not set(issue.chunk_ids) <= known:
raise ModelError("一致性检查引用了未提供的证据")
project.facts = {**facts, "identity_warnings": warnings,
"consistency_issues": [issue.model_dump() for issue in consistency.issues]}
db.commit()
return project.facts
def _score_criterion(db, batch, project, criterion, client, *, retriever):
score = db.scalar(select(CriterionScore).where(CriterionScore.batch_id == batch.id,
CriterionScore.project_id == project.id, CriterionScore.criterion_id == criterion["criterion_id"]))
if score and score.status in ("scored", "reviewed"):
return score
if not score:
score = CriterionScore(batch_id=batch.id, project_id=project.id, criterion_id=criterion["criterion_id"])
db.add(score)
db.flush()
category = "presentation" if criterion["criterion_id"] == "final.presentation" else (
"final_report" if criterion["category"] == "final" else criterion["category"])
files = db.scalars(select(SubmissionFile).where(SubmissionFile.project_id == project.id,
SubmissionFile.category == category, SubmissionFile.recognition_status == "recognized")).all()
ready = [file for file in files if file.parse_status == "ready"]
if len(files) != 1 or len(ready) != 1:
score.status, score.error = "unscorable", "材料缺失、重复、待确认或解析失败"
db.commit()
return score
query = criterion["description"] + " " + " ".join(criterion["required_evidence"])
chunks = retriever(db, course_version_id=batch.course_version_id, project_id=project.id,
category=category, query=query, limit=10, include_public=False)
if not chunks:
score.status, score.error = "unscorable", "无可检索的材料证据"
db.commit()
return score
page_paths = {page["page_number"]: page.get("rendered_image_path") for page in ready[0].parsed["pages"]}
images = [page_paths[page] for page in dict.fromkeys(chunk["page"] for chunk in chunks[:2]) if page_paths.get(page)]
public_context = retriever(db, course_version_id=batch.course_version_id, project_id=project.id,
category=None, query=query, limit=6, public_only=True)
payload = {"criterion": criterion, "project_facts": project.facts, "course_context": public_context,
"evidence": chunks,
"required_output": {"criterion_id": "string", "score": "number", "max_score": "number", "reason": "string",
"evidence": [{"chunk_id": "integer", "page": "integer", "quote": "exact substring"}],
"missing_evidence": ["string"], "suggestion": "string", "confidence": "0..1"}}
result = validate_score(client.json_call(batch.course_snapshot["prompts"]["score"], payload, ScoreResult, images=images), criterion, chunks)
score.model_score, score.model_comment, score.confidence = result.score, result.reason, result.confidence
score.missing_evidence, score.suggestion, score.status, score.error = result.missing_evidence, result.suggestion, "scored", ""
for evidence in db.scalars(select(ScoreEvidence).where(ScoreEvidence.score_id == score.id)).all():
db.delete(evidence)
by_id = {chunk["chunk_id"]: chunk for chunk in chunks}
for evidence in result.evidence:
chunk = by_id[evidence.chunk_id]
doc = db.get(KnowledgeDocument, chunk["document_id"])
db.add(ScoreEvidence(score_id=score.id, document_id=doc.id, submission_file_id=doc.submission_file_id,
page=evidence.page, quote=evidence.quote, image_path=page_paths.get(evidence.page)))
db.commit()
return score
def _run_project(batch_id, project_id, session_factory, client_factory, embedder, retriever):
failures = 0
with session_factory() as db:
batch = db.get(ScoringBatch, batch_id)
project = db.get(Project, project_id)
client = client_factory(db, batch)
files = db.scalars(select(SubmissionFile).where(SubmissionFile.project_id == project.id)).all()
for file in files:
if file.recognition_status == "recognized" and file.parse_status != "ready":
try:
parse_submission(db, file, embedder=embedder)
except Exception:
failures += 1
try:
_project_facts(db, project, batch.course_snapshot, client, retriever=retriever)
except Exception as exc:
project.facts = {"error": str(exc)[:500]}
db.commit()
failures += 1
for criterion in batch.rubric_snapshot["criteria"]:
try:
result = _score_criterion(db, batch, project, criterion, client, retriever=retriever)
if result.status not in ("scored", "reviewed"):
failures += 1
except Exception as exc:
score = db.scalar(select(CriterionScore).where(CriterionScore.batch_id == batch.id,
CriterionScore.project_id == project.id, CriterionScore.criterion_id == criterion["criterion_id"]))
if not score:
score = CriterionScore(batch_id=batch.id, project_id=project.id, criterion_id=criterion["criterion_id"])
db.add(score)
score.status, score.error = "failed", str(exc)[:500]
db.commit()
failures += 1
db.execute(update(ScoringBatch).where(ScoringBatch.id == batch_id).values(progress_done=ScoringBatch.progress_done + 1))
db.commit()
return failures
def run_batch(batch_id: int, *, session_factory=SessionLocal, client_factory=client_for_batch, embedder=None, retriever=retrieve):
with session_factory() as db:
batch = db.get(ScoringBatch, batch_id)
if not batch:
raise ValueError("评分批次不存在")
project_ids = db.scalars(select(Project.id).where(Project.course_version_id == batch.course_version_id).order_by(Project.id)).all()
criteria = batch.rubric_snapshot["criteria"]
batch.status, batch.progress_total, batch.progress_done = "running", len(project_ids) * len(criteria), 0
db.commit()
try:
client_factory(db, batch)
except Exception as exc:
batch.status, batch.error, batch.finished_at = "failed", str(exc), now()
db.commit()
return
failures = 0
with ThreadPoolExecutor(max_workers=max(1, int(batch.model_config_snapshot.get("concurrency", 1)))) as pool:
futures = [pool.submit(_run_project, batch_id, project_id, session_factory, client_factory, embedder, retriever) for project_id in project_ids]
for future in as_completed(futures):
try:
failures += future.result()
except Exception as exc:
failures += 1
batch.error = (batch.error + f" 项目任务失败: {type(exc).__name__}")[:1000]
batch.status, batch.finished_at = ("partial" if failures else "completed"), now()
db.commit()
+93
View File
@@ -0,0 +1,93 @@
from sqlalchemy import select
from .models import Course, CourseVersion, RubricVersion
from .rules import RubricData
from .materialized import sync_course_entities, sync_rubric_entities
INDICATORS = [
{"code": "2.3", "description": "基于文献研究获得复杂自动化工程问题的解决方案及替代方案"},
{"code": "2.4", "description": "运用数理和自动化工程基础知识分析解决方案合理性"},
{"code": "4.1", "description": "调研和分析本领域复杂工程问题的解决方案"},
{"code": "4.2", "description": "选择研究路线并设计实验方案"},
{"code": "4.3", "description": "构建实验系统、安全操作实验并分析解释数据"},
{"code": "4.4", "description": "整理数据、分析结果并撰写实验报告"},
{"code": "11.1", "description": "掌握工程管理原理和经济决策方法"},
{"code": "11.2", "description": "理解自动化系统全周期成本并应用工程管理和经济决策"},
]
OBJECTIVES = [
{"code": "1", "name": "问题解决与研究能力", "indicators": ["2.3", "4.1"]},
{"code": "2", "name": "知识运用与分析能力", "indicators": ["2.4", "11.1", "11.2"]},
{"code": "3", "name": "实验设计与操作能力", "indicators": ["4.2", "4.3"]},
{"code": "4", "name": "数据处理与报告撰写能力", "indicators": ["4.4"]},
]
DIRECTIONS = [
{"name": "赛教融合", "examples": ["西门子杯中智赛", "电子设计竞赛", "挑战杯-揭榜挂帅"]},
{"name": "科教融合", "examples": ["智慧交通", "智慧电网", "智能飞行器"]},
{"name": "产教融合", "examples": ["智能驾驶", "智能制造", "具身智能"]},
]
def criterion(category, code, name, points, evidence, objective, outcomes, description=None, levels=None):
high = int(points * .75 + .5)
mid = int(points * .35 + .5)
levels = levels or [(high, points, "证据充分,分析准确且具体"), (mid, high - 1, "基本满足要求,但证据或分析不充分"), (0, mid - 1, "核心证据缺失或内容明显不足")]
return {
"criterion_id": f"{category}.{code}", "category": category, "name": name,
"max_score": points, "description": description or name,
"required_evidence": evidence, "linked_objectives": objective,
"linked_outcomes": outcomes,
"score_levels": [{"min": low, "max": high_, "description": text} for low, high_, text in levels],
}
INITIAL_CRITERIA = [
criterion("stage1", "problem", "复杂工程问题和需求分析", 25, ["问题定义", "需求分析"], ["1"], ["2.3", "4.1"]),
criterion("stage1", "literature", "文献调研和技术现状总结", 20, ["参考文献", "技术现状"], ["1"], ["2.3", "4.1"]),
criterion("stage1", "solutions", "主方案和替代方案设计", 25, ["主方案", "替代方案", "方案比较"], ["1", "2"], ["2.3", "2.4"]),
criterion("stage1", "feasibility", "可行性、风险和合理性论证", 15, ["工程依据", "风险分析"], ["2"], ["2.4"]),
criterion("stage1", "management", "工程管理、进度和成本分析", 10, ["进度计划", "资源配置", "成本"], ["2"], ["11.1", "11.2"]),
criterion("stage1", "format", "报告完整性和规范性", 5, ["标题", "章节", "参考文献"], ["4"], ["4.4"]),
criterion("stage2", "architecture", "系统架构和实现思路", 20, ["系统架构", "实现思路"], ["2", "3"], ["2.4", "4.2"]),
criterion("stage2", "hardware", "硬件选型及选型依据", 20, ["硬件清单", "参数", "选型依据"], ["3"], ["4.2"]),
criterion("stage2", "interfaces", "系统框图、连接关系和接口设计", 20, ["系统框图", "接口", "连接关系"], ["3"], ["4.2", "4.3"]),
criterion("stage2", "experiment", "实验方案、测试方法和预期指标", 20, ["实验步骤", "测试方法", "预期指标"], ["3"], ["4.2", "4.3"]),
criterion("stage2", "safety", "安全风险和防护措施", 10, ["安全风险", "防护措施"], ["3"], ["4.3"]),
criterion("stage2", "progress", "阶段进展及下一阶段计划", 10, ["进展", "下一阶段计划"], ["2"], ["11.1"]),
criterion("stage3", "software", "软件逻辑和系统实现", 20, ["软件逻辑", "流程图"], ["3"], ["4.2", "4.3"]),
criterion("stage3", "code", "核心代码和功能说明", 20, ["核心代码", "功能说明"], ["3"], ["4.3"]),
criterion("stage3", "debug", "调试过程、问题分析和解决方法", 20, ["故障", "原因", "解决方法"], ["3"], ["4.3"]),
criterion("stage3", "data", "实验数据和初步分析", 20, ["实验数据", "分析"], ["4"], ["4.4"]),
criterion("stage3", "verification", "功能和性能指标验证", 15, ["功能测试", "性能指标"], ["3", "4"], ["4.3", "4.4"]),
criterion("stage3", "plan", "下一阶段计划及报告规范性", 5, ["计划", "章节"], ["2", "4"], ["11.1", "4.4"]),
criterion("final", "completion", "任务完成度", 20, ["设计任务", "实现结果", "功能验证"], ["2", "3"], ["2.4", "4.3"], levels=[(15, 20, "按时完成全部设计与实现任务,系统功能达标且完整"), (7, 14, "完成核心任务,系统功能基本达标"), (0, 6, "未完成核心任务或功能严重缺失")]),
criterion("final", "report", "报告规范性", 20, ["方案论证", "设计过程", "实验数据", "结果分析"], ["4"], ["4.4"], levels=[(15, 20, "结构完整、格式规范、逻辑严谨"), (7, 14, "结构基本完整,格式较规范"), (0, 6, "结构混乱,无法清晰呈现解决方案")]),
criterion("final", "presentation", "汇报答辩(仅按PPT内容评价)", 40, ["PPT逻辑", "解决方案", "成果展示"], ["2", "4"], ["2.4", "4.4"], description="只评价PPT或PPT PDF内容,不评价现场口头答辩", levels=[(30, 40, "PPT内容逻辑清晰,准确阐述方案及合理性"), (13, 29, "PPT基本阐述解决方案"), (0, 12, "PPT无法清晰表达解决方案")]),
criterion("final", "analysis", "结果分析", 20, ["实验数据", "误差分析", "优化方向", "结论"], ["4"], ["4.4"], levels=[(15, 20, "数据完整,分析深入,结论有依据"), (7, 14, "数据基本完整,分析较合理"), (0, 6, "数据缺失,结论缺乏依据")]),
]
INITIAL_PROMPTS = {
"facts": "从证据中抽取项目事实。仅记录证据明确支持的事实;不确定字段留空。返回严格JSON。",
"score": "你是自动化专业综合实践教师。仅根据提供的评分规则和带页码证据评分。不得臆测缺失内容。PPT项只评价页面内容,不评价口头答辩。返回严格JSON。",
"consistency": "检查各阶段报告、最终报告和PPT中项目名称、技术路线、硬件、实验数据及成果的一致性。仅报告有证据的矛盾。返回严格JSON。",
}
COURSE_DOCS = ["课程导学-自动化专业综合实践.pdf", "自动化专业综合实践三融合选题课程概述.pdf", "大作业评分表-自动化专业综合实践.docx", "阶段报告1模板.doc", "阶段报告2模板.doc", "阶段报告3模板.doc"]
def seed_initial(db):
if db.scalar(select(Course.id).limit(1)):
return
rubric = RubricData(criteria=INITIAL_CRITERIA, weights={"stage1": .1, "stage2": .1, "stage3": .1, "final": .7})
course = Course(name="自动化专业综合实践", code="automation_comprehensive_practice")
db.add(course)
db.flush()
version = CourseVersion(course_id=course.id, version=1, title="初始课程版本", objectives=OBJECTIVES, indicators=INDICATORS, directions=DIRECTIONS, prompts=INITIAL_PROMPTS, templates=COURSE_DOCS, published=True)
db.add(version)
db.flush()
sync_course_entities(db, version)
rubric_version = RubricVersion(course_version_id=version.id, version=1, criteria=[c.model_dump() for c in rubric.criteria], weights=rubric.weights, published=True)
db.add(rubric_version)
db.flush()
sync_rubric_entities(db, rubric_version)
db.commit()
+65
View File
@@ -0,0 +1,65 @@
import base64
import hashlib
import json
from cryptography.fernet import Fernet, InvalidToken
from .config import get_settings
from .llm import DeepSeekClient
from .models import AppSetting
def _cipher():
secret = get_settings().wisegrade_secret_key
if len(secret) < 32 or secret == "replace-with-a-random-32-character-secret":
raise ValueError("请设置至少32位随机WISEGRADE_SECRET_KEY后保存模型配置")
return Fernet(base64.urlsafe_b64encode(hashlib.sha256(secret.encode()).digest()))
def _get(db, key):
item = db.get(AppSetting, key)
if not item:
return None
try:
return _cipher().decrypt(item.encrypted_value).decode()
except InvalidToken as exc:
raise ValueError("已保存的模型设置无法解密,请恢复原WISEGRADE_SECRET_KEY") from exc
def _set(db, key, value):
item = db.get(AppSetting, key)
if not item:
item = AppSetting(key=key, encrypted_value=b"")
db.add(item)
item.encrypted_value = _cipher().encrypt(value.encode())
def model_config(db):
settings = get_settings()
base = {"base_url": settings.deepseek_base_url, "model": settings.deepseek_model,
"timeout_seconds": settings.model_timeout_seconds, "concurrency": settings.model_concurrency,
"retries": settings.model_retries}
stored = _get(db, "model_config") if db.get(AppSetting, "model_config") else None
return {**base, **(json.loads(stored) if stored else {})}
def key_configured(db):
return bool(get_settings().deepseek_api_key or db.get(AppSetting, "api_key"))
def save_model_config(db, data: dict):
config = model_config(db)
config.update({key: data[key] for key in config if key in data})
if "api_key" in data and data["api_key"]:
_set(db, "api_key", data["api_key"])
_set(db, "model_config", json.dumps(config))
db.commit()
return {**config, "api_key_configured": key_configured(db)}
def client_for_batch(db, batch):
stored_key = _get(db, "api_key") if db.get(AppSetting, "api_key") else None
config = batch.model_config_snapshot
return DeepSeekClient(base_url=config["base_url"], model=config["model"],
api_key=stored_key or get_settings().deepseek_api_key,
timeout=config["timeout_seconds"], retries=config["retries"])
+149
View File
@@ -0,0 +1,149 @@
import hashlib
import mimetypes
import re
from pathlib import Path, PurePosixPath
from zipfile import ZipFile
from xml.etree import ElementTree
from .config import get_settings
CATEGORIES = ("stage1", "stage2", "stage3", "final_report", "presentation")
SUPPORTED = {".pdf", ".docx", ".pptx"}
NAMES = {"stage1": "阶段报告1", "stage2": "阶段报告2", "stage3": "阶段报告3", "final_report": "最终报告", "presentation": "汇报PPT"}
def secure_path(relative: str, *, root: Path | None = None, must_exist=True) -> Path:
root = (root or get_settings().inbox_path).resolve()
if not relative or "\\" in relative or "\x00" in relative:
raise ValueError("必须使用挂载目录内的相对路径")
path = PurePosixPath(relative)
if path.is_absolute() or any(part in ("..", "") for part in path.parts):
raise ValueError("路径超出挂载目录")
target = root.joinpath(*path.parts)
if any(p.is_symlink() for p in (target, *target.parents) if p != root and root in p.parents):
raise ValueError("不允许符号链接")
resolved = target.resolve(strict=must_exist)
if not resolved.is_relative_to(root):
raise ValueError("路径超出挂载目录")
return resolved
def sha256(path: Path) -> str:
digest = hashlib.sha256()
with path.open("rb") as stream:
for chunk in iter(lambda: stream.read(1024 * 1024), b""):
digest.update(chunk)
return digest.hexdigest()
def parse_student_folder(name: str):
parts = name.split("_", 2)
if len(parts) != 3 or not re.fullmatch(r"\d{5,20}", parts[0]) or not all(parts[1:]):
return {"student_no": "", "name": "", "topic": "", "status": "needs_confirmation"}
return {"student_no": parts[0], "name": parts[1].strip(), "topic": parts[2].strip(), "status": "recognized"}
def extract_identity(text: str):
patterns = {
"student_no": r"学号\s*[::]?\s*(\d{5,20})",
"name": r"姓名\s*[::]?\s*([^\s::]{2,10})",
"topic": r"(?:课题名称|项目名称|题目)\s*[::]?\s*([^\n\r]{3,100})",
}
return {key: (match.group(1).strip() if (match := re.search(pattern, text)) else "") for key, pattern in patterns.items()}
def first_text(path: Path) -> str:
try:
if path.suffix.lower() == ".pdf":
import pymupdf as fitz
with fitz.open(path) as doc:
return doc[0].get_text()[:6000] if len(doc) else ""
if path.suffix.lower() in {".docx", ".pptx"}:
part = "word/document.xml" if path.suffix.lower() == ".docx" else "ppt/slides/slide1.xml"
with ZipFile(path) as archive:
root = ElementTree.fromstring(archive.read(part))
return " ".join(element.text or "" for element in root.iter() if element.tag.endswith("}t"))[:6000]
except Exception as exc:
raise ValueError(f"无法读取文件首页: {exc}") from exc
return ""
def classify(path: Path, text: str = ""):
suffix = path.suffix.lower()
if suffix not in SUPPORTED:
return "unknown", "unsupported"
def identify(value):
value = value.lower().replace(" ", "")
if any(term in value for term in ("汇报ppt", "汇报幻灯", "答辩ppt", "演示文稿", "presentation")):
return "presentation"
for digit, chinese, category in (("1", "一", "stage1"), ("2", "二", "stage2"), ("3", "三", "stage3")):
if any(term in value for term in (f"阶段报告{digit}", f"阶段报告{chinese}", f"第{chinese}阶段", f"阶段{digit}报告")):
return category
if any(term in value for term in ("最终报告", "期末报告", "综合实践报告", "结题报告")):
return "final_report"
return None
if suffix == ".pptx":
return "presentation", "recognized"
category = identify(path.stem) or identify(text[:600])
if category:
return category, "needs_confirmation" if category == "presentation" and suffix == ".pdf" else "recognized"
return "unknown", "needs_confirmation"
def directories(relative: str = ".", *, root: Path | None = None):
path = secure_path(relative, root=root)
if not path.is_dir():
raise ValueError("请选择目录")
base = (root or get_settings().inbox_path).resolve()
return [{"name": item.name, "relative_path": item.relative_to(base).as_posix()} for item in sorted(path.iterdir()) if item.is_dir() and not item.is_symlink()]
def scan(relative: str, *, root: Path | None = None):
base = (root or get_settings().inbox_path).resolve()
path = secure_path(relative, root=base)
if not path.is_dir():
raise ValueError("请选择学生总目录")
projects = []
for folder in sorted(path.iterdir()):
if not folder.is_dir() or folder.is_symlink():
continue
info = parse_student_folder(folder.name)
files = []
extracted = {}
warnings = []
for candidate in sorted(folder.iterdir()):
if not candidate.is_file() or candidate.is_symlink():
continue
if candidate.suffix.lower() not in SUPPORTED:
continue
error = ""
try:
text = first_text(candidate)
category, status = classify(candidate, text)
identity = extract_identity(text)
for key, value in identity.items():
if value and key not in extracted:
extracted[key] = value
except ValueError as exc:
category, status, error = "unknown", "unparseable", str(exc)
stat = candidate.stat()
files.append({"relative_path": candidate.relative_to(base).as_posix(), "category": category, "status": status,
"size": stat.st_size, "mtime": stat.st_mtime, "sha256": sha256(candidate),
"mime_type": mimetypes.guess_type(candidate.name)[0] or "application/octet-stream", "error": error,
"first_text": text[:500] if not error else ""})
for category in CATEGORIES:
matches = [file for file in files if file["category"] == category]
if len(matches) > 1:
for file in matches:
file["status"] = "duplicate"
for key, value in extracted.items():
if info.get(key) and value != info[key]:
warnings.append(f"封面{key}“{value}”与目录名“{info[key]}”不一致")
if warnings:
info["status"] = "needs_confirmation"
info.update({"relative_dir": folder.relative_to(base).as_posix(), "files": files,
"detected_identity": extracted, "warnings": warnings,
"missing": [category for category in CATEGORIES if not any(file["category"] == category for file in files)]})
projects.append(info)
return {"relative_dir": relative, "projects": projects}