feat: harden generic preset, add LLM retry, and finish tooling/docs

- PatternDef.validate + Luhn-gated credit-card detection; de-overlap the
  generic phone/date/IP patterns; presets.swiss unchanged (production behavior)
- strip g/y flags from nameHint so .test() is stateless (latent footgun)
- openAICompatibleProvider: bounded retry on transient failures (network /
  timeout / 429 / 5xx), configurable via retries + retryDelayMs
- eslint + prettier + vitest coverage (97%); CI runs lint/format/coverage
- docs: README badges + new-option docs, SECURITY.md, issue/PR templates

26 tests passing; build emits ESM+CJS+types.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Mobiletic
2026-06-24 17:03:20 +01:00
parent f09b917f1a
commit 81b6b03239
21 changed files with 2039 additions and 27 deletions

View File

@@ -28,14 +28,26 @@ export interface OpenAICompatibleOptions {
apiKey: string;
/** Model id, e.g. `gpt-4o-mini` or `gemma-3-...`. */
model: string;
/** Per-request timeout in milliseconds (default 3000). */
/** Per-request timeout in milliseconds, per attempt (default 3000). */
timeoutMs?: number;
/**
* Extra retries on TRANSIENT failures only (network error, timeout, HTTP 429/5xx).
* Default 1 (→ up to 2 attempts). Worst-case latency is `(retries + 1) × timeoutMs`.
*/
retries?: number;
/** Linear backoff between attempts in milliseconds (delay = attempt × this). Default 250. */
retryDelayMs?: number;
/** Override the system prompt (e.g. for another language or regulation). */
systemPrompt?: string;
/** Extra instructions appended to the system prompt in batch mode. */
batchInstructions?: (usedIds: string[]) => string;
}
const sleep = (ms: number): Promise<void> => new Promise((resolve) => setTimeout(resolve, ms));
/** A transport error that the caller may retry (network / timeout / 429 / 5xx). */
class TransientError extends Error {}
const DEFAULT_BATCH_INSTRUCTIONS = (usedIds: string[]): string =>
'\n\nMODE LOT (segments) :' +
'\n- ENTRÉE : un objet JSON {"segments": ["…", "…"]}.' +
@@ -54,17 +66,20 @@ const DEFAULT_BATCH_INSTRUCTIONS = (usedIds: string[]): string =>
*/
export function openAICompatibleProvider(opts: OpenAICompatibleOptions): LlmProvider {
const timeout = opts.timeoutMs ?? 3000;
const retries = Math.max(0, opts.retries ?? 1);
const retryDelayMs = opts.retryDelayMs ?? 250;
const systemPrompt = opts.systemPrompt ?? DEFAULT_SYSTEM_PROMPT;
const batchInstructions = opts.batchInstructions ?? DEFAULT_BATCH_INSTRUCTIONS;
const isConfigured = (): boolean => !!(opts.baseUrl && opts.apiKey && opts.model);
async function chat(system: string, user: string): Promise<string> {
if (!isConfigured()) throw new Error('LLM_NOT_CONFIGURED');
/** One HTTP attempt. Throws {@link TransientError} for retryable failures. */
async function attempt(system: string, user: string): Promise<string> {
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), timeout);
let res: Response;
try {
const res = await fetch(`${opts.baseUrl}/chat/completions`, {
res = await fetch(`${opts.baseUrl}/chat/completions`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
@@ -81,12 +96,34 @@ export function openAICompatibleProvider(opts: OpenAICompatibleOptions): LlmProv
}),
signal: controller.signal,
});
if (!res.ok) throw new Error(`LLM_HTTP_${res.status}`);
const data = (await res.json()) as { choices?: Array<{ message?: { content?: string } }> };
return data?.choices?.[0]?.message?.content ?? '';
} catch (err) {
// Network failure or timeout/abort → retryable.
throw new TransientError(`LLM_FETCH_FAILED: ${(err as Error).message}`);
} finally {
clearTimeout(timer);
}
if (!res.ok) {
// 429 + 5xx are transient; other 4xx are not (won't fix on retry).
if (res.status === 429 || res.status >= 500) throw new TransientError(`LLM_HTTP_${res.status}`);
throw new Error(`LLM_HTTP_${res.status}`);
}
const data = (await res.json()) as { choices?: Array<{ message?: { content?: string } }> };
return data?.choices?.[0]?.message?.content ?? '';
}
async function chat(system: string, user: string): Promise<string> {
if (!isConfigured()) throw new Error('LLM_NOT_CONFIGURED');
let lastErr: unknown;
for (let i = 0; i <= retries; i++) {
try {
return await attempt(system, user);
} catch (err) {
lastErr = err;
if (!(err instanceof TransientError) || i === retries) throw err;
if (retryDelayMs > 0) await sleep(retryDelayMs * (i + 1));
}
}
throw lastErr; // unreachable, but keeps the type checker happy
}
return {
@@ -95,7 +132,11 @@ export function openAICompatibleProvider(opts: OpenAICompatibleOptions): LlmProv
async anonymize(text: string): Promise<AnonymizationResult> {
const content = await chat(systemPrompt, text);
const parsed = JSON.parse(content) as { texte_anonymise?: string; mapping?: Record<string, string> };
if (typeof parsed.texte_anonymise !== 'string' || typeof parsed.mapping !== 'object' || !parsed.mapping) {
if (
typeof parsed.texte_anonymise !== 'string' ||
typeof parsed.mapping !== 'object' ||
!parsed.mapping
) {
throw new Error('LLM_BAD_SHAPE');
}
return { anon: parsed.texte_anonymise, mapping: parsed.mapping };