Files
WiseGrade/backend/wisegrade/evidence.py
T

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 = 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},
}