From 50a2d0b628348432d68241f820a90f7333c8131b Mon Sep 17 00:00:00 2001 From: Breno Pires Date: Fri, 7 Aug 2026 02:41:39 -0300 Subject: [PATCH] feat(analyze): extracao por LLM com confianca por campo e dupla passada em datas Co-Authored-By: Claude Fable 5 --- .env.example | 6 + AI-HISTORY.md | 14 + CHANGELOG.md | 14 + README.md | 10 + package.json | 2 +- .../__tests__/anthropicAnalyzer.spec.ts | 184 ++++++++++ .../document/analyzer/anthropicAnalyzer.ts | 327 ++++++++++++++++++ .../analyzer/documentAnalyzer.interface.ts | 12 + src/modules/document/document.module.ts | 18 +- .../dto/response/documentResponse.dto.ts | 20 ++ 10 files changed, 603 insertions(+), 4 deletions(-) create mode 100644 src/modules/document/__tests__/anthropicAnalyzer.spec.ts create mode 100644 src/modules/document/analyzer/anthropicAnalyzer.ts diff --git a/.env.example b/.env.example index eadf27e..0c8aee1 100644 --- a/.env.example +++ b/.env.example @@ -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= diff --git a/AI-HISTORY.md b/AI-HISTORY.md index cfb7cc1..d064ac8 100644 --- a/AI-HISTORY.md +++ b/AI-HISTORY.md @@ -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 diff --git a/CHANGELOG.md b/CHANGELOG.md index 57aaae3..7968952 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/README.md b/README.md index 30b7e3f..03ddf1a 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/package.json b/package.json index f77def7..f589225 100644 --- a/package.json +++ b/package.json @@ -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, diff --git a/src/modules/document/__tests__/anthropicAnalyzer.spec.ts b/src/modules/document/__tests__/anthropicAnalyzer.spec.ts new file mode 100644 index 0000000..c9385e4 --- /dev/null +++ b/src/modules/document/__tests__/anthropicAnalyzer.spec.ts @@ -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 => + ({ + 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[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) => ({ + ok: true, + status: 200, + json: async () => ({ + content: [{ type: 'text', text: `Segue o resultado:\n${JSON.stringify(json)}` }], + }), +}); + +const passPayload = (overrides: Record = {}) => ({ + 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); + }); +}); diff --git a/src/modules/document/analyzer/anthropicAnalyzer.ts b/src/modules/document/analyzer/anthropicAnalyzer.ts new file mode 100644 index 0000000..8ea33d2 --- /dev/null +++ b/src/modules/document/analyzer/anthropicAnalyzer.ts @@ -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 { + 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 { + 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 { + 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; + + 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; + const quotes = (raw.quotes ?? {}) as Record; + + 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, + }; + } +} diff --git a/src/modules/document/analyzer/documentAnalyzer.interface.ts b/src/modules/document/analyzer/documentAnalyzer.interface.ts index 4edd6ea..0cc790a 100644 --- a/src/modules/document/analyzer/documentAnalyzer.interface.ts +++ b/src/modules/document/analyzer/documentAnalyzer.interface.ts @@ -8,6 +8,18 @@ export interface IAnalysisResult { expiryDate?: string | null; documentNumber?: string | null; }; + /** Confiança 0–1 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[]; } diff --git a/src/modules/document/document.module.ts b/src/modules/document/document.module.ts index 425db21..ba5772b 100644 --- a/src/modules/document/document.module.ts +++ b/src/modules/document/document.module.ts @@ -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], diff --git a/src/modules/document/dto/response/documentResponse.dto.ts b/src/modules/document/dto/response/documentResponse.dto.ts index 653f6c0..eff4773 100644 --- a/src/modules/document/dto/response/documentResponse.dto.ts +++ b/src/modules/document/dto/response/documentResponse.dto.ts @@ -103,6 +103,26 @@ export class AnalysisResultResponseDto { documentNumber?: string | null; }; + @ApiPropertyOptional({ + description: 'Confiança 0–1 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[]; }