feat: separate contents, abstract and body evidence
This commit is contained in:
@@ -0,0 +1,150 @@
|
||||
"""Evidence policy: which page types may support which kind of judgement.
|
||||
|
||||
The policy is part of the versionable scoring rules. Each required-evidence target
|
||||
declares its ``use``:
|
||||
|
||||
* ``structure`` — the target may be satisfied by a cover page, table of contents or
|
||||
reference list (chapter completeness, bibliography, report layout).
|
||||
* ``content`` — the target may only be satisfied by substantive text, data or tables
|
||||
on body or appendix pages.
|
||||
|
||||
Rules still in the historical string form (``["问题定义", "需求分析"]``) are read with a
|
||||
permissive grammar: wording that names a structural artefact becomes a structure
|
||||
target, everything else becomes a content target. The exact grammar is recorded as
|
||||
``POLICY_VERSION`` in the batch snapshot so a batch can always be re-read against the
|
||||
rules it was scored with.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
|
||||
from .pages import (CONTENT_PAGE_TYPES, NAVIGATION_PAGE_TYPES, STORE_PAGE_TYPES,
|
||||
STRUCTURE_PAGE_TYPES)
|
||||
|
||||
POLICY_VERSION = 1
|
||||
USE_STRUCTURE = "structure"
|
||||
USE_CONTENT = "content"
|
||||
|
||||
#: Wording that names report packaging rather than what the student did.
|
||||
_STRUCTURE_WORDS = (
|
||||
"标题", "封面", "目录", "章节", "参考文献", "引用规范", "格式", "规范性", "结构完整",
|
||||
"排版", "页码", "附录", "图表编号", "摘要",
|
||||
)
|
||||
|
||||
#: Appendix and body pages are the only pages allowed for a content judgement.
|
||||
CONTENT_ALLOWED = tuple(CONTENT_PAGE_TYPES)
|
||||
#: Structural targets may additionally use cover, contents and reference pages.
|
||||
STRUCTURE_ALLOWED = tuple(STRUCTURE_PAGE_TYPES)
|
||||
#: Navigation pages can never carry a content judgement.
|
||||
NAVIGATION = tuple(NAVIGATION_PAGE_TYPES)
|
||||
|
||||
|
||||
def _looks_structural(text: str) -> bool:
|
||||
value = str(text or "").strip()
|
||||
return bool(value) and any(word in value for word in _STRUCTURE_WORDS)
|
||||
|
||||
|
||||
def normalize_target(raw) -> dict:
|
||||
"""Read one required-evidence target in either the rich or the legacy string form."""
|
||||
if isinstance(raw, dict):
|
||||
label = str(raw.get("label") or raw.get("text") or raw.get("name") or "").strip()
|
||||
use = str(raw.get("use") or "").strip().lower()
|
||||
if use not in (USE_STRUCTURE, USE_CONTENT):
|
||||
use = USE_STRUCTURE if _looks_structural(label) else USE_CONTENT
|
||||
allowed = raw.get("allowed_page_types")
|
||||
if not isinstance(allowed, list) or not allowed:
|
||||
allowed = list(STRUCTURE_ALLOWED if use == USE_STRUCTURE else CONTENT_ALLOWED)
|
||||
allowed = [str(item) for item in allowed if str(item) in STORE_PAGE_TYPES]
|
||||
if not allowed:
|
||||
allowed = list(STRUCTURE_ALLOWED if use == USE_STRUCTURE else CONTENT_ALLOWED)
|
||||
if use == USE_CONTENT:
|
||||
# A content target never admits navigation pages, whatever a rule claims.
|
||||
allowed = [item for item in allowed if item not in NAVIGATION] or list(CONTENT_ALLOWED)
|
||||
return {"label": label or "证据", "use": use, "allowed_page_types": allowed,
|
||||
"query": str(raw.get("query") or label).strip() or (label or "证据")}
|
||||
label = str(raw or "").strip()
|
||||
use = USE_STRUCTURE if _looks_structural(label) else USE_CONTENT
|
||||
return {"label": label or "证据", "use": use,
|
||||
"allowed_page_types": list(STRUCTURE_ALLOWED if use == USE_STRUCTURE else CONTENT_ALLOWED),
|
||||
"query": label or "证据"}
|
||||
|
||||
|
||||
def normalize_targets(required_evidence) -> list[dict]:
|
||||
"""Normalize a criterion's required evidence, preserving order and identity."""
|
||||
values = required_evidence if isinstance(required_evidence, list) else [required_evidence]
|
||||
targets = []
|
||||
for index, raw in enumerate(values):
|
||||
if raw is None or (isinstance(raw, str) and not raw.strip()):
|
||||
continue
|
||||
target = normalize_target(raw)
|
||||
target["index"] = index
|
||||
target["target_id"] = str((raw.get("target_id") if isinstance(raw, dict) else "") or f"t{index}")
|
||||
targets.append(target)
|
||||
if not targets:
|
||||
targets.append({"label": "正文实质内容", "use": USE_CONTENT, "allowed_page_types": list(CONTENT_ALLOWED),
|
||||
"query": "正文实质内容", "index": 0, "target_id": "t0"})
|
||||
return targets
|
||||
|
||||
|
||||
def content_targets(criterion: dict) -> list[dict]:
|
||||
return [target for target in normalize_targets(criterion.get("required_evidence")) if target["use"] == USE_CONTENT]
|
||||
|
||||
|
||||
def any_content_target(criterion: dict) -> bool:
|
||||
return bool(content_targets(criterion))
|
||||
|
||||
|
||||
def page_type_allowed(page_type: str, allowed: list[str]) -> bool:
|
||||
"""An ``unknown`` page is a body page only when the target already allows body text.
|
||||
|
||||
``unknown`` marks a page whose reading is genuinely unsettled, so it may never be
|
||||
used as the sole support for a content judgement, and it never widens a structural
|
||||
target either. It stays retrievable so a teacher can inspect it.
|
||||
"""
|
||||
if page_type == "unknown":
|
||||
return "body" in allowed
|
||||
return page_type in allowed
|
||||
|
||||
|
||||
def content_evidence_allowed(page_type: str) -> bool:
|
||||
"""Only substantive body and appendix pages support a content judgement."""
|
||||
return page_type in CONTENT_ALLOWED
|
||||
|
||||
|
||||
def heading_only_text(text: str) -> bool:
|
||||
"""True when a passage is nothing but section titles, so it proves no content.
|
||||
|
||||
The test is structural, not a length test: a quotation is heading-only when every
|
||||
line of it is a chapter/section heading or a contents entry. A short technical
|
||||
quotation such as a measured value is content, so it must not be rejected here.
|
||||
"""
|
||||
from .pages import heading_level, is_toc_entry
|
||||
|
||||
lines = [line.strip() for line in str(text or "").splitlines() if line.strip()]
|
||||
if not lines:
|
||||
return False
|
||||
for line in lines:
|
||||
if is_toc_entry(line) or heading_level(line):
|
||||
continue
|
||||
return False
|
||||
return True
|
||||
|
||||
|
||||
_PLACEHOLDER = re.compile(r"^\s*(待确认|待定|未知|无|none|null|-+|—+)\s*$", re.IGNORECASE)
|
||||
|
||||
|
||||
def meaningful(value) -> bool:
|
||||
text = str(value or "").strip()
|
||||
return bool(text) and not _PLACEHOLDER.match(text)
|
||||
|
||||
|
||||
def plan_snapshot(criteria: list[dict]) -> dict:
|
||||
"""The policy attached to a batch snapshot, so old batches stay re-readable."""
|
||||
return {
|
||||
"version": POLICY_VERSION,
|
||||
"content_allowed_page_types": list(CONTENT_ALLOWED),
|
||||
"structure_allowed_page_types": list(STRUCTURE_ALLOWED),
|
||||
"navigation_page_types": list(NAVIGATION),
|
||||
"criteria": {criterion["criterion_id"]: normalize_targets(criterion.get("required_evidence"))
|
||||
for criterion in criteria},
|
||||
}
|
||||
Reference in New Issue
Block a user