Spaces:
Running
Running
File size: 7,748 Bytes
9d0fd45 | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 | """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"
|