Skip to content

Commit 9715225

Browse files
iguangfeiclaude
andcommitted
feat(quality): 源里有嵌入对象而产物只字未提时显式降级
probe 早就看得见嵌入对象(CLI 会打 "DeckProbe found embedded files or objects"),但这条 结论从没被转成 quality check。于是出现这种局面:同一份 99 页文档,本地报 graphic_frame_partial × 147 + embedded_object_unsupported × 147 而判 degraded;云端一条 check 都没有,判 pass —— 两边差的不是质量,是「知不知道自己丢了东西」。上一个提交去掉 131 条误报之后,这个窟窿反而露了出来。 新增 embedded_object_undetected:probe 确认源里有嵌入对象,而产物既没有 graphic_frame/chart/opaque 节点、也没有 embedded_object_unsupported 之类的 check,就记一条 warning 并降级。判的是证据不是损失,所以文案说「无法确认内容仍在」,不断言已丢。 落在 routeParse 里、assessCandidate 之前:那里是唯一同时拿得到 probe 与 candidate 的地方, 且新 check 能一并进 assessment 的 issues。不按引擎分叉 —— 本地哪天回退成静默丢弃,同样要报。 实测同一份文档:本地 checks 一条没多(它本来就如实报了);云端 pass → degraded、 assessment insufficient_evidence → needs_attention。 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
1 parent ccd1c5b commit 9715225

3 files changed

Lines changed: 94 additions & 2 deletions

File tree

‎src/engine/router.ts‎

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ import { CloudEngine } from './cloud.js';
44
import { LocalEngine } from './local.js';
55
import type { EngineParseInput, EngineParseOptions } from './types.js';
66
import type { CloudClient } from '../cloud/client.js';
7-
import { assessCandidate, improvesCandidate, type Assessment } from '../quality/assessment.js';
7+
import { assessCandidate, crossCheckEmbeddedObjects, improvesCandidate, type Assessment } from '../quality/assessment.js';
88
import { evaluatePolicy, type RouteDecision } from './policy.js';
99
import type { DeckProbeReport } from '../shared/preflight.js';
1010

