From d50ad9e282fefb8990d3a1a8f92ce25dd7067224 Mon Sep 17 00:00:00 2001 From: milky886 Date: Wed, 23 Sep 2026 17:08:17 +0800 Subject: [PATCH 1/5] Improve Web Fetch report evidence drilldown --- harness/web_fetch/report.py | 174 ++++++++++++++++++++++--------- harness/web_fetch/report_i18n.py | 60 +++++++++++ tests/test_end_to_end.py | 4 +- tests/test_report_body_scope.py | 70 ++++++++++--- 4 files changed, 244 insertions(+), 64 deletions(-) diff --git a/harness/web_fetch/report.py b/harness/web_fetch/report.py index 4434ea4a5..f4dbf5640 100644 --- a/harness/web_fetch/report.py +++ b/harness/web_fetch/report.py @@ -561,8 +561,8 @@ def empty_output_reason(record,case,output): return '最终输出确实为空。上游请求有响应,但适配器没有得到可提取的正文。' return '最终输出确实为空;本次历史记录没有保存更具体的原因,不能据此推断是服务端、网络还是适配器问题。' -def call_log_dialog(out,record,case,output): - """Render one honest drill-down view from a native log or legacy result fields.""" +def run_log_section(out,record,output): + """Render one in-page, progressively disclosed view of retained run evidence.""" metadata=record.get('provider_metadata') or {} public_metadata=public_provider_metadata(metadata) logs=[] @@ -576,53 +576,72 @@ def call_log_dialog(out,record,case,output): try:payload=json.loads(log_bytes) except (UnicodeDecodeError,json.JSONDecodeError):payload={} logs.append({'label':label,'path':binding['path'],'text':text,'sha256':observed, - 'bound':bool(binding.get('sha256')),'payload':payload}) + 'bytes':len(log_bytes),'bound':bool(binding.get('sha256')),'payload':payload}) + legacy_path=metadata.get('public_stderr_path') + if str(legacy_path).startswith('private_artifacts/'):legacy_path=None + if legacy_path and not any(item['path']==legacy_path for item in logs): + path=contained_path(out,legacy_path) + if path.is_file(): + log_bytes=path.read_bytes();observed=hashlib.sha256(log_bytes).hexdigest() + declared=metadata.get('public_stderr_sha256') + if declared and declared!=observed:raise ValueError(f'Public stderr hash mismatch: {record["attempt_id"]}') + logs.append({'label':'stderr / debug 日志','path':legacy_path, + 'text':readable_output(log_bytes.decode('utf-8',errors='replace')), + 'sha256':observed,'bytes':len(log_bytes),'bound':bool(declared),'payload':{}}) + declared_logs=bool(call_log_bindings(record) or legacy_path) if logs: - native=(f'{len(logs)} 个物理调用日志已保存并通过 SHA-256 校验。' if all(item['bound'] for item in logs) else - '事件日志文件可以读取,但至少一条记录未声明其 SHA-256;页面会展示内容,但不能证明全部日志均与运行记录绑定。') - elif call_log_bindings(record): - native='记录声明了事件日志,但公开证据中没有可读取文件;下面仅展示可核验的结果字段。' + availability=(f'{len(logs)} 份真实日志文件已保存并通过 SHA-256 校验。' if all(item['bound'] for item in logs) else + f'{len(logs)} 份真实日志文件可以读取;其中至少一份没有在运行记录中声明 SHA-256。') + elif declared_logs: + availability='运行记录声明了日志文件,但公开证据中没有可读取文件;日志证据不完整。' else: - native='这次历史运行没有保存统一事件日志。下面由最终输出、结果字段和公开适配器元数据合成,不能还原未记录的逐行过程。' + availability='这次历史调用没有保存逐行 debug 日志;不会补造不存在的日志。' final_log=logs[-1]['payload'] if logs else {} log_error=final_log.get('error') if isinstance(final_log.get('error'),dict) else {} log_process=final_log.get('process') if isinstance(final_log.get('process'),dict) else {} error_type=record.get('error_type') or log_error.get('type') or '—(未记录)' error_message=(record.get('error_message') or log_error.get('message') or log_process.get('stderr') or ('无报错;请求记录为成功。' if record.get('ok') else '请求失败,但历史记录未保存错误文字。')) - error_class=' call-log-error' if record.get('error_type') or record.get('error_message') or not record.get('ok') else '' - if output: - output_html=f'
{esc(readable_output(output))}
' - else: - output_html=f'
{esc(empty_output_reason(record,case,output))}
' log_request=final_log.get('request') if isinstance(final_log.get('request'),dict) else {} route=(log_request.get('route') or metadata.get('route') or metadata.get('extractor') or metadata.get('output_contract') or '—(未记录)') status=('成功' if record.get('ok') else '失败')+' · HTTP '+(str(record.get('status_code')) if record.get('status_code') is not None else '未提供') retry=metadata.get('retry') - retry_count=(len(retry.get('attempts',[])) if isinstance(retry,dict) else - record.get('physical_attempt_count') or 1) - log_section=('' if not logs else '

原生事件日志

'+''.join( - f'
{esc(item["label"])}

SHA-256:{esc(item["sha256"])}

{esc(item["text"])}
' - for item in logs)+'
') - retry_section=('

本次没有登记重试;只保留一个物理调用。

' if not retry else - f'
{esc(json.dumps(public_provider_metadata(retry),ensure_ascii=False,indent=2))}
') - private_note=('原始供应商响应信封仅在内部证据中保留,不进入公开弹窗或发布包。' - if metadata.get('raw_response_path') or metadata.get('private_artifacts') else - '没有登记仅限内部保存的原始响应信封。') + retry_count=(len(retry.get('attempts',[])) if isinstance(retry,dict) else record.get('physical_attempt_count') or 1) + artifact=contained_path(out,record['artifact_path']) + output_bytes=artifact.stat().st_size if artifact.is_file() else len(output.encode('utf-8')) + output_sha=hashlib.sha256(artifact.read_bytes()).hexdigest() if artifact.is_file() else hashlib.sha256(output.encode('utf-8')).hexdigest() facts=[('请求路线',route),('请求状态',status),('总耗时',ms(record.get('elapsed_ms'))), ('开始时间',record.get('started_at') or '—(未记录)'),('完成时间',record.get('completed_at') or '—(未记录)'), - ('物理调用',str(retry_count))] - fact_html=''.join(f'
{esc(label)}{esc(value)}
' for label,value in facts) - return (f'
' - f'

调用记录

{esc(record.get("run_id") or execution_id(record))}

' - f'

日志可用性:{esc(native)}

' - f'

最后输出

{output_html}
' - f'

最后报错

{esc(error_type)}

{esc(error_message)}

' - f'
{fact_html}
{log_section}' - f'

重试与物理调用

{retry_section}
' - f'
查看公开适配器字段
{esc(json.dumps(public_metadata,ensure_ascii=False,indent=2))}

{esc(private_note)}

' - '
') + ('物理调用',str(retry_count)),('最终输出',f'{output_bytes:,} 字节'),('运行日志',f'{sum(item["bytes"] for item in logs):,} 字节')] + fact_html=''.join(f'
{esc(label)}
{esc(value)}
' for label,value in facts) + log_html=''.join( + f'

{esc(item["label"])}

{item["bytes"]:,} 字节 · SHA-256:{esc(item["sha256"])}

' + f'
{esc(_diagnostic_log_excerpt(item["text"]))}
' + f'

下载完整真实日志

' + for item in logs) + if not log_html:log_html='

没有可展示的逐行日志。

' + download_html=''.join( + f'

下载{esc(item["label"])}完整文件 · ' + f'{item["bytes"]:,} 字节 · SHA-256:{esc(item["sha256"])}

' + for item in logs) + if not download_html:download_html='

没有可下载的逐行日志文件。

' + retry_html=('

本次没有登记重试;只保留一个物理调用。

' if not retry else + f'
{esc(json.dumps(public_provider_metadata(retry),ensure_ascii=False,indent=2))}
') + output_link=(f'下载完整实际输出 · ' + f'{output_bytes:,} 字节 · SHA-256:{esc(output_sha)}') + private_note=('原始供应商响应信封仅在内部证据中保留,不进入公开报告或发布包。' + if metadata.get('raw_response_path') or metadata.get('private_artifacts') else + '没有登记仅限内部保存的原始响应信封。') + return (f'

运行日志与证据

' + f'

日志可用性:{esc(availability)}

' + f'
{fact_html}
' + f'

最后报错:{esc(error_type)} · {esc(error_message)}

' + f'
查看错误与关键日志
{log_html}
' + f'
查看并下载完整 stdout / stderr / debug 证据

{output_link}

{download_html}
' + f'
查看重试与物理调用
{retry_html}
' + f'
查看公开适配器字段与证据边界
{esc(json.dumps(public_metadata,ensure_ascii=False,indent=2))}

