feat(analyze): extracao por LLM com confianca por campo e dupla passada em datas

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Breno Pires 2026-08-07 02:41:39 -03:00
parent 9165f1e9ab
commit 50a2d0b628
10 changed files with 603 additions and 4 deletions

View File

@ -39,3 +39,9 @@ TYPEORM_LOGGING=false
URL_DEVELOPMENT=http://localhost:3000
URL_HOMOLOGATION=https://lab.clubpetro.com/api/v2/documents
# Análise por LLM (Onda 1.1). Sem a chave, a análise usa a heurística por
# nome de arquivo (comportamento v1).
# ANTHROPIC_API_KEY=
# Modelo multimodal usado na extração (default: claude-haiku-4-5-20251001)
# DOCUMENTS_LLM_MODEL=

View File

@ -16,6 +16,20 @@ Ordem cronológica — **mais recente no topo**.
---
### 2026-08-07 — Análise por LLM com confiança por campo (Onda 1.1)
- **Tempo:** ~30 min
- **Prompt:** executar a Onda 1.1 do PLANO-DOCUMENTOS-IA: extração real por LLM atrás da
interface `DocumentAnalyzer`, com confiança por campo, dupla passada em datas e fallback.
- **Mudanças:** `analyzer/anthropicAnalyzer.ts` (Messages API via fetch nativo, content block de
document/image em base64, duas passadas com fraseados diferentes, merge com bônus de
concordância e teto 0,98, parse tolerante, timeout 60s); contrato ganha `fieldConfidences` e
`evidence` (interface + DTO, retrocompatível); factory `documentAnalyzerFactory` no módulo
(sem `ANTHROPIC_API_KEY` → heurística v1 intacta); testes com fetch mockado (8 casos: passadas
convergentes/divergentes, erro de API, tipo inválido, arquivo grande, factory); `.env.example`
e README. Build limpo; 46 testes verdes (38 + 8).
- **Artefatos:** branch `feat/llm-analyzer`
### 2026-08-07 — Criação do serviço (espelho do routines)
- **Tempo:** ~60 min

View File