@@ -26,6 +26,8 @@ export async function routeParse(options: {
2626
options.signal.throwIfAborted();
2727
await options.onCandidate?.(candidate);
2828
}
29+
// 源与产物的交叉核对要在评估之前做:新增的 check 才能一并进 assessment 的 issues。
30+
if (candidate) crossCheckEmbeddedObjects(candidate, options.probe);
2931
let assessment = candidate ? assessCandidate(candidate, options.probe) : undefined;
3032
const decision = evaluatePolicy(options.input, options.parse.flags, options.parse.common, assessment, !support.supported, options.probe);
3133
if (decision.action !== 'keep_local' && options.previousSubmission && !options.cachedCloud && options.cached?.ir.producer.engine !== 'cloud') {
@@ -46,6 +48,7 @@ export async function routeParse(options: {
4648
if (!reusableCloud) options.onSubmission?.({ status: 'submission_unknown' });
4749
const cloud = reusableCloud ?? await new CloudEngine(client!).parse(options.input, { ...options.parse, onTask: task => { attempt.taskId = task.id; attempt.status = 'submitted'; options.onSubmission?.({ status: 'submitted', taskId: task.id }); options.onTask?.(task); } }, options.signal);
4850
options.signal.throwIfAborted();
51+
crossCheckEmbeddedObjects(cloud, options.probe);
4952
const after = assessCandidate(cloud, options.probe, assessment?.summary?.sourcePages);
5053
await options.onCandidate?.(cloud);
5154
attempt.status = reusableCloud ? 'cache_hit' : 'completed';

‎src/quality/assessment.ts‎

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ const REMEDIES: Record<string, string> = {
1212
source_object_loss: 'document_parse', local_parse_failed: 'document_parse', text_layer_suspect: 'document_parse',
1313
group_transform_partial: 'visual_layout', composite_figure_unavailable: 'visual_layout', unrepresentable_content: 'visual_layout',
1414
graphic_frame_partial: 'visual_layout', embedded_object_unsupported: 'embedded_content', media_unsupported: 'embedded_content',
15+
embedded_object_undetected: 'embedded_content',
1516
};
1617
const VISUAL = new Set(['group_transform_partial', 'composite_figure_unavailable', 'media_unsupported', 'embedded_object_unsupported', 'graphic_frame_partial', 'unrepresentable_content']);
1718
const INFORMATIONAL = new Set(['macro_preserved', 'external_asset']);
@@ -71,6 +72,46 @@ export function assessCandidate(candidate: ParseCandidate, probe?: DeckProbeRepo
7172
...(ir.producer.engine === 'local' && reasons.length ? { recommendation: { engine: 'cloud' as const, paid: true as const, uploadScope: 'entire_document' as const, reasonCodes: reasons,
7273
message: 'Consider cloud high-quality parsing (paid; uploads the entire document). This is a recommendation, not an upload. Recovery of content missing from the source is not guaranteed.' } } : {}) };
7374
}
75+
76+
/** 解析器表示或报告嵌入对象的方式;命中任一种就说明它知道这些东西存在。 */
77+
const EMBEDDED_REPORTED = new Set(['embedded_object_unsupported', 'graphic_frame_partial', 'chart_partial', 'smartart_partial', 'media_unsupported']);
78+
const EMBEDDED_TYPES = new Set(['graphic_frame', 'chart', 'opaque']);
79+
/** 只在 OOXML 上判:probe 的嵌入对象事实是按 OOXML 部件数得出的,别的格式没有可比口径。 */
80+
const EMBEDDED_FORMATS = new Set(['pptx', 'docx']);
81+
82+
function exactValue(probe: DeckProbeReport | undefined, target: string): unknown {
83+
const evidence = probe?.results[target];
84+
return evidence?.status === 'resolved' && evidence.confidence === 'exact' ? evidence.value : undefined;
85+
}
86+
87+
/**
88+
* 源里有嵌入对象,产物里却既没表示、也没报告 —— 把这件事显式记成降级。
89+
*
90+
* 这类缺失不会自己暴露:解析器没产出节点,也就没有任何 check,产物于是以 `pass` 交付。
91+
* 实测同一份 99 页文档,本地报 147 条 `embedded_object_unsupported` 而判 degraded,云端
92+
* 一条都没有却判 pass —— 两边差的不是质量,是「知不知道自己丢了东西」。
93+
*
94+
* 判的是证据不是损失:probe 看到了,产物没提到,仅此而已。所以文案说的是「无法确认」,
95+
* 而不是断言内容已丢。按引擎分叉会让这条永远只盯着云端,本地哪天回退同样必须报。
96+
*/
97+
export function crossCheckEmbeddedObjects(candidate: ParseCandidate, probe?: DeckProbeReport): void {
98+
if (!probe || !EMBEDDED_FORMATS.has(candidate.ir.format)) return;
99+
const parts = ['powerpoint.chart_part_count', 'powerpoint.smartart_data_part_count']
100+
.map((target) => exactValue(probe, target))
101+
.filter((value): value is number => typeof value === 'number' && Number.isSafeInteger(value) && value > 0);
102+
const partCount = parts.reduce((sum, value) => sum + value, 0);
103+
if (partCount === 0 && exactValue(probe, 'security.has_embedded_files') !== true) return;
104+
const quality = candidate.ir.quality;
105+
if (quality.checks.some((check) => EMBEDDED_REPORTED.has(check.code))) return;
106+
if (candidate.ir.document.nodes.some((node) => EMBEDDED_TYPES.has(node.type))) return;
107+
quality.checks.push({ code: 'embedded_object_undetected', severity: 'warning',
108+
message: `The source contains embedded objects${partCount ? ` (${partCount} chart/SmartArt parts)` : ''}, but this result neither represents nor reports them; their content cannot be confirmed present.`,
109+
...(partCount ? { detail: { parts: partCount } } : {}) });
110+
quality.status = 'degraded';
111+
// 适配器把同一个 quality 对象同时挂在 candidate 与 ir 上;显式对齐,不依赖这个巧合。
112+
candidate.quality = quality;
113+
}
114+
74115
export function textOnPage(ir: DeckIR, page: number): number { return bodyTextStats(ir).pages.get(page) ?? 0; }
75116
/** Check observed regressions and resolved defects, without a semantic model or provider score. */
76117
export function improvesCandidate(local: ParseCandidate, cloud: ParseCandidate, before: Assessment, after: Assessment): boolean {

‎tests/unit/quality-review.test.ts‎

Lines changed: 49 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
import { describe, it, expect } from 'vitest';
22
import { strToU8, zipSync } from 'fflate';
33
import { makeIr, qualityOf } from '../../src/local/common.js';
4-
import { assessCandidate, improvesCandidate } from '../../src/quality/assessment.js';
4+
import { assessCandidate, crossCheckEmbeddedObjects, improvesCandidate } from '../../src/quality/assessment.js';
55
import { evaluatePolicy } from '../../src/engine/policy.js';
66
import { renderMarkdown } from '../../src/views/markdown.js';
77
import { parseDocx } from '../../src/local/docx/parser.js';
@@ -108,3 +108,51 @@ describe('review: Markdown and DOCX fidelity', () => {
108108
expect(parse('final')).toBe('~~strike~~plainadded\n'); expect(parse('original')).toBe('~~strike~~plaindeleted\n');
109109
});
110110
});
111+
112+
describe('review: source vs result cross-check', () => {
113+
const probe = (results: Record<string, { status: string; confidence: string; value: unknown }>) =>
114+
({ results } as never);
115+
const embeddedProbe = probe({ 'security.has_embedded_files': { status: 'resolved', confidence: 'exact', value: true } });
116+
117+
it('flags embedded objects the result neither represents nor reports', () => {
118+
const subject = candidate([node('n1', 'body')]);
119+
crossCheckEmbeddedObjects(subject, embeddedProbe);
120+
expect(subject.ir.quality.checks.map(c => c.code)).toEqual(['embedded_object_undetected']);
121+
// 没有这一条,产物会以 pass 交付一份悄悄少了嵌入对象的结果。
122+
expect(subject.ir.quality.status).toBe('degraded');
123+
expect(subject.quality.status).toBe('degraded');
124+
expect(assessCandidate(subject, embeddedProbe).status).toBe('needs_attention');
125+
});
126+
127+
it('stays quiet when the parser already reported them', () => {
128+
const subject = candidate([node('n1', 'body')], [
129+
{ code: 'embedded_object_unsupported', severity: 'warning', message: 'unsupported' },
130+
]);
131+
crossCheckEmbeddedObjects(subject, embeddedProbe);
132+
expect(subject.ir.quality.checks.map(c => c.code)).toEqual(['embedded_object_unsupported']);
133+
});
134+
135+
it('stays quiet when the result represents them as nodes', () => {
136+
const subject = candidate([node('n1', '', { type: 'graphic_frame' })]);
137+
crossCheckEmbeddedObjects(subject, embeddedProbe);
138+
expect(subject.ir.quality.checks).toEqual([]);
139+
expect(subject.ir.quality.status).toBe('pass');
140+
});
141+
142+
it('counts chart and SmartArt parts and stays quiet without probe evidence', () => {
143+
const counted = candidate([node('n1', 'body')]);
144+
crossCheckEmbeddedObjects(counted, probe({
145+
'powerpoint.chart_part_count': { status: 'resolved', confidence: 'exact', value: 2 },
146+
'powerpoint.smartart_data_part_count': { status: 'resolved', confidence: 'exact', value: 3 },
147+
}));
148+
expect(counted.ir.quality.checks[0]?.detail).toEqual({ parts: 5 });
149+
150+
const blind = candidate([node('n1', 'body')]);
151+
crossCheckEmbeddedObjects(blind, undefined);
152+
crossCheckEmbeddedObjects(blind, probe({
153+
'security.has_embedded_files': { status: 'resolved', confidence: 'exact', value: false },
154+
}));
155+
// probe 没看到嵌入对象就没有可比口径,不能凭空报降级。
156+
expect(blind.ir.quality.checks).toEqual([]);
157+
});
158+
});

0 commit comments

Comments
 (0)