feat: phase 3 of AI pipeline hardening — extraction quality
Replace stateless one-shot extraction with a stateful, paced, cancellable pipeline. Six subtasks: - 3.1 Sentence-aware chunking with 800-char overlap (was paragraph-only at 4000 chars). Hard-split fallback for runaway sentences. - 3.2 Stateful extraction: chunks 2+ receive an "already-extracted topic IDs" hint capped at 200 IDs, so the model reuses IDs instead of inventing variants like software-developer vs software-engineer. - 3.3 Token-bucket limiter in llmRetry.js (extractionLimiter, 5 req/min). callLLM awaits the limiter before fetch; 429+Retry-After calls pauseUntil. Replaces hard setTimeout(12000) and setTimeout(15000). - 3.4 relevance_locked column on topics — admin edits to relevance are sticky across re-extraction. Migration + merge respects the flag + unlock checkbox in KnowledgeGraph edit form. - 3.5 Unify relation vocabulary — handbook prompt no longer mentions legacy "executes"; one-shot migration rewrites existing executes rows to executed_by with source/target swapped. - 3.6 Cancellation — Cancel button on UploadZone wired to an AbortController threaded into callLLM; aborted runs persist status = "cancelled" rather than "failed". Tests: 16 new unit tests for chunkText, buildKnownIdsHint, and createLimiter. All 61 tests pass, 0 lint errors, build clean. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
72
src/lib/__tests__/llmRetry.test.js
Normal file
72
src/lib/__tests__/llmRetry.test.js
Normal file
@@ -0,0 +1,72 @@
|
||||
import { describe, expect, it, vi, afterEach } from 'vitest';
|
||||
import { createLimiter, extractionLimiter } from '../llmRetry';
|
||||
|
||||
afterEach(() => { vi.useRealTimers(); });
|
||||
|
||||
describe('createLimiter', () => {
|
||||
it('rejects an invalid rps', () => {
|
||||
expect(() => createLimiter({ rps: 0 })).toThrow();
|
||||
expect(() => createLimiter({ rps: -1 })).toThrow();
|
||||
});
|
||||
|
||||
it('rejects an invalid burst', () => {
|
||||
expect(() => createLimiter({ rps: 1, burst: 0 })).toThrow();
|
||||
});
|
||||
|
||||
it('lets the first call through immediately (initial burst token)', async () => {
|
||||
const limiter = createLimiter({ rps: 1, burst: 1 });
|
||||
const start = Date.now();
|
||||
await limiter.acquire();
|
||||
expect(Date.now() - start).toBeLessThan(50);
|
||||
});
|
||||
|
||||
it('queues subsequent calls to respect the spacing', async () => {
|
||||
vi.useFakeTimers();
|
||||
const limiter = createLimiter({ rps: 10, burst: 1 }); // 100ms spacing
|
||||
await limiter.acquire(); // consume initial token
|
||||
|
||||
let resolved = false;
|
||||
const p = limiter.acquire().then(() => { resolved = true; });
|
||||
|
||||
await vi.advanceTimersByTimeAsync(50);
|
||||
expect(resolved).toBe(false);
|
||||
|
||||
await vi.advanceTimersByTimeAsync(100);
|
||||
await p;
|
||||
expect(resolved).toBe(true);
|
||||
});
|
||||
|
||||
it('honours pauseUntil — no acquire returns before the pause expires', async () => {
|
||||
vi.useFakeTimers();
|
||||
const limiter = createLimiter({ rps: 100, burst: 5 });
|
||||
limiter.pauseUntil(Date.now() + 1000);
|
||||
|
||||
let resolved = false;
|
||||
const p = limiter.acquire().then(() => { resolved = true; });
|
||||
|
||||
await vi.advanceTimersByTimeAsync(500);
|
||||
expect(resolved).toBe(false);
|
||||
|
||||
await vi.advanceTimersByTimeAsync(600);
|
||||
await p;
|
||||
expect(resolved).toBe(true);
|
||||
});
|
||||
|
||||
it('aborts a queued acquire when the signal fires', async () => {
|
||||
const limiter = createLimiter({ rps: 1, burst: 1 });
|
||||
await limiter.acquire(); // consume
|
||||
|
||||
const ctl = new AbortController();
|
||||
const p = limiter.acquire({ signal: ctl.signal });
|
||||
ctl.abort();
|
||||
|
||||
await expect(p).rejects.toBeInstanceOf(DOMException);
|
||||
});
|
||||
});
|
||||
|
||||
describe('extractionLimiter', () => {
|
||||
it('is exported and exposes the limiter shape', () => {
|
||||
expect(typeof extractionLimiter.acquire).toBe('function');
|
||||
expect(typeof extractionLimiter.pauseUntil).toBe('function');
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user