From 5a3c8bc85ed14036c4b961e8c3aac5b9c0706961 Mon Sep 17 00:00:00 2001 From: PANGKAIFENG <15700064852@163.com> Date: Tue, 21 Jul 2026 23:37:52 +0800 Subject: [PATCH] fix: tolerate invalid sync candidates (#59) --- src/obsidian-plugin/candidate-extractor.ts | 36 ++++++--- .../candidate-extractor.test.ts | 77 +++++++++++++++++-- 2 files changed, 93 insertions(+), 20 deletions(-) diff --git a/src/obsidian-plugin/candidate-extractor.ts b/src/obsidian-plugin/candidate-extractor.ts index dced543..a1941fa 100644 --- a/src/obsidian-plugin/candidate-extractor.ts +++ b/src/obsidian-plugin/candidate-extractor.ts @@ -3,7 +3,10 @@ import { posix } from 'node:path'; import { parse } from 'yaml'; import { PRIORITIES, type Priority } from '../domain/task.js'; -import type { ClaudeStructuredExecutor } from '../runner/claude-driver.js'; +import { + ClaudeDriverError, + type ClaudeStructuredExecutor, +} from '../runner/claude-driver.js'; import type { SyncSourceRecord } from './sync-source-reader.js'; const MAX_BATCH_RECORDS = 40; @@ -29,7 +32,7 @@ const extractedCandidateSchema: z.ZodType = z.object({ }).strict(); const extractionResultSchema = z.object({ - candidates: z.array(extractedCandidateSchema), + candidates: z.array(z.unknown()), }).strict(); export const candidateExtractionJsonSchema: Record = { @@ -205,28 +208,37 @@ export async function extractTaskCandidates(input: { const sourceByFingerprint = new Map(batch.map((record) => ( [record.fingerprint, record] as const ))); - const raw = await input.executor.execute({ + const execution = { prompt: extractionPrompt(batch), jsonSchema: candidateExtractionJsonSchema, schema: extractionResultSchema, timeoutMs: EXTRACTION_TIMEOUT_MS, - }); + }; + let raw: unknown; + try { + raw = await input.executor.execute(execution); + } catch (error) { + if (!(error instanceof ClaudeDriverError) || error.code !== 'claude_timeout') { + throw error; + } + raw = await input.executor.execute(execution); + } const result = extractionResultSchema.parse(raw); - for (const candidate of result.candidates) { + const represented = new Set(); + for (const rawCandidate of result.candidates) { + const parsedCandidate = extractedCandidateSchema.safeParse(rawCandidate); + if (!parsedCandidate.success) continue; + const candidate = parsedCandidate.data; const source = sourceByFingerprint.get(candidate.sourceRecordFingerprint); - if (source === undefined) { - throw new Error('Claude returned a candidate for an unknown source record'); - } + if (source === undefined) continue; if (!normalizedEvidence(source.content).includes( normalizedEvidence(candidate.sourceQuote), )) { - throw new Error('Claude returned a source quote that is not present in the source record'); + continue; } candidates.push(candidate); + represented.add(candidate.sourceRecordFingerprint); } - const represented = new Set(result.candidates.map((candidate) => ( - candidate.sourceRecordFingerprint - ))); for (const record of batch) { const marker = deterministicMarker(record); if (marker !== null && !represented.has(record.fingerprint)) { diff --git a/tests/unit/obsidian-plugin/candidate-extractor.test.ts b/tests/unit/obsidian-plugin/candidate-extractor.test.ts index db33269..171e203 100644 --- a/tests/unit/obsidian-plugin/candidate-extractor.test.ts +++ b/tests/unit/obsidian-plugin/candidate-extractor.test.ts @@ -5,9 +5,10 @@ import { extractTaskCandidates, } from '../../../src/obsidian-plugin/candidate-extractor.js'; import type { SyncSourceRecord } from '../../../src/obsidian-plugin/sync-source-reader.js'; -import type { - ClaudeStructuredExecutor, - ClaudeStructuredInput, +import { + ClaudeDriverError, + type ClaudeStructuredExecutor, + type ClaudeStructuredInput, } from '../../../src/runner/claude-driver.js'; function record(index: number, content = `#待办 调研工具 ${index}`): SyncSourceRecord { @@ -116,11 +117,32 @@ describe('extractTaskCandidates', () => { sourceRecordFingerprint: record(1).fingerprint, sourceQuote: '引'.repeat(301), }], - ])('rejects %s in model output', async (_label, candidate) => { + ])('skips %s in model output', async (_label, candidate) => { const executor = fakeExecutor([{ candidates: [candidate] }]); - await expect(extractTaskCandidates({ records: [record(1)], executor })) - .rejects.toThrow(); + await expect(extractTaskCandidates({ + records: [record(1, '普通记录,没有明确待办标记。')], + executor, + })).resolves.toEqual([]); + }); + + it('uses the explicit todo fallback when a candidate has an unknown fingerprint', async () => { + const source = record(1, '#待办 调研真实存在的工具'); + const executor = fakeExecutor([{ candidates: [{ + title: '调研真实存在的工具', + summary: '模型引用了不存在的来源记录。', + priority: 'normal', + topicKey: '工具调研', + sourceRecordFingerprint: 'f'.repeat(64), + sourceQuote: source.content, + }] }]); + + await expect(extractTaskCandidates({ records: [source], executor })) + .resolves.toEqual([expect.objectContaining({ + title: '调研真实存在的工具', + sourceRecordFingerprint: source.fingerprint, + sourceQuote: source.content, + })]); }); it('propagates non-JSON structured-executor failures without partial output', async () => { @@ -128,9 +150,44 @@ describe('extractTaskCandidates', () => { await expect(extractTaskCandidates({ records: [record(1)], executor })) .rejects.toThrow('Claude returned invalid JSON'); + expect(executor.execute).toHaveBeenCalledTimes(1); + }); + + it('retries one transient Claude timeout before returning candidates', async () => { + const source = record(1); + const executor = fakeExecutor([ + new ClaudeDriverError('claude_timeout'), + { candidates: [{ + title: '调研工具 1', + summary: '比较工具能力与适用场景。', + priority: 'normal', + topicKey: '工具-1-调研', + sourceRecordFingerprint: source.fingerprint, + sourceQuote: source.content, + }] }, + ]); + + await expect(extractTaskCandidates({ records: [source], executor })) + .resolves.toHaveLength(1); + expect(executor.execute).toHaveBeenCalledTimes(2); }); - it('rejects a source quote that is not present in the referenced record', async () => { + it('skips a candidate whose source quote is not present in the referenced record', async () => { + const source = record(1, '后续评估真实存在的工具'); + const executor = fakeExecutor([{ candidates: [{ + title: '调研不存在的工具', + summary: '模型幻觉出的候选。', + priority: 'normal', + topicKey: '工具调研', + sourceRecordFingerprint: source.fingerprint, + sourceQuote: '后续评估原文中不存在的工具', + }] }]); + + await expect(extractTaskCandidates({ records: [source], executor })) + .resolves.toEqual([]); + }); + + it('uses the explicit todo fallback when a candidate source quote is invalid', async () => { const source = record(1, '#待办 调研真实存在的工具'); const executor = fakeExecutor([{ candidates: [{ title: '调研不存在的工具', @@ -142,7 +199,11 @@ describe('extractTaskCandidates', () => { }] }]); await expect(extractTaskCandidates({ records: [source], executor })) - .rejects.toThrow('source quote'); + .resolves.toEqual([expect.objectContaining({ + title: '调研真实存在的工具', + sourceRecordFingerprint: source.fingerprint, + sourceQuote: '#待办 调研真实存在的工具', + })]); }); it('processes every bounded batch and combines the results', async () => {