{esc(private_note)}

' + '
') def pct(n):return '—' if n is None else f'{n*100:.1f}%' def ms(n):return '—' if n is None else f'{n:,.0f} ms' def link(url,label):return f'{esc(label)}' @@ -636,6 +655,20 @@ def table(headers,rows,id='',sortable=False,cls='',metrics=None,index_header=' sort_attrs=(f' data-col="{default_sort[0]}" data-dir="{default_sort[1]}"' if default_sort else '') return metric_legend(headers if metrics is None else metrics)+f'
{head}{body}
' EXTRA+='\n.change-log section{padding:24px 0}.change-log h2{font-size:24px}.change-log p{margin:10px 0;line-height:1.75}.change-log summary{font-weight:650;color:var(--accent)}.change-log .table-wrap{overflow-x:auto}\n' +EXTRA+=''' +.anchor-grid[data-scroll-after="5"],.answer-list[data-scroll-after="5"],.record-scroll{max-height:620px;overflow:auto;overscroll-behavior:contain;scrollbar-gutter:stable;padding-right:6px} +.region-pair pre{max-height:360px;overflow:auto;scrollbar-gutter:stable} +.diagnostic-log{margin-top:18px;padding:0;border:1px solid var(--line);border-radius:12px;background:var(--panel)} +.diagnostic-log>summary{cursor:pointer;padding:14px 16px;font-weight:800;color:var(--accent)} +.diagnostic-log-body{padding:0 16px 16px}.diagnostic-log article{margin-top:16px;padding-top:16px;border-top:1px solid var(--line)} +.diagnostic-log h3{margin:0 0 10px}.diagnostic-log pre,.log-lines{max-height:360px;overflow:auto;border:1px solid #263746;border-radius:10px;background:#071722;color:#e6edf3;font:12px/1.65 ui-monospace,SFMono-Regular,Menlo,Consolas,monospace} +.diagnostic-log pre{padding:15px}.log-lines{margin:12px 0 14px;padding:15px 16px 15px 52px;white-space:pre-wrap;overflow-wrap:anywhere}.log-lines li::marker{color:#8292a2}.log-lines code{color:inherit;background:transparent} +.log-download{display:inline-block;padding:8px 12px;border-radius:8px;background:var(--accent);color:#fff!important;font-weight:850;text-decoration:none}.log-download:hover{text-decoration:underline}.log-download:focus-visible{outline:3px solid #428bff;outline-offset:3px} +.diagnostic-fields{display:grid;grid-template-columns:minmax(150px,.45fr) 1fr;gap:0;border:1px solid var(--line);border-radius:10px;overflow:hidden}.diagnostic-fields dt,.diagnostic-fields dd{margin:0;padding:10px 12px;border-bottom:1px solid var(--line)}.diagnostic-fields dt{font-weight:750;background:var(--panel2)}.diagnostic-fields dd{overflow-wrap:anywhere}.diagnostic-fields dt:last-of-type,.diagnostic-fields dd:last-of-type{border-bottom:0} +.log-summary{padding:13px 15px;border-radius:10px;background:var(--accent-soft);line-height:1.65} +@media(max-width:700px){.anchor-grid[data-scroll-after="5"],.answer-list[data-scroll-after="5"],.record-scroll{max-height:480px}.region-pair pre{max-height:320px}} +@media(max-width:640px){.diagnostic-fields{grid-template-columns:1fr}.diagnostic-fields dt{border-bottom:0}.log-lines{padding-left:44px}} +''' _RELIABILITY_BANNER='' @@ -873,6 +906,36 @@ def _best_evidence_excerpt(expected, output): if score>best_score:best,best_score=chunk,score return best if best_score>=min(.25,3/max(1,sum(wanted.values()))) else '' +def _complete_evidence_lines(expected, output): + """Return complete readable output lines that materially match one rule region.""" + from .body_coverage import tokens + from .metrics import normalize_for_match + wanted=Counter(tokens(expected)) + normalized_expected=normalize_for_match(expected) + selected=[] + for raw in output.splitlines(): + line=raw.strip() + if not line:continue + normalized_line=normalize_for_match(line) + if normalized_expected and normalized_expected in normalized_line: + if line not in selected:selected.append(line) + continue + found=Counter(tokens(line));denominator=sum(wanted.values()) + overlap=sum((wanted & found).values()) + if wanted and (overlap/max(1,denominator)>=.18 or overlap/max(1,sum(found.values()))>=.45): + if line not in selected:selected.append(line) + return '\n\n'.join(selected) + +def _diagnostic_log_excerpt(text): + """Keep exact retained log lines around reader-relevant diagnostics.""" + pattern=re.compile(r'error|warn|fail|timeout|timed out|panic|denied|challenge',re.I) + lines=text.splitlines();selected=set() + for index,line in enumerate(lines): + if pattern.search(line):selected.update(range(max(0,index-1),min(len(lines),index+2))) + if len(selected)>=36:break + if not selected:selected.update(range(min(12,len(lines)))) + return '\n'.join(f'{index+1:>6} {lines[index]}' for index in sorted(selected)) + def _position_label(index,total): names=['开头','前部','中部','后部','结尾'] if total==5 else [f'位置 {i+1}' for i in range(total)] start=round(index*100/total);end=round((index+1)*100/total) @@ -896,10 +959,10 @@ def body_region_rows(case, metrics, output): css='pass' if passed else 'fail' rows.append(('',cell(esc(label))+cell(f'{expected_count:,}')+cell(f'{matched:,}')+ cell(pct(actual['coverage']))+cell(esc(judgement)))) - excerpt=_best_evidence_excerpt(expected,output) - actual_html=(f'
{esc(excerpt)}
' if excerpt else - '

没有可供人工定位的连续文字样例;机器覆盖率仍按上方完整实际返回计算。

') - cards.append(f'

{index}. {esc(label)}

这一段标准答案产生 {expected_count:,} 个连续六字符片段;在上方完整实际返回({len(output):,} 字符)中命中 {matched:,} 个,所以覆盖率为 {matched:,} ÷ {expected_count:,} = {pct(actual["coverage"])}。目标 {pct(actual["minimum"])},硬下限 {pct(actual.get("floor",actual["minimum"]))}。

{esc(judgement)}
这一位置的完整标准正文
{esc(expected)}
实际输出中的定位样例(仅供人工查看)

这是从完整实际返回中选出的一小段,便于确认文字确实出现;它不是覆盖率计算所使用的全部实际输出。

{actual_html}
') + passage=_complete_evidence_lines(expected,output) + actual_html=(f'
{esc(passage)}
' if passage else + '

完整实际返回中没有找到属于这一段的可读对应文字。

') + cards.append(f'

{index}. {esc(label)}

本段覆盖率为 {pct(actual["coverage"])}。每段至少达到 {pct(actual.get("floor",actual["minimum"]))};达到 {pct(actual["minimum"])} 时算作主要完整段落。

{esc(judgement)}
这一位置的完整标准正文
{esc(expected)}
完整实际返回中的对应文字{actual_html}
') return rows,cards def stable_answer_panel(contract): @@ -913,7 +976,10 @@ def stable_answer_panel(contract): maximum=contract.get('maximum_records') count=(f'至少 {minimum} 条完整且不重复的记录' if maximum is None else f'{minimum}–{maximum} 条完整且不重复的记录') - return f'
结构要求
{esc(count)}
{"".join(items)}
' + scroll=' data-scroll-after="5"' if len(anchors)>5 else '' + note=(f'

共 {len(anchors)} 组稳定文字,全部属于标准答案;当前区域显示约 5 组高度,向下滚动查看全部。

' + if len(anchors)>5 else '') + return note+f'
结构要求
{esc(count)}
{"".join(items)}
' def _stable_field_comparison(metrics,item): """Find the closest parsed record for a structured anchor, for display only.""" @@ -962,9 +1028,12 @@ def stable_anchor_evidence(metrics,output): alternatives=item.get('alternatives',[]);expected='\n或\n'.join(alternatives) excerpt='' for alternative in alternatives: - candidate=_best_evidence_excerpt(alternative,output) + candidate=_complete_evidence_lines(alternative,output) if candidate:excerpt=candidate;break passed=bool(item.get('pass'));css='pass' if passed else 'fail' + if passed and not excerpt: + matched=[value for value in item.get('matched',[]) if value] + excerpt='\n'.join(matched) comparison=_stable_field_comparison(metrics,item) if not passed else None field_detail='';badge='命中' if passed else '未命中' if comparison: @@ -980,7 +1049,11 @@ def stable_anchor_evidence(metrics,output): badge='字段边界不合格' actual=(f'

{esc(excerpt)}

' if excerpt else '

实际返回中未找到可供定位的连续原文。

') cards.append(f'

稳定文字 {index}

{badge}
标准答案

{esc(expected)}

实际返回证据{actual}
{field_detail}
') - return '
'+''.join(cards)+'
' if cards else '' + if not cards:return '' + note=(f'

共 {len(cards)} 项稳定文字判定,全部参与评分;当前区域显示约 5 项高度,向下滚动查看全部。

' + if len(cards)>5 else '') + scroll=' data-scroll-after="5"' if len(cards)>5 else '' + return note+f'
'+''.join(cards)+'
' def failure_category(r,c): if not measured(r,c):return '未测:缺少验收证据' @@ -1071,7 +1144,9 @@ def stable_checks(m): checks.append(('',cell(f'页面模式 {i}')+cell(esc(x['pattern']))+cell('通过' if x['pass'] else '未通过')+cell('—'))) content=table(['稳定内容核对点','目标','判定','实际命中'],checks) if checks else '

本例未设置稳定文字核对点,不作文字内容已核验的结论。

' records=[('',cell(esc(json.dumps(x.get('fields',{}),ensure_ascii=False)))+cell('通过' if x.get('valid') else '未通过')+cell(esc(';'.join(x.get('errors',[])) or '—'))) for x in m.get('stable_records',[])] - return content+table(['条目字段','结构判定','原因'],records)+('

未通过原因:'+esc(stable_failure_summary(m))+'。

' if m.get('stable_task_errors') else '') + record_table=table(['条目字段','结构判定','原因'],records) + if len(records)>5:record_table=f'
{record_table}
' + return content+record_table+('

未通过原因:'+esc(stable_failure_summary(m))+'。

' if m.get('stable_task_errors') else '') def ranking(records,cases,track,base='',id=None,compact=False,audit=None): from .report_components import comparable_body_reference @@ -1528,7 +1603,7 @@ def generate(out:Path,dataset:dict,records:list,manifests:list,*,include_english summary+=('

查看判定使用的标准答案' '查看本次完整实际返回' '查看逐项判定' - '

') + '查看运行日志与证据

') if r.get('diagnosis') and not passed: d=r['diagnosis'];s=d.get('independent_source_check') or {} quote_issue=_quote_field_issue(m) @@ -1618,20 +1693,19 @@ def generate(out:Path,dataset:dict,records:list,manifests:list,*,include_english region_rows,region_cards=body_region_rows(c,m,output);rules=c.get('body_requirements',{}) required=rules.get('minimum_region_passes',len(rules.get('regions',[]))) region_count=len(rules.get('regions',[])) - region_intro=(f'
这项分数怎样计算:评分使用上方完整标准答案和完整实际返回,不使用下面展示的短定位样例。' - '两边先统一大小写和常见排版差异,去掉 Markdown 链接地址、空白及标点,再切成连续六字符片段。' - f'标准答案按前后位置分成 {region_count} 份;每份覆盖率 = 在完整实际返回中找到的片段数 ÷ 该份标准片段总数。' - f'本任务要求每个位置至少达到 {pct(rules.get("minimum_floor",rules.get("minimum_coverage")))},且至少 {required}/{region_count} 个位置达到 {pct(rules.get("minimum_coverage"))}。' - '所有位置同时做全局容量分配,且同一个实际片段实例只能计入一个位置;因此重复文字不会被多次借用,位置排列顺序也不会改变判定。全文完整度、纯净度和 F1 仍按完整文本另行计算。
') + region_intro=(f'
正文判定:本例分为 {region_count} 段。' + f'每段至少达到 {pct(rules.get("minimum_floor",rules.get("minimum_coverage")))},' + f'其中至少 {required} 段达到 {pct(rules.get("minimum_coverage"))}。' + '先看下表;需要核对原文时,再展开下面的左右对照。
') body+=section('正文是否抓全',region_intro+table(['标准答案位置','标准片段数','命中片段数','覆盖率','判定'],region_rows,metrics=[])+ - '
查看各位置的完整标准正文和人工定位样例'+''.join(region_cards)+'
','body-coverage') + '
查看各段完整标准正文和实际对应文字'+''.join(region_cards)+'
','body-coverage') if title_details:body+=section('标题文字质量',title_details) runtime=('
查看完整运行、哈希与评分字段' +metric_legend(detail_metrics)+table(['项目','值'],rows,metrics=[])+ f'
接口返回的公开附加字段
{esc(json.dumps(public_provider_metadata(r.get("provider_metadata",{})),ensure_ascii=False,indent=2))}
' f'

{link(c["url"],"打开源页 ↗")}{link("../datasets/"+c["id"]+".html","查看该任务的全部方法")}

') body+=section('运行与评分信息',runtime,'run-info') - body+=call_log_dialog(out,r,c,output) + body+=run_log_section(out,r,output) if r.get('provider_metadata',{}).get('rendered_html_path'): body+=section('浏览器 HTML 证据','

'+link('../'+r['provider_metadata']['rendered_html_path'],'下载转换前的完整 DOM HTML')+'

') if r.get('provider_metadata',{}).get('raw_stdout_path'): diff --git a/harness/web_fetch/report_i18n.py b/harness/web_fetch/report_i18n.py index 256f421fb..5a6d00826 100644 --- a/harness/web_fetch/report_i18n.py +++ b/harness/web_fetch/report_i18n.py @@ -2951,6 +2951,7 @@ def _register_release_translations() -> None: "这次历史运行没有保存统一事件日志。下面由最终输出、结果字段和公开适配器元数据合成,不能还原未记录的逐行过程。": "This historical run did not retain a unified event log. The view below is reconstructed from the final output, result fields, and public adapter metadata; it cannot recover unrecorded step-by-step events.", "最后输出": "Final output", "最后报错": "Final error", + "最后报错:": "Final error: ", "无报错;请求记录为成功。": "No error was recorded; the request is recorded as successful.", "请求失败,但历史记录未保存错误文字。": "The request failed, but the historical record retained no error text.", "请求路线": "Request route", @@ -2971,8 +2972,67 @@ def _register_release_translations() -> None: "最终输出确实为空;本次历史记录没有保存更具体的原因,不能据此推断是服务端、网络还是适配器问题。": "The final output is genuinely empty. This historical record retained no more specific reason, so it cannot establish whether the cause was the server, network, or adapter.", "—(未记录)": "— (not recorded)", "—(未记录错误类型)": "— (error type not recorded)", + "查看运行日志与证据": "View run logs and evidence", + "运行日志与证据": "Run logs and evidence", + "这次历史调用没有保存逐行 debug 日志;不会补造不存在的日志。": "This historical call did not retain a line-by-line debug log; no missing log is reconstructed.", + "运行记录声明了日志文件,但公开证据中没有可读取文件;日志证据不完整。": "The run record declares a log file, but no readable file is present in the public evidence; log evidence is incomplete.", + "运行日志": "Run logs", + "查看错误与关键日志": "View errors and key log lines", + "没有可展示的逐行日志。": "No line-by-line log is available to display.", + "查看并下载完整 stdout / stderr / debug 证据": "View and download complete stdout / stderr / debug evidence", + "下载完整实际输出": "Download complete actual output", + "没有可下载的逐行日志文件。": "No line-by-line log file is available to download.", + "查看重试与物理调用": "View retries and physical calls", + "查看公开适配器字段与证据边界": "View public adapter fields and evidence boundaries", + "原始供应商响应信封仅在内部证据中保留,不进入公开报告或发布包。": "The original provider response envelope is retained only in internal evidence and is excluded from the public report and publication bundle.", + "这一位置的完整标准正文": "Complete reference text for this position", + "完整实际返回中的对应文字": "Corresponding text in the complete actual output", + "完整实际返回中没有找到属于这一段的可读对应文字。": "No readable corresponding text for this region was found in the complete actual output.", + "查看各段完整标准正文和实际对应文字": "View complete reference text and corresponding actual text for each region", + "正文判定:": "Body-text rule: ", }) +REGEX.insert(0, ( + re.compile(r"^(\d+) 份真实日志文件已保存并通过 SHA-256 校验。$"), + r"\1 real log files were retained and verified by SHA-256.", +)) +REGEX.insert(0, ( + re.compile(r"^([\d,]+) 字节 · SHA-256:$"), + r"\1 bytes · SHA-256: ", +)) +REGEX.insert(0, ( + re.compile(r"^· ([\d,]+) 字节 · SHA-256:$"), + r"· \1 bytes · SHA-256: ", +)) +REGEX.insert(0, ( + re.compile(r"^(\d+) 份真实日志文件可以读取;其中至少一份没有在运行记录中声明 SHA-256。$"), + r"\1 real log files are readable; at least one has no SHA-256 declared in the run record.", +)) +REGEX.insert(0, ( + re.compile(r"^下载(.+)完整文件$"), + r"Download the complete \1 file", +)) +REGEX.insert(0, ( + re.compile(r"^正文判定:本例分为 (\d+) 段。每段至少达到 ([\d.]+)%,其中至少 (\d+) 段达到 ([\d.]+)%。先看下表;需要核对原文时,再展开下面的左右对照。$"), + r"Body-text rule: this case has \1 regions. Every region must reach at least \2%, and at least \3 regions must reach \4%. Check the table first, then expand the side-by-side evidence when needed.", +)) +REGEX.insert(0, ( + re.compile(r"^本例分为 (\d+) 段。每段至少达到 ([\d.]+)%,其中至少 (\d+) 段达到 ([\d.]+)%。先看下表;需要核对原文时,再展开下面的左右对照。$"), + r"This case has \1 regions. Every region must reach at least \2%, and at least \3 regions must reach \4%. Check the table first, then expand the side-by-side evidence when needed.", +)) +REGEX.insert(0, ( + re.compile(r"^本段覆盖率为 ([\d.]+)%。每段至少达到 ([\d.]+)%;达到 ([\d.]+)% 时算作主要完整段落。$"), + r"This region has \1% coverage. Every region must reach at least \2%; reaching \3% counts as a substantially complete region.", +)) +REGEX.insert(0, ( + re.compile(r"^共 (\d+) 组稳定文字,全部属于标准答案;当前区域显示约 5 组高度,向下滚动查看全部。$"), + r"All \1 stable-text groups belong to the reference answer. This area shows about five groups at a time; scroll to view all.", +)) +REGEX.insert(0, ( + re.compile(r"^共 (\d+) 项稳定文字判定,全部参与评分;当前区域显示约 5 项高度,向下滚动查看全部。$"), + r"All \1 stable-text checks contribute to scoring. This area shows about five at a time; scroll to view all.", +)) + REGEX.insert(0, ( re.compile(r"^(\d+) 个物理调用日志已保存并通过 SHA-256 校验。$"), r"Logs for \1 physical calls were retained and verified by SHA-256.", diff --git a/tests/test_end_to_end.py b/tests/test_end_to_end.py index a6a3673ba..0a0e93adf 100644 --- a/tests/test_end_to_end.py +++ b/tests/test_end_to_end.py @@ -31,7 +31,9 @@ def test_fixture_run_and_current_report(self): self.assertIn(records[1]['run_id'],analysis) self.assertIn('开始时间(UTC)',attempt) self.assertGreaterEqual(attempt.count('
查看运行日志与证据',attempt)
+            self.assertIn('
',attempt) + self.assertNotIn('data-call-log-open="call-log-dialog"',attempt) for kind in ('reference', 'output', 'metadata'): self.assertIn(f'
', attempt)
             self.assertTrue((report.parent/'review/index.html').exists())
diff --git a/tests/test_report_body_scope.py b/tests/test_report_body_scope.py
index 29e1b86c5..95675a226 100644
--- a/tests/test_report_body_scope.py
+++ b/tests/test_report_body_scope.py
@@ -10,7 +10,7 @@
 
 from harness.web_fetch.report import (
     TRACKS, attempt_row, case_rate, generate, overview_card, ranking, reason, stable_anchor_evidence,
-    stable_failure_summary, write_support,
+    stable_answer_panel, stable_checks, stable_failure_summary, write_support,
 )
 from harness.web_fetch.report_components import (
     task_contract_guide, task_pass_guide, examples, list_rule_description, metric_guide, reference_status, task_instruction,
@@ -49,6 +49,21 @@ def table_cells(rendered, identifier):
 
 
 class ReportBodyScopeTests(unittest.TestCase):
+    def test_long_stable_text_and_record_lists_scroll_only_after_five_items(self):
+        anchors=[{'id':f'a-{index}','alternatives':[f'Stable text {index}']} for index in range(6)]
+        contract={'minimum_records':6,'maximum_records':6,'stable_text_anchors':anchors}
+        answer=stable_answer_panel(contract)
+        self.assertIn('
',answer) + metrics={'stable_anchor_results':[ + {'id':item['id'],'alternatives':item['alternatives'],'matched':item['alternatives'],'pass':True} + for item in anchors], + 'stable_records':[{'fields':{'title':f'Item {index}'},'valid':True,'errors':[]} for index in range(6)], + 'stable_page_pattern_results':[],'stable_task_errors':[]} + evidence=stable_anchor_evidence(metrics,'\n'.join(item['alternatives'][0] for item in anchors)) + self.assertIn('
',evidence) + self.assertIn('
',stable_checks(metrics)) + self.assertNotIn('data-scroll-after="5"',stable_answer_panel({**contract,'stable_text_anchors':anchors[:5]})) + def test_request_failure_reason_precedes_derived_empty_body_metrics(self): c=case('document','body','document');r=record(c,passed=False,ok=False) r['metrics']['body_region_results']=[{'coverage':0,'target_pass':False}] @@ -285,18 +300,25 @@ def test_body_detail_leads_with_exact_reference_and_output_and_explains_position out=Path(directory);artifact=out/r['artifact_path'];artifact.parent.mkdir(parents=True) artifact.write_text('alpha beta gamma delta') with patch('harness.web_fetch.report_explainer.render_explainer',return_value='article'), patch('harness.web_fetch.report.write_support'): - generate(out,{'cases':[c]},[r],[]) + generate(out,{'cases':[c]},[r],[],include_english=True) detail=(out/'attempts'/f"{r['attempt_id']}.html").read_text() self.assertLess(detail.index('标准答案与实际返回'),detail.index('为什么这样判')) self.assertIn('alpha beta gamma delta',detail) - for phrase in ['评分使用上方完整标准答案和完整实际返回', '连续六字符片段', - '12 ÷ 12 = 100.0%', '定位样例(仅供人工查看)', + for phrase in ['正文判定:', '每段至少达到 60.0%', + '本段覆盖率为 100.0%', '完整实际返回中的对应文字', + '查看各段完整标准正文和实际对应文字', '查看判定使用的标准答案', '查看本次完整实际返回']: self.assertIn(phrase,detail) - self.assertNotIn('实际返回中的对应原文',detail) + self.assertNotIn('定位样例(仅供人工查看)',detail) + self.assertNotIn('这项分数怎样计算',detail) + self.assertEqual(detail.count('data-kind="region-output"'),1) self.assertIn('方法直接取得原始文档 → pypdf → 可读正文',detail) self.assertIn('标准答案位置',detail) self.assertNotIn('第 1/1 段',detail) + english=(out/'en/attempts'/f"{r['attempt_id']}.html").read_text() + for phrase in ['Body-text rule:', 'Every region must reach at least 60.0%', + 'Corresponding text in the complete actual output']: + self.assertIn(phrase,english) dataset=(out/'datasets/article.html').read_text() self.assertIn('同一份标准答案',dataset) self.assertNotIn('字符六元组数',dataset) @@ -315,16 +337,17 @@ def test_attempt_call_record_explains_empty_output_and_hides_private_envelope(se generate(out,{'cases':[c]},[r],[],include_english=True) detail=(out/'attempts'/f"{r['attempt_id']}.html").read_text() english=(out/'en/attempts'/f"{r['attempt_id']}.html").read_text() - for phrase in ['data-call-log-open="call-log-dialog"','
',detail) - for phrase in ['Open complete call record','Final output','Final error', - 'This historical run did not retain a unified event log', - 'Close call record']: + for phrase in ['View run logs and evidence','Run logs and evidence','Final error', + 'This historical call did not retain a line-by-line debug log']: self.assertIn(phrase,english) def test_attempt_call_record_uses_hash_bound_native_log_when_available(self): @@ -339,10 +362,31 @@ def test_attempt_call_record_uses_hash_bound_native_log_when_available(self): with patch('harness.web_fetch.report_explainer.render_explainer',return_value='article'), patch('harness.web_fetch.report.write_support'): generate(out,{'cases':[c]},[r],[]) detail=(out/'attempts'/f"{r['attempt_id']}.html").read_text() - self.assertIn('1 个物理调用日志已保存并通过 SHA-256 校验',detail) - self.assertIn('
event=request.start',detail)
+            self.assertIn('1 份真实日志文件已保存并通过 SHA-256 校验',detail)
+            self.assertIn('
     1  event=request.start',detail)
+            self.assertIn('查看错误与关键日志',detail)
+            self.assertIn('查看并下载完整 stdout / stderr / debug 证据',detail)
             self.assertIn(r['call_log_sha256'],detail)
 
+    def test_attempt_log_drilldown_accepts_hash_bound_public_debug_stderr(self):
+        c=case('debugged','body','web');r=record(c,passed=False,ok=False)
+        debug=b'INFO request started\nERROR challenge page returned\nINFO request ended\n'
+        debug_path='logs/fixture/debugged/run-001.stderr.log'
+        r['error_type']='challenge';r['error_message']='challenge page returned'
+        r['provider_metadata']={'public_stderr_path':debug_path,
+                                'public_stderr_sha256':hashlib.sha256(debug).hexdigest()}
+        with tempfile.TemporaryDirectory() as directory:
+            out=Path(directory);artifact=out/r['artifact_path'];artifact.parent.mkdir(parents=True)
+            artifact.write_text('output')
+            log=out/debug_path;log.parent.mkdir(parents=True);log.write_bytes(debug)
+            with patch('harness.web_fetch.report_explainer.render_explainer',return_value='article'), patch('harness.web_fetch.report.write_support'):
+                generate(out,{'cases':[c]},[r],[])
+            detail=(out/'attempts'/f"{r['attempt_id']}.html").read_text()
+            self.assertIn('stderr / debug 日志',detail)
+            self.assertIn('ERROR challenge page returned',detail)
+            self.assertIn('../'+debug_path,detail)
+            self.assertIn(hashlib.sha256(debug).hexdigest(),detail)
+
     def test_minimal_document_scope_is_explicit_and_does_not_claim_long_document_coverage(self):
         c=case('minimal','body','document')
         c['ground_truth_main_content']='Dummy PDF file'

From da659cef94569d68debcde8c74d947f0306054f8 Mon Sep 17 00:00:00 2001
From: milky886 
Date: Wed, 23 Sep 2026 19:57:15 +0800
Subject: [PATCH 2/5] Fix report evidence provenance

---
 harness/web_fetch/report.py      | 120 +++++++++++++++++++++----------
 harness/web_fetch/report_i18n.py |  14 ++--
 tests/test_report_body_scope.py  |  60 ++++++++++++----
 3 files changed, 135 insertions(+), 59 deletions(-)

diff --git a/harness/web_fetch/report.py b/harness/web_fetch/report.py
index f4dbf5640..cca410086 100644
--- a/harness/web_fetch/report.py
+++ b/harness/web_fetch/report.py
@@ -564,7 +564,6 @@ def empty_output_reason(record,case,output):
 def run_log_section(out,record,output):
  """Render one in-page, progressively disclosed view of retained run evidence."""
  metadata=record.get('provider_metadata') or {}
- public_metadata=public_provider_metadata(metadata)
  logs=[]
  for label,binding in call_log_bindings(record):
   path=contained_path(out,binding['path'])
@@ -577,18 +576,7 @@ def run_log_section(out,record,output):
   except (UnicodeDecodeError,json.JSONDecodeError):payload={}
   logs.append({'label':label,'path':binding['path'],'text':text,'sha256':observed,
                'bytes':len(log_bytes),'bound':bool(binding.get('sha256')),'payload':payload})
- legacy_path=metadata.get('public_stderr_path')
- if str(legacy_path).startswith('private_artifacts/'):legacy_path=None
- if legacy_path and not any(item['path']==legacy_path for item in logs):
-  path=contained_path(out,legacy_path)
-  if path.is_file():
-   log_bytes=path.read_bytes();observed=hashlib.sha256(log_bytes).hexdigest()
-   declared=metadata.get('public_stderr_sha256')
-   if declared and declared!=observed:raise ValueError(f'Public stderr hash mismatch: {record["attempt_id"]}')
-   logs.append({'label':'stderr / debug 日志','path':legacy_path,
-                'text':readable_output(log_bytes.decode('utf-8',errors='replace')),
-                'sha256':observed,'bytes':len(log_bytes),'bound':bool(declared),'payload':{}})
- declared_logs=bool(call_log_bindings(record) or legacy_path)
+ declared_logs=bool(call_log_bindings(record))
  if logs:
   availability=(f'{len(logs)} 份真实日志文件已保存并通过 SHA-256 校验。' if all(item['bound'] for item in logs) else
                 f'{len(logs)} 份真实日志文件可以读取;其中至少一份没有在运行记录中声明 SHA-256。')
@@ -609,20 +597,20 @@ def run_log_section(out,record,output):
  retry=metadata.get('retry')
  retry_count=(len(retry.get('attempts',[])) if isinstance(retry,dict) else record.get('physical_attempt_count') or 1)
  artifact=contained_path(out,record['artifact_path'])
- output_bytes=artifact.stat().st_size if artifact.is_file() else len(output.encode('utf-8'))
- output_sha=hashlib.sha256(artifact.read_bytes()).hexdigest() if artifact.is_file() else hashlib.sha256(output.encode('utf-8')).hexdigest()
+ output_data=artifact.read_bytes() if artifact.is_file() else output.encode('utf-8')
+ output_bytes=len(output_data);output_sha=hashlib.sha256(output_data).hexdigest()
  facts=[('请求路线',route),('请求状态',status),('总耗时',ms(record.get('elapsed_ms'))),
         ('开始时间',record.get('started_at') or '—(未记录)'),('完成时间',record.get('completed_at') or '—(未记录)'),
         ('物理调用',str(retry_count)),('最终输出',f'{output_bytes:,} 字节'),('运行日志',f'{sum(item["bytes"] for item in logs):,} 字节')]
  fact_html=''.join(f'
{esc(label)}
{esc(value)}
' for label,value in facts) log_html=''.join( f'

{esc(item["label"])}

{item["bytes"]:,} 字节 · SHA-256:{esc(item["sha256"])}

' - f'
{esc(_diagnostic_log_excerpt(item["text"]))}
' - f'

下载完整真实日志

' + f'
{esc(_call_log_excerpt(item))}
' + f'

下载完整调用日志 JSON

' for item in logs) if not log_html:log_html='

没有可展示的逐行日志。

' download_html=''.join( - f'

下载{esc(item["label"])}完整文件 · ' + f'

下载完整调用日志 JSON · ' f'{item["bytes"]:,} 字节 · SHA-256:{esc(item["sha256"])}

' for item in logs) if not download_html:download_html='

没有可下载的逐行日志文件。

' @@ -630,18 +618,16 @@ def run_log_section(out,record,output): f'
{esc(json.dumps(public_provider_metadata(retry),ensure_ascii=False,indent=2))}
') output_link=(f'下载完整实际输出 · ' f'{output_bytes:,} 字节 · SHA-256:{esc(output_sha)}') - private_note=('原始供应商响应信封仅在内部证据中保留,不进入公开报告或发布包。' - if metadata.get('raw_response_path') or metadata.get('private_artifacts') else - '没有登记仅限内部保存的原始响应信封。') + private_note=('

原始供应商响应信封仅在内部证据中保留,不进入公开报告或发布包。

' + if metadata.get('raw_response_path') or metadata.get('private_artifacts') else '') return (f'

运行日志与证据

' f'

日志可用性:{esc(availability)}

' f'
{fact_html}
' f'

最后报错:{esc(error_type)} · {esc(error_message)}

' f'
查看错误与关键日志
{log_html}
' - f'
查看并下载完整 stdout / stderr / debug 证据

{output_link}

{download_html}
' + f'
查看并下载完整保存证据

{output_link}

{download_html}
' f'
查看重试与物理调用
{retry_html}
' - f'
查看公开适配器字段与证据边界
{esc(json.dumps(public_metadata,ensure_ascii=False,indent=2))}

{esc(private_note)}

' - '
') + f'{private_note}
') def pct(n):return '—' if n is None else f'{n*100:.1f}%' def ms(n):return '—' if n is None else f'{n:,.0f} ms' def link(url,label):return f'{esc(label)}' @@ -906,11 +892,9 @@ def _best_evidence_excerpt(expected, output): if score>best_score:best,best_score=chunk,score return best if best_score>=min(.25,3/max(1,sum(wanted.values()))) else '' -def _complete_evidence_lines(expected, output): - """Return complete readable output lines that materially match one rule region.""" - from .body_coverage import tokens +def _exact_evidence_lines(expected, output): + """Return complete output lines containing the normalized expected text.""" from .metrics import normalize_for_match - wanted=Counter(tokens(expected)) normalized_expected=normalize_for_match(expected) selected=[] for raw in output.splitlines(): @@ -919,11 +903,6 @@ def _complete_evidence_lines(expected, output): normalized_line=normalize_for_match(line) if normalized_expected and normalized_expected in normalized_line: if line not in selected:selected.append(line) - continue - found=Counter(tokens(line));denominator=sum(wanted.values()) - overlap=sum((wanted & found).values()) - if wanted and (overlap/max(1,denominator)>=.18 or overlap/max(1,sum(found.values()))>=.45): - if line not in selected:selected.append(line) return '\n\n'.join(selected) def _diagnostic_log_excerpt(text): @@ -936,6 +915,68 @@ def _diagnostic_log_excerpt(text): if not selected:selected.update(range(min(12,len(lines)))) return '\n'.join(f'{index+1:>6} {lines[index]}' for index in sorted(selected)) +def _call_log_excerpt(item): + """Prefer exact structured diagnostics while retaining plain legacy logs.""" + payload=item['payload'] + if not payload:return _diagnostic_log_excerpt(item['text']) + parts=[] + error=payload.get('error') if isinstance(payload.get('error'),dict) else {} + process=payload.get('process') if isinstance(payload.get('process'),dict) else {} + events=payload.get('events') if isinstance(payload.get('events'),list) else [] + if error.get('type') or error.get('message'): + parts.append('error: '+str(error.get('type') or '—')+' · '+str(error.get('message') or '—')) + if process.get('stderr'): + parts.append('stderr:\n'+str(process['stderr'])) + elif process.get('stdout'): + parts.append('stdout:\n'+str(process['stdout'])) + for event in events: + if isinstance(event,dict) and (event.get('error_type') or event.get('stage')): + parts.append('event: '+json.dumps(event,ensure_ascii=False,sort_keys=True)) + return _diagnostic_log_excerpt('\n'.join(parts) if parts else item['text']) + +def _body_region_evidence(content,contract): + """Map canonical scorer allocations to complete lines without changing scoring output.""" + from math import ceil + from .body_coverage import _global_region_allocation,compact_ngrams,tokens + version=contract['version'];size=6 if version==3 else 3 + units=tokens(content) if version==1 else compact_ngrams(content,size) + predicted=Counter(units);expected={} + for region in contract['regions']: + values=(region['text'].split() if version==1 else compact_ngrams(region['text'],size)) + expected[region['id']]=Counter(values) + if version==3: + floor=contract['minimum_floor'];target=contract['minimum_coverage'] + floors={identifier:ceil(floor*sum(row.values())-1e-12) for identifier,row in expected.items()} + targets={identifier:ceil(target*sum(row.values())-1e-12) for identifier,row in expected.items()} + assigned,_=_global_region_allocation( + predicted,expected,floors,targets,contract['minimum_region_passes']) + else: + assigned={identifier:predicted & row for identifier,row in expected.items()} + remaining={identifier:row.copy() for identifier,row in assigned.items()} + evidence={identifier:[] for identifier in assigned} + lines=[raw.strip() for raw in content.splitlines() if raw.strip()] + def assign(line_units,display_lines): + shared=line_units.copy() + for identifier in sorted(remaining): + available=shared if version==3 else line_units + contribution=available & remaining[identifier] + if not contribution:continue + for display in display_lines: + if display not in evidence[identifier]:evidence[identifier].append(display) + remaining[identifier]-=contribution + if version==3:shared-=contribution + for line in lines: + line_units=Counter(tokens(line) if version==1 else compact_ngrams(line,size)) + assign(line_units,[line]) + if version!=1: + for left,right in zip(lines,lines[1:]): + boundary=(Counter(compact_ngrams(left+right,size))- + Counter(compact_ngrams(left,size))-Counter(compact_ngrams(right,size))) + assign(boundary,[left,right]) + if any(remaining.values()): + raise ValueError('Body evidence could not locate every allocated scorer unit') + return evidence,{identifier:sum(row.values()) for identifier,row in assigned.items()} + def _position_label(index,total): names=['开头','前部','中部','后部','结尾'] if total==5 else [f'位置 {i+1}' for i in range(total)] start=round(index*100/total);end=round((index+1)*100/total) @@ -946,10 +987,13 @@ def body_region_rows(case, metrics, output): contract=case.get('body_requirements') or {} if not contract:return [],'' recalculated={x['id']:x for x in evaluate_body_requirements(output,contract)['body_region_results']} + evidence,evidence_counts=_body_region_evidence(output,contract) regions={x['id']:x for x in contract['regions']} rows=[];cards=[];total=len(regions) for index,item in enumerate(metrics.get('body_region_results',[]),1): actual={**recalculated.get(item['id'],{}),**item} + if evidence_counts.get(item['id'])!=recalculated.get(item['id'],{}).get('matched_units'): + raise ValueError('Body evidence allocation differs from scorer: '+item['id']) expected=regions.get(item['id'],{}).get('text','') expected_count=actual.get('expected_units',actual.get('expected_tokens',len(expected.split()))) matched=actual.get('matched_units',actual.get('matched_tokens',round(actual['coverage']*expected_count))) @@ -959,10 +1003,10 @@ def body_region_rows(case, metrics, output): css='pass' if passed else 'fail' rows.append(('',cell(esc(label))+cell(f'{expected_count:,}')+cell(f'{matched:,}')+ cell(pct(actual['coverage']))+cell(esc(judgement)))) - passage=_complete_evidence_lines(expected,output) + passage='\n\n'.join(evidence.get(item['id'],[])) actual_html=(f'
{esc(passage)}
' if passage else - '

完整实际返回中没有找到属于这一段的可读对应文字。

') - cards.append(f'

{index}. {esc(label)}

本段覆盖率为 {pct(actual["coverage"])}。每段至少达到 {pct(actual.get("floor",actual["minimum"]))};达到 {pct(actual["minimum"])} 时算作主要完整段落。

{esc(judgement)}
这一位置的完整标准正文
{esc(expected)}
完整实际返回中的对应文字{actual_html}
') + '

评分器没有向这一段分配可展示的实际返回文字。

') + cards.append(f'

{index}. {esc(label)}

本段覆盖率为 {pct(actual["coverage"])}。每段至少达到 {pct(actual.get("floor",actual["minimum"]))};达到 {pct(actual["minimum"])} 时算作主要完整段落。

{esc(judgement)}
这一位置的完整标准正文
{esc(expected)}
参与本段计分的完整实际返回行{actual_html}
') return rows,cards def stable_answer_panel(contract): @@ -1028,7 +1072,7 @@ def stable_anchor_evidence(metrics,output): alternatives=item.get('alternatives',[]);expected='\n或\n'.join(alternatives) excerpt='' for alternative in alternatives: - candidate=_complete_evidence_lines(alternative,output) + candidate=_exact_evidence_lines(alternative,output) if candidate:excerpt=candidate;break passed=bool(item.get('pass'));css='pass' if passed else 'fail' if passed and not excerpt: @@ -1698,7 +1742,7 @@ def generate(out:Path,dataset:dict,records:list,manifests:list,*,include_english f'其中至少 {required} 段达到 {pct(rules.get("minimum_coverage"))}。' '先看下表;需要核对原文时,再展开下面的左右对照。') body+=section('正文是否抓全',region_intro+table(['标准答案位置','标准片段数','命中片段数','覆盖率','判定'],region_rows,metrics=[])+ - '
查看各段完整标准正文和实际对应文字'+''.join(region_cards)+'
','body-coverage') + '
查看各段完整标准正文和计分行'+''.join(region_cards)+'
','body-coverage') if title_details:body+=section('标题文字质量',title_details) runtime=('
查看完整运行、哈希与评分字段' +metric_legend(detail_metrics)+table(['项目','值'],rows,metrics=[])+ diff --git a/harness/web_fetch/report_i18n.py b/harness/web_fetch/report_i18n.py index 5a6d00826..9260e4725 100644 --- a/harness/web_fetch/report_i18n.py +++ b/harness/web_fetch/report_i18n.py @@ -2979,16 +2979,16 @@ def _register_release_translations() -> None: "运行日志": "Run logs", "查看错误与关键日志": "View errors and key log lines", "没有可展示的逐行日志。": "No line-by-line log is available to display.", - "查看并下载完整 stdout / stderr / debug 证据": "View and download complete stdout / stderr / debug evidence", + "查看并下载完整保存证据": "View and download complete retained evidence", "下载完整实际输出": "Download complete actual output", + "下载完整调用日志 JSON": "Download complete call-log JSON", "没有可下载的逐行日志文件。": "No line-by-line log file is available to download.", "查看重试与物理调用": "View retries and physical calls", - "查看公开适配器字段与证据边界": "View public adapter fields and evidence boundaries", "原始供应商响应信封仅在内部证据中保留,不进入公开报告或发布包。": "The original provider response envelope is retained only in internal evidence and is excluded from the public report and publication bundle.", "这一位置的完整标准正文": "Complete reference text for this position", - "完整实际返回中的对应文字": "Corresponding text in the complete actual output", - "完整实际返回中没有找到属于这一段的可读对应文字。": "No readable corresponding text for this region was found in the complete actual output.", - "查看各段完整标准正文和实际对应文字": "View complete reference text and corresponding actual text for each region", + "参与本段计分的完整实际返回行": "Complete actual-output lines used to score this region", + "评分器没有向这一段分配可展示的实际返回文字。": "The scorer assigned no displayable actual-output text to this region.", + "查看各段完整标准正文和计分行": "View complete reference text and scoring lines for each region", "正文判定:": "Body-text rule: ", }) @@ -3008,10 +3008,6 @@ def _register_release_translations() -> None: re.compile(r"^(\d+) 份真实日志文件可以读取;其中至少一份没有在运行记录中声明 SHA-256。$"), r"\1 real log files are readable; at least one has no SHA-256 declared in the run record.", )) -REGEX.insert(0, ( - re.compile(r"^下载(.+)完整文件$"), - r"Download the complete \1 file", -)) REGEX.insert(0, ( re.compile(r"^正文判定:本例分为 (\d+) 段。每段至少达到 ([\d.]+)%,其中至少 (\d+) 段达到 ([\d.]+)%。先看下表;需要核对原文时,再展开下面的左右对照。$"), r"Body-text rule: this case has \1 regions. Every region must reach at least \2%, and at least \3 regions must reach \4%. Check the table first, then expand the side-by-side evidence when needed.", diff --git a/tests/test_report_body_scope.py b/tests/test_report_body_scope.py index 95675a226..b69bfa7da 100644 --- a/tests/test_report_body_scope.py +++ b/tests/test_report_body_scope.py @@ -9,7 +9,7 @@ from unittest.mock import patch from harness.web_fetch.report import ( - TRACKS, attempt_row, case_rate, generate, overview_card, ranking, reason, stable_anchor_evidence, + TRACKS, _body_region_evidence, attempt_row, case_rate, generate, overview_card, ranking, reason, stable_anchor_evidence, stable_answer_panel, stable_checks, stable_failure_summary, write_support, ) from harness.web_fetch.report_components import ( @@ -49,6 +49,29 @@ def table_cells(rendered, identifier): class ReportBodyScopeTests(unittest.TestCase): + def test_body_evidence_uses_the_canonical_global_allocation(self): + shared='abcdefghijklmnopqrstuvwx'*3 + contract={'version':3,'unit':'region_compact_hexagram_recall', + 'minimum_coverage':.8,'minimum_floor':.6,'minimum_region_passes':1, + 'regions':[{'id':'opening','text':shared+'AAAAAAAABBBBBBBB'}, + {'id':'ending','text':shared+'CCCCCCCCDDDDDDDD'}]} + content=shared+'\n'+shared[:30]+'AAAAAAAABBBBBBBBCCCCCCCCDDDDDDDD' + evidence,counts=_body_region_evidence(content,contract) + from harness.web_fetch.body_coverage import evaluate_body_requirements + scored={row['id']:row['matched_units'] for row in + evaluate_body_requirements(content,contract)['body_region_results']} + self.assertEqual(counts,scored) + self.assertTrue(evidence['opening']) + self.assertTrue(evidence['ending']) + + def test_body_evidence_keeps_both_lines_for_cross_line_scoring_units(self): + contract={'version':3,'unit':'region_compact_hexagram_recall', + 'minimum_coverage':.8,'minimum_floor':.6,'minimum_region_passes':1, + 'regions':[{'id':'body','text':'abcdefghijkl'}]} + evidence,counts=_body_region_evidence('abcdef\nghijkl',contract) + self.assertEqual(evidence['body'],['abcdef','ghijkl']) + self.assertEqual(counts['body'],7) + def test_long_stable_text_and_record_lists_scroll_only_after_five_items(self): anchors=[{'id':f'a-{index}','alternatives':[f'Stable text {index}']} for index in range(6)] contract={'minimum_records':6,'maximum_records':6,'stable_text_anchors':anchors} @@ -305,8 +328,8 @@ def test_body_detail_leads_with_exact_reference_and_output_and_explains_position self.assertLess(detail.index('标准答案与实际返回'),detail.index('为什么这样判')) self.assertIn('alpha beta gamma delta',detail) for phrase in ['正文判定:', '每段至少达到 60.0%', - '本段覆盖率为 100.0%', '完整实际返回中的对应文字', - '查看各段完整标准正文和实际对应文字', + '本段覆盖率为 100.0%', '参与本段计分的完整实际返回行', + '查看各段完整标准正文和计分行', '查看判定使用的标准答案', '查看本次完整实际返回']: self.assertIn(phrase,detail) self.assertNotIn('定位样例(仅供人工查看)',detail) @@ -317,7 +340,7 @@ def test_body_detail_leads_with_exact_reference_and_output_and_explains_position self.assertNotIn('第 1/1 段',detail) english=(out/'en/attempts'/f"{r['attempt_id']}.html").read_text() for phrase in ['Body-text rule:', 'Every region must reach at least 60.0%', - 'Corresponding text in the complete actual output']: + 'Complete actual-output lines used to score this region']: self.assertIn(phrase,english) dataset=(out/'datasets/article.html').read_text() self.assertIn('同一份标准答案',dataset) @@ -365,27 +388,40 @@ def test_attempt_call_record_uses_hash_bound_native_log_when_available(self): self.assertIn('1 份真实日志文件已保存并通过 SHA-256 校验',detail) self.assertIn('
     1  event=request.start',detail)
             self.assertIn('查看错误与关键日志',detail)
-            self.assertIn('查看并下载完整 stdout / stderr / debug 证据',detail)
+            self.assertIn('查看并下载完整保存证据',detail)
             self.assertIn(r['call_log_sha256'],detail)
 
-    def test_attempt_log_drilldown_accepts_hash_bound_public_debug_stderr(self):
+    def test_attempt_log_drilldown_uses_canonical_call_log_stderr_in_both_languages(self):
         c=case('debugged','body','web');r=record(c,passed=False,ok=False)
-        debug=b'INFO request started\nERROR challenge page returned\nINFO request ended\n'
-        debug_path='logs/fixture/debugged/run-001.stderr.log'
+        payload={'request':{'route':'fixture'},
+                 'process':{'stdout':'adapter started','stderr':'ERROR challenge page returned'},
+                 'error':{'type':'challenge','message':'challenge page returned'},
+                 'events':[{'stage':'call_started','error_type':None},
+                           {'stage':'call_completed','error_type':'challenge'}]}
+        debug=json.dumps(payload,ensure_ascii=False).encode()
+        debug_path='call-logs/fixture/debugged/run-001.json'
         r['error_type']='challenge';r['error_message']='challenge page returned'
-        r['provider_metadata']={'public_stderr_path':debug_path,
-                                'public_stderr_sha256':hashlib.sha256(debug).hexdigest()}
+        r['provider_metadata']={'call_log':{'path':debug_path,
+                                            'sha256':hashlib.sha256(debug).hexdigest(),
+                                            'bytes':len(debug)}}
         with tempfile.TemporaryDirectory() as directory:
             out=Path(directory);artifact=out/r['artifact_path'];artifact.parent.mkdir(parents=True)
             artifact.write_text('output')
             log=out/debug_path;log.parent.mkdir(parents=True);log.write_bytes(debug)
             with patch('harness.web_fetch.report_explainer.render_explainer',return_value='article'), patch('harness.web_fetch.report.write_support'):
-                generate(out,{'cases':[c]},[r],[])
+                generate(out,{'cases':[c]},[r],[],include_english=True)
             detail=(out/'attempts'/f"{r['attempt_id']}.html").read_text()
-            self.assertIn('stderr / debug 日志',detail)
             self.assertIn('ERROR challenge page returned',detail)
             self.assertIn('../'+debug_path,detail)
             self.assertIn(hashlib.sha256(debug).hexdigest(),detail)
+            self.assertIn('下载完整调用日志 JSON',detail)
+            english=(out/'en/attempts'/f"{r['attempt_id']}.html").read_text()
+            for phrase in ['Final physical call','ERROR challenge page returned',
+                           'Download complete call-log JSON',
+                           'View and download complete retained evidence']:
+                self.assertIn(phrase,english)
+            for phrase in ['最终物理调用','下载完整调用日志','查看并下载完整保存证据']:
+                self.assertNotIn(phrase,english)
 
     def test_minimal_document_scope_is_explicit_and_does_not_claim_long_document_coverage(self):
         c=case('minimal','body','document')

From dc65cdd966084c3bfa58d6bfd95c60b782248066 Mon Sep 17 00:00:00 2001
From: milky886 
Date: Wed, 23 Sep 2026 20:08:37 +0800
Subject: [PATCH 3/5] Handle multiline body evidence

---
 harness/web_fetch/report.py     | 53 +++++++++++++++++++--------------
 tests/test_report_body_scope.py |  8 +++++
 2 files changed, 39 insertions(+), 22 deletions(-)

diff --git a/harness/web_fetch/report.py b/harness/web_fetch/report.py
index cca410086..3bb19c28e 100644
--- a/harness/web_fetch/report.py
+++ b/harness/web_fetch/report.py
@@ -952,29 +952,38 @@ def _body_region_evidence(content,contract):
    predicted,expected,floors,targets,contract['minimum_region_passes'])
  else:
   assigned={identifier:predicted & row for identifier,row in expected.items()}
- remaining={identifier:row.copy() for identifier,row in assigned.items()}
- evidence={identifier:[] for identifier in assigned}
  lines=[raw.strip() for raw in content.splitlines() if raw.strip()]
- def assign(line_units,display_lines):
-  shared=line_units.copy()
-  for identifier in sorted(remaining):
-   available=shared if version==3 else line_units
-   contribution=available & remaining[identifier]
-   if not contribution:continue
-   for display in display_lines:
-    if display not in evidence[identifier]:evidence[identifier].append(display)
-   remaining[identifier]-=contribution
-   if version==3:shared-=contribution
- for line in lines:
-  line_units=Counter(tokens(line) if version==1 else compact_ngrams(line,size))
-  assign(line_units,[line])
- if version!=1:
-  for left,right in zip(lines,lines[1:]):
-   boundary=(Counter(compact_ngrams(left+right,size))-
-             Counter(compact_ngrams(left,size))-Counter(compact_ngrams(right,size)))
-   assign(boundary,[left,right])
- if any(remaining.values()):
-  raise ValueError('Body evidence could not locate every allocated scorer unit')
+ evidence_lines={identifier:set() for identifier in assigned}
+ if version==1:
+  occurrences=defaultdict(list);flattened=[]
+  for line_index,line in enumerate(lines):
+   for unit in tokens(line):
+    flattened.append(unit);occurrences[unit].append((line_index,))
+  if flattened!=tokens(content):
+   raise ValueError('Body token evidence differs from scorer normalization')
+  for identifier,row in assigned.items():
+   for unit,count in row.items():
+    for location in occurrences[unit][:count]:evidence_lines[identifier].update(location)
+ else:
+  compact_lines=[compact_ngrams(line,1) for line in lines]
+  flattened=[character for line in compact_lines for character in line]
+  if flattened!=compact_ngrams(content,1):
+   raise ValueError('Body fragment evidence differs from scorer normalization')
+  character_lines=[]
+  for line_index,line in enumerate(compact_lines):character_lines.extend([line_index]*len(line))
+  occurrences=defaultdict(list)
+  for index,unit in enumerate(compact_ngrams(content,size)):
+   occurrences[unit].append(tuple(dict.fromkeys(character_lines[index:index+size])))
+  offsets=Counter()
+  for identifier in sorted(assigned):
+   for unit,count in assigned[identifier].items():
+    start=offsets[unit] if version==3 else 0
+    selected=occurrences[unit][start:start+count]
+    if len(selected)!=count:raise ValueError('Body evidence allocation exceeds scorer units')
+    for location in selected:evidence_lines[identifier].update(location)
+    if version==3:offsets[unit]+=count
+ evidence={identifier:[lines[index] for index in sorted(indices)]
+           for identifier,indices in evidence_lines.items()}
  return evidence,{identifier:sum(row.values()) for identifier,row in assigned.items()}
 
 def _position_label(index,total):
diff --git a/tests/test_report_body_scope.py b/tests/test_report_body_scope.py
index b69bfa7da..b26d456bb 100644
--- a/tests/test_report_body_scope.py
+++ b/tests/test_report_body_scope.py
@@ -72,6 +72,14 @@ def test_body_evidence_keeps_both_lines_for_cross_line_scoring_units(self):
         self.assertEqual(evidence['body'],['abcdef','ghijkl'])
         self.assertEqual(counts['body'],7)
 
+    def test_body_evidence_maps_one_scoring_unit_across_three_short_lines(self):
+        contract={'version':3,'unit':'region_compact_hexagram_recall',
+                  'minimum_coverage':.8,'minimum_floor':.6,'minimum_region_passes':1,
+                  'regions':[{'id':'body','text':'abcdef'}]}
+        evidence,counts=_body_region_evidence('ab\ncd\nef',contract)
+        self.assertEqual(evidence['body'],['ab','cd','ef'])
+        self.assertEqual(counts['body'],1)
+
     def test_long_stable_text_and_record_lists_scroll_only_after_five_items(self):
         anchors=[{'id':f'a-{index}','alternatives':[f'Stable text {index}']} for index in range(6)]
         contract={'minimum_records':6,'maximum_records':6,'stable_text_anchors':anchors}

From 9fb3c27e675586b751911a0f521de34f5d0ce058 Mon Sep 17 00:00:00 2001
From: milky886 
Date: Wed, 23 Sep 2026 20:15:06 +0800
Subject: [PATCH 4/5] Remove report renderer redundancy

---
 harness/web_fetch/report.py      | 5 +++--
 harness/web_fetch/report_i18n.py | 1 -
 2 files changed, 3 insertions(+), 3 deletions(-)

diff --git a/harness/web_fetch/report.py b/harness/web_fetch/report.py
index 3bb19c28e..e227dd8ee 100644
--- a/harness/web_fetch/report.py
+++ b/harness/web_fetch/report.py
@@ -565,7 +565,8 @@ def run_log_section(out,record,output):
  """Render one in-page, progressively disclosed view of retained run evidence."""
  metadata=record.get('provider_metadata') or {}
  logs=[]
- for label,binding in call_log_bindings(record):
+ bindings=call_log_bindings(record)
+ for label,binding in bindings:
   path=contained_path(out,binding['path'])
   if not path.is_file():continue
   log_bytes=path.read_bytes();observed=hashlib.sha256(log_bytes).hexdigest()
@@ -576,7 +577,7 @@ def run_log_section(out,record,output):
   except (UnicodeDecodeError,json.JSONDecodeError):payload={}
   logs.append({'label':label,'path':binding['path'],'text':text,'sha256':observed,
                'bytes':len(log_bytes),'bound':bool(binding.get('sha256')),'payload':payload})
- declared_logs=bool(call_log_bindings(record))
+ declared_logs=bool(bindings)
  if logs:
   availability=(f'{len(logs)} 份真实日志文件已保存并通过 SHA-256 校验。' if all(item['bound'] for item in logs) else
                 f'{len(logs)} 份真实日志文件可以读取;其中至少一份没有在运行记录中声明 SHA-256。')
diff --git a/harness/web_fetch/report_i18n.py b/harness/web_fetch/report_i18n.py
index 9260e4725..ce9f67d99 100644
--- a/harness/web_fetch/report_i18n.py
+++ b/harness/web_fetch/report_i18n.py
@@ -2985,7 +2985,6 @@ def _register_release_translations() -> None:
     "没有可下载的逐行日志文件。": "No line-by-line log file is available to download.",
     "查看重试与物理调用": "View retries and physical calls",
     "原始供应商响应信封仅在内部证据中保留,不进入公开报告或发布包。": "The original provider response envelope is retained only in internal evidence and is excluded from the public report and publication bundle.",
-    "这一位置的完整标准正文": "Complete reference text for this position",
     "参与本段计分的完整实际返回行": "Complete actual-output lines used to score this region",
     "评分器没有向这一段分配可展示的实际返回文字。": "The scorer assigned no displayable actual-output text to this region.",
     "查看各段完整标准正文和计分行": "View complete reference text and scoring lines for each region",

From e0042d1a31c4fa8d04e7ed2c138e372ae0c1ce66 Mon Sep 17 00:00:00 2001
From: milky886 
Date: Wed, 23 Sep 2026 20:21:49 +0800
Subject: [PATCH 5/5] Translate final output log label

---
 harness/web_fetch/report_i18n.py | 1 +
 tests/test_report_body_scope.py  | 1 +
 2 files changed, 2 insertions(+)

diff --git a/harness/web_fetch/report_i18n.py b/harness/web_fetch/report_i18n.py
index ce9f67d99..40933dad9 100644
--- a/harness/web_fetch/report_i18n.py
+++ b/harness/web_fetch/report_i18n.py
@@ -2977,6 +2977,7 @@ def _register_release_translations() -> None:
     "这次历史调用没有保存逐行 debug 日志;不会补造不存在的日志。": "This historical call did not retain a line-by-line debug log; no missing log is reconstructed.",
     "运行记录声明了日志文件,但公开证据中没有可读取文件;日志证据不完整。": "The run record declares a log file, but no readable file is present in the public evidence; log evidence is incomplete.",
     "运行日志": "Run logs",
+    "最终输出": "Final output",
     "查看错误与关键日志": "View errors and key log lines",
     "没有可展示的逐行日志。": "No line-by-line log is available to display.",
     "查看并下载完整保存证据": "View and download complete retained evidence",
diff --git a/tests/test_report_body_scope.py b/tests/test_report_body_scope.py
index b26d456bb..f27464296 100644
--- a/tests/test_report_body_scope.py
+++ b/tests/test_report_body_scope.py
@@ -425,6 +425,7 @@ def test_attempt_log_drilldown_uses_canonical_call_log_stderr_in_both_languages(
             self.assertIn('下载完整调用日志 JSON',detail)
             english=(out/'en/attempts'/f"{r['attempt_id']}.html").read_text()
             for phrase in ['Final physical call','ERROR challenge page returned',
+                           'Final output',
                            'Download complete call-log JSON',
                            'View and download complete retained evidence']:
                 self.assertIn(phrase,english)