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:
@@ -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 };
|
||||
|
||||
Reference in New Issue
Block a user