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