feat: separate contents, abstract and body evidence

This commit is contained in:
2026-10-01 00:07:40 +08:00
parent 540d0e2918
commit b7634c8374
25 changed files with 2592 additions and 172 deletions
+234 -34
View File
@@ -6,15 +6,30 @@ from sqlalchemy import select, update
from .config import get_settings
from .db import SessionLocal
from .evidence import 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, KnowledgeDocument, Project, ScoreEvidence, ScoringBatch,
SubmissionFile, now)
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, ingest, retrieve
from .rag import (INDEX_VERSION, body_heading_index, ingest, retrieve, retrieve_targets)
from .storage import secure_path, sha256
from .settings_store import client_for_batch
#: Criterion statuses. ``needs_review`` is deliberately distinct from a missing
#: material, a parse failure and a model failure: the retrieval ran but only found
#: navigation pages where the rule requires body content.
STATUS_NEEDS_REVIEW = "needs_review"
STATUS_UNSCORABLE = "unscorable"
STATUS_FAILED = "failed"
STATUS_SCORED = "scored"
STATUS_REVIEWED = "reviewed"
#: Statuses that a batch counts as finished work.
DONE_STATUSES = (STATUS_SCORED, STATUS_REVIEWED, STATUS_NEEDS_REVIEW)
#: Statuses the dashboard shows as needing teacher attention.
ATTENTION_STATUSES = (STATUS_FAILED, STATUS_UNSCORABLE, STATUS_NEEDS_REVIEW)
@contextmanager
def file_index_lock(file_id: int):
@@ -72,12 +87,21 @@ def _parse_submission_locked(db, file: SubmissionFile, *, embedder=None):
def _project_facts(db, project, course_snapshot, client, *, retriever):
"""Extract project facts from body text only.
Implementation facts must not be inferred from a contents heading or an abstract
claim, so the search admits body and appendix pages exclusively. Project identity
(name, student number, topic) legitimately lives on the cover page and is read from
the teacher-confirmed import record instead of from retrieved text.
"""
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))
category=category, query="项目名称 目标 指标 硬件 软件 实验 数据 成本 结论",
limit=3, include_public=False, allowed_page_types=["body", "appendix"],
substantive_only=True))
if not snippets:
return {}
if client is None:
@@ -100,10 +124,54 @@ def _project_facts(db, project, course_snapshot, client, *, retriever):
return project.facts
def _score_criterion(db, batch, project, criterion, client, *, retriever):
def _body_navigator(db, *, course_version_id, project_id, category):
"""Find real body pages for a target whose contents entries name missing sections.
The contents page is used only as a map: its headings are matched against headings
that actually occur in body text, and the body sections and their neighbouring pages
are then searched. The final quotation always lands on a real body page.
"""
headings = body_heading_index(db, course_version_id=course_version_id, project_id=project_id, category=category)
if not headings:
return None
def navigate(target):
label = normalize_heading(target.get("label") or "")
if not label:
return []
candidates = []
for heading, places in headings.items():
if not heading:
continue
if heading.startswith(label) or label.startswith(heading) or label in heading or heading in label:
candidates.extend(places)
if not candidates:
return []
pages = sorted({(place["document_id"], place["page"]) for place in candidates})
neighbours = set()
for document_id, page in pages:
neighbours.update({(document_id, page), (document_id, page + 1)})
wanted = sorted(neighbours)
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()
hits = []
for chunk in rows:
hits.append({"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,
"chapter": chunk.chapter, "page_type": chunk.page_type, "block_type": chunk.block_type,
"text": chunk.text, "rank": 0.5})
return hits
return navigate
def _score_criterion(db, batch, project, criterion, client, *, retriever, multi_retriever=retrieve_targets):
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"):
if score and score.status in (STATUS_SCORED, STATUS_REVIEWED):
return score
if not score:
score = CriterionScore(batch_id=batch.id, project_id=project.id, criterion_id=criterion["criterion_id"])
@@ -115,44 +183,103 @@ def _score_criterion(db, batch, project, criterion, client, *, retriever):
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", "材料缺失、重复、待确认或解析失败"
score.status, score.error = STATUS_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)
plan = batch.rubric_snapshot.get("evidence_plan") or {}
targets = (plan.get("criteria") or {}).get(criterion["criterion_id"]) or normalize_targets(
criterion.get("required_evidence"))
navigator = _body_navigator(db, course_version_id=batch.course_version_id,
project_id=project.id, category=category)
# The single ``retriever`` stays the seam: each per-target search goes through it,
# so a caller that injects a retriever (tests, alternate index) still applies.
selection = multi_retriever(db, course_version_id=batch.course_version_id, project_id=project.id,
targets=targets, category=category, navigator=navigator,
retrieve_fn=retriever)
chunks = _attach_page_types(db, selection["evidence"])
gaps = selection["uncovered_targets"]
if not chunks:
score.status, score.error = "unscorable", "无可检索的材料证据"
# Nothing admissible was found for any target. Content targets go to review;
# a purely structural criterion only depends on navigation pages, which exist.
score.status = STATUS_NEEDS_REVIEW if content_targets(criterion) else STATUS_UNSCORABLE
score.error = "未检索到允许作为证据的正文页"
score.model_score = None
score.missing_evidence = [target["label"] for target in gaps]
_record_evidence_gaps(score, gaps)
db.commit()
return score
if client is None:
raise ModelError("未配置或无法连接评分模型")
image_sources = dict.fromkeys((chunk["document_id"], chunk["page"]) for chunk in chunks[:2])
content_gaps = [target for target in gaps if target["use"] == USE_CONTENT]
if content_gaps:
# The model is not asked to grade from navigation pages. Record the gap and keep
# the model score null so a teacher decides, rather than asserting a low score.
score.model_score, score.confidence, score.status = None, None, STATUS_NEEDS_REVIEW
score.model_comment = "检索仅命中目录、摘要或章节标题,缺少可用于内容判断的正文证据,转教师复核。"
score.missing_evidence = [target["label"] for target in content_gaps]
score.suggestion = "请补充该章节的正文页,或由教师直接核定该项分数。"
score.error = "缺少正文证据:" + "、".join(target["label"] for target in content_gaps)
_record_evidence_gaps(score, content_gaps, searched=_searched_page_types(plan, content_gaps))
db.commit()
return score
# Page images go to the model as well, so prefer substantive body pages over the
# navigation pages that may legitimately appear in a structural target's evidence.
image_sources = list(dict.fromkeys(
(chunk["document_id"], chunk["page"]) for chunk in chunks
if chunk.get("page_type") == "body"))
if not image_sources:
image_sources = list(dict.fromkeys((chunk["document_id"], chunk["page"]) for chunk in chunks))
images = []
for document_id, page in image_sources:
for document_id, page in image_sources[:3]:
doc = db.get(KnowledgeDocument, document_id)
path = page_image_path(db, doc, page) if doc else None
if path:
images.append(str(path))
query = criterion["description"] + " " + " ".join(target["label"] for target in targets)
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"}}
payload = {"criterion": criterion,
"required_evidence_targets": [{"target_id": target["target_id"], "label": target["label"],
"use": target["use"],
"allowed_page_types": target["allowed_page_types"]}
for target in targets],
"evidence_policy": {
"structure": "目录、封面和参考文献页可以证明章节结构和报告完整性,不能证明章节内容或完成质量。",
"content": "内容判断只能使用正文或附录中的实质段落、数据、表格;目录、摘要和单独章节标题不能作为内容证据。"},
"project_facts": project.facts, "course_context": public_context, "evidence": chunks,
"required_output": {"criterion_id": "string", "score": "number or null", "max_score": "number",
"reason": "string",
"evidence": [{"chunk_id": "integer", "page": "integer",
"quote": "exact substring", "target_id": "string"}],
"missing_evidence": ["string"], "suggestion": "string", "confidence": "0..1",
"note": "证据不足以判断内容时 score 填 null,并在 missing_evidence 中列出缺少的正文目标。"}}
prompt = (batch.course_snapshot["prompts"]["score"] +
" 证据quote必须从所引用chunk_id的text逐字复制连续片段,保留原文标点和换行;page必须等于该片段的page。不要改写、缩写或拼接引文。")
" 证据quote必须从所引用chunk_id的text逐字复制连续片段,保留原文标点和换行;page必须等于该片段的page;"
"target_id必须取自required_evidence_targets。不要改写、缩写或拼接引文。"
"不得把目录、摘要或章节标题当作内容证据;无法据此判断内容时把score设为null。")
error = ""
result = None
for attempt in range(2):
try:
result = validate_score(client.json_call(prompt, payload, ScoreResult, images=images), criterion, chunks)
result = validate_score(client.json_call(prompt, payload, ScoreResult, images=images), criterion, chunks,
targets=targets)
break
except ModelError:
except ModelError as exc:
error = str(exc)
if attempt:
raise
prompt += " 上次回答的证据未通过原文和页码校验;请重新选择片段并逐字复制quote。"
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", ""
prompt += f" 上次回答未通过校验({error});请改用允许页面类型中的实质正文片段,并逐字复制quote。"
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, "模型认为现有正文证据不足以判断该项,转教师复核。"
else:
score.status, score.error = STATUS_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}
@@ -160,14 +287,75 @@ def _score_criterion(db, batch, project, criterion, client, *, retriever):
chunk = by_id[evidence.chunk_id]
doc = db.get(KnowledgeDocument, chunk["document_id"])
path = page_image_path(db, doc, evidence.page)
target = next((item for item in targets if item["target_id"] == evidence.target_id), None)
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=str(path) if path else None))
page=evidence.page, quote=evidence.quote, image_path=str(path) if path else None,
target_id=evidence.target_id, target_label=target["label"] if target else "",
page_type=chunk.get("page_type") or "unknown",
source_role=target["use"] if target else USE_CONTENT))
db.commit()
return score
def _run_project(batch_id, project_id, session_factory, client_factory, embedder, retriever):
def _attach_page_types(db, chunks: list[dict]) -> list[dict]:
"""Attach the stored page classification to each retrieved chunk.
The page type recorded in ``knowledge_pages`` is the authority for the evidence
policy. Reading it here, rather than trusting whatever a retriever happened to put
in the dict, means a statement about what a page is cannot be forged by a caller
or lost by an alternate index.
"""
keys = {(chunk.get("document_id"), chunk.get("page")) for chunk in chunks}
keys.discard((None, None))
if not keys:
return chunks
documents = {document_id for document_id, _ in keys}
pages = {page for _, page in keys}
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}
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",
"section": chunk.get("section") or record.section or "",
"chapter": chunk.get("chapter") or record.chapter or ""})
return output
def _searched_page_types(plan, targets):
allowed = set()
for target in targets:
allowed.update(target.get("allowed_page_types") or [])
return sorted(allowed or (plan.get("content_allowed_page_types") or []))
def _record_evidence_gaps(score, gaps, searched=None):
"""Store which content targets lacked body evidence and where retrieval looked.
The record is kept through teacher review so the original retrieval problem stays
auditable next to the score the teacher finally entered.
"""
score.evidence_gaps = [
{"target_id": target.get("target_id"), "target_label": target.get("label"),
"searched_page_types": searched or target.get("allowed_page_types") or [],
"reason": "补检后仍未找到正文证据"}
for target in gaps]
def _run_project(batch_id, project_id, session_factory, client_factory, embedder, retriever,
multi_retriever=retrieve_targets):
"""Score one project. Returns ``(failures, reviews)``.
Failures are missing material, parse problems and model errors. Reviews count the
items that only found navigation pages where content evidence is required; those
need a teacher decision rather than a retry.
"""
failures = 0
reviews = 0
with session_factory() as db:
batch = db.get(ScoringBatch, batch_id)
project = db.get(Project, project_id)
@@ -190,8 +378,11 @@ def _run_project(batch_id, project_id, session_factory, client_factory, embedder
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"):
result = _score_criterion(db, batch, project, criterion, client, retriever=retriever,
multi_retriever=multi_retriever)
if result.status == STATUS_NEEDS_REVIEW:
reviews += 1
elif result.status not in (STATUS_SCORED, STATUS_REVIEWED):
failures += 1
except Exception as exc:
score = db.scalar(select(CriterionScore).where(CriterionScore.batch_id == batch.id,
@@ -199,15 +390,16 @@ def _run_project(batch_id, project_id, session_factory, client_factory, embedder
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]
score.status, score.error = STATUS_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
return failures, reviews
def run_batch(batch_id: int, *, session_factory=SessionLocal, client_factory=client_for_batch, embedder=None, retriever=retrieve):
def run_batch(batch_id: int, *, session_factory=SessionLocal, client_factory=client_for_batch, embedder=None,
retriever=retrieve, multi_retriever=retrieve_targets):
with session_factory() as db:
batch = db.get(ScoringBatch, batch_id)
if not batch:
@@ -218,13 +410,21 @@ def run_batch(batch_id: int, *, session_factory=SessionLocal, client_factory=cli
batch.status, batch.progress_total, batch.progress_done, batch.finished_at = "running", len(project_ids) * len(criteria), 0, None
db.commit()
failures = 0
reviews = 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]
futures = [pool.submit(_run_project, batch_id, project_id, session_factory, client_factory,
embedder, retriever, multi_retriever) for project_id in project_ids]
for future in as_completed(futures):
try:
failures += future.result()
project_failures, project_reviews = future.result()
failures += project_failures
reviews += project_reviews
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()
# 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")
batch.finished_at = now()
db.commit()
return {"failures": failures, "reviews": reviews, "status": batch.status}