diff --git a/.agents/skills/briefloop/CHANGELOG.md b/.agents/skills/briefloop/CHANGELOG.md index d2c88265..2b634abd 100644 --- a/.agents/skills/briefloop/CHANGELOG.md +++ b/.agents/skills/briefloop/CHANGELOG.md @@ -1,5 +1,14 @@ # BriefLoop Operator Skill Changelog +## briefloop-operator-skill-v0.1.7 — 2026-07-01 + +- Added Support-Calibrated Wording guidance for warning-only + `support_wording` diagnostics over reader Markdown, Claim Ledger metadata, + source taxonomy, and valid Claim-Support Matrix policy signals. +- Clarified that Support-Calibrated Wording is deterministic lexical projection + only; it does not judge claim truth, generate or accept support rows, run + gates, block delivery, approve release, or create a quality score. + ## briefloop-operator-skill-v0.1.6 — 2026-07-01 - Added Reader Template Conformance v1 guidance for `reader_contract` diff --git a/.agents/skills/briefloop/references/control-record-map.md b/.agents/skills/briefloop/references/control-record-map.md index 4296163d..0842f4b4 100644 --- a/.agents/skills/briefloop/references/control-record-map.md +++ b/.agents/skills/briefloop/references/control-record-map.md @@ -45,6 +45,11 @@ Owning commands for recent control-tool projections: workspace focus terms. It has no standalone control file and must not be patched into screening output, Claim Ledger, gates, delivery, or release records. +- Support-Calibrated Wording is a status / Quality Panel projection derived + from existing reader Markdown, Claim Ledger metadata, source taxonomy, and + valid Claim-Support Matrix policy signals. It has no standalone control file, + does not create accepted support rows, and must not be patched into gates, + delivery, or release records. These files are operator/audit projections or approval records. They are not agent draft surfaces, not final reader content, and not repair shortcuts. diff --git a/.agents/skills/briefloop/references/status-and-gates.md b/.agents/skills/briefloop/references/status-and-gates.md index c508b8e5..060083b0 100644 --- a/.agents/skills/briefloop/references/status-and-gates.md +++ b/.agents/skills/briefloop/references/status-and-gates.md @@ -52,6 +52,15 @@ slots, and Source Appendix position warnings. It can appear in status, handoff, sections, parse DOCX content, run gates, block delivery, approve release, score prose quality, or prove semantic correctness. +Support-Calibrated Wording is warning-only. It reads existing reader Markdown, +Claim Ledger metadata, source taxonomy, and valid Claim-Support Matrix policy +signals when present to surface `support_wording` risks such as weak support +with strong wording, inference without framing, unsupported claims in reader +text, and media/report-style sources written with strong unattributed wording. +Treat these as operator review diagnostics, not proof. It does not judge claim +truth, generate or accept support rows, run gates, block delivery, approve +release, create repair authority, or create a quality score. + ## Gates `gates check` writes stage-scoped gate reports. It is not a read-only helper. diff --git a/.agents/skills/briefloop/references/version-matrix.md b/.agents/skills/briefloop/references/version-matrix.md index 64fc27a9..2a1628cb 100644 --- a/.agents/skills/briefloop/references/version-matrix.md +++ b/.agents/skills/briefloop/references/version-matrix.md @@ -146,6 +146,17 @@ Historical implementation name: MABW screening mutation, candidate resurrection, Claim Ledger mutation, gate authority, delivery approval, release readiness decision, or quality score + - Support-Calibrated Wording warning projection: + - surfaced through `multi-agent-brief status --workspace + --json` and Quality Panel as `support_wording` + - reads existing reader Markdown, Claim Ledger metadata, source taxonomy, + and valid Claim-Support Matrix policy signals when present + - surfaces warning-only risks such as weak support with strong wording, + inference without framing, unsupported claims in reader text, and + media/report-style source classes written with strong unattributed wording + - deterministic lexical projection only; no claim-truth judgment, + support-row generation or acceptance, gate execution, delivery block, + release authority, or quality score - Packaged synthetic eval fixtures include a trajectory retry-budget case that proves repeated retry decisions project human-review guidance without mutating workflow state, plus a guidance manifestation `not_observable` diff --git a/CHANGELOG.md b/CHANGELOG.md index bb4a6667..051caec9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,14 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added +- **Support-calibrated wording warnings**: `status --json` and Quality Panel + now surface warning-only `support_wording` diagnostics when reader-facing + Markdown uses strong or unframed wording for claims with explicit weak, + downgrade-required, inferential, unsupported, or media/report-style support + metadata. The projection consumes recorded Claim Ledger, source taxonomy, and + valid Claim-Support Matrix policy signals when present. It does not judge + claim truth, generate or accept support rows, run gates, block delivery, + approve release, or create a quality score. - **Reader Template Conformance v1**: packaged ReportTemplates can now declare warning-only reader contracts for required reader blocks, Markdown table slots, executive-summary length, and Source Appendix position. Status, diff --git a/docs/architecture-status.md b/docs/architecture-status.md index 0b8068d6..50e74899 100644 --- a/docs/architecture-status.md +++ b/docs/architecture-status.md @@ -168,6 +168,14 @@ breaking rename. It is deterministic keyword diagnostics only: it does not infer semantic importance, mutate screening output, resurrect candidates, alter the Claim Ledger, run gates, approve delivery, or decide release readiness. + Experimental Support-Calibrated Wording projection can read existing reader + Markdown, Claim Ledger metadata, source taxonomy, and valid Claim-Support + Matrix policy signals to surface warning-only `support_wording` diagnostics + for strong or unframed wording on weak, downgrade-required, inferential, + unsupported, or media/report-style support records. This is deterministic + lexical projection only: it does not judge claim truth, generate or accept + support rows, run gates, block delivery, approve release, or create a quality + score. These contracts describe report type metadata over the existing Claim Ledger, artifact registry, gates, event log, archive, source appendix, support records, frozen-artifact integrity, and human delivery approval spine. These diff --git a/docs/architecture-status.zh-CN.md b/docs/architecture-status.zh-CN.md index c9bdb066..ea9466be 100644 --- a/docs/architecture-status.zh-CN.md +++ b/docs/architecture-status.zh-CN.md @@ -79,6 +79,13 @@ Python package/module 路径、artifact 名称、workspace 格式和实验 ID。 `char_end`)和 raw-excerpt hashes。它仍然不解析 PDF 或二进制文档、不判断 语义支持、不生成 Claim-Support Matrix rows、不形成法律或披露结论、不运行 stages、不批准 delivery,也不绕过 gates。 + Experimental Support-Calibrated Wording projection 可以读取已有 reader + Markdown、Claim Ledger metadata、source taxonomy 和有效 Claim-Support Matrix + policy signals,输出 warning-only 的 `support_wording` diagnostics,用于提示 + weak / downgrade-required / inferential / unsupported / media-report support + 与强措辞或缺少归因/不确定性框架之间的错配。它只是确定性 lexical + projection,不判断 claim 真伪、不生成或接受 support rows、不运行 gates、 + 不阻断 delivery、不批准 release,也不创建 quality score。 这些契约只在现有 Claim Ledger、artifact registry、gates、event log、 archive、source appendix、support records、frozen-artifact integrity 和 human delivery approval 主链之上描述 report type metadata。这些 product-layer diff --git a/docs/support-matrix.md b/docs/support-matrix.md index 4a587395..a6151ada 100644 --- a/docs/support-matrix.md +++ b/docs/support-matrix.md @@ -235,6 +235,13 @@ terms after capacity or scope screening. It is deterministic keyword diagnostics only: Python does not infer semantic importance, mutate screening results, resurrect candidates, alter the Claim Ledger, run gates, approve delivery, or decide release readiness. +Support-Calibrated Wording projection reads existing reader Markdown, Claim +Ledger metadata, source taxonomy, and valid Claim-Support Matrix policy +signals to surface warning-only `support_wording` diagnostics for strong or +unframed wording on weak, downgrade-required, inferential, unsupported, or +media/report-style support metadata. It does not judge claim truth, generate or +accept support rows, run gates, block delivery, approve release, or create a +quality score. Workspace creation may use an explicit `--policy-profile` or deterministic `--industry` hint, but the result is written into `report_spec.yaml` with its resolution source and is not diff --git a/integrations/hermes-plugin/mabw/skills/briefloop/CHANGELOG.md b/integrations/hermes-plugin/mabw/skills/briefloop/CHANGELOG.md index d2c88265..2b634abd 100644 --- a/integrations/hermes-plugin/mabw/skills/briefloop/CHANGELOG.md +++ b/integrations/hermes-plugin/mabw/skills/briefloop/CHANGELOG.md @@ -1,5 +1,14 @@ # BriefLoop Operator Skill Changelog +## briefloop-operator-skill-v0.1.7 — 2026-07-01 + +- Added Support-Calibrated Wording guidance for warning-only + `support_wording` diagnostics over reader Markdown, Claim Ledger metadata, + source taxonomy, and valid Claim-Support Matrix policy signals. +- Clarified that Support-Calibrated Wording is deterministic lexical projection + only; it does not judge claim truth, generate or accept support rows, run + gates, block delivery, approve release, or create a quality score. + ## briefloop-operator-skill-v0.1.6 — 2026-07-01 - Added Reader Template Conformance v1 guidance for `reader_contract` diff --git a/integrations/hermes-plugin/mabw/skills/briefloop/references/control-record-map.md b/integrations/hermes-plugin/mabw/skills/briefloop/references/control-record-map.md index 4296163d..0842f4b4 100644 --- a/integrations/hermes-plugin/mabw/skills/briefloop/references/control-record-map.md +++ b/integrations/hermes-plugin/mabw/skills/briefloop/references/control-record-map.md @@ -45,6 +45,11 @@ Owning commands for recent control-tool projections: workspace focus terms. It has no standalone control file and must not be patched into screening output, Claim Ledger, gates, delivery, or release records. +- Support-Calibrated Wording is a status / Quality Panel projection derived + from existing reader Markdown, Claim Ledger metadata, source taxonomy, and + valid Claim-Support Matrix policy signals. It has no standalone control file, + does not create accepted support rows, and must not be patched into gates, + delivery, or release records. These files are operator/audit projections or approval records. They are not agent draft surfaces, not final reader content, and not repair shortcuts. diff --git a/integrations/hermes-plugin/mabw/skills/briefloop/references/status-and-gates.md b/integrations/hermes-plugin/mabw/skills/briefloop/references/status-and-gates.md index c508b8e5..060083b0 100644 --- a/integrations/hermes-plugin/mabw/skills/briefloop/references/status-and-gates.md +++ b/integrations/hermes-plugin/mabw/skills/briefloop/references/status-and-gates.md @@ -52,6 +52,15 @@ slots, and Source Appendix position warnings. It can appear in status, handoff, sections, parse DOCX content, run gates, block delivery, approve release, score prose quality, or prove semantic correctness. +Support-Calibrated Wording is warning-only. It reads existing reader Markdown, +Claim Ledger metadata, source taxonomy, and valid Claim-Support Matrix policy +signals when present to surface `support_wording` risks such as weak support +with strong wording, inference without framing, unsupported claims in reader +text, and media/report-style sources written with strong unattributed wording. +Treat these as operator review diagnostics, not proof. It does not judge claim +truth, generate or accept support rows, run gates, block delivery, approve +release, create repair authority, or create a quality score. + ## Gates `gates check` writes stage-scoped gate reports. It is not a read-only helper. diff --git a/integrations/hermes-plugin/mabw/skills/briefloop/references/version-matrix.md b/integrations/hermes-plugin/mabw/skills/briefloop/references/version-matrix.md index 64fc27a9..2a1628cb 100644 --- a/integrations/hermes-plugin/mabw/skills/briefloop/references/version-matrix.md +++ b/integrations/hermes-plugin/mabw/skills/briefloop/references/version-matrix.md @@ -146,6 +146,17 @@ Historical implementation name: MABW screening mutation, candidate resurrection, Claim Ledger mutation, gate authority, delivery approval, release readiness decision, or quality score + - Support-Calibrated Wording warning projection: + - surfaced through `multi-agent-brief status --workspace + --json` and Quality Panel as `support_wording` + - reads existing reader Markdown, Claim Ledger metadata, source taxonomy, + and valid Claim-Support Matrix policy signals when present + - surfaces warning-only risks such as weak support with strong wording, + inference without framing, unsupported claims in reader text, and + media/report-style source classes written with strong unattributed wording + - deterministic lexical projection only; no claim-truth judgment, + support-row generation or acceptance, gate execution, delivery block, + release authority, or quality score - Packaged synthetic eval fixtures include a trajectory retry-budget case that proves repeated retry decisions project human-review guidance without mutating workflow state, plus a guidance manifestation `not_observable` diff --git a/scripts/check_briefloop_skill_freshness.py b/scripts/check_briefloop_skill_freshness.py index 80abc67e..b3e56926 100644 --- a/scripts/check_briefloop_skill_freshness.py +++ b/scripts/check_briefloop_skill_freshness.py @@ -35,6 +35,8 @@ "Guidance Manifestation", "Materiality Selection", "Reader Template Conformance", + "Support-Calibrated Wording", + "support_wording", "reader_contract", "report_template_conformance", "reader_block_warnings", @@ -66,6 +68,7 @@ "Guidance Manifestation is diagnostic-only", "Materiality Selection is diagnostic-only", "Reader Template Conformance is warning-only", + "Support-Calibrated Wording is warning-only", ], "references/control-record-map.md": [ "quality_panel.json", @@ -73,6 +76,7 @@ "quality_panel.html", "guidance_manifestation_report.json", "Materiality Selection is a status / Quality Panel projection", + "Support-Calibrated Wording is a status / Quality Panel projection", "release_readiness_report.json", ], "references/repo-development.md": [ diff --git a/src/multi_agent_brief/product/quality_panel.py b/src/multi_agent_brief/product/quality_panel.py index 373c9b92..348e1740 100644 --- a/src/multi_agent_brief/product/quality_panel.py +++ b/src/multi_agent_brief/product/quality_panel.py @@ -21,6 +21,7 @@ validate_guidance_manifestation_projection_payload, ) from multi_agent_brief.product.materiality_selection import validate_materiality_selection_payload +from multi_agent_brief.product.support_wording import validate_support_wording_payload from multi_agent_brief.product.template_conformance import validate_report_template_conformance_payload from multi_agent_brief.product.trajectory_regulation import validate_trajectory_regulation_payload @@ -71,6 +72,7 @@ "block_run", "review_materiality_exclusions", "review_reader_template_conformance", + "review_support_wording_warnings", } @@ -128,6 +130,11 @@ def build_quality_panel(workspace: str | Path) -> dict[str, Any]: if isinstance(workspace_status.get("report_template_conformance"), dict) else {} ) + support_wording = ( + workspace_status.get("support_wording") + if isinstance(workspace_status.get("support_wording"), dict) + else {} + ) control_integrity = { "run_integrity": run_integrity.get("status") or "unknown", "reference_eligible": bool(run_integrity.get("reference_eligible")), @@ -143,6 +150,7 @@ def build_quality_panel(workspace: str | Path) -> dict[str, Any]: trajectory=trajectory, materiality_selection=materiality_selection, report_template_conformance=report_template_conformance, + support_wording=support_wording, ) overall_status = _overall_status( workspace_status=workspace_status, @@ -154,6 +162,7 @@ def build_quality_panel(workspace: str | Path) -> dict[str, Any]: delivery=delivery, materiality_selection=materiality_selection, report_template_conformance=report_template_conformance, + support_wording=support_wording, ) return { @@ -174,6 +183,7 @@ def build_quality_panel(workspace: str | Path) -> dict[str, Any]: "guidance_manifestation": guidance_manifestation, "materiality_selection": materiality_selection, "report_template_conformance": report_template_conformance, + "support_wording": support_wording, "recommended_actions": recommended_actions, "non_goals": [ "quality_score", @@ -233,6 +243,8 @@ def render_quality_summary( materiality = materiality if isinstance(materiality, Mapping) else {} template_conformance = panel_payload.get("report_template_conformance") template_conformance = template_conformance if isinstance(template_conformance, Mapping) else {} + support_wording = panel_payload.get("support_wording") + support_wording = support_wording if isinstance(support_wording, Mapping) else {} actions = panel_payload.get("recommended_actions") actions = actions if isinstance(actions, list) else [] @@ -266,6 +278,7 @@ def render_quality_summary( delivery, materiality, template_conformance, + support_wording, ), ) lines.extend(["", "## Missing Or Incomplete Surfaces", ""]) @@ -303,6 +316,8 @@ def render_quality_summary( f"`{_text(template_conformance.get('status')) or 'unknown'}`", "- Reader template warnings: " f"`{_template_conformance_warning_count(template_conformance)}`", + f"- Support wording status: `{_text(support_wording.get('status')) or 'unknown'}`", + f"- Support wording warnings: `{_support_wording_warning_count(support_wording)}`", "", "## Recommended Next Actions", "", @@ -374,6 +389,8 @@ def render_quality_panel_html( materiality = materiality if isinstance(materiality, Mapping) else {} template_conformance = panel_payload.get("report_template_conformance") template_conformance = template_conformance if isinstance(template_conformance, Mapping) else {} + support_wording = panel_payload.get("support_wording") + support_wording = support_wording if isinstance(support_wording, Mapping) else {} actions = panel_payload.get("recommended_actions") actions = actions if isinstance(actions, list) else [] overall_status = _text(panel_payload.get("overall_status")) or "unknown" @@ -400,6 +417,11 @@ def render_quality_panel_html( _template_conformance_warning_count(template_conformance), "warning", ), + ( + "Support wording", + _support_wording_warning_count(support_wording), + "warning", + ), ("Recommended actions", len(actions), "action"), ] ), @@ -468,6 +490,14 @@ def render_quality_panel_html( "Reader template warnings", str(_template_conformance_warning_count(template_conformance)), ), + ( + "Support wording", + _text(support_wording.get("status")) or "unknown", + ), + ( + "Support wording warnings", + str(_support_wording_warning_count(support_wording)), + ), ], ), _html_section( @@ -590,6 +620,13 @@ def validate_quality_panel_payload(payload: Any) -> str | None: template_error = validate_report_template_conformance_payload(template_conformance) if template_error: return f"quality_panel_schema_error:report_template_conformance:{template_error}" + support_wording = payload.get("support_wording") + if support_wording is not None: + if not isinstance(support_wording, dict): + return "quality_panel_schema_error:support_wording" + support_wording_error = validate_support_wording_payload(support_wording) + if support_wording_error: + return f"quality_panel_schema_error:support_wording:{support_wording_error}" recommended_actions = payload.get("recommended_actions") if not isinstance(recommended_actions, list): return "quality_panel_schema_error:recommended_actions" @@ -834,6 +871,7 @@ def _overall_status( delivery: Mapping[str, Any], materiality_selection: Mapping[str, Any], report_template_conformance: Mapping[str, Any], + support_wording: Mapping[str, Any], ) -> str: if not workspace_status.get("ok"): return "incomplete" @@ -868,6 +906,7 @@ def _overall_status( or claims.get("weak_support_count", 0) > 0 or _materiality_selection_warning_count(materiality_selection) > 0 or _template_conformance_warning_count(report_template_conformance) > 0 + or _support_wording_warning_count(support_wording) > 0 ): return "warning" return "pass" @@ -884,6 +923,7 @@ def _recommended_actions( trajectory: Mapping[str, Any], materiality_selection: Mapping[str, Any], report_template_conformance: Mapping[str, Any], + support_wording: Mapping[str, Any], ) -> list[dict[str, str]]: actions: list[dict[str, str]] = [] if workflow.get("blocked"): @@ -951,6 +991,21 @@ def _recommended_actions( "action": "review_reader_template_conformance", "reason": "reader_template_conformance_warning_only", }) + support_counts = ( + support_wording.get("summary_counts") + if isinstance(support_wording.get("summary_counts"), Mapping) + else {} + ) + if int(support_counts.get("unsupported_reader_claim_count") or 0) > 0: + actions.append({ + "action": "request_human_review", + "reason": "unsupported_claim_present_in_reader_text", + }) + elif _support_wording_warning_count(support_wording) > 0: + actions.append({ + "action": "review_support_wording_warnings", + "reason": "support_calibrated_wording_warning_only", + }) for item in trajectory.get("recommended_actions") or []: if not isinstance(item, Mapping): continue @@ -988,6 +1043,15 @@ def _template_conformance_warning_count(report_template_conformance: Mapping[str ) +def _support_wording_warning_count(support_wording: Mapping[str, Any]) -> int: + counts = ( + support_wording.get("summary_counts") + if isinstance(support_wording.get("summary_counts"), Mapping) + else {} + ) + return int(counts.get("finding_count") or 0) + + def _fact_layer_status(artifacts: Mapping[str, Any], source_evidence: Mapping[str, Any]) -> str: claim_ledger_status = _optional_artifact_status(_artifact_record(artifacts, "claim_ledger")) source_pack_status = str(source_evidence.get("source_pack_status") or "") @@ -1086,6 +1150,7 @@ def _quality_summary_warning_items( delivery: Mapping[str, Any], materiality_selection: Mapping[str, Any], report_template_conformance: Mapping[str, Any], + support_wording: Mapping[str, Any], ) -> list[str]: items: list[str] = [] if _text(source.get("source_pack_status")) == "invalid": @@ -1115,6 +1180,11 @@ def _quality_summary_warning_items( "Reader template conformance projection found " f"`{_template_conformance_warning_count(report_template_conformance)}` warning(s)." ) + if _support_wording_warning_count(support_wording) > 0: + items.append( + "Support-calibrated wording projection found " + f"`{_support_wording_warning_count(support_wording)}` reader wording warning(s)." + ) return items diff --git a/src/multi_agent_brief/product/support_wording.py b/src/multi_agent_brief/product/support_wording.py new file mode 100644 index 00000000..d29b3b9f --- /dev/null +++ b/src/multi_agent_brief/product/support_wording.py @@ -0,0 +1,500 @@ +"""Read-only support-calibrated wording diagnostics. + +This module surfaces deterministic reader-facing wording risks from already +recorded source/support metadata. It does not judge claim truth, generate or +accept Claim-Support Matrix rows, run gates, approve delivery, or decide release +readiness. +""" + +from __future__ import annotations + +import json +import re +from pathlib import Path +from typing import Any, Mapping + +from multi_agent_brief.core.claim_ledger import ClaimLedger + + +SUPPORT_WORDING_SCHEMA_VERSION = "briefloop.support_wording.v1" +SUPPORT_WORDING_BOUNDARY = "support_wording_projection_only_not_support_truth_or_gate_authority" +SUPPORT_WORDING_RUNTIME_EFFECT = "none" + +SUPPORT_WORDING_STATUSES = { + "checked", + "invalid_claim_ledger", + "no_reader_targets", + "not_available", +} +SUPPORT_WORDING_FINDING_TYPES = { + "unsupported_claim_reaches_reader", + "weak_support_strong_wording", + "inference_without_framing", + "source_class_strong_wording", +} +SUPPORT_WORDING_ACTIONS = {"review_support_wording_warnings", "request_human_review"} +SUPPORT_WORDING_NON_GOALS = { + "semantic_truth_proof", + "support_truth_assessment", + "claim_support_matrix_generation", + "semantic_assessment_acceptance", + "gate_decision", + "delivery_approval", + "release_authority", + "quality_score", +} +SUPPORT_WORDING_SUMMARY_FIELDS = { + "target_count", + "present_target_count", + "unreadable_target_count", + "finding_count", + "unsupported_reader_claim_count", + "weak_support_strong_wording_count", + "inference_without_framing_count", + "source_class_strong_wording_count", +} + +_INTERMEDIATE = Path("output/intermediate") +_READER_TARGETS = ("output/brief.md", "output/delivery/brief.md") +_AUTHORITY_KEYS = { + "approve_delivery", + "approved_for_delivery", + "accepted_support_truth", + "claim_support_matrix_generation", + "delivery_approval", + "gate_decision", + "quality_score", + "release_authority", + "semantic_truth_proof", + "state_transition", + "support_truth_assessment", +} +_STRONG_WORDING_RE = re.compile( + r"\b(will|must|guarantee(?:s|d)?|prove(?:s|d)?|confirm(?:s|ed)?|demonstrate(?:s|d)?|" + r"certain(?:ly)?|undeniabl(?:e|y)|officially\s+confirmed)\b|" + r"(必然|一定|保证|证明|证实|确认|将会|毫无疑问)", + re.IGNORECASE, +) +_FRAMING_RE = re.compile( + r"\b(may|might|could|suggest(?:s|ed)?|appear(?:s|ed)?|report(?:s|ed)?|according\s+to|" + r"indicat(?:e|es|ed)|estimate(?:s|d)?|scenario|possible|potential)\b|" + r"(可能|或许|据|显示|表明|估计|情景|潜在|报道称|报告称)", + re.IGNORECASE, +) +_ATTRIBUTION_RE = re.compile( + r"\b(according\s+to|reported\s+by|the\s+report\s+(?:said|says)|sources?\s+(?:said|say))\b|" + r"(据|报道称|报告称|来源称)", + re.IGNORECASE, +) +_TOKEN_RE = re.compile(r"[a-z0-9]+|[\u4e00-\u9fff]{2,}", re.IGNORECASE) + + +def project_workspace_support_wording( + workspace: str | Path, + *, + claim_support_matrix: Mapping[str, Any] | None = None, +) -> dict[str, Any]: + """Project reader wording risks without writing workspace state.""" + + ws = Path(workspace).expanduser().resolve() + base = _base_projection() + claims, reason = _read_claims(ws / _INTERMEDIATE / "claim_ledger.json") + if reason: + status = "not_available" if reason == "claim_ledger_missing" else "invalid_claim_ledger" + return { + **base, + "status": status, + "reason": reason, + "claim_count": 0, + "support_artifact_status": "not_available", + "targets": _missing_targets(ws), + "summary_counts": _summary_counts([]), + } + + if isinstance(claim_support_matrix, Mapping): + support_projection = dict(claim_support_matrix) + else: + from multi_agent_brief.orchestrator.runtime_state.claim_support_matrix import ( + project_claim_support_matrix_from_workspace, + ) + + support_projection = project_claim_support_matrix_from_workspace(ws) + support_artifact_status = _support_artifact_status(support_projection) + support_index = _support_index(support_projection) if support_artifact_status == "valid" else {} + targets = [_project_target(ws, rel_path, claims, support_index) for rel_path in _READER_TARGETS] + present_targets = [target for target in targets if target.get("status") not in {"missing", "unreadable"}] + unreadable_target_count = sum(1 for target in targets if target.get("status") == "unreadable") + findings = [finding for target in targets for finding in target.get("findings", []) if isinstance(finding, dict)] + status = "checked" if present_targets else "not_available" if unreadable_target_count else "no_reader_targets" + reason = None + if not present_targets: + reason = "reader_targets_unreadable" if unreadable_target_count else "reader_targets_missing" + return { + **base, + "status": status, + "reason": reason, + "claim_count": len(claims), + "support_artifact_status": support_artifact_status, + "targets": targets, + "findings": findings[:50], + "summary_counts": _summary_counts( + findings, + present_target_count=len(present_targets), + unreadable_target_count=unreadable_target_count, + ), + "recommended_actions": _recommended_actions(findings), + } + + +def validate_support_wording_payload(payload: Any) -> str | None: + if not isinstance(payload, dict): + return "support_wording_schema_error:not_object" + if _contains_authority_key(payload): + return "support_wording_schema_error:authority_field" + if payload.get("schema_version") != SUPPORT_WORDING_SCHEMA_VERSION: + return "support_wording_schema_error:schema_version" + if payload.get("boundary") != SUPPORT_WORDING_BOUNDARY: + return "support_wording_schema_error:boundary" + if payload.get("runtime_effect") != SUPPORT_WORDING_RUNTIME_EFFECT: + return "support_wording_schema_error:runtime_effect" + if payload.get("read_only") is not True: + return "support_wording_schema_error:read_only" + if payload.get("status") not in SUPPORT_WORDING_STATUSES: + return "support_wording_schema_error:status" + if not SUPPORT_WORDING_NON_GOALS.issubset({str(item) for item in payload.get("non_goals", [])}): + return "support_wording_schema_error:non_goals" + for field in ("targets", "findings", "recommended_actions", "non_goals"): + if not isinstance(payload.get(field), list): + return f"support_wording_schema_error:{field}" + summary = payload.get("summary_counts") + if not isinstance(summary, dict): + return "support_wording_schema_error:summary_counts" + for field in SUPPORT_WORDING_SUMMARY_FIELDS: + if not isinstance(summary.get(field), int) or summary.get(field, 0) < 0: + return f"support_wording_schema_error:summary_counts.{field}" + for target in payload.get("targets", []): + if not isinstance(target, dict): + return "support_wording_schema_error:targets" + if target.get("status") not in {"missing", "pass", "warning", "unreadable"}: + return "support_wording_schema_error:targets.status" + for finding in payload.get("findings", []): + if not isinstance(finding, dict): + return "support_wording_schema_error:findings" + if str(finding.get("finding_type") or "") not in SUPPORT_WORDING_FINDING_TYPES: + return "support_wording_schema_error:findings.finding_type" + if str(finding.get("severity") or "") not in {"warning", "human_review"}: + return "support_wording_schema_error:findings.severity" + for action in payload.get("recommended_actions", []): + if not isinstance(action, dict): + return "support_wording_schema_error:recommended_actions" + if str(action.get("action") or "") not in SUPPORT_WORDING_ACTIONS: + return "support_wording_schema_error:recommended_actions.action" + return None + + +def _base_projection() -> dict[str, Any]: + return { + "schema_version": SUPPORT_WORDING_SCHEMA_VERSION, + "read_only": True, + "runtime_effect": SUPPORT_WORDING_RUNTIME_EFFECT, + "boundary": SUPPORT_WORDING_BOUNDARY, + "semantic_boundary": "deterministic_lexical_projection_from_recorded_support_metadata_only", + "targets": [], + "findings": [], + "summary_counts": _summary_counts([]), + "recommended_actions": [], + "non_goals": sorted(SUPPORT_WORDING_NON_GOALS), + } + + +def _read_claims(path: Path) -> tuple[list[dict[str, Any]], str | None]: + if not path.exists(): + return [], "claim_ledger_missing" + try: + payload = json.loads(path.read_text(encoding="utf-8")) + claims = ClaimLedger._claim_items_from_json(payload) + except (OSError, UnicodeDecodeError, json.JSONDecodeError, ValueError) as exc: + return [], f"claim_ledger_unreadable:{type(exc).__name__}" + return [dict(claim) for claim in claims], None + + +def _missing_targets(workspace: Path) -> list[dict[str, Any]]: + return [ + {"target_artifact": rel_path, "status": "missing", "finding_count": 0, "findings": []} + for rel_path in _READER_TARGETS + if not (workspace / rel_path).exists() + ] + + +def _project_target( + workspace: Path, + rel_path: str, + claims: list[dict[str, Any]], + support_index: Mapping[str, Mapping[str, Any]], +) -> dict[str, Any]: + path = workspace / rel_path + if not path.exists(): + return {"target_artifact": rel_path, "status": "missing", "finding_count": 0, "findings": []} + try: + text = path.read_text(encoding="utf-8") + except (OSError, UnicodeDecodeError) as exc: + return { + "target_artifact": rel_path, + "status": "unreadable", + "error": str(exc), + "finding_count": 0, + "findings": [], + } + findings: list[dict[str, Any]] = [] + for claim in claims: + context = _matching_context(text, _text(claim.get("statement"))) + if not context: + continue + finding_base = { + "target_artifact": rel_path, + "claim_id": _text(claim.get("claim_id")), + "evidence_ref": _short_text(context), + } + support = dict(support_index.get(_text(claim.get("claim_id"))) or _claim_intrinsic_support_risk(claim)) + source_class = _claim_source_class(claim) + strong_wording = bool(_STRONG_WORDING_RE.search(context)) + framed = bool(_FRAMING_RE.search(context)) + attributed = bool(_ATTRIBUTION_RE.search(context)) + if support.get("blocking"): + findings.append(_finding( + finding_type="unsupported_claim_reaches_reader", + severity="human_review", + description="A claim with explicit unsupported/contradicted/insufficient support records appears in reader text.", + recommendation="Downgrade, remove, or route the claim through support repair before relying on it in reader-facing text.", + metadata={"support_labels": support.get("support_labels", []), "support_source": support.get("source")}, + **finding_base, + )) + if strong_wording and (support.get("weak") or support.get("downgrade_required")): + findings.append(_finding( + finding_type="weak_support_strong_wording", + severity="warning", + description="Reader text uses strong certainty wording for a claim recorded as weak or downgrade-required support.", + recommendation="Use attributed, qualified wording or resolve the support record before retaining strong phrasing.", + metadata={"support_labels": support.get("support_labels", []), "support_source": support.get("source")}, + **finding_base, + )) + if support.get("inference_required") and not framed: + findings.append(_finding( + finding_type="inference_without_framing", + severity="warning", + description="Reader text presents an inferred or interpretive claim without visible uncertainty/attribution framing.", + recommendation="Frame the wording as an inference, estimate, scenario, or attributed report.", + metadata={"support_labels": support.get("support_labels", []), "support_source": support.get("source")}, + **finding_base, + )) + if source_class in {"news_media", "market_report", "media_report"} and strong_wording and not attributed: + findings.append(_finding( + finding_type="source_class_strong_wording", + severity="warning", + description="Reader text uses strong wording for a claim sourced from media/report-style evidence without visible attribution.", + recommendation="Attribute the source class or soften certainty wording unless stronger primary support is available.", + metadata={"source_class": source_class}, + **finding_base, + )) + return { + "target_artifact": rel_path, + "status": "warning" if findings else "pass", + "finding_count": len(findings), + "findings": [ + {**finding, "finding_id": f"SUPWORD-{idx:03d}"} + for idx, finding in enumerate(findings, start=1) + ], + } + + +def _support_artifact_status(projection: Mapping[str, Any]) -> str: + status = _text(projection.get("status")) + if status == "valid": + return "valid" + if status in {"invalid_matrix", "invalid"}: + return "invalid" + if status in {"not_available", ""}: + return "not_available" + return status + + +def _support_index(projection: Mapping[str, Any]) -> dict[str, dict[str, Any]]: + index: dict[str, dict[str, Any]] = {} + atoms = projection.get("atoms") if isinstance(projection.get("atoms"), list) else [] + for atom in atoms: + if not isinstance(atom, Mapping): + continue + claim_id = _text(atom.get("claim_id")) + if not claim_id: + continue + item = index.setdefault( + claim_id, + { + "source": "claim_support_matrix", + "support_labels": set(), + "blocking": False, + "weak": False, + "downgrade_required": False, + "inference_required": False, + }, + ) + item["blocking"] = bool(item["blocking"] or atom.get("blocking")) + item["weak"] = bool(item["weak"] or atom.get("weak_support")) + item["downgrade_required"] = bool(item["downgrade_required"] or atom.get("downgrade_required")) + item["inference_required"] = bool(item["inference_required"] or atom.get("inference_framing_required")) + labels = atom.get("support_labels") if isinstance(atom.get("support_labels"), list) else [] + item["support_labels"].update(str(label) for label in labels if str(label).strip()) + for item in index.values(): + item["support_labels"] = sorted(item["support_labels"]) + return index + + +def _claim_intrinsic_support_risk(claim: Mapping[str, Any]) -> dict[str, Any]: + evidence_relation = _text(claim.get("evidence_relation")) + epistemic_type = _text(claim.get("epistemic_type")) + confidence = _text(claim.get("confidence")) + claim_type = _text(claim.get("claim_type")) + return { + "source": "claim_ledger_metadata", + "support_labels": [], + "blocking": False, + "weak": confidence == "low", + "downgrade_required": False, + "inference_required": evidence_relation in {"inferred", "analogous"} + or epistemic_type in {"interpreted", "hypothesis", "analogy"} + or claim_type in {"interpretation", "forecast", "risk"}, + } + + +def _claim_source_class(claim: Mapping[str, Any]) -> str: + metadata = claim.get("metadata") if isinstance(claim.get("metadata"), Mapping) else {} + for key in ("source_category", "underlying_evidence_type", "retrieval_source_type"): + value = _text(metadata.get(key)) + if value: + return value + return _text(claim.get("source_type")) + + +def _matching_context(text: str, statement: str) -> str: + if not statement.strip(): + return "" + target_sentences = _sentences(text) + statement_norm = _normalize_text(statement) + for sentence in target_sentences: + sentence_norm = _normalize_text(sentence) + if statement_norm and statement_norm in sentence_norm: + return sentence.strip() + statement_tokens = _tokens(statement) + if len(statement_tokens) < 4: + return "" + threshold = max(3, int(len(statement_tokens) * 0.65)) + for sentence in target_sentences: + sentence_tokens = set(_tokens(sentence)) + if len(statement_tokens & sentence_tokens) >= threshold: + return sentence.strip() + return "" + + +def _sentences(text: str) -> list[str]: + chunks = re.split(r"(?<=[.!?。!?])\s+|\n+", text) + return [chunk.strip() for chunk in chunks if chunk.strip()] + + +def _tokens(text: str) -> set[str]: + return set(_token_list(text)) + + +def _normalize_text(text: str) -> str: + return " ".join(_token_list(text)) + + +def _token_list(text: str) -> list[str]: + return [match.group(0).lower() for match in _TOKEN_RE.finditer(text)] + + +def _finding( + *, + finding_type: str, + severity: str, + description: str, + recommendation: str, + target_artifact: str, + claim_id: str, + evidence_ref: str, + metadata: Mapping[str, Any], +) -> dict[str, Any]: + return { + "finding_type": finding_type, + "severity": severity, + "target_artifact": target_artifact, + "claim_id": claim_id, + "description": description, + "recommendation": recommendation, + "evidence_ref": evidence_ref, + "blocking": False, + "runtime_effect": "none", + "metadata": { + **dict(metadata), + "semantic_boundary": ( + "deterministic lexical warning from recorded support/source metadata; not support truth" + ), + }, + } + + +def _summary_counts( + findings: list[Mapping[str, Any]], + *, + present_target_count: int = 0, + unreadable_target_count: int = 0, +) -> dict[str, int]: + counts = { + "target_count": len(_READER_TARGETS), + "present_target_count": present_target_count, + "unreadable_target_count": unreadable_target_count, + "finding_count": len(findings), + "unsupported_reader_claim_count": 0, + "weak_support_strong_wording_count": 0, + "inference_without_framing_count": 0, + "source_class_strong_wording_count": 0, + } + for finding in findings: + finding_type = _text(finding.get("finding_type")) + if finding_type == "unsupported_claim_reaches_reader": + counts["unsupported_reader_claim_count"] += 1 + elif finding_type == "weak_support_strong_wording": + counts["weak_support_strong_wording_count"] += 1 + elif finding_type == "inference_without_framing": + counts["inference_without_framing_count"] += 1 + elif finding_type == "source_class_strong_wording": + counts["source_class_strong_wording_count"] += 1 + return counts + + +def _recommended_actions(findings: list[Mapping[str, Any]]) -> list[dict[str, str]]: + if not findings: + return [] + if any(_text(finding.get("severity")) == "human_review" for finding in findings): + return [{"action": "request_human_review", "reason": "unsupported_claim_present_in_reader_text"}] + return [{"action": "review_support_wording_warnings", "reason": "support_calibrated_wording_warning"}] + + +def _contains_authority_key(value: Any) -> bool: + if isinstance(value, Mapping): + for key, child in value.items(): + if str(key) in _AUTHORITY_KEYS: + return True + if _contains_authority_key(child): + return True + elif isinstance(value, list): + return any(_contains_authority_key(item) for item in value) + return False + + +def _text(value: Any) -> str: + return value.strip() if isinstance(value, str) else "" + + +def _short_text(value: str, limit: int = 220) -> str: + collapsed = " ".join(value.split()) + return collapsed if len(collapsed) <= limit else collapsed[: limit - 3].rstrip() + "..." diff --git a/src/multi_agent_brief/status.py b/src/multi_agent_brief/status.py index 920cf0e0..d03c7e42 100644 --- a/src/multi_agent_brief/status.py +++ b/src/multi_agent_brief/status.py @@ -28,6 +28,7 @@ from multi_agent_brief.product.guidance_manifestation import project_workspace_guidance_manifestation from multi_agent_brief.product.materiality_selection import project_workspace_materiality_selection from multi_agent_brief.product.policy_projection import project_workspace_policy_profile +from multi_agent_brief.product.support_wording import project_workspace_support_wording from multi_agent_brief.product.template_conformance import project_workspace_report_template_conformance from multi_agent_brief.product.template_projection import project_workspace_report_template from multi_agent_brief.product.template_render_plan import project_workspace_report_template_render_plan @@ -70,6 +71,7 @@ def build_workspace_status(workspace: str | Path) -> dict[str, Any]: "trajectory_regulation": {}, "guidance_manifestation": {}, "materiality_selection": {}, + "support_wording": {}, "timing": {}, "stale_or_unknown": [], "suggested_next_command": None, @@ -138,6 +140,10 @@ def build_workspace_status(workspace: str | Path) -> dict[str, Any]: ws, policy_profile=payload["policy_profile"], ) + payload["support_wording"] = project_workspace_support_wording( + ws, + claim_support_matrix=payload["claim_support_matrix"], + ) payload["report_template"] = project_workspace_report_template(ws) payload["report_template_conformance"] = project_workspace_report_template_conformance(ws) payload["report_template_render_plan"] = project_workspace_report_template_render_plan(ws) @@ -215,6 +221,7 @@ def format_workspace_status(status: dict[str, Any]) -> str: trajectory_regulation = status.get("trajectory_regulation") or {} guidance_manifestation = status.get("guidance_manifestation") or {} materiality_selection = status.get("materiality_selection") or {} + support_wording = status.get("support_wording") or {} events = status.get("events") or {} timing = status.get("timing") or {} run_integrity = workflow.get("run_integrity") if isinstance(workflow.get("run_integrity"), dict) else {} @@ -386,6 +393,18 @@ def format_workspace_status(status: dict[str, Any]) -> str: "boundary=projection_only " "runtime_effect=none" ) + if support_wording.get("status") not in {None, "not_available"}: + counts = support_wording.get("summary_counts") + counts = counts if isinstance(counts, dict) else {} + lines.append( + "[status] support_wording: " + f"{support_wording.get('status')} " + f"findings={counts.get('finding_count', 0)} " + f"unsupported_reader={counts.get('unsupported_reader_claim_count', 0)} " + f"weak_strong={counts.get('weak_support_strong_wording_count', 0)} " + "boundary=projection_only " + "runtime_effect=none" + ) for marker in status.get("stale_or_unknown") or []: lines.append(f"[status] stale_or_unknown: {marker}") lines.append(f"[status] suggested_next: {status.get('suggested_next_command')}") @@ -486,7 +505,7 @@ def _read_optional_text(path: Path) -> str | None: return None try: return path.read_text(encoding="utf-8") - except OSError: + except (OSError, UnicodeDecodeError): return None diff --git a/tests/test_support_wording.py b/tests/test_support_wording.py new file mode 100644 index 00000000..ed8a6c16 --- /dev/null +++ b/tests/test_support_wording.py @@ -0,0 +1,259 @@ +from __future__ import annotations + +import json +from pathlib import Path + +from multi_agent_brief.cli.main import main +from multi_agent_brief.product.quality_panel import build_quality_panel, validate_quality_panel_payload +from multi_agent_brief.product.support_wording import ( + SUPPORT_WORDING_BOUNDARY, + project_workspace_support_wording, + validate_support_wording_payload, +) +from multi_agent_brief.status import build_workspace_status, format_workspace_status + + +def _workspace(tmp_path: Path) -> Path: + ws = tmp_path / "ws" + ws.mkdir() + (ws / "config.yaml").write_text("project:\n name: Support Wording Test\n", encoding="utf-8") + assert main(["state", "init", "--workspace", str(ws)]) == 0 + return ws + + +def _write_json(path: Path, payload: object) -> None: + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text(json.dumps(payload, ensure_ascii=False, indent=2, sort_keys=True) + "\n", encoding="utf-8") + + +def _claim(*, source_category: str = "company_press_release", confidence: str = "medium") -> dict: + return { + "claim_id": "CL-0001", + "statement": "ExampleCo will expand shipments this quarter.", + "source_id": "SRC-001", + "evidence_text": "ExampleCo may expand shipments this quarter.", + "claim_type": "forecast", + "confidence": confidence, + "source_type": "manual", + "metadata": { + "source_title": "Example Source", + "source_category": source_category, + "retrieval_source_type": "local_file", + "underlying_evidence_type": "company_claim", + }, + "schema_version": "v2", + "epistemic_type": "hypothesis", + "evidence_relation": "inferred", + } + + +def _write_claim_stack(ws: Path, *, row: dict | None = None, claim: dict | None = None) -> None: + claim_payload = claim or _claim() + _write_json(ws / "output" / "intermediate" / "claim_ledger.json", [claim_payload]) + source_file = ws / "input" / "sources" / "source-001.txt" + source_file.parent.mkdir(parents=True, exist_ok=True) + source_file.write_text("ExampleCo may expand shipments this quarter.", encoding="utf-8") + _write_json( + ws / "output" / "intermediate" / "atomic_claim_graph.json", + { + "schema_version": "mabw.atomic_claim_graph.v1", + "claims": [ + { + "claim_id": "CL-0001", + "atoms": [ + { + "atom_id": "AC-0001-01", + "text": "ExampleCo may expand shipments this quarter.", + "claim_role": "forward_looking_inference", + "materiality": "high", + } + ], + "edges": [], + } + ], + }, + ) + _write_json( + ws / "output" / "intermediate" / "evidence_span_registry.json", + { + "schema_version": "mabw.evidence_span_registry.v1", + "sources": [ + { + "source_id": "SRC-001", + "source_type": "manual", + "source_tier": "company_official", + "source_path": "input/sources/source-001.txt", + "retrieved_at": "2026-07-01T00:00:00Z", + "spans": [ + { + "span_id": "ESP-001-01", + "raw_excerpt": "ExampleCo may expand shipments this quarter.", + "hash": "sha256:004025835bb813954b9ec7592145fa8513a5aefe6483aafd610ef0a818b67e16", + "span_role": "direct_statement", + } + ], + } + ], + }, + ) + if row is not None: + _write_json( + ws / "output" / "intermediate" / "claim_support_matrix.json", + {"schema_version": "mabw.claim_support_matrix.v1", "rows": [row]}, + ) + + +def _csm_row(*, support_label: str, support_strength: str, required_action: str) -> dict: + return { + "row_id": "CSM-0001", + "atom_id": "AC-0001-01", + "claim_id": "CL-0001", + "evidence_span_id": None if support_label in {"unsupported", "insufficient_evidence"} else "ESP-001-01", + "support_label": support_label, + "support_strength": support_strength, + "support_reason": "Synthetic support record for wording projection tests.", + "required_action": required_action, + "repair_owner": "human_review" if required_action == "human_adjudication" else "editor", + "decision_source": "human", + } + + +def test_support_wording_warns_on_weak_support_strong_reader_wording(tmp_path: Path) -> None: + ws = _workspace(tmp_path) + _write_claim_stack( + ws, + row=_csm_row( + support_label="weak_support", + support_strength="low", + required_action="downgrade_wording", + ), + ) + (ws / "output" / "brief.md").write_text( + "# Brief\n\nExampleCo will expand shipments this quarter. [S1]\n", + encoding="utf-8", + ) + + projection = project_workspace_support_wording(ws) + + assert projection["boundary"] == SUPPORT_WORDING_BOUNDARY + assert projection["status"] == "checked" + assert projection["support_artifact_status"] == "valid" + assert projection["summary_counts"]["weak_support_strong_wording_count"] == 1 + assert {finding["finding_type"] for finding in projection["findings"]} >= { + "weak_support_strong_wording", + } + assert validate_support_wording_payload(projection) is None + + +def test_support_wording_ignores_invalid_csm_as_support_authority(tmp_path: Path) -> None: + ws = _workspace(tmp_path) + _write_claim_stack(ws, row=None, claim={**_claim(source_category="company_press_release"), "confidence": "medium"}) + _write_json( + ws / "output" / "intermediate" / "claim_support_matrix.json", + {"schema_version": "mabw.claim_support_matrix.v1", "rows": "not-a-list"}, + ) + (ws / "output" / "brief.md").write_text( + "# Brief\n\nExampleCo will expand shipments this quarter. [S1]\n", + encoding="utf-8", + ) + + projection = project_workspace_support_wording(ws) + + assert projection["support_artifact_status"] == "invalid" + assert projection["summary_counts"]["unsupported_reader_claim_count"] == 0 + assert all(finding["finding_type"] != "unsupported_claim_reaches_reader" for finding in projection["findings"]) + + +def test_support_wording_unreadable_reader_target_is_not_checked(tmp_path: Path) -> None: + ws = _workspace(tmp_path) + _write_claim_stack(ws, row=None) + (ws / "output" / "brief.md").parent.mkdir(parents=True, exist_ok=True) + (ws / "output" / "brief.md").write_bytes(b"\xff\xfe\xfa") + + projection = project_workspace_support_wording(ws) + + assert projection["status"] == "not_available" + assert projection["reason"] == "reader_targets_unreadable" + assert projection["summary_counts"]["unreadable_target_count"] == 1 + assert projection["summary_counts"]["present_target_count"] == 0 + assert projection["findings"] == [] + assert validate_support_wording_payload(projection) is None + + +def test_status_and_quality_panel_survive_unreadable_reader_markdown(tmp_path: Path) -> None: + ws = _workspace(tmp_path) + _write_claim_stack(ws, row=None) + (ws / "output" / "brief.md").parent.mkdir(parents=True, exist_ok=True) + (ws / "output" / "brief.md").write_bytes(b"\xff\xfe\xfa") + + status = build_workspace_status(ws) + panel = build_quality_panel(ws) + + assert status["atomic_reader_projection"]["reader_brief"]["status"] == "not_available" + assert status["support_wording"]["status"] == "not_available" + assert status["support_wording"]["reason"] == "reader_targets_unreadable" + assert panel["support_wording"]["status"] == "not_available" + assert panel["support_wording"]["summary_counts"]["unreadable_target_count"] == 1 + assert validate_quality_panel_payload(panel) is None + + +def test_support_wording_projects_to_status_and_quality_panel(tmp_path: Path) -> None: + ws = _workspace(tmp_path) + _write_claim_stack( + ws, + row=_csm_row( + support_label="unsupported", + support_strength="none", + required_action="human_adjudication", + ), + ) + (ws / "output" / "brief.md").write_text( + "# Brief\n\nExampleCo will expand shipments this quarter. [S1]\n", + encoding="utf-8", + ) + + status = build_workspace_status(ws) + panel = build_quality_panel(ws) + + assert status["support_wording"]["summary_counts"]["unsupported_reader_claim_count"] == 1 + assert "[status] support_wording: checked" in format_workspace_status(status) + assert panel["support_wording"]["summary_counts"]["unsupported_reader_claim_count"] == 1 + assert {item["action"] for item in panel["recommended_actions"]} >= {"request_human_review"} + assert validate_quality_panel_payload(panel) is None + + +def test_quality_panel_rejects_forged_support_wording_authority() -> None: + payload = { + "schema_version": "briefloop.support_wording.v1", + "read_only": True, + "runtime_effect": "state_transition", + "boundary": SUPPORT_WORDING_BOUNDARY, + "status": "checked", + "targets": [], + "findings": [], + "summary_counts": { + "target_count": 2, + "present_target_count": 0, + "unreadable_target_count": 0, + "finding_count": 0, + "unsupported_reader_claim_count": 0, + "weak_support_strong_wording_count": 0, + "inference_without_framing_count": 0, + "source_class_strong_wording_count": 0, + }, + "recommended_actions": [{"action": "approve_delivery"}], + "non_goals": sorted( + { + "semantic_truth_proof", + "support_truth_assessment", + "claim_support_matrix_generation", + "semantic_assessment_acceptance", + "gate_decision", + "delivery_approval", + "release_authority", + "quality_score", + } + ), + } + + assert validate_support_wording_payload(payload) == "support_wording_schema_error:runtime_effect"