@ -2,6 +2,20 @@
Formato baseado em [Keep a Changelog](https://keepachangelog.com/en/1.0.0/); versionamento semântico.
## [1.1.0] - 2026-08-07
### Added
- **Análise por LLM** (`AnthropicAnalyzer`, Onda 1.1 do PLANO-DOCUMENTOS-IA): com
`ANTHROPIC_API_KEY`, o `POST /documents/analyze` extrai tipo, datas e número do PDF/imagem via
modelo multimodal (default Haiku, configurável por `DOCUMENTS_LLM_MODEL`), com **confiança por
campo** (`fieldConfidences`), **citação do trecho de origem** (`evidence`) e **dupla passada em
datas** — divergência entre as duas leituras zera o campo e devolve `dateMismatch` (data errada
é pior que campo vazio). Warnings novos no contrato: `dateMismatch`, `lowConfidence`,
`llmUnavailable`.
- Fallback gracioso: erro de API, arquivo >10MB ou mime não suportado caem na heurística por nome
de arquivo com `llmUnavailable` — a análise nunca falha por causa do LLM. Sem a chave, o
comportamento v1 fica intacto (factory em `document.module.ts`).
## [1.0.0] - 2026-08-07
### Added

View File

@ -27,6 +27,16 @@ Regras de negócio centrais em `src/modules/common/utils/status.util.ts` (status
como fonte da verdade. O detalhe jurídico que o modelo carrega: **renovação protocolada no prazo
mantém o documento operante** (na LO da CETESB, protocolo ≥120 dias antes prorroga a validade).
## Análise por LLM
Com `ANTHROPIC_API_KEY` no ambiente, o `POST /documents/analyze` extrai os campos do PDF/imagem
com um modelo multimodal (`DOCUMENTS_LLM_MODEL`, default Haiku): confiança por campo
(`fieldConfidences`), citação do trecho de origem (`evidence`) e **dupla passada em datas**
divergência entre as duas leituras zera o campo e devolve o warning `dateMismatch`, porque data
errada é pior que campo vazio. Qualquer falha do LLM (API fora, arquivo >10MB, mime estranho) cai
na heurística por nome de arquivo com o warning `llmUnavailable` — a análise nunca quebra o upload.
Sem a chave, o serviço se comporta como o v1 (heurística pura).
## Rodar local
```bash

View File

@ -1,6 +1,6 @@
{
"name": "documents",
"version": "1.0.0",
"version": "1.1.0",
"description": "Serviço de documentos e licenças dos postos (catálogo legal por UF, documentos com versões, conformidade e análise de upload)",
"author": "ClubPetro",
"private": true,

View File

@ -0,0 +1,184 @@
import { DocumentType } from '../../catalog/documentType.entity';
import { AnthropicAnalyzer } from '../analyzer/anthropicAnalyzer';
import { KeywordAnalyzer } from '../analyzer/keywordAnalyzer';
import { documentAnalyzerFactory } from '../document.module';
const TODAY = '2026-08-07';
const type = (partial: Partial<DocumentType>): DocumentType =>
({
code: 'x',
name: 'X',
keywords: [],
defaultValidityMonths: null,
renewalLeadDays: 30,
...partial,
}) as DocumentType;
const TYPES = [
type({
code: 'avcb',
name: 'AVCB',
keywords: ['avcb', 'bombeiros'],
defaultValidityMonths: null,
}),
type({
code: 'afericao-bombas',
name: 'Verificação INMETRO',
keywords: ['inmetro', 'ipem'],
defaultValidityMonths: 12,
}),
];
const input = (overrides: Partial<Parameters<AnthropicAnalyzer['analyze']>[0]> = {}) => ({
fileName: 'documento.pdf',
buffer: Buffer.from('%PDF-1.4 conteudo'),
contentType: 'application/pdf',
...overrides,
});
/** Resposta da API de Messages com o JSON do modelo dentro do bloco de texto. */
const apiResponse = (json: Record<string, unknown>) => ({
ok: true,
status: 200,
json: async () => ({
content: [{ type: 'text', text: `Segue o resultado:\n${JSON.stringify(json)}` }],
}),
});
const passPayload = (overrides: Record<string, unknown> = {}) => ({
typeCode: 'avcb',
issueDate: '2026-01-10',
expiryDate: '2029-01-10',
documentNumber: 'AVCB-123',
confidence: {
typeCode: 0.9,
issueDate: 0.9,
expiryDate: 0.85,
documentNumber: 0.8,
},
quotes: {
issueDate: 'emitido em 10 de janeiro de 2026',
expiryDate: 'válido até 10/01/2029',
documentNumber: 'AVCB nº AVCB-123',
},
...overrides,
});
describe('AnthropicAnalyzer', () => {
const originalFetch = global.fetch;
let fetchMock: jest.Mock;
beforeEach(() => {
process.env.ANTHROPIC_API_KEY = 'test-key';
fetchMock = jest.fn();
global.fetch = fetchMock as unknown as typeof fetch;
});
afterEach(() => {
global.fetch = originalFetch;
delete process.env.ANTHROPIC_API_KEY;
});
const analyzer = () => new AnthropicAnalyzer(new KeywordAnalyzer());
it('passadas convergentes: campos, confiança com bônus e evidência', async () => {
fetchMock.mockResolvedValue(apiResponse(passPayload()));
const result = await analyzer().analyze(input(), TYPES, TODAY);
expect(fetchMock).toHaveBeenCalledTimes(2);
expect(result.suggestedTypeCode).toBe('avcb');
expect(result.detectedFields).toEqual({
issueDate: '2026-01-10',
expiryDate: '2029-01-10',
documentNumber: 'AVCB-123',
});
// bônus de concordância sobre a menor autoavaliação, teto 0,98
expect(result.fieldConfidences?.issueDate).toBeCloseTo(0.98, 5);
expect(result.fieldConfidences?.expiryDate).toBeCloseTo(0.95, 5);
expect(result.evidence?.expiryDate).toBe('válido até 10/01/2029');
expect(result.warnings).not.toContain('dateMismatch');
expect(result.confidence).toBeLessThanOrEqual(0.98);
});
it('datas divergentes entre as passadas viram null com dateMismatch', async () => {
fetchMock
.mockResolvedValueOnce(apiResponse(passPayload()))
.mockResolvedValueOnce(
apiResponse(passPayload({ expiryDate: '2028-01-10' })),
);
const result = await analyzer().analyze(input(), TYPES, TODAY);
expect(result.detectedFields.expiryDate).toBeNull();
expect(result.detectedFields.issueDate).toBe('2026-01-10');
expect(result.fieldConfidences?.expiryDate).toBeLessThanOrEqual(0.5);
expect(result.warnings).toContain('dateMismatch');
});
it('erro da API cai na heurística com llmUnavailable', async () => {
fetchMock.mockResolvedValue({ ok: false, status: 529, json: async () => ({}) });
const result = await analyzer().analyze(
input({ fileName: 'avcb-bombeiros-2026.pdf' }),
TYPES,
TODAY,
);
// resultado é o da heurística (nome do arquivo reconhece o AVCB)
expect(result.suggestedTypeCode).toBe('avcb');
expect(result.warnings).toContain('llmUnavailable');
});
it('typeCode fora do catálogo vira null com typeNotRecognized', async () => {
fetchMock.mockResolvedValue(
apiResponse(passPayload({ typeCode: 'tipo-inventado' })),
);
const result = await analyzer().analyze(input(), TYPES, TODAY);
expect(result.suggestedTypeCode).toBeNull();
expect(result.warnings).toContain('typeNotRecognized');
});
it('arquivo grande demais nem chama a API', async () => {
const result = await analyzer().analyze(
input({ buffer: Buffer.alloc(11 * 1024 * 1024) }),
TYPES,
TODAY,
);
expect(fetchMock).not.toHaveBeenCalled();
expect(result.warnings).toContain('llmUnavailable');
});
it('tipo sem validade padrão e sem data extraída avisa validityVariesByState', async () => {
fetchMock.mockResolvedValue(
apiResponse(
passPayload({ expiryDate: null, quotes: { issueDate: 'emitido em 10/01/2026' } }),
),
);
const result = await analyzer().analyze(input(), TYPES, TODAY);
expect(result.detectedFields.expiryDate).toBeNull();
expect(result.warnings).toContain('validityVariesByState');
});
});
describe('documentAnalyzerFactory', () => {
afterEach(() => {
delete process.env.ANTHROPIC_API_KEY;
});
it('sem chave devolve a heurística', () => {
delete process.env.ANTHROPIC_API_KEY;
expect(documentAnalyzerFactory()).toBeInstanceOf(KeywordAnalyzer);
});
it('com chave devolve o analisador por LLM', () => {
process.env.ANTHROPIC_API_KEY = 'test-key';
expect(documentAnalyzerFactory()).toBeInstanceOf(AnthropicAnalyzer);
});
});

View File

@ -0,0 +1,327 @@
import { Injectable, Logger } from '@nestjs/common';
import { DocumentType } from '../../catalog/documentType.entity';
import {
DocumentAnalyzer,
IAnalysisResult,
IAnalyzerInput,
} from './documentAnalyzer.interface';
const API_URL = 'https://api.anthropic.com/v1/messages';
const API_VERSION = '2023-06-01';
const DEFAULT_MODEL = 'claude-haiku-4-5-20251001';
const TIMEOUT_MS = 60_000;
const MAX_FILE_BYTES = 10 * 1024 * 1024;
const SUPPORTED_TYPES = ['application/pdf', 'image/png', 'image/jpeg'];
const DATE_PATTERN = /^\d{4}-\d{2}-\d{2}$/;
/** O que UMA passada do modelo devolve, já validada. */
interface IPassResult {
typeCode: string | null;
issueDate: string | null;
expiryDate: string | null;
documentNumber: string | null;
confidence: {
typeCode?: number;
issueDate?: number;
expiryDate?: number;
documentNumber?: number;
};
quotes: {
issueDate?: string;
expiryDate?: string;
documentNumber?: string;
};
}
/**
* Análise por LLM (Onda 1.1 do PLANO-DOCUMENTOS-IA).
*
* Princípios do plano, na ordem em que aparecem no código:
*
* - **IA preenche, humano confirma**: confiança nunca passa de 0,98 e todo
* campo carrega a citação do trecho de onde saiu, para a UI de conferência.
* - **Dupla passada em datas**: duas chamadas independentes com fraseados
* diferentes; data que diverge entre elas vira null + warning
* `dateMismatch` datas são o campo onde alucinação mais dói.
* - **A análise nunca falha por causa do LLM**: qualquer erro (API fora,
* JSON inválido, arquivo grande demais) cai no analisador heurístico com o
* warning `llmUnavailable`.
*/
@Injectable()
export class AnthropicAnalyzer implements DocumentAnalyzer {
private readonly logger = new Logger(AnthropicAnalyzer.name);
constructor(private readonly fallback: DocumentAnalyzer) {}
private get model(): string {
return process.env.DOCUMENTS_LLM_MODEL || DEFAULT_MODEL;
}
async analyze(
input: IAnalyzerInput,
types: DocumentType[],
today: string,
): Promise<IAnalysisResult> {
const unsupported =
!input.buffer ||
!input.contentType ||
!SUPPORTED_TYPES.includes(input.contentType) ||
input.buffer.length > MAX_FILE_BYTES;
if (unsupported) {
return this.fallbackWith(input, types, today);
}
try {
// Passadas independentes: fraseados diferentes para não repetir o
// mesmo erro duas vezes. A concordância entre elas é o que dá confiança.
const [first, second] = await Promise.all([
this.extract(input, types, 'primary'),
this.extract(input, types, 'verification'),
]);
return this.merge(first, second, types);
} catch (error) {
this.logger.warn(
`Análise por LLM indisponível (${(error as Error).message}); usando heurística.`,
);
return this.fallbackWith(input, types, today);
}
}
/* ------------------------------------------------------------------ */
private async fallbackWith(
input: IAnalyzerInput,
types: DocumentType[],
today: string,
): Promise<IAnalysisResult> {
const result = await this.fallback.analyze(input, types, today);
return { ...result, warnings: [...result.warnings, 'llmUnavailable'] };
}
private async extract(
input: IAnalyzerInput,
types: DocumentType[],
variant: 'primary' | 'verification',
): Promise<IPassResult> {
const contentBlock =
input.contentType === 'application/pdf'
? {
type: 'document',
source: {
type: 'base64',
media_type: 'application/pdf',
data: input.buffer!.toString('base64'),
},
}
: {
type: 'image',
source: {
type: 'base64',
media_type: input.contentType,
data: input.buffer!.toString('base64'),
},
};
const response = await fetch(API_URL, {
method: 'POST',
headers: {
'content-type': 'application/json',
'x-api-key': process.env.ANTHROPIC_API_KEY!,
'anthropic-version': API_VERSION,
},
body: JSON.stringify({
model: this.model,
max_tokens: 1024,
temperature: 0,
system: this.systemPrompt(types),
messages: [
{
role: 'user',
content: [contentBlock, { type: 'text', text: this.instruction(variant) }],
},
],
}),
signal: AbortSignal.timeout(TIMEOUT_MS),
});
if (!response.ok) {
throw new Error(`Anthropic API respondeu ${response.status}`);
}
const payload = (await response.json()) as {
content?: { type: string; text?: string }[];
};
const text = (payload.content ?? [])
.filter(block => block.type === 'text')
.map(block => block.text ?? '')
.join('');
return this.parse(text);
}
private systemPrompt(types: DocumentType[]): string {
const catalog = types
.map(
type =>
`- ${type.code}: ${type.name} (palavras típicas: ${(type.keywords ?? []).join(', ')})`,
)
.join('\n');
return [
'Você extrai dados de documentos regulatórios de postos de combustíveis brasileiros (licenças, alvarás, laudos, certificados).',
'Tipos possíveis (use EXATAMENTE um destes códigos, ou null se nenhum servir):',
catalog,
'',
'Responda SOMENTE com um objeto JSON neste formato, sem comentários:',
'{"typeCode": string|null, "issueDate": "YYYY-MM-DD"|null, "expiryDate": "YYYY-MM-DD"|null, "documentNumber": string|null, "confidence": {"typeCode": 0-1, "issueDate": 0-1, "expiryDate": 0-1, "documentNumber": 0-1}, "quotes": {"issueDate": string, "expiryDate": string, "documentNumber": string}}',
'',
'Regras:',
'- Se um dado não estiver legível no documento, devolva null nesse campo — NUNCA invente.',
'- Datas sempre em ISO YYYY-MM-DD.',
'- Em "quotes", copie o trecho LITERAL do documento de onde cada valor saiu (omita a chave se o valor for null).',
'- "confidence" é sua avaliação honesta de 0 a 1 por campo.',
].join('\n');
}
private instruction(variant: 'primary' | 'verification'): string {
return variant === 'primary'
? 'Extraia os dados deste documento conforme o formato combinado.'
: 'Releia o documento com atenção às datas de emissão e de validade e extraia os dados no formato combinado. Confira cada data contra o texto antes de responder.';
}
/** Parse tolerante: pega o primeiro bloco {...} e valida campo a campo. */
private parse(text: string): IPassResult {
const match = text.match(/\{[\s\S]*\}/);
if (!match) {
throw new Error('resposta do modelo sem JSON');
}
const raw = JSON.parse(match[0]) as Record<string, unknown>;
const asDate = (value: unknown): string | null =>
typeof value === 'string' && DATE_PATTERN.test(value) ? value : null;
const asString = (value: unknown): string | null =>
typeof value === 'string' && value.trim() ? value.trim() : null;
const asConfidence = (value: unknown): number | undefined =>
typeof value === 'number' && value >= 0 && value <= 1
? value
: undefined;
const confidence = (raw.confidence ?? {}) as Record<string, unknown>;
const quotes = (raw.quotes ?? {}) as Record<string, unknown>;
return {
typeCode: asString(raw.typeCode),
issueDate: asDate(raw.issueDate),
expiryDate: asDate(raw.expiryDate),
documentNumber: asString(raw.documentNumber),
confidence: {
typeCode: asConfidence(confidence.typeCode),
issueDate: asConfidence(confidence.issueDate),
expiryDate: asConfidence(confidence.expiryDate),
documentNumber: asConfidence(confidence.documentNumber),
},
quotes: {
issueDate: asString(quotes.issueDate) ?? undefined,
expiryDate: asString(quotes.expiryDate) ?? undefined,
documentNumber: asString(quotes.documentNumber) ?? undefined,
},
};
}
private merge(
first: IPassResult,
second: IPassResult,
types: DocumentType[],
): IAnalysisResult {
const warnings: string[] = [];
// typeCode: só vale se existe no catálogo; primeira passada manda,
// segunda cobre quando a primeira falhou
const validCode = (code: string | null): string | null =>
code && types.some(type => type.code === code) ? code : null;
const typeCode = validCode(first.typeCode) ?? validCode(second.typeCode);
if (!typeCode) warnings.push('typeNotRecognized');
const mergeDate = (
field: 'issueDate' | 'expiryDate',
): { value: string | null; confidence: number } => {
const a = first[field];
const b = second[field];
if (a !== b) {
// Divergência entre passadas: melhor campo vazio que data errada
if (!warnings.includes('dateMismatch')) warnings.push('dateMismatch');
return { value: null, confidence: 0.5 };
}
if (a === null) return { value: null, confidence: 0.5 };
const self = Math.min(
first.confidence[field] ?? 0.8,
second.confidence[field] ?? 0.8,
);
// Concordância entre passadas independentes vale um bônus — mas o
// humano sempre confirma, então nunca 1.0
return { value: a, confidence: Math.min(self + 0.1, 0.98) };
};
const issue = mergeDate('issueDate');
const expiry = mergeDate('expiryDate');
const numberAgrees = first.documentNumber === second.documentNumber;
const documentNumber = first.documentNumber ?? second.documentNumber;
const numberConfidence = documentNumber
? numberAgrees
? Math.min(
Math.min(
first.confidence.documentNumber ?? 0.8,
second.confidence.documentNumber ?? 0.8,
) + 0.1,
0.98,
)
: 0.5
: 0.5;
const suggestedType = types.find(type => type.code === typeCode);
if (suggestedType && !suggestedType.defaultValidityMonths && !expiry.value) {
warnings.push('validityVariesByState');
}
const fieldConfidences = {
issueDate: issue.confidence,
expiryDate: expiry.confidence,
documentNumber: numberConfidence,
};
if (
Object.values(fieldConfidences).some(value => value < 0.7) ||
(typeCode && (first.confidence.typeCode ?? 1) < 0.7)
) {
warnings.push('lowConfidence');
}
const typeConfidence = typeCode
? Math.min(first.confidence.typeCode ?? 0.85, 0.98)
: 0.2;
return {
suggestedTypeCode: typeCode,
confidence: Math.min(
(typeConfidence + issue.confidence + expiry.confidence) / 3,
0.98,
),
detectedFields: {
issueDate: issue.value,
expiryDate: expiry.value,
documentNumber,
},
fieldConfidences,
evidence: {
issueDate: first.quotes.issueDate ?? second.quotes.issueDate,
expiryDate: first.quotes.expiryDate ?? second.quotes.expiryDate,
documentNumber:
first.quotes.documentNumber ?? second.quotes.documentNumber,
},
warnings,
};
}
}

View File

@ -8,6 +8,18 @@ export interface IAnalysisResult {
expiryDate?: string | null;
documentNumber?: string | null;
};
/** Confiança 01 por campo — a UI destaca o que precisa de conferência. */
fieldConfidences?: {
issueDate?: number;
expiryDate?: number;
documentNumber?: number;
} | null;
/** Citação literal do trecho do documento de onde cada campo saiu. */
evidence?: {
issueDate?: string;
expiryDate?: string;
documentNumber?: string;
} | null;
warnings: string[];
}

View File

@ -3,13 +3,26 @@ import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { CatalogModule } from '../catalog/catalog.module';
import { DOCUMENT_ANALYZER } from './analyzer/documentAnalyzer.interface';
import { AnthropicAnalyzer } from './analyzer/anthropicAnalyzer';
import {
DocumentAnalyzer,
DOCUMENT_ANALYZER,
} from './analyzer/documentAnalyzer.interface';
import { KeywordAnalyzer } from './analyzer/keywordAnalyzer';
import { DocumentController } from './document.controller';
import { DocumentService } from './document.service';
import { DocumentVersion } from './documentVersion.entity';
import { StoreDocument } from './storeDocument.entity';
/**
* Com ANTHROPIC_API_KEY a análise é por LLM (com a heurística de fallback
* dentro dela); sem a chave, o comportamento v1 fica intacto.
*/
export const documentAnalyzerFactory = (): DocumentAnalyzer =>
process.env.ANTHROPIC_API_KEY
? new AnthropicAnalyzer(new KeywordAnalyzer())
: new KeywordAnalyzer();
@Module({
imports: [
TypeOrmModule.forFeature([StoreDocument, DocumentVersion]),
@ -19,8 +32,7 @@ import { StoreDocument } from './storeDocument.entity';
],
providers: [
DocumentService,
// Ponto de troca da análise: v1.1 substitui por OCR/LLM sem tocar no resto
{ provide: DOCUMENT_ANALYZER, useClass: KeywordAnalyzer },
{ provide: DOCUMENT_ANALYZER, useFactory: documentAnalyzerFactory },
],
controllers: [DocumentController],
exports: [DocumentService],

View File

@ -103,6 +103,26 @@ export class AnalysisResultResponseDto {
documentNumber?: string | null;
};
@ApiPropertyOptional({
description: 'Confiança 01 por campo (análise por LLM)',
nullable: true,
})
fieldConfidences?: {
issueDate?: number;
expiryDate?: number;
documentNumber?: number;
} | null;
@ApiPropertyOptional({
description: 'Trecho literal do documento de onde cada campo saiu',
nullable: true,
})
evidence?: {
issueDate?: string;
expiryDate?: string;
documentNumber?: string;
} | null;
@ApiProperty({ type: [String] })
warnings: string[];
}