Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
36 changes: 24 additions & 12 deletions src/obsidian-plugin/candidate-extractor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -29,7 +32,7 @@ const extractedCandidateSchema: z.ZodType<ExtractedCandidate> = z.object({
}).strict();

const extractionResultSchema = z.object({
candidates: z.array(extractedCandidateSchema),
candidates: z.array(z.unknown()),
}).strict();

export const candidateExtractionJsonSchema: Record<string, unknown> = {
Expand Down Expand Up @@ -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<string>();
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)) {
Expand Down
77 changes: 69 additions & 8 deletions tests/unit/obsidian-plugin/candidate-extractor.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -116,21 +117,77 @@ 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 () => {
const executor = fakeExecutor([new Error('Claude returned invalid JSON')]);

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: '调研不存在的工具',
Expand All @@ -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 () => {
Expand Down