"""Shared helpers for deterministic inline-citation report finalization.""" from __future__ import annotations import asyncio import logging import re from collections.abc import Callable, Mapping, Sequence from dataclasses import dataclass, field from functools import lru_cache from typing import Any from frontier_agent.core.messages import system_msg, text_of, user_msg from workflows._shared.citation_utils import ( _format_references_section, finalize_report, ) logger = logging.getLogger(__name__) Reference = Mapping[str, str] class CitationValidationError(ValueError): """Raised when a report body cannot be finalized with valid citations.""" def __init__(self, issues: Sequence[str]) -> None: self.issues = list(issues) super().__init__("; ".join(self.issues)) _CITE_GROUP_RE = re.compile(r"\[(\s*\d[\d,;\s]*)\]") # A citation index written with trailing descriptive junk inside the # bracket — ``[2的相关背景]`` / ``[2 - see above]`` — that ``_CITE_GROUP_RE`` # does NOT recognize (so it survives as literal text and leaks into the # rendered report). The leading 1-3 digits are a plausible citation index; # the char right after must be a NON-digit, NON-separator, NON-bracket so # that multi-cites (``[2, 3]`` / ``[2; 3]``) and 4-digit years # (``[2024年]``) are left untouched. Everything up to the close bracket is # then dropped, normalizing to ``[N]``. _MALFORMED_CITE_RE = re.compile(r"\[\s*(\d{1,3})\s*[^\d,;\s\]][^\]]*\]") def normalize_malformed_citations(text: str) -> tuple[str, int]: """Rewrite ``[]`` → ``[]``. Returns ``(text, n)``. Belt-and-suspenders for the citation contract: the LLM occasionally writes a descriptive citation (``[2的相关背景]``) instead of the bare ``[2]``. Left as-is it is neither renumbered nor validated — it just leaks. This collapses it to the canonical form so the downstream renumber/validate sees a real ``[N]``. """ if not text: return text, 0 count = 0 def _sub(m: re.Match) -> str: nonlocal count count += 1 return f"[{m.group(1)}]" return _MALFORMED_CITE_RE.sub(_sub, text), count # A markdown footnote reference (``[^idc_infra]`` / ``[^gs_dc]``) the LLM # minted for a claim whose source isn't in the citation whitelist. The # Deterministic Citation Contract mandates numeric ``[N]`` markers only and # tells the LLM to keep an un-sourced claim as analysis *without* a citation; # a footnote-style marker is off-contract. Worse, the LLM never writes the # matching ``[^id]: …`` definition, so the marker has nothing to resolve to # and leaks into the rendered report as literal ``[^id]`` text. The label is # ``^`` + the usual footnote id charset (letters, digits, ``_``, ``-``); the # definition form is the same token followed by ``:``. # The optional single leading space is consumed with the marker so a # space-separated ``word [^x] more`` collapses cleanly to ``word more`` # instead of leaving a double space; CJK text (``亿[^x],``) has no such # space, so nothing is over-consumed there. Only ONE space is eaten, so # line-leading indentation is never swallowed. _FOOTNOTE_REF_RE = re.compile(r" ?\[\^([A-Za-z0-9_-]+)\]") _FOOTNOTE_DEF_LINE_RE = re.compile(r"(?m)^[ \t]*\[\^([A-Za-z0-9_-]+)\]:.*(?:\n|$)") def strip_orphan_footnote_citations(text: str) -> tuple[str, int]: """Strip off-contract markdown footnote citations. Returns ``(text, n)``. Belt-and-suspenders for the Deterministic Citation Contract, which pins the reporter to numeric ``[N]`` markers and a system-appended References block. The LLM occasionally mints a markdown footnote marker (``$7,580亿[^idc_infra]``) for a claim whose source isn't in the whitelist, but never writes the ``[^idc_infra]: …`` definition. With no definition the marker can't resolve, the contract's ``[N]`` renumber ignores it, and it leaks into the final report as literal ``[^idc_infra]`` text — a broken reference. This removes every footnote reference whose definition is absent, plus any orphan definition lines, leaving the surrounding claim intact as analysis (exactly what the contract prescribes for un-sourced claims). A footnote that *does* have a matching definition is left untouched — that is a well-formed footnote the renderer can resolve, so removing it would be lossy; the contract simply shouldn't produce one. """ if not text: return text, 0 defined = {m.group(1) for m in _FOOTNOTE_DEF_LINE_RE.finditer(text)} count = 0 def _sub(m: re.Match) -> str: nonlocal count if m.group(1) in defined: return m.group(0) count += 1 return "" return _FOOTNOTE_REF_RE.sub(_sub, text), count # A bullet list whose items got flattened onto one line — the LLM emits # ``...句子。 - **下一条**:...`` with no newline before the second/third # ``- ``, so only the first renders as a list item and the rest collapse # into the preceding item's text. We re-break a ``- **`` bullet that # directly follows sentence-ending punctuation onto its own line. The # trailing ``**`` requirement (a bold lead-in, which every flattened # bullet here uses) keeps this off ordinary in-prose hyphens / em-dashes. _INLINE_BULLET_RE = re.compile(r"(?<=[。!?.!?])[ \t]+-[ \t]+(?=\*\*)") def normalize_inline_bullets(text: str) -> tuple[str, int]: """Re-break a ``- **`` bullet flattened onto the prior line. ``(text, n)``. Markdown only renders ``- `` as a list item at line start. When the LLM drops the newline (``...这一时间框架。 - **每次最多3人共享**``) the second and later items collapse into the first item's body. This puts each such bullet back on its own line so the list renders. """ if not text: return text, 0 n = len(_INLINE_BULLET_RE.findall(text)) return _INLINE_BULLET_RE.sub("\n- ", text), n _TRAILING_REFS_RE = re.compile( r"(?im)^(?:#{2,4}\s*)?(References|参考文献|参考资料|Sources|来源)\s*$[\s\S]*\Z", ) _URL_SCHEMES = ("http://", "https://") # web_fetch's tool output uses ``[N] URL: …\n Info: …`` — when the # regex-driven title fallback in ``extract_evidence_cards`` picks up the # ``Info:`` line we'd otherwise emit ``[N] Info: # …`` in References. _INFO_TITLE_PREFIX_RE = re.compile(r"^Info:\s*#?\s*", re.IGNORECASE) # Structural defence-in-depth for titles. ``EvidenceObserver`` now # derives web_fetch titles from the URL alone (no body scavenging), but # third-party tool plugins or future tools may still hand us free-form # strings. Rather than blacklist every reasoning-preamble shape we've # seen (``Hmm,`` / ``Let me`` / ``我需要`` / future-LLM variants), we # reject titles whose *shape* says "this is not a title": # # * longer than 200 chars (real titles are short; reasoning is paragraphs) # * contains a newline (titles are single-line) # * looks like a JSON literal (``[...]`` or ``{...}`` at both ends) # # Triggering any of these falls back to the URL — which is always safe. _TITLE_MAX_LEN = 200 def _looks_like_non_title(title: str) -> bool: if not title: return True if len(title) > _TITLE_MAX_LEN: return True if "\n" in title or "\r" in title: return True stripped = title.strip() return bool((stripped.startswith("[") and stripped.endswith("]")) or (stripped.startswith("{") and stripped.endswith("}"))) def coerce_url(value: Any) -> str: """Coerce ``source.url`` to a single canonical URL string. Inputs seen in the wild (in priority order): * plain string ``"https://..."`` * Python list / tuple of URLs (LLM batched ``web_fetch``) * a JSON-array literal *string* like ``'["https://a", "https://b"]'`` (the LLM JSON-encoded the list before placing it in the slot — the tool extractor stored the raw string verbatim) Returns the first non-empty ``http(s)://`` URL, or ``""``. Never returns a list literal or JSON blob — the rendered References block can rely on getting a real URL string. """ if value is None: return "" if isinstance(value, (list, tuple)): for item in value: if isinstance(item, str): stripped = item.strip() if stripped.startswith(_URL_SCHEMES): return stripped return "" if not isinstance(value, str): return "" s = value.strip() if s.startswith("[") and s.endswith("]"): # JSON-array-literal-as-string. Try JSON first; fall back to # regex extraction (LLMs sometimes miss a quote / comma). import json try: parsed = json.loads(s) except (json.JSONDecodeError, ValueError): parsed = None if isinstance(parsed, list): for item in parsed: if isinstance(item, str) and item.strip().startswith(_URL_SCHEMES): return item.strip() m = re.search(r"https?://[^\s\"',\]]+", s) if m: return m.group(0) return "" # Plaintext path: only accept real ``http(s)://`` URLs. Upstream # extractors occasionally place a human label ("Oracle earnings # release") in ``source_url`` when the LLM hallucinates a citation # without a real source; returning the label here makes the rendered # References row "[N] Title. Title" because the renderer falls back # to URL for missing title. Reject non-URL strings so the upstream # ``if not url: continue`` guard drops the entry instead. if s.startswith(_URL_SCHEMES): return s return "" def coerce_title(value: Any, fallback_url: str) -> str: """Clean a ``source.title`` field for the References block. Strips the ``Info:`` prefix that legacy extractors used to leave on web_fetch titles, then applies a structural sanity check: anything that does not look title-shaped (too long, multi-line, JSON-shaped) is replaced with ``fallback_url``. This protects against any upstream source — third-party tool plugins, custom observers, future extractors — without depending on a blacklist of "known bad" phrases. """ if isinstance(value, (list, tuple)): value = next( (v for v in value if isinstance(v, str) and v.strip()), "", ) if not isinstance(value, str): value = str(value) if value is not None else "" title = _INFO_TITLE_PREFIX_RE.sub("", value).strip() if _looks_like_non_title(title): return fallback_url return title def citation_numbers(text: str) -> list[int]: """Extract numeric citation markers from ``[N]`` and ``[1, 2]`` forms.""" nums: list[int] = [] for match in _CITE_GROUP_RE.finditer(text or ""): nums.extend(int(n) for n in re.findall(r"\d+", match.group(1))) return nums def has_any_citation(text: str) -> bool: return bool(citation_numbers(text)) def strip_trailing_references(text: str) -> str: """Drop a trailing References/Sources block, if it is plausibly a footer. The match must begin after the first 30% of the body. This avoids deleting an unusual but legitimate mid-body section named "References". """ if not text: return "" match = _TRAILING_REFS_RE.search(text) if match is None: return text if match.start() < len(text) * 0.3: return text return text[: match.start()].rstrip() def build_citation_maps( references: Sequence[Reference], ) -> tuple[dict[str, str], dict[str, str]]: """Build ``{N -> url}`` / ``{N -> title}`` from references. URL-less entries are silently skipped so the visible ``[N]`` numbering matches the *position* in ``references``, not the position after compaction. Heavy_reporter / swarm_reporter already pre-filter empty URLs upstream, so in practice ``citation_map`` keys are dense ``"1".."N"``; the gap-tolerant skip is defence-in-depth — callers that hand us a raw list with holes won't silently produce a body citing ``[2]`` when reference 2 has no URL (the orphan-citation check in :func:`validate_citation_body` catches it instead). """ citation_map: dict[str, str] = {} citation_titles: dict[str, str] = {} for i, ref in enumerate(references, 1): key = str(i) url = (ref.get("url") or "").strip() title = (ref.get("title") or "").strip() if not url: continue citation_map[key] = url if title: citation_titles[key] = title return citation_map, citation_titles def render_references_block(references: Sequence[Reference]) -> str: lines: list[str] = [] for i, ref in enumerate(references, 1): title = (ref.get("title") or ref.get("url") or "").strip() url = (ref.get("url") or "").strip() if not url: continue lines.append(f"[{i}] {title} — {url}") return "\n".join(lines) def format_references_block_plain( references: Sequence[Reference], *, language: str = "auto", ) -> str: """Render the canonical ``## References`` block in plain ``[N] title. url`` style without going through :func:`finalize_report`'s renumber-and-validate pipeline. Used by the heavy_reporter degraded-finalize fallback paths so users see the system's canonical URL list even when validation/repair couldn't make the body's ``[N]`` markers consistent. The body's markers may stay dangling, but at least the source list is visible — strictly better UX than no References at all. """ citation_map, citation_titles = build_citation_maps(references) if not citation_map: return "" return _format_references_section( citation_map, citation_titles, language=language, style="plain", ) def validate_citation_body( body: str, *, max_ref: int, valid_indices: Sequence[int] | None = None, ) -> list[str]: """Validate that every inline ``[N]`` marker in the body resolves. Two modes: * **Range mode** (default): ``[N]`` is valid iff ``1 <= N <= max_ref``. Used by callers that hand us a contiguous references list and cannot easily compute the membership set. * **Membership mode** (``valid_indices`` provided): ``[N]`` is valid iff ``N in valid_indices``. Catches "orphan" citations — indices that fall in the range but point to a URL-less / dropped reference, where range mode would let them silently retarget at renumber. """ nums = citation_numbers(body) if not nums: return ["no inline [N] citations were emitted"] if valid_indices is not None: valid = set(valid_indices) invalid = sorted({n for n in nums if n not in valid}) if invalid: valid_list = ", ".join(str(n) for n in valid_indices) or "(none)" return [ "orphan citation indices: " + ", ".join(str(n) for n in invalid) + f" (valid indices are: {valid_list})", ] return [] invalid = sorted({n for n in nums if n < 1 or n > max_ref}) if invalid: return [ "out-of-range citation indices: " + ", ".join(str(n) for n in invalid) + f" (valid range is 1..{max_ref})", ] return [] # Parse rows like ``[3] Title — https://example.com``, ``3. Title - URL``, # ``[3] Title. URL``, ``- [3] Title (URL)`` — the formats reporter LLMs # tend to emit when they ignore the contract and write their own # References block. The capture is permissive on the title/URL separator # (em-dash, ASCII dash, period, colon, "—", "–") and tolerates surrounding # whitespace / markdown link wrapping. _REF_ROW_RE = re.compile( r"""(?mx) ^\s*[-*]?\s* # optional bullet (?:\[\s*(?P\d+)\s*\]|(?P\d+)\.) # [3] or 3. \s+ (?P.+?) \s*$ """, ) # Pull URL out of the row body — accepts bare URLs, ``[title](url)`` md # links, and parenthesised URLs. _URL_IN_ROW_RE = re.compile( r"\((?Phttps?://[^\s)]+)\)|(?Phttps?://[^\s)\]]+)", ) def parse_trailing_references(text: str) -> dict[str, dict[str, str]]: """Extract ``{N: {title, url}}`` from a trailing References footer. Returns ``{}`` when there is no plausible References footer or no rows parse cleanly. Used to detect Mode-B silent corruption: when the reporter LLM ignores the citation contract and writes its own ``## References`` block, body ``[N]`` markers and the LLM's own References numbering may bind to URLs different from the system's canonical ``report_references[N-1]``. Detection lets the repair pass surface the mismatch instead of producing silently-wrong citations. """ if not text: return {} match = _TRAILING_REFS_RE.search(text) if match is None or match.start() < len(text) * 0.3: return {} footer = text[match.start():] out: dict[str, dict[str, str]] = {} for row in _REF_ROW_RE.finditer(footer): idx = row.group("n1") or row.group("n2") if not idx: continue rest = row.group("rest").strip() url_match = _URL_IN_ROW_RE.search(rest) url = "" title = rest if url_match: url = url_match.group("paren") or url_match.group("bare") or "" # Strip the URL substring + any trailing punctuation/whitespace # so the remaining text is the title. title = (rest[:url_match.start()] + rest[url_match.end():]).strip() title = title.rstrip(".,;:—–-([) ").strip() # Markdown-link form ``[Title](url)`` — pull the title out of # the brackets if the residue still has them. md_link = re.match(r"^\[(?P.+?)\]\s*$", title) if md_link: title = md_link.group("t").strip() out[str(int(idx))] = {"title": title, "url": url} return out def _normalize_url(url: str) -> str: """Loose URL canonicalisation for cross-source comparison. Strips scheme, ``www.``, trailing slash, and trailing punctuation. Used by :func:`detect_url_misalignment` so that ``http://www.x.com/`` == ``https://x.com`` for misalignment detection — we are looking for *clearly different* URLs, not bit-for-bit equality. """ if not url: return "" u = url.strip().rstrip(".,;:)]") u = re.sub(r"^https?://", "", u, flags=re.IGNORECASE) u = re.sub(r"^www\.", "", u, flags=re.IGNORECASE) u = u.rstrip("/").lower() return u def detect_url_misalignment( body: str, references: Sequence[Reference], ) -> list[str]: """Return issues when the body's own References URLs disagree with the system's canonical ``report_references`` at the same index. No-op when the body has no trailing References footer or when none of its parsed rows include a URL. Conservative: only flags indices that are present in BOTH lists with a clearly different URL — partial / missing rows are tolerated. """ llm_refs = parse_trailing_references(body) if not llm_refs: return [] issues: list[str] = [] for i, ref in enumerate(references, 1): sys_url = _normalize_url(ref.get("url") or "") if not sys_url: continue llm_row = llm_refs.get(str(i)) if not llm_row: continue llm_url = _normalize_url(llm_row.get("url") or "") if not llm_url or llm_url == sys_url: continue # Substring match in either direction (handles ``en.wikipedia.org/wiki/Foo`` # vs ``en.wikipedia.org/wiki/Foo?lang=en`` style minor drifts). if llm_url in sys_url or sys_url in llm_url: continue issues.append( f"citation [{i}] is misaligned: body's own References lists " f"{llm_row.get('url')!r} at index {i}, but the system reference " f"[{i}] is {ref.get('url')!r} — body's [{i}] markers will " f"silently retarget when the canonical References block is " f"appended. Rewrite citations using the indices from " f"``get_report_references()``." ) return issues # Programmatic numeric grounding audit. # # Picks up where validate_citation_body / detect_url_misalignment stop: # those check that ``[N]`` markers resolve and URLs align, but say # nothing about whether the specific NUMBERS the body cites against # ``[N]`` actually appear in reference [N]'s snippet. The 2026-05-20 # fabrication-prone smoke (``temp/2026-05-20_phase3_fabrication_smoke/``) # showed 23% of numeric tokens in the final report were cited but absent # from any cited snippet — fabrication, citation drift, or unlabelled # derivations like ``-2.5% from $220.61 → $215.10``. # Tighter regex than the smoke-analysis script's: matches financial / # scientific number tokens most prone to fabrication. Excludes bare # years (``2026``) and section labels (``Q4 FY2026``) because flagging # those produces noise without catching real fabrications. # # Magnitude → multiplier maps for cross-notation form generation. # When adding a new entry, you do NOT need to change the regex — bare # branches build their alternation dynamically from this dict, and # the parser uses longest-prefix matching for ``$``-prefixed tokens. # Missing a magnitude here just means the token falls back to raw- # string substring matching instead of getting cross-notation # equivalents. That's a graceful degradation, not a bug. # # ``兆`` is ambiguous (mainland: 10^12 = trillion; Taiwan/HK Cantonese: # 10^6 = million). We use the mainland convention. _CN_MAGNITUDE: dict[str, float] = { "万亿": 1e12, "兆": 1e12, "千亿": 1e11, "百亿": 1e10, "亿": 1e8, "千万": 1e7, "百万": 1e6, "万": 1e4, "千": 1e3, } # Pre-computed longest-first ordering for greedy prefix matching. _CN_MAGNITUDE_LONGEST_FIRST: tuple[str, ...] = tuple( sorted(_CN_MAGNITUDE.keys(), key=len, reverse=True), ) _EN_MAGNITUDE_FULL: dict[str, float] = { "billion": 1e9, "million": 1e6, "trillion": 1e12, } _EN_MAGNITUDE_SHORT: dict[str, float] = {"b": 1e9, "m": 1e6, "t": 1e12} # Build a magnitude alternation from the dict (longest-first so the # regex tries ``万亿`` before ``万`` / ``亿`` and doesn't truncate). _CN_MAGNITUDE_ALT: str = "|".join(_CN_MAGNITUDE_LONGEST_FIRST) # Numeric-token capture for the audit. Design (Plan B — broad capture # for ``$``-prefixed tokens + dictionary-anchored capture for bare): # # Branch order matters. The specific shapes (bps / %) come BEFORE the # generic Chinese-suffix branch, otherwise greedy CJK eating would # swallow ``个基`` from ``88个基点`` and break the bps↔% equivalence. # # ``$``-prefixed Chinese-suffix branch (3) is tried BEFORE the English # branch (4) — otherwise the English branch greedily matches ``$1.04``, # backtracks to ``$1`` (since ``.`` isn't a word boundary), and loses # the magnitude. The ``$`` branch deliberately uses a broad ``[一-鿿] # {1,2}`` capture (graceful fallback in the parser): an unknown # magnitude still produces a token, just without cross-notation # equivalents. # # Bare branches (5-6) use the **known-magnitude allowlist** instead of # broad CJK capture. Without ``$`` to anchor the intent, broad CJK # would catch ``2 年期`` / ``5 月`` / ``Q1 季度`` as numeric tokens — # they're labels, not value claims. Restricting to dictionary entries # avoids the false positives at the cost of not catching bare # unknown-magnitude tokens (users should write the ``$`` prefix # anyway for currency claims). _NUMERIC_GROUNDING_RX = re.compile( # 1. Basis points (specific — must precede generic CJK). r"(? int: return ( len(self.grounded) + len(self.grounded_derived) + len(self.suspect) + len(self.uncited) ) @property def suspect_ratio(self) -> float: return len(self.suspect) / max(self.total, 1) # (Magnitude dicts moved up — they're referenced by the regex above.) def _fmt_value_strings(v: float) -> set[str]: """Common decimal renderings: '5', '5.0', '5.00'-trimmed, '%g', and comma-grouped ``5,000`` when integer. Comma form matters for grounding thousand-scale claims (``$5千`` ↔ ``$5,000``).""" out: set[str] = set() if v == int(v): out.add(str(int(v))) out.add(f"{int(v):,}") out.add(f"{v:g}") out.add(f"{v:.1f}") out.add(f"{v:.2f}".rstrip("0").rstrip(".")) return out def _cross_notation_forms(value: float) -> set[str]: """Generate string equivalents for a USD value across en/zh notations. For a value like ``5_000_000_000`` (5 billion), emits forms like ``$5 billion``, ``5 billion``, ``$5B``, ``$5b``, ``$50亿``, ``50亿``, ``$50亿美元``, etc. Also emits thousand-scale forms (``$5千`` ↔ ``$5,000``) so a Chinese token like ``$5千`` grounds against an English snippet writing ``$5,000`` and vice versa. Filters out absurd magnitudes (a value of 5e9 won't try ``0.005 trillion`` because the ratio is too unfamiliar in finance prose). """ out: set[str] = set() # Raw-integer / comma-grouped form (covers $5,000 ↔ $5千 axis where # English notation doesn't use a magnitude word at all). if 0 < value < 1e7: for s in _fmt_value_strings(value): out.add(f"${s}") out.add(s) # English magnitudes — only emit ranges typical in finance text. for divisor, unit, short in ( (1e12, "trillion", "t"), (1e9, "billion", "b"), (1e6, "million", "m"), ): v = value / divisor if v < 0.01 or v >= 10_000: continue for s in _fmt_value_strings(v): out.add(f"${s} {unit}") out.add(f"{s} {unit}") out.add(f"${s}{short}") out.add(f"{s}{short}") # Chinese magnitudes — covers the full ladder, not just 万亿/亿/万, # so a Chinese report writing ``$5千`` (= $5,000) generates the # English ``$5,000`` form for grounding. for divisor, unit in ( (1e12, "万亿"), (1e11, "千亿"), (1e10, "百亿"), (1e8, "亿"), (1e7, "千万"), (1e6, "百万"), (1e4, "万"), (1e3, "千"), ): v = value / divisor if v < 0.01 or v >= 10_000: continue for s in _fmt_value_strings(v): out.add(f"${s}{unit}") out.add(f"{s}{unit}") out.add(f"${s} {unit}") out.add(f"{s} {unit}") out.add(f"{s}{unit}美元") out.add(f"${s}{unit}美元") return out @lru_cache(maxsize=256) def _numeric_equivalent_forms(tok: str) -> frozenset[str]: """Equivalent lower-cased string forms a snippet might use for ``tok``. Handles three notation axes: 1. Punctuation: ``$1,234`` ↔ ``$1234`` (thousands-separator) 2. English magnitude: ``$30.8B`` ↔ ``$30.8 billion`` ↔ ``30.8 billion`` 3. Cross-language magnitude: ``$50亿`` ↔ ``$5 billion`` ↔ ``5,000 million`` (because the report may be Chinese while the source snippet is English, or vice versa) Also handles basis points (English ``88 bps`` ↔ Chinese ``88 基点`` / ``88 个基点`` ↔ ``0.88%``). Doesn't try to unify across genuinely different values (``$30.8 billion`` vs ``30,800 million`` are equivalent — yes; but ``$30 million`` vs ``$30 billion`` are not). """ raw = tok.lower().strip() forms = {raw, raw.replace(",", ""), raw.replace(" ", "")} forms.add(raw.replace(",", "").replace(" ", "")) # Parser branch order: try the *specific* units (bps, %, English # magnitudes) BEFORE the generic Chinese-suffix fallback. Otherwise # tokens like ``88 个基点`` get caught by the broad CJK branch with # an unrecognised suffix ``个基点`` and lose their bps↔% equivalence. # 1. Basis points: 88 bps / 88 个基点 / 88 基点 m = re.match( r"([\d,]+(?:\.\d+)?)\s*(?:bps|basis\s*points|个基点|基点)\s*$", raw, re.IGNORECASE, ) if m: try: n = float(m.group(1).replace(",", "")) except ValueError: return frozenset(forms) for s in _fmt_value_strings(n): forms.add(f"{s} bps") forms.add(f"{s}bps") forms.add(f"{s} basis points") forms.add(f"{s} 个基点") forms.add(f"{s}个基点") forms.add(f"{s} 基点") forms.add(f"{s}基点") # 88 bps ↔ 0.88% pct = n / 100 for s in _fmt_value_strings(pct): forms.add(f"{s}%") forms.add(f"{s} %") return frozenset(forms) # 2. Percentage (English % / Chinese 个百分点): 1% ↔ 100 bps. m = re.match( r"([\d,]+(?:\.\d+)?)\s*(?:%|个百分点)\s*$", raw, ) if m: try: n = float(m.group(1).replace(",", "")) except ValueError: return frozenset(forms) for s in _fmt_value_strings(n): forms.add(f"{s}%") forms.add(f"{s} %") forms.add(f"{s}个百分点") forms.add(f"{s} 个百分点") bps = n * 100 for s in _fmt_value_strings(bps): forms.add(f"{s}bps") forms.add(f"{s} bps") forms.add(f"{s} 基点") forms.add(f"{s}基点") forms.add(f"{s} 个基点") forms.add(f"{s}个基点") return frozenset(forms) # 3. English full magnitude: $5 billion / 5 billion / $5.0 trillion m = re.match( r"\$?\s*([\d,]+(?:\.\d+)?)\s*(billion|million|trillion)\s*$", raw, re.IGNORECASE, ) if m: num_str, unit = m.group(1), m.group(2).lower() try: n = float(num_str.replace(",", "")) except ValueError: return frozenset(forms) value = n * _EN_MAGNITUDE_FULL[unit] forms.update(_cross_notation_forms(value)) return frozenset(forms) # 4. English short magnitude: $5B / 5.0b / 5T m = re.match( r"\$?\s*([\d,]+(?:\.\d+)?)\s*([bmt])\s*$", raw.replace(" ", ""), ) if m: num_str, unit = m.group(1), m.group(2) try: n = float(num_str.replace(",", "")) except ValueError: return frozenset(forms) value = n * _EN_MAGNITUDE_SHORT[unit] forms.update(_cross_notation_forms(value)) return frozenset(forms) # 5. Chinese-suffix branch — broad capture, longest-prefix match # against the magnitude dict. Unknown suffix → keep raw forms only # (graceful fallback — substring match still works if both body # and snippet use the same notation; missing magnitudes only lose # cross-notation equivalence, not detection). m = re.match( r"\$?\s*([\d,]+(?:\.\d+)?)\s*([一-鿿]+)\s*$", raw, ) if m: num_str, suffix = m.group(1), m.group(2) for magnitude_word in _CN_MAGNITUDE_LONGEST_FIRST: if suffix.startswith(magnitude_word): try: n = float(num_str.replace(",", "")) except ValueError: return frozenset(forms) value = n * _CN_MAGNITUDE[magnitude_word] forms.update(_cross_notation_forms(value)) # Canonical Chinese form without currency identifier # (so ``$50亿美`` matches ``$50亿`` in a snippet). forms.add(f"${n:g}{magnitude_word}") forms.add(f"{n:g}{magnitude_word}") return frozenset(forms) # Unknown magnitude → graceful fallback (raw forms only). return frozenset(forms) return frozenset(forms) _SNIPPET_DECIMAL_DOLLAR_RX = re.compile( r"\$?\s*([\d,]+)\.(\d+)\s*([bmt])\b", re.IGNORECASE, ) def _decimal_precision_match(tok: str, snippet: str) -> bool: """True when ``tok`` is a body number that rounds back from a higher-precision value present in ``snippet``. Catches the common case where the writer drops a decimal place for readability (``$903.4B``) while the source carries the full precision (``$903.394B``). Equivalence holds iff ``round(snippet_value, body_precision) == body_value`` — strictly tighter than a percentage tolerance, so it cannot accidentally equate ``$903.4B`` with ``$900.0B`` or ``$50B`` with ``$5B``. Only handles ``$NNN.DDD[BMT]`` (dollar + decimal + single-letter magnitude) — the shape that triggered the regression. Other tokens drop through to ``suspect``. """ m = re.match(r"\$?\s*([\d,]+)\.(\d+)\s*([bmt])\s*$", tok.strip(), re.IGNORECASE) if not m: return False body_int = m.group(1).replace(",", "") body_frac = m.group(2) unit = m.group(3).lower() precision = len(body_frac) try: body_value = float(f"{body_int}.{body_frac}") except ValueError: return False for sm in _SNIPPET_DECIMAL_DOLLAR_RX.finditer(snippet): if sm.group(3).lower() != unit: continue try: snip_value = float( f"{sm.group(1).replace(',', '')}.{sm.group(2)}" ) except ValueError: continue if round(snip_value, precision) == body_value: return True return False def validate_numeric_grounding( body: str, *, snippet_lookup: Callable[[int], str] | None = None, ) -> NumericGroundingReport: """Classify each specific number in ``body`` as grounded / suspect / uncited. Args: body: The report markdown (post strip-trailing-references). snippet_lookup: ``index -> snippet_text``. When None, the audit degrades to citation-presence only — every cited number is grounded (no snippet-content check). A heavy-backend caller with its own evidence store would wire this to that store's snippet lookup; the default-research path with no such store passes None. Returns: :class:`NumericGroundingReport`. Callers decide what to do with suspects: log + continue (warn mode) or raise + repair (strict). This function never raises on suspects — its job is to classify, not enforce. """ if not body: return NumericGroundingReport() snippet_cache: dict[int, str] = {} def snippet_for(idx: int) -> str: if snippet_lookup is None: return "" if idx not in snippet_cache: try: text = snippet_lookup(idx) or "" except Exception: # A misbehaving lookup must not crash the audit — treat as # "no snippet available", which conservatively marks the # token suspect rather than silently passing it. text = "" snippet_cache[idx] = text.lower() return snippet_cache[idx] report = NumericGroundingReport() for m in _NUMERIC_GROUNDING_RX.finditer(body): tok = m.group(0).strip() tail = body[m.end(): m.end() + _NUMERIC_CITE_WINDOW] # ``[3][8]`` / ``[3, 8]`` / ``[3] (see also [8])`` — collect all # indices from every ``[…]`` bracket pair in the window. The # first bracket counts as long as it sits in the 80-char window # (already enforced by the tail slice); subsequent brackets only # count if they tail-follow the previous one within ≤4 chars, # since once non-cite text intervenes they belong to a different # claim. Reuses ``_CITE_GROUP_RE`` from the existing citation # parsing — keeps the bracket regex single-sourced. ids: list[int] = [] prev_end: int | None = None for cm in _CITE_GROUP_RE.finditer(tail): if prev_end is not None and cm.start() - prev_end > 4: break ids.extend(int(n) for n in re.findall(r"\d+", cm.group(1))) prev_end = cm.end() if not ids: report.uncited.append(tok) continue if snippet_lookup is None: report.grounded.append((tok, ids[0])) continue forms = _numeric_equivalent_forms(tok) matched = any( any(f in snippet_for(idx) for f in forms) for idx in ids ) if matched: report.grounded.append((tok, ids[0])) continue # Decimal-precision rescue: body rounded ``$903.4B`` against a # snippet carrying the higher-precision ``$903.394B``. The check # is mathematically strict (rounds back at body's precision), so # ``$903.4B`` vs ``$900.0B`` still classifies suspect. if any( _decimal_precision_match(tok, snippet_for(idx)) for idx in ids ): report.grounded.append((tok, ids[0])) continue # Rescue check: if the token's immediate surroundings carry a # derivation marker (``~1.4% below close``, ``2× ATR``, # ``= $215.10``), the value is the prompt's bucket-2 form — # explicitly derived from cited inputs with shown math — not a # fabrication. The window straddles the token (20 chars before, # 80 chars after) because some markers cross the token boundary # (e.g. ``= $215.10`` has the ``=`` before the dollar). context = body[max(0, m.start() - 20): m.end() + 80] if _DERIVATION_SIGNAL_RX.search(context): report.grounded_derived.append((tok, ids[0])) else: report.suspect.append((tok, ids[0])) return report def finalize_cited_report( body: str, references: Sequence[Reference], *, language: str = "auto", style: str = "markdown", snippet_lookup: Callable[[int], str] | None = None, ) -> str: """Validate citations, then append canonical References (+ citation-map in ``markdown`` style). Uses membership mode so a body citing an index that was skipped by :func:`build_citation_maps` (URL-less reference) fails closed rather than silently retargeting to a different URL after renumber. When ``snippet_lookup`` is provided, also runs the numeric-grounding audit and logs a warning summary if any tokens classify as suspect. The audit never raises — callers decide whether a suspect ratio above some threshold warrants a repair pass. ``snippet_lookup`` is opt-in because not every caller has snippet access (a caller without an evidence store passes None; a heavy-backend reporter would wire it to that store's snippet lookup). """ clean_body = strip_trailing_references(body).strip() citation_map, citation_titles = build_citation_maps(references) valid_indices = [int(k) for k in citation_map] issues = validate_citation_body( clean_body, max_ref=len(citation_map), valid_indices=valid_indices, ) if issues: raise CitationValidationError(issues) if snippet_lookup is not None: audit = validate_numeric_grounding( clean_body, snippet_lookup=snippet_lookup, ) if audit.suspect: logger.warning( "numeric grounding audit: %d suspect of %d tokens (%.1f%%)" "; %d grounded-derived, %d uncited", len(audit.suspect), audit.total, audit.suspect_ratio * 100, len(audit.grounded_derived), len(audit.uncited), ) for tok, idx in audit.suspect[:5]: logger.warning(" suspect %r cited to [%d]", tok, idx) return finalize_report( clean_body, citation_map, citation_titles, language, style=style, ) async def repair_citations_once( *, llm: Any, question: str, draft_answer: str, body: str, references_block: str, issues: Sequence[str], timeout_s: float, ) -> str: """Ask the reporter LLM to repair citation markers without rewriting facts.""" system = """You repair citation markers in a Markdown report body. Rules: - Preserve the report's meaning, structure, claims, numbers, dates, and language. - Use only citation indices from the provided Available references list. - Add [N] citations to supported factual claims. - Remove or replace invalid/out-of-range citation markers. - Do NOT add a References/Sources section. Output only the repaired body. - Do NOT add preamble, analysis, tool markup, or code fences.""" user = f"""# Original question {question or "(unspecified)"} # Validation errors to fix {chr(10).join(f"- {issue}" for issue in issues)} # Available references {references_block} # Original coordinator/report draft {draft_answer} # Body to repair {body} # Task Return the same Markdown body with valid inline [N] citations. Output only the body. Do not append References.""" response = await asyncio.wait_for( llm.chat([ system_msg(system), user_msg(user), ]), timeout=timeout_s, ) content = response.content if hasattr(response, "content") else str(response) return content if isinstance(content, str) else text_of(content) __all__ = [ "CitationValidationError", "NumericGroundingReport", "build_citation_maps", "citation_numbers", "coerce_title", "coerce_url", "detect_url_misalignment", "finalize_cited_report", "format_references_block_plain", "has_any_citation", "normalize_inline_bullets", "normalize_malformed_citations", "parse_trailing_references", "render_references_block", "repair_citations_once", "strip_orphan_footnote_citations", "strip_trailing_references", "validate_citation_body", "validate_numeric_grounding", ]