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:
@@ -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}
|
||||
|
||||
Reference in New Issue
Block a user