import { describe, it, expect } from 'vitest'; import { extract, parseExtractJson, type LlmCaller } from '../src/lib/extract'; const GOOD_JSON = JSON.stringify({ nodes: [ { name: '原子筆記', gloss: '一個不可再分論點的記錄單元' }, { name: '傳統筆記', gloss: '多主題混雜的記錄' }, ], triplets: [{ subject: '原子筆記', predicate: '對立於', object: '傳統筆記', confidence: 0.9 }], }); function caller(model: string, out: string | (() => Promise)): LlmCaller { return { model, call: typeof out === 'string' ? async () => out : out }; } describe('parseExtractJson', () => { it('解析 fenced JSON + 打標 embed/predicate_embed', () => { const g = parseExtractJson('```json\n' + GOOD_JSON + '\n```'); expect(g.triplets[0].predicate_embed).toBe(true); expect(g.nodes[0].embed).toBe(true); expect(g.triplets[0].confidence).toBe(0.9); }); it('無 triplets → throw', () => { expect(() => parseExtractJson(JSON.stringify({ nodes: [], triplets: [] }))).toThrow(); }); }); describe('extract', () => { it('淺萃成功不升級', async () => { const r = await extract('原文', caller('haiku', GOOD_JSON)); expect(r.tier).toBe('shallow'); expect(r.escalated).toBe(false); expect(r.model).toBe('haiku'); }); it('淺萃 JSON-fail → 升 deep(升級閘)', async () => { const r = await extract('原文', caller('haiku', 'not json at all'), caller('claude', GOOD_JSON)); expect(r.escalated).toBe(true); expect(r.tier).toBe('deep'); expect(r.model).toBe('claude'); expect(r.triplets.length).toBe(1); }); it('淺萃失敗且無 deep caller → throw', async () => { await expect(extract('原文', caller('haiku', 'garbage'))).rejects.toThrow(); }); it('端點對齊護欄:模型吐對不齊端點 → 自動補進 nodes', async () => { const skewed = JSON.stringify({ nodes: [{ name: 'A' }], triplets: [{ subject: 'A', predicate: '連到', object: 'B(沒在 nodes)' }], }); const r = await extract('原文', caller('haiku', skewed)); // B 被自動補成 node → 端點全對齊 expect(r.nodes.some((n) => n.name === 'B(沒在 nodes)')).toBe(true); }); });