合入期 5 验收修复 M1:核查 agent 正文里的依据标记不再断句——只装着依据的括号(含【】[]〔〕[])连同里面的停顿标点一起去掉,去掉之后按真正挨上的两个字补空格,两个数不再并成一个;核查 agent 的提示词改成依据写在句末;收尾超时照实记 timeout_s;没给理由时照实说没有给出理由

This commit is contained in:
ThearchyHelios
2026-10-12 13:12:20 +08:00
9 changed files with 1586 additions and 34 deletions
+249 -27
View File
@@ -1811,8 +1811,43 @@ _UNIT_KINDS = frozenset({"m", "i", "v"})
#: 渲染结果末尾的单位:最后一个数字后面的字(12,345人次 → 人次,4.57万元 → 万元,12.5% → %)
_UNIT_TAIL = re.compile(r"\d([^\d\s][^\d]*)$")
_BLANK = " \t\u3000"
_SEE_RUN = re.compile(r"\[\[" + _MARKER_GUARD + r"[ \t]*see[ \t]*:[^\[\]\n]*?\]\]"
r"(?:[" + _BLANK + r"]*\[\[" + _MARKER_GUARD + r"[ \t]*see[ \t]*:[^\[\]\n]*?\]\])*")
#: 依据标记 [[see:…]](同 MARKER_RE,只认 MARKER_MAX 以内的)
_SEE_MARK = r"\[\[" + _MARKER_GUARD + r"[ \t]*see[ \t]*:[^\[\]\n]*?\]\]"
#: 括号,每类一对:圆括号、方括号(全角、半角,模型偶尔一对里混用,也算一对)、方头括号、六角括号。核查 agent 拿到的
#: 查询结果都以「【证据 Qn】」开头,模型照着写成「【[[see:Q1]]】」并不意外
_PAIRS = (("((", "))"), ("[[", "]]"), ("【", "】"), ("〔", "〕"))
_OPEN, _CLOSE = frozenset("".join(o for o, _ in _PAIRS)), frozenset("".join(c for _, c in _PAIRS))
#: 依据和括号、依据和依据之间的分隔标点(「([[see:Q3]],原报告…)」「[[see:Q1]]、[[see:Q2]]」)
_SEPS = ",、;,;::"
_SEP = frozenset(_SEPS)
#: 其中的停顿标点(不含冒号):依据挂在它后面、紧接着就到句末时,它悬在句末(「华南居首,。」)。冒号后面另起一行
#: 常常是它引出的表格、列表,不动
_PAUSE = _SEP - frozenset("::")
#: 句末标点:停顿标点悬在它前面时去掉(还有全文结尾)。半角句点不算:后面跟着数字时它是小数点。换行不算:列表项末尾
#: 的「;」、软换行处连着两个分句的「,」是写作者自己的,依据挂在它后面正合「挂在句末」的规矩
_ENDING = frozenset("。!?!?")
#: 依据周围、渲染后会挤在一起的字:空白和分隔标点
_GAP = frozenset(_BLANK) | _SEP
#: 依据渲染成空、周围的字去掉之后,挨上的两个字会读成一串(_glue):左边是数字、字母,或者这几个——标记的结尾(渲染
#: 出一个数)、百分号、小数点、半角标点(「212,5」读成一个数;半角标点按排版习惯后面本来就隔一个空格);右边是数字、
#: 字母、一个渲染出字的标记,或者紧贴着这几样的半角逗号。中文和数字之间不加空格也读得对,不算
_STICKY = frozenset("]%‰.,;:")
#: 渲染出来什么都没有的东西(依据标记、只装着依据的括号)在遮盖文本里换成这个字,位置和原文一一对应(_voids)
_VOID = "\x00"
#: 只装着依据的括号(_PAIRS 里同一类的一对):里面除了依据(和已经认出来的、套在里面的这种括号)只有空白、分隔标点。
#: 头一个依据前面只认空白、标点,免得一长串依据后面没有右括号时来回试
_VOID_BRACKET = re.compile("|".join(
"[" + re.escape(o) + "][" + _BLANK + _SEPS + "]*" + _VOID + "[" + _BLANK + _SEPS + _VOID + "]*"
"[" + re.escape(c) + "]" for o, c in _PAIRS))
#: 连着写的几个:中间只隔着空白、分隔标点
_VOID_GROUP = re.compile(_VOID + "+(?:[" + _BLANK + _SEPS + "]*" + _VOID + "+)*")
#: 其中只隔着空白的一段(两边都是字时,中间的分隔标点可能就是分句的标点,按它断开各算各的)
_VOID_RUN = re.compile(_VOID + "+(?:[" + _BLANK + "]*" + _VOID + "+)*")
#: 遮盖文本里看得见的一段:一组里夹着的空白、标点,括号连同里面的空白、标点
_SOLID = re.compile("[^" + _VOID + "]+")
#: 粗体、斜体的星号,粗体的两个下划线,删除线的两个波浪线:前端按 Markdown 渲染,不显示(组装文档时包着标记的粗体
#: 也去掉了星号)。删除线画出来的字照样挨着后面的字读
_EMPHASIS = re.compile(r"\*+|__+|~~+")
def _rendered_unit(marker: dict[str, Any], catalog: dict[str, Any], snaps: _Snapshots,
@@ -1830,8 +1865,9 @@ def _tidy(text: str, catalog: dict[str, Any], snaps: _Snapshots, cells_allowed:
- 数字标记后面重复写的单位去掉。[[m:visits]] 渲染出来已经带单位(12,345人次),写作者常常
还跟着写一遍([[m:visits]] 人次),读者看到的是「12,345人次 人次」。单字的单位后面紧跟着字时
不动:写作者写的可能是另一个单位(口径卡是「人」,正文写「人次」),硬删会把不一致藏起来;
- [[see:…]] 前面的空白去掉。see 渲染成空,「最高月份 [[see:Q2]]。」会剩下「最高月份 。」。
see 后面紧跟着字、要靠这个空白隔开的(「A [[see:Q1]]B」)留着;行首的缩进不动。
- [[see:…]] 渲染成空,它周围留下的空白、括号、标点去掉(_voids、_see_cuts):「最高月份 [[see:Q2]]。」会剩下
「最高月份 。」,「结果一致([[see:Q3]],原报告…)」会剩下「结果一致(,原报告…)」。依据(连同去掉的字)两边
挨上的字会读成一串的,补一个空格(_glue):「4,919.5[[see:Q1]]212 笔」会读成「4,919.5212 笔」。
prev 是这段文字前面那个字(流式渲染分块时用),判断空白是不是在行首。
"""
@@ -1847,29 +1883,210 @@ def _tidy(text: str, catalog: dict[str, Any], snaps: _Snapshots, cells_allowed:
after = text[hit.end():hit.end() + 1]
if len(unit) > 1 or not after.isalnum():
cuts.append((marker["end"], hit.end()))
for run in _SEE_RUN.finditer(text):
start = run.start()
while start > 0 and text[start - 1] in _BLANK:
start -= 1
before = text[start - 1] if start > 0 else prev
after = text[run.end():run.end() + 1]
# 几个 see 之间的空白也一样渲染成多余的空格
parts = list(MARKER_RE.finditer(text, run.start(), run.end()))
cuts.extend((m.end(), n.start()) for m, n in zip(parts, parts[1:]) if m.end() < n.start())
if start < run.start() and before not in ("", "\n") and not (after.isalnum() or after == "["):
cuts.append((start, run.start()))
if not cuts:
see: list[tuple[int, int]] = []
spaces: set[int] = set()
if "see" in text: # 没有依据标记就没有要收拾的(流式渲染的每一块都走这里,先挡掉)
masked, see = _voids(text)
for group in _VOID_GROUP.finditer(masked):
see += _see_cuts(text, masked, group.start(), group.end(), prev)
spaces = _glue(text, masked, see, prev)
if not cuts and not see and not spaces:
return text
# 几处要去掉的字可能重叠、相接(依据前面的空白、它和括号之间的标点):按并集去掉,要补空格的那一片换成一个空格;
# 补空格的地方没有要去掉的字(依据两边本来就什么都没有、紧贴着下文的半角逗号后面),在那里插一个
merged: list[list[int]] = []
for a, b in sorted(cuts + see):
if merged and a <= merged[-1][1]:
merged[-1][1] = max(merged[-1][1], b)
else:
merged.append([a, b])
edits = [(a, b, " " if any(a <= x < b for x in spaces) else "") for a, b in merged]
edits += [(x, x, " ") for x in spaces if not any(a <= x < b for a, b in merged)]
out, last = [], 0
for a, b in sorted(cuts):
if a < last:
continue
out.append(text[last:a])
for a, b, put in sorted(edits):
out += [text[last:a], put]
last = b
out.append(text[last:])
return "".join(out)
def _voids(text: str) -> tuple[str, list[tuple[int, int]]]:
"""渲染出来什么都没有的东西:依据标记,和只装着依据的括号(里面可以隔着空白、分隔标点,套着的也算)。返回遮盖文本
(这些地方换成 _VOID,位置和原文一一对应)和这种括号连同里面的空白、标点:要一起去掉的字(「合计 6,635.68
([[see:Q1]])。」不剩「()」)。依据标记本身留着:组装文档时句子靠它挂依据(unit.see),渲染时它本来就不显示。
认出来的括号和依据一样当一个不显示的东西:连着写的几个(「([[see:Q1]])([[see:m:gmv]])」「[[see:Q1]]
([[see:Q2]])」)由 _VOID_GROUP 并成一组,外面照 _see_cuts 收拾一遍(「华南居首,([[see:Q1]])。」的逗号)。"""
chars = list(text.replace(_VOID, "\x01"))
for m in MARKER_RE.finditer(text):
if m.group("kind") == "see":
chars[m.start():m.end()] = _VOID * (m.end() - m.start())
masked, cuts = "".join(chars), []
while hits := list(_VOID_BRACKET.finditer(masked)):
# 一轮认出最里面的一层:套在外面的括号下一轮才只剩依据
for hit in hits:
cuts += [(s.start(), s.end()) for s in _SOLID.finditer(masked, hit.start(), hit.end())]
chars[hit.start():hit.end()] = _VOID * (hit.end() - hit.start())
masked = "".join(chars)
return masked, cuts
def _see_cuts(text: str, masked: str, start: int, end: int, prev: str) -> list[tuple[int, int]]:
"""一组不显示的东西(_VOID_GROUP:依据、只装着依据的括号,连着写的几个,masked[start:end])渲染成空之后,周围要
一起去掉的字:
- 在括号开头(「([[see:Q3]],原报告…)」):它和括号之间、它后面的标点和空白;在括号末尾(「(同 SQL,
[[see:Q2]])」)同理
- 后面就到句末(句末标点、全文结尾),前面、中间有停顿标点的(「华南居首,[[see:Q1]]。」「华南居首,
([[see:Q1]])。」):这些停顿标点(前面是冒号的留着:它引出的可能是下一行的表格、列表);没有停顿标点、到句末
只隔着空白的,空白去掉(「华南居首 [[see:Q1]] 。」)
- 以上几种里,中间的空白、分隔标点一并去掉(「(同 SQL;[[see:Q1]]、[[see:Q2]])」)
- 两边都是字的,中间的分隔标点可能就是分句的标点:按它断开,每段照 _run_cuts
不含依据的括号、标点一个字不动。要看的只有这组前后连着的空白、分隔标点,外加再往外的一个字:流式渲染把这些字攒到
后面的字来了才放(_TRAILING_SEE),分块渲染和整篇渲染判得一样。prev 是这段文字前面那个字。
"""
gaps = [(s.start(), s.end()) for s in _SOLID.finditer(masked, start, end)]
lo, hi = start, end
while lo > 0 and text[lo - 1] in _GAP:
lo -= 1
while hi < len(text) and text[hi] in _GAP:
hi += 1
opener = text[lo - 1] if lo > 0 else prev
closer = text[hi] if hi < len(text) else ""
left, right = text[lo:start], text[end:hi]
if opener in _OPEN:
return [(lo, start), *gaps, (end, hi)]
if closer in _CLOSE:
# 和括号之间只有空白的,照依据前的空白处理(行首的缩进不动)
return [*gaps, (end, hi), *([(lo, start)] if _SEP & set(left) else _blank_cut(text, start, end, prev))]
tail: list[tuple[int, int]] = []
if closer in _ENDING or closer == "":
if _PAUSE & set(left + "".join(text[a:b] for a, b in gaps)):
# 依据后面、句末前面的停顿标点是写作者自己的(「增长 [[see:Q1]],」),不是依据留下的,不动;只剩空白的去掉
return [*gaps, *([] if _SEP & set(right) else [(end, hi)]),
*([(lo, start)] if _PAUSE & set(left) else _blank_cut(text, start, end, prev))]
if not _SEP & set(right):
tail = [(end, hi)]
return [*tail, *(cut for run in _VOID_RUN.finditer(masked, start, end)
for cut in _run_cuts(text, masked, run.start(), run.end(), prev))]
def _run_cuts(text: str, masked: str, start: int, end: int, prev: str) -> list[tuple[int, int]]:
"""两边都是字的一段(_VOID_RUN,中间只隔着空白):
- 中间的空白,和前面的空白(_blank_cut)
- 夹在两个分隔标点中间(「结果一致,[[see:Q1]],原报告…」):去掉后面那个(连同它前面的空白,后面的空白留着
隔开下文:「a, [[see:Q1]], b」→「a, b」)。去掉之后前一个贴着下文、读成一串的(「212,[[see:Q1]],5」),_glue
补回一个空格
"""
cuts = [(s.start(), s.end()) for s in _SOLID.finditer(masked, start, end)]
cuts += _blank_cut(text, start, end, prev)
lo, hi = start, end
while lo > 0 and text[lo - 1] in _GAP:
lo -= 1
while hi < len(text) and text[hi] in _GAP:
hi += 1
left, right = text[lo:start], text[end:hi]
if _SEP & set(left) and _SEP & set(right):
cuts.append((end, end + max(i for i, ch in enumerate(right) if ch in _SEP) + 1))
return cuts
def _blank_cut(text: str, start: int, end: int, prev: str) -> list[tuple[int, int]]:
"""text[start:end](依据,或者只装着依据的一对括号)渲染成空,它前面的空白去掉:「最高月份 [[see:Q2]]。」会剩下
「最高月份 。」。后面紧跟着字、要靠这个空白隔开的(「A [[see:Q1]]B」)留着;行首的缩进不动。"""
blank = start
while blank > 0 and text[blank - 1] in _BLANK:
blank -= 1
before = text[blank - 1] if blank > 0 else prev
after = text[end:end + 1]
if blank < start and before not in ("", "\n") and not (after.isalnum() or after == "["):
return [(blank, start)]
return []
def _glue(text: str, masked: str, see: list[tuple[int, int]], prev: str) -> set[int]:
"""依据渲染成空、它周围的字(see)去掉之后,两边真正挨上的两个看得见的字会读成一串的(_STICKY):返回要补空格的
位置,_tidy 在那里补一个(位置上的字是要去掉的就换成空格,不是就插在它前面)。
「[[v:Q1.r0.销售额]]([[see:Q1]])2 月」去掉括号读成「4,919.52 月」,「[[v:Q1.r0.销售额]][[see:Q1]][[v:Q1.r0.订单数]]
笔」读成「4,919.5212 笔」,是另一个数,而且哪项核对都查不出来。
每个依据(连着写的一串、只装着依据的括号都算)两边都判,不只看去掉了字的地方:依据前后什么都没有时,两边一样
直接挨上。往两边找挨上的字时越过一切不显示的:去掉的字、依据标记、粗体斜体的星号和下划线、删除线的波浪线。
两个括号连着写(「([[see:Q1]])([[see:m:gmv]])」)、数字包着粗体(「**[[v:…]]**[[see:Q1]]2 月」)都照挨上
的字判;依据标记的「]」越过去,不当数字(「华南居首。[[see:Q1]]([[see:Q2]])2 月」不补)。一片里只补一个
(「212,[[see:Q1]],[[see:m:gmv]],5」→「212, 5」);挨上的是写作者留着的空白的,不补。右边挨上的是紧贴着下文的
半角逗号的(「1,200([[see:Q1]]),900」读成「1,200,900」),空格补在逗号后面,和上面那种读起来一样。
空格补在这一片头一个去掉的字那里;依据两边本来什么都没有、没有去掉的字的,补在头一个依据前面。粗体收尾的星号
在它左边,粗体照样成对。粗体把依据、括号包在里面时(「**[[v:…]]([[see:Q1]])**2 月」)它落在收尾的星号前面:
前端的行内 Markdown 照样认这对星号,组装文档时包着标记的星号也去掉了,读者看到的一样。要挪到星号外面得先知道
这对星号从哪开始,流式渲染的一块里不一定有。
越过的这些字(空白、括号、分隔标点、依据、星号、下划线、波浪线)流式渲染都攒到后面的字来了才放(_TRAILING_SEE):
一片总落在同一块里,往左越出这一块就是 prev(它不会是这些字),分块渲染和整篇渲染判得一样。"""
cut = bytearray(len(text))
for a, b in see:
cut[a:b] = b"\x01" * (b - a)
gone = bytearray(cut)
for void in re.finditer(_VOID + "+", masked):
gone[void.start():void.end()] = b"\x01" * (void.end() - void.start())
spaces: set[int] = set()
seen: set[tuple[int, int]] = set()
for run in re.finditer(rb"\x01+", gone):
p, q = run.start(), run.end()
while p > 0:
if gone[p - 1]:
p -= 1
elif text[p - 1] == "*" or (p > 1 and text[p - 2:p] in ("__", "~~")):
mark = text[p - 1]
while p > 0 and text[p - 1] == mark:
p -= 1
else:
break
while q < len(text):
if gone[q]:
q += 1
elif emphasis := _EMPHASIS.match(text, q):
q = emphasis.end()
else:
break
if (p, q) in seen:
continue
seen.add((p, q))
left = text[p - 1] if p > 0 else prev
if not (_wordish(left) or left in _STICKY):
continue
if _shows_word(text, q):
first = cut.find(b"\x01", run.start(), q)
spaces.add(first if first != -1 else run.start())
elif text[q:q + 1] == ",":
# 右边是写作者紧贴着下文写的半角逗号(「1,200([[see:Q1]]),900」):去掉之后读成「1,200,900」,空格照半角
# 逗号的排版习惯补在它后面(同「212,[[see:Q1]],5」→「212, 5」)。下文包着粗体的越过星号看。逗号、星号都是
# _TRAILING_SEE 攒着的字,再往后那个字和它们落在同一块里
after = q + 1
while emphasis := _EMPHASIS.match(text, after):
after = emphasis.end()
if _shows_word(text, after):
spaces.add(q + 1)
return spaces
def _shows_word(text: str, i: int) -> bool:
"""text[i] 起头的是数字、字母,或者一个渲染出字的标记(依据不算):挨在一个数后面会读成一串。"""
if text[i:i + 1] == "[":
marker = MARKER_RE.match(text, i)
return marker is not None and marker.group("kind") != "see"
return _wordish(text[i:i + 1])
def _wordish(ch: str) -> bool:
"""数字(全角的也算)或者半角字母:两个挨在一起读成一个数、一个词。"""
return ch.isdecimal() or (ch.isascii() and ch.isalnum())
def _render(text: str, catalog: dict[str, Any], snaps: _Snapshots, cells_allowed: bool, prev: str = "") -> str:
text = _tidy(_expand_tables(text, catalog, snaps, cells_allowed, prev), catalog, snaps, cells_allowed, prev)
out: list[str] = []
@@ -1893,8 +2110,11 @@ def render_markers(text: str, catalog: dict[str, Any], *, cells_allowed: bool =
# 流式渲染:让 llm.token 里出现的就是最终的数字
# --------------------------------------------------------------------------
#: 文字末尾的空白和 [[see:…]]:流式渲染先攒着,等后面的字来了再一起交给 _tidy
_TRAILING_SEE = re.compile(r"(?:[" + _BLANK + r"]|\[\[" + _MARKER_GUARD + r"[ \t]*see[ \t]*:[^\[\]\n]*?\]\])+$")
#: 文字末尾的空白、括号、分隔标点、星号、下划线、波浪线和 [[see:…]]:流式渲染先攒着,等后面的字来了再一起交给 _tidy
#: (依据周围的这些字去不去掉、补不补空格,要看再往后的那个字,见 _see_cuts、_glue)。半角方括号也在里面:标记收尾的
#: 「]]」不算,_safe_cut 另外挑出来
_TRAILING_SEE = re.compile(r"(?:[" + _BLANK + re.escape("".join(sorted(_OPEN | _CLOSE))) + _SEPS + "*_~" + r"]|"
+ _SEE_MARK + r")+$")
def _safe_cut(text: str, has_unit: Callable[[dict[str, Any]], bool] | None = None) -> int:
@@ -1905,9 +2125,9 @@ def _safe_cut(text: str, has_unit: Callable[[dict[str, Any]], bool] | None = Non
开头可能是另一个 [。
整表标记闭合了也先不放:它展开时要看紧跟在后面的那个字(是不是换行),那个字还没到。
_tidy 要往后看的也先不放:末尾的空白和 [[see:…]](后面来的字决定空白去不去掉),
以及后面还没攒够 UNIT_LOOKAHEAD 个字的数字标记(后面可能是重复的单位)。has_unit 判断一个
标记渲染出来带不带单位,不带的(运行输入里的周次、没有单位的格)不用攒;不给就都攒。
_tidy 要往后看的也先不放:末尾的空白、括号、分隔标点、星号、下划线、波浪线和 [[see:…]](后面来的字决定它们
去不去掉、要不要补空格),以及后面还没攒够 UNIT_LOOKAHEAD 个字的数字标记(后面可能是重复的单位)。has_unit 判断
一个标记渲染出来带不带单位,不带的(运行输入里的周次、没有单位的格)不用攒;不给就都攒。
"""
cut = len(text)
start = text.rfind("[[")
@@ -1924,7 +2144,10 @@ def _safe_cut(text: str, has_unit: Callable[[dict[str, Any]], bool] | None = Non
if m and m.end() == held and m.group("kind") == "table":
held = start
if (trail := _TRAILING_SEE.search(text, 0, held)) and trail.start() < held:
held = trail.start()
# 攒的一段从一个标记中间开始(标记收尾的「]]」、标记里末尾的空白也是那几种字):标记整个放出去,从它后面攒起
s = text.rfind("[[", 0, trail.start())
inside = MARKER_RE.match(text, s, held) if s != -1 else None
held = inside.end() if inside and inside.end() > trail.start() else trail.start()
numeric = [m for m in MARKER_RE.finditer(text, 0, held) if m.group("kind") in _UNIT_KINDS
and (has_unit is None or has_unit(parse_markers(m.group(0))[0]))]
if numeric and len(text[numeric[-1].end():held]) < UNIT_LOOKAHEAD \
@@ -3055,7 +3278,6 @@ SEE_AND_CODE_RULES = (
#: 句中的 [[see:]]:前面是介词、后面接着句子的写法(「以 [[see:Q1]] 为依据」「根据 [[see:Q1]],」「详见 [[see:Q1]]」)。
#: 只认这几种拿得准的:「以」前面是「所、可、加……」的是另一个词(所以、可以),不算;「依据 [[see:…]]。」可能是
#: 「这是结论的依据」,不算。认出来就交回写作者重写(report.REWRITE_ONLY:重写不好也不判失败)
_SEE_MARK = r"\[\[" + _MARKER_GUARD + r"[ \t]*see[ \t]*:[^\[\]\n]*?\]\]"
_INLINE_SEE = re.compile(
r"(?<![所可加予得难足用是何之])以[ \t\u3000]*" + _SEE_MARK + r"[ \t\u3000]*为"
r"|(?:根据|按照|基于|参见|详见)[ \t\u3000]*" + _SEE_MARK
+10 -3
View File
@@ -125,7 +125,7 @@ CHALLENGE_SYSTEM = (
"原因说过了头;refuted 查到的数据和这句结论相矛盾;inconclusive 查不到判断所需的数据\n"
"- 没有找到反例不等于结论被推翻:只有数据和结论冲突时才判 refuted\n"
"- 结论要有出处:一条查询都没执行成功时,判什么都不会被采信;判 refuted 时,理由里至少要引用一处你自己查询的"
"结果([[v:Qn.rX.列名]] 或 [[see:Qn]]),否则同样不会被采信"
"结果:写 [[v:Qn.rX.列名]] 引用一格,或者在那句的句末写 [[see:Qn]],否则同样不会被采信"
)
CHALLENGE_TASK_PROMPT = (
"要审的结论(报告「{report}」中的一句{section}):\n「{claim}」\n\n"
@@ -771,9 +771,17 @@ class _Review:
message=f"反方模型与报告的写作模型相同(「{outcome.model}」),难以发现写作模型自身的错误。"
f"请在设置的「核查」一节选择另一个模型,或在节点的「{CHALLENGE_LABEL['model']}」中指定")
claim, why = _claim_of(cand, outcome)
# 触顶的计在哪一组:时长按剩余时间收紧过的另算一组(_SQUEEZED),说运行时长上限将至
group = _SQUEEZED if outcome.limit == "timeout_s" and allot.tightened else outcome.limit
if outcome.limit == "daily_max_usd" and outcome.result is None and not outcome.usage.get("calls"):
# 派出去就撞上每日金额上限、一次模型都没调用:和后面不再派出的句子是同一回事,同一种说法
claim = _claim(cand, "inconclusive", self._capped(), queries=outcome.queries, usage=outcome.usage)
elif outcome.result is None and outcome.error == verify_agent.SETTLE_TIMEOUT:
# 查完了、交结论那次超过时长上限:照上限说,和时间线上的 challenge_limit 一致。核查 agent 的原话(「收尾时
# 超过时长上限,没能交结果」)是内部的说法,不摆上审批卡
words = self._limit_words(_SQUEEZED if allot.tightened else "timeout_s")
claim = _claim(cand, "inconclusive", f"反查未完成:已达上限({words})", queries=outcome.queries,
usage=outcome.usage)
elif lost and why is None:
# 反查了,可该查的源有停用了的:判断照常采信,理由末尾写明哪几个没查(只写在任务里的话,审稿记录和
# 审批卡上的「经受住」看着就像每个源都查过了)
@@ -782,8 +790,7 @@ class _Review:
claims.append(claim)
usage = _add_usage(usage, outcome.usage)
if outcome.limit in _CLAIM_LIMITS:
squeezed = outcome.limit == "timeout_s" and allot.tightened
counted = hit.setdefault(_SQUEEZED if squeezed else outcome.limit, [0, 0])
counted = hit.setdefault(group, [0, 0])
counted[0] += 1
counted[1] += why is not None
broke = outcome.limit == "daily_max_usd"
+43 -4
View File
@@ -54,6 +54,7 @@ import asyncio
import inspect
import json
import logging
import re
import time
from collections.abc import Callable, Iterable
from dataclasses import asdict, dataclass, field, replace
@@ -101,6 +102,9 @@ _REF_DEPTH = 8
RATIONALE = "rationale"
#: 触顶原因
LIMITS = ("max_queries", "timeout_s", "max_cost_usd", "daily_max_usd", "max_steps")
#: 收尾那次调用超过时长上限、没交成结果时的原话(limit 记 timeout_s)。是内部的说法,给人看时调用方换成自己的话:质询的
#: _error_words、反方审稿的「反查未完成:已达上限(…)」、双盲复算按 limit 写的无法复算原因
SETTLE_TIMEOUT = "收尾时超过时长上限,没能交结果"
#: 核查模型的输出到了上限被截断、没交成结果时给人看的话:双盲复算的无法复算、反方审稿的「反查未完成」、质询的报错都照
#: 它说,也是 verify_truncated 日志的原话。cut 是 budget.cut_words 的「核查模型的输出达到上限(N token)被截断」。开头只说
#: 「未能给出结果」:质询里提问人看到的就是这句,它问的是问题,不是在核查
@@ -125,7 +129,10 @@ VERIFY_FINAL_PROMPT = (
"row 是行号(从 0 数),column 是列名。系统按引用从查询结果里取值,你自己写的数字不会被采用;查不到就写 null,"
"不要估算\n"
"- rationale 写理由,简短说清依据。理由里出现的每个数字都写成 [[v:Q1.r0.列名]] 这样的引用,系统会换成查询结果"
"里的值;不要直接写数字,提到某次查询时写 [[see:Q1]]\n"
"里的值;不要直接写数字\n"
"- 要指明一句话依据的是哪次查询,在这句的句末写 [[see:Q1]],比如「……结果一致。[[see:Q3]]」。[[see:…]] 渲染后"
"不显示,只能挂在句末:不要放进括号,也不要写进句子中间、写在「依据」「见」「取自」这类字后面,否则会留下「()」"
"「(,」「依据。」这样的断句\n"
"- 其余字段按各自的说明填写"
)
_CELL_REF: dict[str, Any] = {
@@ -687,6 +694,26 @@ def _parsed(value: Any) -> dict[str, Any] | None:
return value if isinstance(value, dict) else None
#: 只是占位词的理由(只留字母、数字和汉字,不分大小写):模型照着结构填了个字面的 placeholder、N/A,等于没给理由
_PLACEHOLDERS = frozenset({"placeholder", "todo", "tbd", "na", "null", "nil", "rationale", "reason", "略", "理由"})
#: 说「没有」的词:理由是另一个结果的说明时(双盲复算的数、反方审稿的结论),它等于没给理由;理由就是结果本身时(审批
#: 质询,结构里只有理由一项)它可能就是回答——问「有没有测试账号」,答「无。」
_NOTHING = frozenset({"none", "无", "暂无"})
#: 依据写在引出语后面(「依据 [[see:Q1]]。」「结果见:[[see:Q1]]」「(来源:[[see:Q1]])」):依据渲染后不显示,引出语悬在
#: 那里(「依据。」),渲染时去不掉(evidence._see_cuts 只收拾依据周围的空白、括号和标点)。交结果的提示要求写在句末,
#: 模型不照做时记一条日志,验收时查得到
_LEAD_IN_SEE = re.compile(r"(?:依据|见|取自|来源|出处)[ \t\u3000]*[::]?[ \t\u3000]*\[\[[ \t]*see[ \t]*:[^\[\]\n]*\]\]")
def _no_rationale(shown: str, *, answer: bool = False) -> bool:
"""渲染出来的理由等于没给:空的、只有标点(只写了依据:[[see:Q1]]、「([[see:Q1]])」渲染出来是空的),或者只是一个
占位词(「placeholder。[[see:Q1]]」渲染出来只剩占位词)。按渲染后的正文判,不按原文:标记里的字(see、Q1)不显示。
answer:理由就是交回的结果本身(审批质询的回答),说「没有」的词不算。当没给理由处理(理由为空、没有理由文档),
审批卡照「没有给出理由」说,不把占位词当理由展示,判「被推翻」的也不当有出处。"""
word = "".join(ch for ch in shown if ch.isalnum()).casefold()
return not word or word in _PLACEHOLDERS or (not answer and word in _NOTHING)
@dataclass
class _State:
"""一次核查过程中攒下的东西:出意外时也照这些交回,用了多少、查了什么不丢。"""
@@ -1032,10 +1059,14 @@ async def _verify(state: _State, *, spec: ModelSpec, system: str, task: str, sou
if parsed is None:
if settle_cut:
return await report_cut("verify_result", settler.max_tokens)
why = "收尾时超过时长上限,没能交结果" if isinstance(failure, asyncio.TimeoutError) else \
timed_out = isinstance(failure, asyncio.TimeoutError)
why = SETTLE_TIMEOUT if timed_out else \
f"核查模型没有按结构交结果({explain_model_error(failure, model_id)})" if failure is not None else \
"核查模型没有按结构交结果"
return outcome(limit=stopped, answered=answered, error=why)
# 收尾超时:没交成结果是因为时长上限,limit 照实记 timeout_s,和原因对得上——模型自己停了查询(stopped 为 None)
# 时以前记成没触顶,质询的报错说时长上限、界面却没有「已达时长上限」;循环里先查满了次数的,记成查询次数上限
# 会让双盲复算、反方审稿说错原因(查满了照样能交结果)。上面金额被拒的收尾仍报循环里先撞上的那个
return outcome(limit="timeout_s" if timed_out else stopped, answered=answered, error=why)
declared = list((resolved.get("properties") or {}) if _is_object(resolved) else {})
if declared and not any(k in parsed for k in declared):
# 一个要求的字段都没交:不是「查不到」,是没按结构交。不这么分,字段名对不上的回复会被当成什么都没查到
@@ -1050,6 +1081,13 @@ async def _verify(state: _State, *, spec: ModelSpec, system: str, task: str, sou
doc = None
if isinstance(rationale, str) and rationale.strip():
doc = compose_doc(rationale, build_catalog(nodes={}, ledger=ledger), node_id=owner, run_id=run_id or None)
if hits := _LEAD_IN_SEE.findall(rationale):
logger.warning("核查 agent 的理由里依据写在了引出语后面,渲染后引出语会悬空(运行 %s,节点 %s):%s",
run_id or "-", owner, "、".join(hits[:5]))
# 按渲染出来的判:只写了依据的、占位词后面挂着依据的,原文里有字,显示出来没有理由。结构里只有理由一项(审批
# 质询)时理由就是回答,「无」是一个回答
if _no_rationale(doc["markdown"], answer=declared == [RATIONALE]):
doc = None
result[RATIONALE] = doc["markdown"] if doc else ""
return outcome(result=result, cells=cells.cells, rationale=doc, limit=stopped, answered=answered)
@@ -1130,4 +1168,5 @@ async def _run_tool(tool: Any, name: str, args: dict[str, Any], call_id: str, re
return content
__all__ = ["LIMITS", "RATIONALE", "SETTLE_GRACE_S", "Default", "VerifyOutcome", "result_schema", "run_verify_agent"]
__all__ = ["LIMITS", "RATIONALE", "SETTLE_GRACE_S", "SETTLE_TIMEOUT", "Default", "VerifyOutcome", "result_schema",
"run_verify_agent"]