fix: improve import, evidence indexing and material-scoped grading

Preserve document revisions and evidence, harden indexing and export behavior, add explicit material selection and direct-folder import, validate model JSON schemas with safe error messages, and retain student selection while loading scoring data.
This commit is contained in:
2026-10-14 13:52:56 +08:00
parent 29c95ce5ad
commit ebe33392ca
42 changed files with 2332 additions and 304 deletions
+56 -64
View File
@@ -1,20 +1,16 @@
from concurrent.futures import ThreadPoolExecutor, as_completed
from contextlib import contextmanager
import fcntl
from sqlalchemy import select, update
from sqlalchemy import and_, or_, select, update
from .config import get_settings
from .db import SessionLocal
from .evidence import USE_CONTENT, content_targets, normalize_targets
from .evidence import POLICY_VERSION, USE_CONTENT, content_targets, normalize_targets
from .knowledge_pages import page_image_path
from .llm import ConsistencyResult, FactResult, ModelError, ScoreResult, validate_score
from .models import (CriterionScore, KnowledgeChunk, KnowledgeDocument, KnowledgePage, Project,
ScoreEvidence, ScoringBatch, SubmissionFile, now)
from .pages import normalize_heading
from .parsing import parse_document
from .rag import (INDEX_VERSION, body_heading_index, ingest, retrieve, retrieve_targets)
from .storage import secure_path, sha256
from .submissions import file_index_lock, parse_submission
from .rag import (INDEX_VERSION, body_heading_index, current_document_ids, retrieve, retrieve_targets)
from .settings_store import client_for_batch
#: Criterion statuses. ``needs_review`` is deliberately distinct from a missing
@@ -31,59 +27,24 @@ DONE_STATUSES = (STATUS_SCORED, STATUS_REVIEWED, STATUS_NEEDS_REVIEW)
ATTENTION_STATUSES = (STATUS_FAILED, STATUS_UNSCORABLE, STATUS_NEEDS_REVIEW)
@contextmanager
def file_index_lock(file_id: int):
root = get_settings().processed_path
root.mkdir(parents=True, exist_ok=True)
with (root / f"file-{file_id}.lock").open("a+b") as handle:
fcntl.flock(handle, fcntl.LOCK_EX)
try:
yield
finally:
fcntl.flock(handle, fcntl.LOCK_UN)
def parse_submission(db, file: SubmissionFile, *, embedder=None):
with file_index_lock(file.id):
db.refresh(file)
return _parse_submission_locked(db, file, embedder=embedder)
def _parse_submission_locked(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:
document = db.scalar(select(KnowledgeDocument).where(KnowledgeDocument.submission_file_id == file.id))
if not document or document.source_hash != file.sha256 or document.index_version != INDEX_VERSION or document.status != "ready":
project = db.get(Project, file.project_id)
kwargs = {"embedder": embedder} if embedder else {}
try:
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=file.parsed["pages"], **kwargs)
except Exception as exc:
file.parse_status, file.parse_error = "failed", str(exc)[:1000]
db.commit()
raise
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 refresh_batch_status(db, batch):
"""Recompute a finished batch after review without disturbing running workers."""
if batch.status in ("queued", "running"):
return batch.status
project_ids = batch.project_ids if batch.project_ids is not None else db.scalars(
select(Project.id).where(Project.course_version_id == batch.course_version_id)).all()
wanted = {(project_id, criterion["criterion_id"]) for project_id in project_ids
for criterion in batch.rubric_snapshot["criteria"]}
scores = db.scalars(select(CriterionScore).where(CriterionScore.batch_id == batch.id)).all()
states = {(score.project_id, score.criterion_id): score.status for score in scores}
statuses = [states.get(key, "pending") for key in wanted]
if any(status not in DONE_STATUSES for status in statuses):
batch.status = "partial"
elif STATUS_NEEDS_REVIEW in statuses:
batch.status = "review_required"
else:
batch.status = "completed"
return batch.status
def _project_facts(db, project, course_snapshot, client, *, retriever):
@@ -155,8 +116,9 @@ def _body_navigator(db, *, course_version_id, project_id, category):
rows = db.scalars(select(KnowledgeChunk).where(
KnowledgeChunk.course_version_id == course_version_id, KnowledgeChunk.project_id == project_id,
KnowledgeChunk.page_type == "body", KnowledgeChunk.block_type != "toc",
KnowledgeChunk.document_id.in_([item[0] for item in wanted]),
KnowledgeChunk.page.in_([item[1] for item in wanted])).order_by(KnowledgeChunk.id)).all()
KnowledgeChunk.document_id.in_(current_document_ids()),
or_(*(and_(KnowledgeChunk.document_id == document_id, KnowledgeChunk.page == page)
for document_id, page in wanted))).order_by(KnowledgeChunk.id)).all()
hits = []
for chunk in rows:
hits.append({"chunk_id": chunk.id, "document_id": chunk.document_id, "project_id": chunk.project_id,
@@ -272,12 +234,21 @@ def _score_criterion(db, batch, project, criterion, client, *, retriever, multi_
if attempt:
raise
prompt += f" 上次回答未通过校验({error});请改用允许页面类型中的实质正文片段,并逐字复制quote。"
score = db.scalar(select(CriterionScore).where(CriterionScore.id == score.id)
.with_for_update().execution_options(populate_existing=True))
if score.status == STATUS_REVIEWED or score.teacher_score is not None:
return score # A teacher decision made during the model call wins.
score.model_score, score.model_comment = result.score, result.reason
score.confidence = result.confidence
score.missing_evidence, score.suggestion = result.missing_evidence, result.suggestion
if result.score is None:
# The retrieval found body pages but the model judged them insufficient.
score.status, score.error = STATUS_NEEDS_REVIEW, "模型认为现有正文证据不足以判断该项,转教师复核。"
declined = [target for target in targets if target["use"] == USE_CONTENT and
(not result.missing_evidence or target["label"] in result.missing_evidence)]
_record_evidence_gaps(score, declined or [target for target in targets if target["use"] == USE_CONTENT])
for gap in score.evidence_gaps:
gap["reason"] = result.reason
else:
score.status, score.error = STATUS_SCORED, ""
for evidence in db.scalars(select(ScoreEvidence).where(ScoreEvidence.score_id == score.id)).all():
@@ -314,13 +285,26 @@ def _attach_page_types(db, chunks: list[dict]) -> list[dict]:
records = db.scalars(select(KnowledgePage).where(KnowledgePage.document_id.in_(documents),
KnowledgePage.page.in_(pages))).all()
by_key = {(item.document_id, item.page): item for item in records}
stored = {item.id: item for item in db.scalars(select(KnowledgeChunk).where(
KnowledgeChunk.id.in_([chunk["chunk_id"] for chunk in chunks if chunk.get("chunk_id") is not None])))}
output = []
for chunk in chunks:
record = by_key.get((chunk.get("document_id"), chunk.get("page")))
if record is None:
output.append({**chunk, "page_type": chunk.get("page_type") or "unknown"})
continue
output.append({**chunk, "page_type": record.page_type or "unknown",
source_type = record.page_type or "unknown"
saved = stored.get(chunk.get("chunk_id"))
if (saved is not None and saved.document_id == record.document_id and saved.page == record.page
and saved.page_type == "unknown"):
source_type = "unknown"
if (record.mixed and saved is not None and saved.document_id == record.document_id
and saved.page == record.page and saved.block_type == "body"
and saved.page_type in ("body", "appendix")):
source_type = saved.page_type
if any(char in chunk["text"] for char in ("\ufffd", "\ufffe", "\uffff")):
source_type = "unknown"
output.append({**chunk, "page_type": source_type,
"section": chunk.get("section") or record.section or "",
"chapter": chunk.get("chapter") or record.chapter or ""})
return output
@@ -373,6 +357,7 @@ def _run_project(batch_id, project_id, session_factory, client_factory, embedder
try:
_project_facts(db, project, batch.course_snapshot, client, retriever=retriever)
except Exception as exc:
db.rollback()
project.facts = {"error": str(exc)[:500]}
db.commit()
failures += 1
@@ -385,6 +370,7 @@ def _run_project(batch_id, project_id, session_factory, client_factory, embedder
elif result.status not in (STATUS_SCORED, STATUS_REVIEWED):
failures += 1
except Exception as exc:
db.rollback()
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:
@@ -404,6 +390,10 @@ def run_batch(batch_id: int, *, session_factory=SessionLocal, client_factory=cli
batch = db.get(ScoringBatch, batch_id)
if not batch:
raise ValueError("评分批次不存在")
if batch.rubric_snapshot.get("evidence_plan", {}).get("version") != POLICY_VERSION:
batch.status, batch.error = "failed", "该批次使用旧证据政策,请保留历史结果并创建新批次"
db.commit()
return {"failures": 1, "reviews": 0, "status": "failed"}
project_ids = batch.project_ids if batch.project_ids is not None else db.scalars(
select(Project.id).where(Project.course_version_id == batch.course_version_id).order_by(Project.id)).all()
criteria = batch.rubric_snapshot["criteria"]
@@ -425,6 +415,8 @@ def run_batch(batch_id: int, *, session_factory=SessionLocal, client_factory=cli
# A batch with unresolved review items is finished running, but it is not a
# completed grading result: the teacher still has to decide those criteria.
batch.status = "partial" if failures else ("review_required" if reviews else "completed")
if not failures:
refresh_batch_status(db, batch)
batch.finished_at = now()
db.commit()
return {"failures": failures, "reviews": reviews, "status": batch.status}