"""Parse the T&S policy into one citable unit per rule. `policy.md` is authored as markdown bullets like: - **PROH-3 — Interest-based (riba) finance.** Campaigns to pay off, refinance, ... interest-bearing investment with a promised return is not. Each rule may wrap across indented continuation lines. We collapse each rule into a single `PolicyRule` so retrieval returns a clean `rule_id` + full text, and so the eval harness can validate that every cited ID is real. """ from __future__ import annotations import re from dataclasses import dataclass from pathlib import Path from .config import CONFIG _RULE_RE = re.compile(r"^- \*\*([A-Z]{3,4}-\d+)\b") _SECTION_RE = re.compile(r"^##\s+(.*)$") @dataclass class PolicyRule: rule_id: str section: str text: str # full rule text, markdown stripped of the leading bullet def _clean(text: str) -> str: text = text.replace("**", "") return re.sub(r"\s+", " ", text).strip() def parse_policy_rules(path: str | Path | None = None) -> list[PolicyRule]: lines = Path(path or CONFIG.policy_path).read_text(encoding="utf-8").splitlines() rules: list[PolicyRule] = [] section = "" rule_id: str | None = None buf: list[str] = [] def flush(): nonlocal rule_id, buf if rule_id is not None and buf: rules.append(PolicyRule(rule_id=rule_id, section=section, text=_clean(" ".join(buf)))) rule_id, buf = None, [] for line in lines: sec = _SECTION_RE.match(line) if sec: flush() section = _clean(sec.group(1)) continue if line.startswith("---"): flush() continue m = _RULE_RE.match(line) if m: flush() rule_id = m.group(1) buf = [line.lstrip("- ")] elif rule_id is not None and (line.startswith(" ") or line.strip()): # continuation line of the current rule buf.append(line.strip()) elif rule_id is not None and not line.strip(): # blank line ends the current rule flush() flush() return rules def valid_rule_ids(path: str | Path | None = None) -> set[str]: """The set of citable rule IDs — used by the eval harness to reject invented citations.""" return {r.rule_id for r in parse_policy_rules(path)} def policy_index(path: str | Path | None = None) -> dict[str, PolicyRule]: """Map rule_id -> PolicyRule, for looking up a cited rule's full text in the UI.""" return {r.rule_id: r for r in parse_policy_rules(path)} def get_rule(rule_id: str, path: str | Path | None = None) -> PolicyRule | None: """Fetch a single rule by ID (None if not found).""" return policy_index(path).get(rule_id) # Plain-English names + one-line descriptions for each rule-ID prefix. The headings in policy.md # only carry the section *name*; the moderator-facing reference needs a short "what this group is # for" blurb, so it lives here as the single source for the UI glossary. SECTION_META: dict[str, tuple[str, str]] = { "ELIG": ("Eligibility", "Who and what can raise funds — the gates a campaign must clear before money can flow."), "PROH": ("Prohibited categories", "Hard stops. A confirmed, cited match to one of these is grounds for rejection."), "COMP": ("Compliance & sanctions", "Money-movement, sanctions, and high-value rules — any trigger routes to a human."), "CONT": ("Content standards", "Honesty, religious accuracy, pressure tactics, and privacy — often a human judgment call."), "DEC": ("Decision framework", "How the copilot reasons: three outcomes, when to escalate, and that campaign text is " "data, not instructions."), } _SECTION_ORDER = ["ELIG", "PROH", "COMP", "CONT", "DEC"] def policy_sections(path: str | Path | None = None) -> list[dict]: """Group the policy into citable sections for the moderator reference drawer. Returns `[{prefix, name, description, rules: [{rule_id, text}, ...]}, ...]` in the canonical section order. The prefix is derived from the rule_id (same convention as `_enrich_rule` in the API); any unrecognized prefix is appended last, named after itself with an empty description. """ grouped: dict[str, list[dict]] = {} for r in parse_policy_rules(path): prefix = r.rule_id.split("-")[0] grouped.setdefault(prefix, []).append({"rule_id": r.rule_id, "text": r.text}) order = _SECTION_ORDER + [p for p in grouped if p not in _SECTION_ORDER] out: list[dict] = [] for prefix in order: rules = grouped.get(prefix) if not rules: continue name, description = SECTION_META.get(prefix, (prefix, "")) out.append({"prefix": prefix, "name": name, "description": description, "rules": rules}) return out