frontier-agent-demo / workflows /_shared /citation_utils.py
christlurker's picture
FrontierAgent react demo
9d0fd45
Raw
History Blame Contribute Delete
7.75 kB
"""Citation renumbering and deterministic report finalization helpers."""
from __future__ import annotations
import json
import re
from urllib.parse import urlparse
from frontier_agent.utils.language import is_chinese_label
# Match bracket groups whose body is *only* digits + commas / semicolons /
# whitespace, with at least one digit somewhere. ``[1]`` / ``[1, 13, 14]`` /
# ``[1; 2]`` / ``[ 5 , 6 ]`` all match; ``[citation needed]`` and
# ``[1, foo]`` do not. The captured group is the inner body.
_CITE_GROUP_RE = re.compile(r"\[(\s*\d[\d,;\s]*)\]")
def _normalize_multi_citations(report: str, *, max_ref: int | None = None) -> str:
"""Rewrite ``[1, 13, 14]`` → ``[1][13][14]``.
The LLM frequently emits comma-separated multi-citations (a more readable
form), but downstream renumber + reference rendering both expect the
single-number form ``[N]``. Without this pass, ``re.findall(r"\\[(\\d+)\\]",
...)`` skips every multi-citation block, so the renumbered citation_map
ends up with only the handful of citations that happened to appear as
``[N]`` standalone — see the bug post-mortem in
``data/reports/70feda8cbb39.md`` (Strategy A smoke test).
``max_ref``, when given, gates the rewrite on plausibility: a group is
only split when every number in it could be a real 1-indexed citation
(``1 <= n <= max_ref``). Real reports also carry bracketed numeric
ranges/id-pairs copied verbatim from source data (e.g. ``[684, 821]``
from a revenue-projection table, far outside any real reference list) —
without this gate those get corrupted into ``[684][821]``, silently
dropping the comma that made the range legible. ``max_ref=None`` keeps
the old unconditional behavior, for callers with no reference list to
check against (:func:`renumber_citations`'s citation map isn't bounded
to a contiguous ``1..N`` range).
"""
def _expand(match: re.Match) -> str:
body = match.group(1)
nums = [int(n) for n in re.findall(r"\d+", body)]
if len(nums) <= 1:
# Single-number bracket — leave it alone (avoid touching
# mathematical notation, code references, etc.).
return match.group(0)
if max_ref is not None and not all(1 <= n <= max_ref for n in nums):
return match.group(0)
return "".join(f"[{n}]" for n in nums)
return _CITE_GROUP_RE.sub(_expand, report)
def renumber_citations(
report: str, citation_map: dict[str, str]
) -> tuple[str, dict[str, str]]:
"""Renumber citations in an LLM report so they are sequential (1, 2, 3, ...).
The LLM sees all evidence numbered 1..N but often only cites a subset,
producing gaps like [1][2][4][10][15]. This remaps them to [1][2][3][4][5].
Multi-citation forms like ``[1, 13, 14]`` are normalized to
``[1][13][14]`` first so they participate in the renumber pass too.
Returns (new_report, new_citation_map).
"""
report = _normalize_multi_citations(report)
used_nums = sorted(set(int(m) for m in re.findall(r"\[(\d+)\]", report)))
if not used_nums:
return report, citation_map
old_to_new: dict[int, int] = {}
for new_num, old_num in enumerate(used_nums, start=1):
old_to_new[old_num] = new_num
# Replace largest numbers first to avoid partial replacement
new_report = report
for old_num in sorted(old_to_new.keys(), reverse=True):
new_num = old_to_new[old_num]
if old_num != new_num:
new_report = new_report.replace(
f"[{old_num}]", f"[__CITE_{new_num}__]"
)
for new_num in old_to_new.values():
new_report = new_report.replace(f"[__CITE_{new_num}__]", f"[{new_num}]")
new_citation_map: dict[str, str] = {}
for old_num, new_num in old_to_new.items():
old_key = str(old_num)
if old_key in citation_map:
new_citation_map[str(new_num)] = citation_map[old_key]
return new_report, new_citation_map
def _format_references_section(
citation_map: dict[str, str],
citation_titles: dict[str, str] | None = None,
language: str = "auto",
style: str = "markdown",
) -> str:
"""Build a visible References section for downloaded reports.
``style="markdown"`` (default) — ``N. [title](url)`` numbered list,
rendered as clickable links in the frontend. Used by the HTTP API path
(the research workflows).
``style="plain"`` — ``[N] title. url`` plain text, no markdown links.
Used by SDK-CLI workflows where the output
is consumed as raw text rather than rendered as HTML.
Frontend's `MarkdownRenderer` strips any trailing References/参考文献
section before rendering (it emits its own), so adding this block is safe
for in-app display and strictly improves offline / downloaded markdown.
"""
if not citation_map:
return ""
heading = "## 参考文献" if is_chinese_label(language) else "## References"
titles = citation_titles or {}
lines: list[str] = [heading, ""]
for key in sorted(citation_map, key=lambda k: int(k) if k.isdigit() else 0):
url = (citation_map.get(key) or "").strip()
title = (titles.get(key) or "").strip()
if not title and url:
host = urlparse(url).netloc or url
title = host
# When the upstream whitelist couldn't recover a real page title it
# leaves ``title`` empty or equal to the URL; printing both then
# reads as a duplicated URL (``[N] url. url`` / ``[N] [url](url)``).
# Collapse to a single URL in that case.
has_title = bool(title) and title != url
if style == "plain":
if url:
lines.append(f"[{key}] {title}. {url}" if has_title else f"[{key}] {url}")
else:
lines.append(f"[{key}] {title or '(no source)'}")
else:
if url:
lines.append(
f"{key}. [{title}]({url})" if has_title else f"{key}. [{url}]({url})"
)
else:
lines.append(f"{key}. {title or '(no source)'}")
lines.append("")
return "\n".join(lines)
def finalize_report(
report: str,
citation_map: dict[str, str],
citation_titles: dict[str, str] | None = None,
language: str = "auto",
*,
style: str = "markdown",
) -> str:
"""Renumber citations, append a visible References section, and (in
``markdown`` style) a machine-readable citation-map HTML comment for
frontend parsing.
``style="plain"`` produces plain ``[N] title. url`` references with no
citation-map comment — used by SDK-CLI workflows whose consumer reads
the markdown as text.
"""
# Build url→title using the pre-renumber keys, so we can re-key titles
# against the post-renumber citation_map (which uses new keys but same
# URLs).
title_by_url: dict[str, str] = {}
for key, url in citation_map.items():
title = (citation_titles or {}).get(key, "").strip()
if url and title:
title_by_url.setdefault(url, title)
report, citation_map = renumber_citations(report, citation_map)
renumbered_titles: dict[str, str] = {}
for new_key, url in citation_map.items():
if url in title_by_url:
renumbered_titles[new_key] = title_by_url[url]
parts = [report.rstrip()]
refs = _format_references_section(
citation_map, renumbered_titles, language, style=style,
)
if refs:
parts.append(refs)
if style != "plain":
map_json = json.dumps(citation_map, ensure_ascii=False)
parts.append(f"<!-- citation-map: {map_json} -->")
return "\n\n".join(parts) + "\n"