From 50a2d0b628348432d68241f820a90f7333c8131b Mon Sep 17 00:00:00 2001 From: Breno Pires Date: Fri, 7 Aug 2026 02:41:39 -0300 Subject: [PATCH 1/2] 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[]; } -- 2.45.2 From 56368d5517203c99c2fd2669032f9e854ede5289 Mon Sep 17 00:00:00 2001 From: Breno Pires Date: Fri, 7 Aug 2026 02:44:25 -0300 Subject: [PATCH 2/2] feat(compliance): prazo de renovacao dinamico pelo tempo observado por orgao Cada renovacao que sai do orgao vira ciclo observado (protocolo -> emissao); a mediana por orgao/tipo estica o prazo de inicio de renovacao quando o orgao esta comprovadamente mais lento que o catalogo. O catalogo e piso legal: os 120 dias da LO nunca encolhem. Campos aditivos effectiveLeadDays/observed no upcoming e nos alertas; GET /compliance/lead-times; seed deterministico. Co-Authored-By: Claude Fable 5 --- AI-HISTORY.md | 13 ++ CHANGELOG.md | 14 ++ package.json | 2 +- src/migrations/1786233600000-migration.ts | 34 ++++ .../alerts/__tests__/alerts.service.spec.ts | 29 +++ src/modules/alerts/alerts.service.ts | 11 +- .../__tests__/compliance.service.spec.ts | 53 ++++++ .../__tests__/leadTime.service.spec.ts | 153 +++++++++++++++ .../compliance/compliance.controller.ts | 18 +- src/modules/compliance/compliance.module.ts | 8 +- src/modules/compliance/compliance.service.ts | 38 ++-- .../compliance/dto/response/compliance.dto.ts | 16 +- .../compliance/dto/response/leadTime.dto.ts | 44 +++++ src/modules/compliance/leadTime.service.ts | 174 ++++++++++++++++++ src/modules/compliance/renewalCycle.entity.ts | 44 +++++ .../__tests__/document.service.spec.ts | 89 +++++++++ src/modules/document/document.service.ts | 42 ++++- src/scripts/seed-demo-cycles.ts | 98 ++++++++++ 18 files changed, 859 insertions(+), 21 deletions(-) create mode 100644 src/migrations/1786233600000-migration.ts create mode 100644 src/modules/compliance/__tests__/leadTime.service.spec.ts create mode 100644 src/modules/compliance/dto/response/leadTime.dto.ts create mode 100644 src/modules/compliance/leadTime.service.ts create mode 100644 src/modules/compliance/renewalCycle.entity.ts create mode 100644 src/scripts/seed-demo-cycles.ts diff --git a/AI-HISTORY.md b/AI-HISTORY.md index cfb7cc1..6c1a158 100644 --- a/AI-HISTORY.md +++ b/AI-HISTORY.md @@ -16,6 +16,19 @@ Ordem cronológica — **mais recente no topo**. --- +### 2026-08-07 02:45 — Prazo de renovação dinâmico por órgão (Onda 1.2) + +- **Tempo:** ~30 min +- **Prompt:** substituir o lead fixo do catálogo pelo tempo real observado de emissão por órgão na + carteira (v0 do plano de IA — estatística descritiva, sem ML). +- **Mudanças:** entity + migration `renewal_cycle` (gravada automaticamente quando um save zera um + protocolo; descarta intervalos implausíveis); `LeadTimeService` (mediana/p90 por tipo e por + órgão; `effectiveLeadDays = max(catálogo, mediana+15)` com amostra ≥ 3 — catálogo é piso); + `GET /compliance/lead-times`; campos aditivos `effectiveLeadDays`/`observed` no upcoming e no + alerts/preview, com o degrau `lead` da régua usando o prazo dinâmico; seed determinístico + `seed-demo-cycles.ts`; 16 testes novos (54 no total, 7 suites). CHANGELOG 1.1.0. +- **Artefatos:** branch `feat/dynamic-lead-times` (worktree `documentos/documents-service-lead`) + ### 2026-08-07 — Criação do serviço (espelho do routines) - **Tempo:** ~60 min diff --git a/CHANGELOG.md b/CHANGELOG.md index 57aaae3..54da8fc 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 +- **Prazo de renovação dinâmico por órgão (Onda 1.2 do plano de IA).** Cada renovação que sai do + órgão vira um ciclo observado (`renewal_cycle`: protocolo → emissão), gravado automaticamente no + save que zera um protocolo; ciclos implausíveis (negativos ou > 2 anos) são descartados com log. +- `GET /compliance/lead-times?programId`: mediana, p90 e prazo efetivo por órgão e por tipo. + Régua: `effectiveLeadDays = max(catálogo, mediana + 15)` com amostra ≥ 3 — o catálogo é piso + legal (os 120 dias da LO nunca encolhem), a estatística só estica quando o órgão está lento. +- `GET /compliance/upcoming` e `GET /alerts/preview` passam a carregar os campos aditivos + `effectiveLeadDays` e `observed` `{count, medianDays}`; o degrau `lead` da régua de alertas + dispara pelo prazo dinâmico ("a SEMAD está levando 140 dias — comece agora"). +- Seed de demonstração `src/scripts/seed-demo-cycles.ts` (determinístico) para o LAB. + ## [1.0.0] - 2026-08-07 ### Added 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/migrations/1786233600000-migration.ts b/src/migrations/1786233600000-migration.ts new file mode 100644 index 0000000..720bcc2 --- /dev/null +++ b/src/migrations/1786233600000-migration.ts @@ -0,0 +1,34 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +/** + * Ciclos de renovação observados (protocolo → emissão), base do prazo de + * renovação dinâmico por órgão. Reusa o `issuing_body_enum` da migration + * inicial; índice por (programId, issuingBody) porque toda leitura agrega + * dentro do programa. + */ +export class migration1786233600000 implements MigrationInterface { + name = 'migration1786233600000'; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + CREATE TABLE IF NOT EXISTS public.renewal_cycle ( + id uuid PRIMARY KEY DEFAULT gen_random_uuid(), + create_date timestamp NOT NULL DEFAULT LOCALTIMESTAMP, + update_date timestamp NOT NULL DEFAULT LOCALTIMESTAMP, + "programId" uuid NOT NULL, + "storeId" uuid NOT NULL, + "typeCode" varchar(80) NOT NULL, + "issuingBody" public.issuing_body_enum NOT NULL, + "protocolDate" date NOT NULL, + "issuedDate" date NOT NULL, + "emissionDays" int NOT NULL + ); + CREATE INDEX IF NOT EXISTS idx_renewal_cycle_program_body + ON public.renewal_cycle ("programId","issuingBody"); + `); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(`DROP TABLE IF EXISTS public.renewal_cycle;`); + } +} diff --git a/src/modules/alerts/__tests__/alerts.service.spec.ts b/src/modules/alerts/__tests__/alerts.service.spec.ts index a0f7143..283b52c 100644 --- a/src/modules/alerts/__tests__/alerts.service.spec.ts +++ b/src/modules/alerts/__tests__/alerts.service.spec.ts @@ -71,4 +71,33 @@ describe('AlertsService.computeDueAlerts — a régua', () => { await service.computeDueAlerts({ programId: PROGRAM_ID, storeId: STORE_ID }), ).toHaveLength(0); }); + + it('prazo dinâmico: effectiveLeadDays maior que o catálogo dispara o degrau lead mais cedo', async () => { + // 130 dias para vencer: fora do lead do catálogo (120), dentro do + // observado (mediana 140 + 15 = 155) + const service = setup([ + upcoming('lo', 130, { + renewalLeadDays: 120, + effectiveLeadDays: 155, + observed: { count: 6, medianDays: 140 }, + }), + ]); + + const alerts = await service.computeDueAlerts({ + programId: PROGRAM_ID, + storeId: STORE_ID, + }); + + expect(alerts).toHaveLength(1); + expect(alerts[0].trigger).toBe('lead'); + expect(alerts[0].effectiveLeadDays).toBe(155); + expect(alerts[0].observed).toEqual({ count: 6, medianDays: 140 }); + }); + + it('sem effectiveLeadDays no item, a régua usa o prazo do catálogo (retrocompatível)', async () => { + const service = setup([upcoming('lo', 130, { renewalLeadDays: 120 })]); + expect( + await service.computeDueAlerts({ programId: PROGRAM_ID, storeId: STORE_ID }), + ).toHaveLength(0); + }); }); diff --git a/src/modules/alerts/alerts.service.ts b/src/modules/alerts/alerts.service.ts index c269b49..e1ef89a 100644 --- a/src/modules/alerts/alerts.service.ts +++ b/src/modules/alerts/alerts.service.ts @@ -11,6 +11,10 @@ export interface IDueAlert { trigger: 'lead' | 'd30' | 'd15' | 'd7' | 'd1' | 'expired'; daysToExpiry: number; expiryDate: string; + /** O prazo que disparou o degrau `lead` — dinâmico quando há amostra. */ + effectiveLeadDays: number; + /** Amostra observada do órgão que esticou o prazo; nulo = só catálogo. */ + observed?: { count: number; medianDays: number } | null; } /** @@ -47,7 +51,10 @@ export class AlertsService { } private toAlert(item: UpcomingExpirationDto): IDueAlert | null { - const trigger = this.resolveTrigger(item.daysToExpiry, item.renewalLeadDays); + // Prazo dinâmico (Onda 1.2): quando o órgão está comprovadamente mais + // lento que o prazo do catálogo, o degrau `lead` dispara mais cedo. + const lead = item.effectiveLeadDays ?? item.renewalLeadDays; + const trigger = this.resolveTrigger(item.daysToExpiry, lead); if (!trigger) return null; return { @@ -56,6 +63,8 @@ export class AlertsService { trigger, daysToExpiry: item.daysToExpiry, expiryDate: item.expiryDate, + effectiveLeadDays: lead, + observed: item.observed ?? null, }; } diff --git a/src/modules/compliance/__tests__/compliance.service.spec.ts b/src/modules/compliance/__tests__/compliance.service.spec.ts index 98adc3c..52942e5 100644 --- a/src/modules/compliance/__tests__/compliance.service.spec.ts +++ b/src/modules/compliance/__tests__/compliance.service.spec.ts @@ -5,7 +5,9 @@ import { StoreDocumentStatus, } from '../../common/enum/document.enum'; import { todayKey } from '../../common/utils/status.util'; +import { IssuingBody } from '../../common/enum/document.enum'; import { ComplianceService } from '../compliance.service'; +import { LeadTimeService } from '../leadTime.service'; const PROGRAM_ID = '47031ca8-7586-40ba-acf6-f7af26d56a13'; const STORE_ID = '6b4f2dfa-2400-4b6e-a587-18978677ef20'; @@ -20,12 +22,14 @@ const type = (code: string, criticality: DocumentCriticality, lead = 30) => ({ code, name: code, criticality, + issuingBody: IssuingBody.OrgaoAmbiental, renewalLeadDays: lead, }); const setup = ({ types = [] as any[], documents = [] as any[], + cycles = [] as any[], } = {}) => { const documentRepository = repositoryMock({ find: jest.fn().mockResolvedValue(documents), @@ -33,9 +37,19 @@ const setup = ({ const catalogService = { getActiveTypes: jest.fn().mockResolvedValue(types), }; + // LeadTimeService real com repositório mockado: os specs do upcoming + // exercitam a integração de verdade, não um dublê da régua. + const cycleRepository = repositoryMock({ + find: jest.fn().mockResolvedValue(cycles), + }); + const leadTimeService = new LeadTimeService( + cycleRepository as any, + catalogService as any, + ); const service = new ComplianceService( documentRepository as any, catalogService as any, + leadTimeService, ); return { service }; }; @@ -175,4 +189,43 @@ describe('ComplianceService.getUpcoming', () => { expect(upcoming[0].daysToExpiry).toBe(-3); expect(upcoming[0].status).toBe(StoreDocumentStatus.Expired); }); + + it('sem histórico, o prazo efetivo é o do catálogo e observed é nulo', async () => { + const { service } = setup({ + types: [type('a', DocumentCriticality.Multa, 30)], + documents: [ + { typeCode: 'a', expiryDate: futureDate(10), renewalProtocolNumber: null }, + ], + }); + const [item] = await service.getUpcoming({ + programId: PROGRAM_ID, + storeId: STORE_ID, + days: 90, + }); + expect(item.effectiveLeadDays).toBe(30); + expect(item.observed).toBeNull(); + }); + + it('com histórico do órgão mais lento que o catálogo, o prazo efetivo estica', async () => { + const cycle = (emissionDays: number) => ({ + typeCode: 'a', + issuingBody: IssuingBody.OrgaoAmbiental, + emissionDays, + }); + const { service } = setup({ + types: [type('a', DocumentCriticality.Multa, 30)], + documents: [ + { typeCode: 'a', expiryDate: futureDate(10), renewalProtocolNumber: null }, + ], + cycles: [cycle(60), cycle(70), cycle(80)], + }); + const [item] = await service.getUpcoming({ + programId: PROGRAM_ID, + storeId: STORE_ID, + days: 90, + }); + expect(item.effectiveLeadDays).toBe(85); // mediana 70 + margem 15 + expect(item.observed).toEqual({ count: 3, medianDays: 70 }); + expect(item.renewalLeadDays).toBe(30); // o do catálogo continua exposto + }); }); diff --git a/src/modules/compliance/__tests__/leadTime.service.spec.ts b/src/modules/compliance/__tests__/leadTime.service.spec.ts new file mode 100644 index 0000000..871534e --- /dev/null +++ b/src/modules/compliance/__tests__/leadTime.service.spec.ts @@ -0,0 +1,153 @@ +/* eslint-disable @typescript-eslint/no-explicit-any */ +import { IssuingBody } from '../../common/enum/document.enum'; +import { repositoryMock } from '../../common/__tests__/mock/repository.mock'; +import { LeadTimeService, MIN_SAMPLE, SAFETY_MARGIN_DAYS } from '../leadTime.service'; + +const PROGRAM_ID = '47031ca8-7586-40ba-acf6-f7af26d56a13'; + +const cycle = ( + emissionDays: number, + overrides: Record = {}, +): Record => ({ + programId: PROGRAM_ID, + storeId: 'store-1', + typeCode: 'licenca-operacao', + issuingBody: IssuingBody.OrgaoAmbiental, + emissionDays, + ...overrides, +}); + +const loType = { + code: 'licenca-operacao', + issuingBody: IssuingBody.OrgaoAmbiental, + renewalLeadDays: 120, +}; + +const setup = (cycles: any[], types: any[] = [loType]) => { + const cycleRepository = repositoryMock({ + find: jest.fn().mockResolvedValue(cycles), + }); + const catalogService = { + getActiveTypes: jest.fn().mockResolvedValue(types), + }; + return new LeadTimeService(cycleRepository as any, catalogService as any); +}; + +describe('LeadTimeService — mediana e p90', () => { + it('mediana com amostra ímpar é o valor do meio', async () => { + const service = setup([cycle(100), cycle(140), cycle(180)]); + const index = await service.getIndex(PROGRAM_ID); + expect(index.byType.get('licenca-operacao')).toEqual({ + count: 3, + medianDays: 140, + p90Days: 180, + }); + }); + + it('mediana com amostra par é a média (arredondada) dos dois do meio', async () => { + const service = setup([cycle(100), cycle(120), cycle(141), cycle(200)]); + const index = await service.getIndex(PROGRAM_ID); + const stats = index.byType.get('licenca-operacao'); + expect(stats?.medianDays).toBe(131); // (120 + 141) / 2 arredondado + expect(stats?.p90Days).toBe(200); + }); +}); + +describe('LeadTimeService.effectiveLeadFor — a régua', () => { + it('sem amostra suficiente (count < MIN_SAMPLE) vale o catálogo, sem observed', async () => { + const service = setup([cycle(300), cycle(310)]); // só 2 + const index = await service.getIndex(PROGRAM_ID); + + expect(service.effectiveLeadFor(loType, index)).toEqual({ + effectiveLeadDays: 120, + observed: null, + }); + expect(MIN_SAMPLE).toBeGreaterThan(2); + }); + + it('órgão mais lento que o catálogo estica o prazo: mediana + margem', async () => { + const service = setup([cycle(130), cycle(140), cycle(150)]); + const index = await service.getIndex(PROGRAM_ID); + + expect(service.effectiveLeadFor(loType, index)).toEqual({ + effectiveLeadDays: 140 + SAFETY_MARGIN_DAYS, + observed: { count: 3, medianDays: 140 }, + }); + }); + + it('órgão rápido NUNCA encolhe o prazo abaixo do catálogo (piso legal)', async () => { + const service = setup([cycle(20), cycle(25), cycle(30)]); + const index = await service.getIndex(PROGRAM_ID); + + const lead = service.effectiveLeadFor(loType, index); + expect(lead.effectiveLeadDays).toBe(120); // catálogo é piso + expect(lead.observed).toEqual({ count: 3, medianDays: 25 }); + }); + + it('sem amostra do tipo, cai na amostra do órgão inteiro', async () => { + const service = setup([ + cycle(200, { typeCode: 'destinacao-residuos' }), + cycle(210, { typeCode: 'destinacao-residuos' }), + cycle(220, { typeCode: 'destinacao-residuos' }), + ]); + const index = await service.getIndex(PROGRAM_ID); + + // licenca-operacao não tem ciclo próprio, mas o órgão ambiental tem 3 + expect(service.effectiveLeadFor(loType, index)).toEqual({ + effectiveLeadDays: 210 + SAFETY_MARGIN_DAYS, + observed: { count: 3, medianDays: 210 }, + }); + }); + + it('amostra do tipo vence a do órgão quando as duas existem', async () => { + const service = setup([ + cycle(140), + cycle(150), + cycle(160), + cycle(40, { typeCode: 'destinacao-residuos' }), + cycle(45, { typeCode: 'destinacao-residuos' }), + cycle(50, { typeCode: 'destinacao-residuos' }), + ]); + const index = await service.getIndex(PROGRAM_ID); + + expect(service.effectiveLeadFor(loType, index).observed?.medianDays).toBe( + 150, + ); + }); +}); + +describe('LeadTimeService.getLeadTimes — tabela do painel', () => { + it('linha por órgão (typeCode nulo) + linha por tipo, com o catálogo como piso', async () => { + const service = setup([cycle(140), cycle(150), cycle(160)]); + + const rows = await service.getLeadTimes(PROGRAM_ID); + + const bodyRow = rows.find(row => row.typeCode === null); + const typeRow = rows.find(row => row.typeCode === 'licenca-operacao'); + + expect(bodyRow).toMatchObject({ + issuingBody: IssuingBody.OrgaoAmbiental, + count: 3, + medianDays: 150, + catalogLeadDays: 120, + effectiveLeadDays: 150 + SAFETY_MARGIN_DAYS, + }); + expect(typeRow).toMatchObject({ + count: 3, + effectiveLeadDays: 150 + SAFETY_MARGIN_DAYS, + }); + }); + + it('amostra pequena aparece na tabela mas o prazo efetivo fica no catálogo', async () => { + const service = setup([cycle(300)]); + + const rows = await service.getLeadTimes(PROGRAM_ID); + const typeRow = rows.find(row => row.typeCode === 'licenca-operacao'); + + expect(typeRow).toMatchObject({ + count: 1, + medianDays: 300, + effectiveLeadDays: 120, + }); + }); +}); diff --git a/src/modules/compliance/compliance.controller.ts b/src/modules/compliance/compliance.controller.ts index 528de12..c6642f9 100644 --- a/src/modules/compliance/compliance.controller.ts +++ b/src/modules/compliance/compliance.controller.ts @@ -12,13 +12,15 @@ import { import { ApiBearerAuth, ApiResponse, ApiTags } from '@nestjs/swagger'; import { FiltersInterceptor } from '../../interceptors/filters.interceptor'; -import { TenantWithStoreDto } from '../common/dto/tenant.dto'; +import { TenantDto, TenantWithStoreDto } from '../common/dto/tenant.dto'; import { ComplianceService } from './compliance.service'; import { ComplianceSummaryResponseDto, UpcomingExpirationDto, } from './dto/response/compliance.dto'; +import { LeadTimesResponseDto } from './dto/response/leadTime.dto'; import { GetUpcomingDto } from './dto/request/getUpcoming.dto'; +import { LeadTimeService } from './leadTime.service'; @ApiTags('Conformidade') @Controller('compliance') @@ -26,7 +28,10 @@ import { GetUpcomingDto } from './dto/request/getUpcoming.dto'; @UseInterceptors(FiltersInterceptor) @UseGuards(JwtAuthGuard) export class ComplianceController { - constructor(private readonly service: ComplianceService) {} + constructor( + private readonly service: ComplianceService, + private readonly leadTimeService: LeadTimeService, + ) {} @Get('summary') @ApiBearerAuth() @@ -45,4 +50,13 @@ export class ComplianceController { days: filters.days ?? 90, }); } + + @Get('lead-times') + @ApiBearerAuth() + @ApiResponse({ status: HttpStatus.OK, type: LeadTimesResponseDto }) + async getLeadTimes( + @Query() filters: TenantDto, + ): Promise { + return { data: await this.leadTimeService.getLeadTimes(filters.programId) }; + } } diff --git a/src/modules/compliance/compliance.module.ts b/src/modules/compliance/compliance.module.ts index edfb830..83bf3e9 100644 --- a/src/modules/compliance/compliance.module.ts +++ b/src/modules/compliance/compliance.module.ts @@ -6,16 +6,18 @@ import { CatalogModule } from '../catalog/catalog.module'; import { StoreDocument } from '../document/storeDocument.entity'; import { ComplianceController } from './compliance.controller'; import { ComplianceService } from './compliance.service'; +import { LeadTimeService } from './leadTime.service'; +import { RenewalCycle } from './renewalCycle.entity'; @Module({ imports: [ - TypeOrmModule.forFeature([StoreDocument]), + TypeOrmModule.forFeature([StoreDocument, RenewalCycle]), CatalogModule, // O AuthModule precisa ser importado em cada módulo que usa o JwtAuthGuard. AuthModule, ], - providers: [ComplianceService], + providers: [ComplianceService, LeadTimeService], controllers: [ComplianceController], - exports: [ComplianceService], + exports: [ComplianceService, LeadTimeService], }) export class ComplianceModule {} diff --git a/src/modules/compliance/compliance.service.ts b/src/modules/compliance/compliance.service.ts index bea77ac..1fee307 100644 --- a/src/modules/compliance/compliance.service.ts +++ b/src/modules/compliance/compliance.service.ts @@ -18,6 +18,7 @@ import { ComplianceSummaryResponseDto, UpcomingExpirationDto, } from './dto/response/compliance.dto'; +import { LeadTimeService } from './leadTime.service'; /** Peso da criticidade no score — o mesmo do painel. */ const CRITICALITY_WEIGHT: Record = { @@ -38,6 +39,7 @@ export class ComplianceService { @InjectRepository(StoreDocument) private readonly documentRepository: Repository, private readonly catalogService: CatalogService, + private readonly leadTimeService: LeadTimeService, ) {} /** @@ -109,26 +111,40 @@ export class ComplianceService { }; } - /** Vencimentos na janela, ordenados — a matéria-prima dos alertas. */ + /** + * Vencimentos na janela, ordenados — a matéria-prima dos alertas. + * + * Cada item carrega o prazo dinâmico (`effectiveLeadDays`): o catálogo é + * piso, e a mediana observada do órgão na carteira o estica quando o órgão + * está mais lento que o prazo legal supõe. + */ async getUpcoming(params: { programId: string; storeId: string; days: number; }): Promise { - const obligations = await this.getObligations(params); + const [obligations, leadIndex] = await Promise.all([ + this.getObligations(params), + this.leadTimeService.getIndex(params.programId), + ]); const today = todayKey(); return obligations .filter(({ document }) => !!document?.expiryDate) - .map(({ type, status, document }) => ({ - typeCode: type.code, - typeName: type.name, - criticality: type.criticality, - status, - expiryDate: document!.expiryDate!, - daysToExpiry: daysBetween(today, document!.expiryDate!), - renewalLeadDays: type.renewalLeadDays, - })) + .map(({ type, status, document }) => { + const lead = this.leadTimeService.effectiveLeadFor(type, leadIndex); + return { + typeCode: type.code, + typeName: type.name, + criticality: type.criticality, + status, + expiryDate: document!.expiryDate!, + daysToExpiry: daysBetween(today, document!.expiryDate!), + renewalLeadDays: type.renewalLeadDays, + effectiveLeadDays: lead.effectiveLeadDays, + observed: lead.observed, + }; + }) .filter(item => item.daysToExpiry <= params.days) .sort((a, b) => a.daysToExpiry - b.daysToExpiry); } diff --git a/src/modules/compliance/dto/response/compliance.dto.ts b/src/modules/compliance/dto/response/compliance.dto.ts index 984411e..df13df2 100644 --- a/src/modules/compliance/dto/response/compliance.dto.ts +++ b/src/modules/compliance/dto/response/compliance.dto.ts @@ -1,9 +1,10 @@ -import { ApiProperty } from '@nestjs/swagger'; +import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; import { DocumentCriticality, StoreDocumentStatus, } from '../../../common/enum/document.enum'; +import { ObservedLeadDto } from './leadTime.dto'; export class ComplianceTotalsDto { @ApiProperty() @@ -60,4 +61,17 @@ export class UpcomingExpirationDto { @ApiProperty() renewalLeadDays: number; + + @ApiPropertyOptional({ + description: + 'Prazo dinâmico: max(catálogo, mediana observada do órgão + margem)', + }) + effectiveLeadDays?: number; + + @ApiPropertyOptional({ + description: 'Amostra que informou o prazo; nulo = valeu só o catálogo', + type: ObservedLeadDto, + nullable: true, + }) + observed?: ObservedLeadDto | null; } diff --git a/src/modules/compliance/dto/response/leadTime.dto.ts b/src/modules/compliance/dto/response/leadTime.dto.ts new file mode 100644 index 0000000..02fdc92 --- /dev/null +++ b/src/modules/compliance/dto/response/leadTime.dto.ts @@ -0,0 +1,44 @@ +import { ApiProperty } from '@nestjs/swagger'; + +import { IssuingBody } from '../../../common/enum/document.enum'; + +export class LeadTimeRowDto { + @ApiProperty({ enum: IssuingBody }) + issuingBody: IssuingBody; + + @ApiProperty({ + description: 'Nulo = agregado do órgão inteiro', + nullable: true, + }) + typeCode: string | null; + + @ApiProperty({ description: 'Ciclos observados na amostra' }) + count: number; + + @ApiProperty() + medianDays: number; + + @ApiProperty() + p90Days: number; + + @ApiProperty({ description: 'Prazo do catálogo (piso legal)' }) + catalogLeadDays: number; + + @ApiProperty({ + description: 'max(catálogo, mediana + margem) quando a amostra basta', + }) + effectiveLeadDays: number; +} + +export class LeadTimesResponseDto { + @ApiProperty({ type: [LeadTimeRowDto] }) + data: LeadTimeRowDto[]; +} + +export class ObservedLeadDto { + @ApiProperty() + count: number; + + @ApiProperty() + medianDays: number; +} diff --git a/src/modules/compliance/leadTime.service.ts b/src/modules/compliance/leadTime.service.ts new file mode 100644 index 0000000..6ab5d12 --- /dev/null +++ b/src/modules/compliance/leadTime.service.ts @@ -0,0 +1,174 @@ +import { Injectable } from '@nestjs/common'; +import { InjectRepository } from '@nestjs/typeorm'; +import { Repository } from 'typeorm'; + +import { CatalogService } from '../catalog/catalog.service'; +import { IssuingBody } from '../common/enum/document.enum'; +import { LeadTimeRowDto } from './dto/response/leadTime.dto'; +import { RenewalCycle } from './renewalCycle.entity'; + +/** Amostra mínima para confiar na estatística em vez do catálogo. */ +export const MIN_SAMPLE = 3; + +/** Folga somada à mediana — o órgão não avisa quando resolve atrasar. */ +export const SAFETY_MARGIN_DAYS = 15; + +export interface ILeadTimeStats { + count: number; + medianDays: number; + p90Days: number; +} + +export interface ILeadTimeIndex { + byType: Map; + byBody: Map; +} + +export interface IEffectiveLead { + effectiveLeadDays: number; + /** A amostra que informou a decisão; nulo quando valeu só o catálogo. */ + observed: { count: number; medianDays: number } | null; +} + +const median = (sorted: number[]): number => { + const middle = Math.floor(sorted.length / 2); + return sorted.length % 2 + ? sorted[middle] + : Math.round((sorted[middle - 1] + sorted[middle]) / 2); +}; + +/** Percentil 90 por nearest-rank — simples e sem interpolação discutível. */ +const p90 = (sorted: number[]): number => + sorted[Math.max(Math.ceil(sorted.length * 0.9) - 1, 0)]; + +const toStats = (days: number[]): ILeadTimeStats => { + const sorted = [...days].sort((a, b) => a - b); + return { count: sorted.length, medianDays: median(sorted), p90Days: p90(sorted) }; +}; + +/** + * Prazo de renovação dinâmico (Onda 1.2 do plano de IA) — v0, estatística + * descritiva, sem ML. + * + * A régua: `effectiveLeadDays = max(catálogo, mediana observada + 15)` quando + * há amostra (>= MIN_SAMPLE ciclos). O catálogo é PISO, nunca teto — o prazo + * legal da LO (120 dias) não encolhe porque um órgão andou rápido; mas se a + * SEMAD está levando 140 dias, avisar com 120 é avisar tarde. + */ +@Injectable() +export class LeadTimeService { + constructor( + @InjectRepository(RenewalCycle) + private readonly cycleRepository: Repository, + private readonly catalogService: CatalogService, + ) {} + + /** Uma ida ao banco; agregação em memória (amostras são pequenas). */ + async getIndex(programId: string): Promise { + const cycles = await this.cycleRepository.find({ where: { programId } }); + + const byTypeDays = new Map(); + const byBodyDays = new Map(); + + cycles.forEach(cycle => { + byTypeDays.set(cycle.typeCode, [ + ...(byTypeDays.get(cycle.typeCode) ?? []), + cycle.emissionDays, + ]); + byBodyDays.set(cycle.issuingBody, [ + ...(byBodyDays.get(cycle.issuingBody) ?? []), + cycle.emissionDays, + ]); + }); + + return { + byType: new Map( + [...byTypeDays.entries()].map(([code, days]) => [code, toStats(days)]), + ), + byBody: new Map( + [...byBodyDays.entries()].map(([body, days]) => [body, toStats(days)]), + ), + }; + } + + /** + * O prazo efetivo de UM tipo: amostra do tipo > amostra do órgão > catálogo. + * A amostra específica do tipo vence a do órgão porque uma LO não se renova + * no ritmo de um CADRI, mesmo sendo o mesmo órgão. + */ + effectiveLeadFor( + type: { code: string; issuingBody: IssuingBody; renewalLeadDays: number }, + index: ILeadTimeIndex, + ): IEffectiveLead { + const stats = + this.usable(index.byType.get(type.code)) ?? + this.usable(index.byBody.get(type.issuingBody)); + + if (!stats) { + return { effectiveLeadDays: type.renewalLeadDays, observed: null }; + } + + return { + effectiveLeadDays: Math.max( + type.renewalLeadDays, + stats.medianDays + SAFETY_MARGIN_DAYS, + ), + observed: { count: stats.count, medianDays: stats.medianDays }, + }; + } + + /** Tabela para o painel: uma linha por órgão + uma por tipo com amostra. */ + async getLeadTimes(programId: string): Promise { + const [index, types] = await Promise.all([ + this.getIndex(programId), + this.catalogService.getActiveTypes(), + ]); + + const rows: LeadTimeRowDto[] = []; + + index.byBody.forEach((stats, issuingBody) => { + // O piso do órgão é o maior prazo entre os tipos dele — conservador + const catalogLeadDays = Math.max( + 0, + ...types + .filter(type => type.issuingBody === issuingBody) + .map(type => type.renewalLeadDays), + ); + rows.push({ + issuingBody, + typeCode: null, + ...stats, + catalogLeadDays, + effectiveLeadDays: + stats.count >= MIN_SAMPLE + ? Math.max(catalogLeadDays, stats.medianDays + SAFETY_MARGIN_DAYS) + : catalogLeadDays, + }); + }); + + index.byType.forEach((stats, typeCode) => { + const type = types.find(item => item.code === typeCode); + if (!type) return; + rows.push({ + issuingBody: type.issuingBody, + typeCode, + ...stats, + catalogLeadDays: type.renewalLeadDays, + effectiveLeadDays: + stats.count >= MIN_SAMPLE + ? Math.max(type.renewalLeadDays, stats.medianDays + SAFETY_MARGIN_DAYS) + : type.renewalLeadDays, + }); + }); + + return rows.sort( + (a, b) => + a.issuingBody.localeCompare(b.issuingBody) || + (a.typeCode ?? '').localeCompare(b.typeCode ?? ''), + ); + } + + private usable(stats?: ILeadTimeStats): ILeadTimeStats | null { + return stats && stats.count >= MIN_SAMPLE ? stats : null; + } +} diff --git a/src/modules/compliance/renewalCycle.entity.ts b/src/modules/compliance/renewalCycle.entity.ts new file mode 100644 index 0000000..671ceed --- /dev/null +++ b/src/modules/compliance/renewalCycle.entity.ts @@ -0,0 +1,44 @@ +import { ApiProperty } from '@nestjs/swagger'; +import { Column, Entity } from 'typeorm'; + +import { BaseCollection } from '../common/entities/base.entity'; +import { IssuingBody } from '../common/enum/document.enum'; + +/** + * Um ciclo de renovação observado: do protocolo no órgão até a emissão do + * documento novo. + * + * É a matéria-prima do prazo de renovação dinâmico (Onda 1.2): a mediana de + * `emissionDays` por órgão na carteira substitui o chute fixo do catálogo. + * Gravado automaticamente no save que zera um protocolo — nunca editado. + */ +@Entity('renewal_cycle') +export class RenewalCycle extends BaseCollection { + @ApiProperty({ description: 'Programa (rede) dono do ciclo' }) + @Column({ type: 'uuid' }) + programId: string; + + @ApiProperty({ description: 'Loja dona do ciclo' }) + @Column({ type: 'uuid' }) + storeId: string; + + @ApiProperty({ description: 'Código do tipo no catálogo' }) + @Column({ type: 'varchar', length: 80 }) + typeCode: string; + + @ApiProperty({ enum: IssuingBody }) + @Column({ type: 'enum', enum: IssuingBody, enumName: 'issuing_body_enum' }) + issuingBody: IssuingBody; + + @ApiProperty({ description: 'Data do protocolo no órgão' }) + @Column({ type: 'date' }) + protocolDate: string; + + @ApiProperty({ description: 'Data de emissão do documento renovado' }) + @Column({ type: 'date' }) + issuedDate: string; + + @ApiProperty({ description: 'Dias entre protocolo e emissão' }) + @Column({ type: 'int' }) + emissionDays: number; +} diff --git a/src/modules/document/__tests__/document.service.spec.ts b/src/modules/document/__tests__/document.service.spec.ts index edf9a2d..726c893 100644 --- a/src/modules/document/__tests__/document.service.spec.ts +++ b/src/modules/document/__tests__/document.service.spec.ts @@ -23,11 +23,18 @@ const futureDate = (days: number): string => { const avcbType = { code: 'avcb', name: 'AVCB', + issuingBody: 'corpoDeBombeiros', renewalLeadDays: 60, keywords: ['avcb'], defaultValidityMonths: null, }; +/** Chamadas de save do manager para uma entidade (pelo nome da classe). */ +const savesOf = (manager: Record, entityName: string) => + (manager.save as jest.Mock).mock.calls.filter( + ([entity]: any[]) => entity?.name === entityName, + ); + interface ISetup { documents?: any[]; manager?: Record; @@ -190,6 +197,88 @@ describe('DocumentService.save', () => { expect(existing.renewalProtocolDate).toBeNull(); }); + it('renovação com protocolo prévio grava o ciclo protocolo→emissão', async () => { + const existing = { + id: DOC_ID, + programId: PROGRAM_ID, + storeId: STORE_ID, + typeCode: 'avcb', + expiryDate: futureDate(-5), + renewalProtocolNumber: 'PROT-1', + renewalProtocolDate: '2026-07-01', + }; + const manager = entityManagerMock({ + findOne: jest.fn().mockResolvedValue(existing), + }); + const { service, documentRepository } = setup({ manager }); + documentRepository.findOne = jest + .fn() + .mockResolvedValue({ ...existing, versions: [] }); + + await service.save(input); // issueDate 2026-08-01 + + const cycleSaves = savesOf(manager, 'RenewalCycle'); + expect(cycleSaves).toHaveLength(1); + expect(cycleSaves[0][1]).toMatchObject({ + typeCode: 'avcb', + issuingBody: 'corpoDeBombeiros', + protocolDate: '2026-07-01', + issuedDate: '2026-08-01', + emissionDays: 31, + }); + }); + + it('primeira versão ou renovação SEM protocolo não gera ciclo', async () => { + const existing = { + id: DOC_ID, + programId: PROGRAM_ID, + storeId: STORE_ID, + typeCode: 'avcb', + expiryDate: futureDate(-5), + renewalProtocolNumber: null, + renewalProtocolDate: null, + }; + const manager = entityManagerMock({ + findOne: jest.fn().mockResolvedValue(existing), + }); + const { service, documentRepository } = setup({ manager }); + documentRepository.findOne = jest + .fn() + .mockResolvedValue({ ...existing, versions: [] }); + + await service.save(input); + + expect(savesOf(manager, 'RenewalCycle')).toHaveLength(0); + }); + + it('ciclo implausível (negativo ou > 2 anos) é descartado com log', async () => { + const existing = { + id: DOC_ID, + programId: PROGRAM_ID, + storeId: STORE_ID, + typeCode: 'avcb', + expiryDate: futureDate(-5), + renewalProtocolNumber: 'PROT-VELHO', + renewalProtocolDate: '2020-01-01', // > 2 anos atrás: protocolo esquecido + }; + const manager = entityManagerMock({ + findOne: jest.fn().mockResolvedValue(existing), + }); + const { service, documentRepository } = setup({ manager }); + documentRepository.findOne = jest + .fn() + .mockResolvedValue({ ...existing, versions: [] }); + + await service.save(input); + expect(savesOf(manager, 'RenewalCycle')).toHaveLength(0); + + // protocolo DEPOIS da emissão (intervalo negativo) também não vale + existing.renewalProtocolNumber = 'PROT-2'; + existing.renewalProtocolDate = '2026-09-01'; + await service.save(input); + expect(savesOf(manager, 'RenewalCycle')).toHaveLength(0); + }); + it('recusa tipo inexistente antes de gravar qualquer coisa', async () => { const { service, catalogService, manager } = setup(); catalogService.getByCode = jest.fn().mockRejectedValue(new Error('TYPE_NOT_FOUND')); diff --git a/src/modules/document/document.service.ts b/src/modules/document/document.service.ts index f3b6e3c..bf62e7d 100644 --- a/src/modules/document/document.service.ts +++ b/src/modules/document/document.service.ts @@ -1,11 +1,16 @@ import { MappedException } from '@clubpetrodev/nestjs-mapped-exception'; -import { Inject, Injectable } from '@nestjs/common'; +import { Inject, Injectable, Logger } from '@nestjs/common'; import { InjectRepository } from '@nestjs/typeorm'; import { DataSource, Repository } from 'typeorm'; import { CatalogService } from '../catalog/catalog.service'; import { StorageService } from '../common/storage/storage.service'; -import { resolveStatus, todayKey } from '../common/utils/status.util'; +import { + daysBetween, + resolveStatus, + todayKey, +} from '../common/utils/status.util'; +import { RenewalCycle } from '../compliance/renewalCycle.entity'; import { DOCUMENT_ANALYZER, DocumentAnalyzer, @@ -36,8 +41,13 @@ export interface ISaveDocumentInput { }; } +/** Ciclo acima disso é dado sujo (protocolo esquecido), não órgão lento. */ +const MAX_PLAUSIBLE_EMISSION_DAYS = 730; + @Injectable() export class DocumentService { + private readonly logger = new Logger(DocumentService.name); + constructor( @InjectRepository(StoreDocument) private readonly documentRepository: Repository, @@ -112,6 +122,12 @@ export class DocumentService { }); } + // Se havia protocolo, esta versão nova é a renovação saindo do órgão — + // o intervalo protocolo → emissão vira amostra do prazo dinâmico. + const previousProtocolDate = document.renewalProtocolNumber + ? document.renewalProtocolDate ?? null + : null; + document.issueDate = input.issueDate; document.expiryDate = input.expiryDate ?? null; document.documentNumber = @@ -124,6 +140,28 @@ export class DocumentService { document = await manager.save(StoreDocument, document); + if (previousProtocolDate) { + const emissionDays = daysBetween(previousProtocolDate, input.issueDate); + if (emissionDays >= 0 && emissionDays <= MAX_PLAUSIBLE_EMISSION_DAYS) { + await manager.save( + RenewalCycle, + manager.create(RenewalCycle, { + programId: input.programId, + storeId: input.storeId, + typeCode: input.typeCode, + issuingBody: type.issuingBody, + protocolDate: previousProtocolDate, + issuedDate: input.issueDate, + emissionDays, + }), + ); + } else { + this.logger.warn( + `Ciclo de renovação descartado (${input.typeCode}): ${emissionDays} dias entre protocolo e emissão não é plausível`, + ); + } + } + const storagePath = this.storageService.buildPath({ programId: input.programId, storeId: input.storeId, diff --git a/src/scripts/seed-demo-cycles.ts b/src/scripts/seed-demo-cycles.ts new file mode 100644 index 0000000..c8870b7 --- /dev/null +++ b/src/scripts/seed-demo-cycles.ts @@ -0,0 +1,98 @@ +/* eslint-disable no-console */ +import datasource from '../ormconfig'; +import { IssuingBody } from '../modules/common/enum/document.enum'; +import { RenewalCycle } from '../modules/compliance/renewalCycle.entity'; + +/** + * Seed de demonstração do prazo dinâmico (LAB). + * + * Gera ciclos protocolo→emissão plausíveis por órgão para o + * `/compliance/lead-times` ter o que mostrar antes de existir histórico real. + * Determinístico (LCG com semente fixa): rodar duas vezes limpa e regrava. + * + * Uso: ts-node -r tsconfig-paths/register src/scripts/seed-demo-cycles.ts [storeId] + */ + +const PROFILE: Array<{ + issuingBody: IssuingBody; + typeCode: string; + medianDays: number; + spreadDays: number; + samples: number; +}> = [ + // Órgão ambiental é o caso-motivo da feature: mediana ACIMA dos 120 do catálogo + { issuingBody: IssuingBody.OrgaoAmbiental, typeCode: 'licenca-operacao', medianDays: 140, spreadDays: 35, samples: 6 }, + { issuingBody: IssuingBody.CorpoDeBombeiros, typeCode: 'avcb', medianDays: 45, spreadDays: 15, samples: 5 }, + { issuingBody: IssuingBody.Prefeitura, typeCode: 'alvara-funcionamento', medianDays: 30, spreadDays: 12, samples: 5 }, + { issuingBody: IssuingBody.Ipem, typeCode: 'afericao-bombas', medianDays: 12, spreadDays: 6, samples: 4 }, + // Amostra pequena de propósito: fica ABAIXO de MIN_SAMPLE e o catálogo vale + { issuingBody: IssuingBody.VigilanciaSanitaria, typeCode: 'alvara-sanitario', medianDays: 25, spreadDays: 8, samples: 2 }, +]; + +/** LCG determinístico — o seed precisa dar o mesmo banco em toda máquina. */ +const makeRandom = (seed: number) => { + let state = seed; + return () => { + state = (state * 48271) % 2147483647; + return state / 2147483647; + }; +}; + +const dateKey = (date: Date): string => date.toISOString().slice(0, 10); + +async function main() { + const [programId, storeId] = process.argv.slice(2); + if (!programId) { + console.error('Uso: seed-demo-cycles [storeId]'); + process.exit(1); + } + + await datasource.initialize(); + const repository = datasource.getRepository(RenewalCycle); + + await repository.delete({ programId }); + + const random = makeRandom(20260807); + const cycles: RenewalCycle[] = []; + + PROFILE.forEach(profile => { + for (let index = 0; index < profile.samples; index += 1) { + const emissionDays = Math.max( + 1, + Math.round( + profile.medianDays + (random() * 2 - 1) * profile.spreadDays, + ), + ); + // Protocolos espalhados pelos últimos ~18 meses + const protocolAt = new Date(); + protocolAt.setDate( + protocolAt.getDate() - Math.round(60 + random() * 480) - emissionDays, + ); + const issuedAt = new Date(protocolAt); + issuedAt.setDate(issuedAt.getDate() + emissionDays); + + cycles.push( + repository.create({ + programId, + storeId: storeId ?? programId, + typeCode: profile.typeCode, + issuingBody: profile.issuingBody, + protocolDate: dateKey(protocolAt), + issuedDate: dateKey(issuedAt), + emissionDays, + }), + ); + } + }); + + await repository.save(cycles); + console.log( + `Seed ok: ${cycles.length} ciclos para o programa ${programId} (${PROFILE.length} órgãos/tipos).`, + ); + await datasource.destroy(); +} + +main().catch(error => { + console.error(error); + process.exit(1); +}); -- 2.45.2