feat: phase 3 of AI pipeline hardening — extraction quality
All checks were successful
On Pull Request to Main / test (pull_request) Successful in 31s
On Pull Request to Main / publish (pull_request) Successful in 1m1s
On Pull Request to Main / deploy-dev (pull_request) Successful in 1m32s

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:
RaymondVerhoef
2026-05-20 17:56:45 +02:00
parent 9771928926
commit aeb197d5f4
10 changed files with 509 additions and 48 deletions

View 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');
});
});