feat: add versioned backend scoring workflow
This commit is contained in:
@@ -0,0 +1 @@
|
||||
"""WiseGrade backend."""
|
||||
@@ -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
|
||||
@@ -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()
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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"}
|
||||
@@ -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()
|
||||
@@ -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)
|
||||
@@ -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}
|
||||
@@ -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]]
|
||||
@@ -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}
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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"])
|
||||
@@ -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}
|
||||
Reference in New Issue
Block a user