ebe33392ca
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.
151 lines
6.4 KiB
Python
151 lines
6.4 KiB
Python
"""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 = 2
|
|
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},
|
|
}
|