diff --git a/services/aiService.ts b/services/aiService.ts index 7472987..9845bd3 100644 --- a/services/aiService.ts +++ b/services/aiService.ts @@ -1,6 +1,7 @@ import { GoogleGenAI, Type } from "@google/genai"; import type { AIProvider } from '../types/providers'; -import type { ReportData } from '../types'; +import type { ReportData, Standard } from '../types'; +import { validateCertificateAgainstStandard } from './validationEngine'; interface AnalyzeOptions { provider: AIProvider; @@ -73,7 +74,7 @@ const reportSchema = { compliance: { type: Type.OBJECT, properties: { - status: { type: Type.STRING, description: "'CONFORME' ou 'NÃO CONFORME'" }, + status: { type: Type.STRING, description: "'CONFORME' ou 'NÃO CONFORME' (deixe vazio se não souber)" }, nonComplianceReason: { type: Type.STRING }, mechanical: { type: Type.ARRAY, @@ -81,12 +82,9 @@ const reportSchema = { type: Type.OBJECT, properties: { property: { type: Type.STRING }, - norm: { type: Type.STRING }, - certificate: { type: Type.STRING }, - status: { type: Type.STRING, description: "'OK' ou 'REPROVADO'" }, - differencePercentage: { type: Type.STRING } + certificate: { type: Type.STRING } }, - required: ["property", "norm", "certificate", "status", "differencePercentage"] + required: ["property", "certificate"] } }, chemical: { @@ -95,11 +93,9 @@ const reportSchema = { type: Type.OBJECT, properties: { element: { type: Type.STRING }, - norm: { type: Type.STRING }, - certificate: { type: Type.STRING }, - status: { type: Type.STRING, description: "'OK' ou 'REPROVADO'" } + certificate: { type: Type.STRING } }, - required: ["element", "norm", "certificate", "status"] + required: ["element", "certificate"] } }, otherTests: { @@ -108,14 +104,13 @@ const reportSchema = { type: Type.OBJECT, properties: { test: { type: Type.STRING }, - result: { type: Type.STRING }, - status: { type: Type.STRING } + result: { type: Type.STRING } }, - required: ["test", "result", "status"] + required: ["test", "result"] } } }, - required: ["status", "nonComplianceReason", "mechanical", "chemical", "otherTests"] + required: ["mechanical", "chemical", "otherTests"] }, overPerformance: { type: Type.ARRAY, @@ -150,39 +145,20 @@ const arraySchema = { items: reportSchema }; -export const buildAnalysisPrompt = (standardsContext?: any[]) => { - let prompt = `Você é o "SteelCheck", um especialista sênior em engenharia de materiais, metalurgia, calibração e controle de qualidade industrial. +export const buildAnalysisPrompt = () => { + let prompt = `Você é o "SteelCheck", um especialista sênior em extração de dados de Certificados de Qualidade. -Sua tarefa principal é transcrever com precisão matemática e zero alucinação os dados de um Certificado de Qualidade (Mill Test Report) e gerar um "Relatório de Análise Técnica" em formato JSON. +Sua tarefa principal é transcrever com precisão matemática e zero alucinação os dados de um Certificado de Qualidade (Mill Test Report) e gerar um JSON estruturado. ### DIRETRIZES RÍGIDAS DE TRANSCRIÇÃO (OCR DE ALTA PRECISÃO): 1. **Zero Alucinação de Normas:** No campo "standards", extraia APENAS e EXATAMENTE o que estiver escrito na imagem. -2. **Extração de Identificação:** Transcreva números de certificado, lotes, corridas e quantidades exatamente como aparecem. -3. **Mapeamento Preciso de Colunas:** Mapeie composição química e propriedades mecânicas com precisão absoluta. +2. **Extração de Identificação:** Transcreva o produto (incluindo dimensões/bitola, ex: "CHAPA 50MM" ou "PERFIL W360x44.6"), números de certificado, lotes, corridas e quantidades exatamente como aparecem. +3. **Mapeamento Preciso de Colunas:** Mapeie composição química e propriedades mecânicas com precisão absoluta para a chave "certificate". Não preencha "norm" nem "status". 4. **Múltiplas Linhas:** Se houver múltiplas linhas de teste, gere um objeto para cada uma. 5. **Orientação:** Rotacione mentalmente a imagem para ler corretamente. -### ANÁLISE DE CONFORMIDADE: -- Compare os valores reais extraídos com os requisitos mínimos/máximos da norma técnica declarada no certificado. -- FORMATO DO CAMPO "NORM": Sempre que a norma (ou o Banco Local) estabelecer uma FAIXA (um mínimo e um máximo) para um elemento, você DEVE transcrever AMBOS os limites no campo "norm" do JSON (Exemplo: "Min: 0.005% - Max: 0.05%"). Nunca oculte o limite máximo ou mínimo se ambos existirem. -- LAUDOS COM DUPLA ESPECIFICAÇÃO (MÚLTIPLAS NORMAS): Se o certificado citar mais de uma norma (ex: "ASTM A572 GR50 / NBR 7007 AR350"), você DEVE adotar sempre o critério MAIS RÍGIDO (o mais conservador e restritivo) entre as normas citadas para garantir que o material atenda a ambas. Exemplo: se a ASTM não possui limite para Carbono Equivalente (CEQ), mas a NBR exige Máx: 0.47%, você DEVE usar Máx: 0.47% como parâmetro de reprovação. -- REGRA CRÍTICA DE STATUS GERAL: Se QUALQUER item (mecânico, químico ou outro) for avaliado como "REPROVADO", o status geral do laudo ("compliance.status") DEVE ser OBRIGATORIAMENTE "NÃO CONFORME". Só atribua "CONFORME" se ABSOLUTAMENTE TODOS os itens individuais forem "OK".`; +- No campo "analysisSource" do JSON, preencha EXATAMENTE com "Rede Neural". - if (standardsContext && standardsContext.length > 0) { - prompt += `\n\n=== BANCO DE NORMAS LOCAL (USO OBRIGATÓRIO) ===\n`; - prompt += `Você DEVE utilizar as regras rígidas do seguinte banco de dados local para confrontar e validar os resultados do certificado. SE a norma citada no laudo estiver presente na lista abaixo, USE ESTRITAMENTE os valores desta lista para verificar o "status" (CONFORME / REPROVADO):\n\n`; - prompt += JSON.stringify(standardsContext, null, 2); - prompt += `\n\n- No campo "analysisSource" do JSON, preencha EXATAMENTE com "Banco Local" se você usou as regras deste banco.`; - prompt += `\n=================================================\n`; - } else { - prompt += `\n- Utilize o seu conhecimento paramétrico interno de engenharia de materiais para aplicar os limites corretos da norma encontrada no laudo.\n`; - prompt += `- No campo "analysisSource" do JSON, preencha EXATAMENTE com "Rede Neural".\n`; - } - - prompt += ` -- **Fórmula de Variação (differencePercentage):** Calcule a variação percentual do valor do certificado em relação ao mínimo exigido pela norma. - USE ESTRITAMENTE A FÓRMULA: ((Valor_Certificado - Valor_Norma) / Valor_Norma) * 100. - Exemplo: Se a Norma exige Mínimo 345, e o Certificado acusa 415, o resultado é ((415-345)/345)*100 = +20.3%. Retorne APENAS o JSON no formato do array especificado, sem explicações ou markdown. @@ -202,13 +178,13 @@ FORMATO DO RETORNO: "quantity": "8,560 T" }, "compliance": { - "status": "CONFORME", + "status": "", "nonComplianceReason": "", "mechanical": [ - { "property": "Yield Strength (Limite de Escoamento)", "norm": "Min: 345 MPa", "certificate": "415 MPa", "status": "OK", "differencePercentage": "+20.3%" } + { "property": "Yield Strength (Limite de Escoamento)", "certificate": "415 MPa" } ], "chemical": [ - { "element": "C (Carbon)", "norm": "Max: 0.23%", "certificate": "0.22%", "status": "OK" } + { "element": "C (Carbon)", "certificate": "0.22%" } ], "otherTests": [] }, @@ -227,7 +203,7 @@ export const analyzeWithGemini = async (file: File, apiKey: string, model: strin const ai = new GoogleGenAI({ apiKey: apiKey }); const imagePart = await fileToGenerativePart(file); - const prompt = buildAnalysisPrompt(standardsContext); + const prompt = buildAnalysisPrompt(); const response = await ai.models.generateContent({ model, @@ -241,8 +217,14 @@ export const analyzeWithGemini = async (file: File, apiKey: string, model: strin try { const text = response.text; - const data = cleanAndParseJson(text); - return data as ReportData[]; + const data = cleanAndParseJson(text) as ReportData[]; + + // Apply programmatic validation + if (standardsContext && standardsContext.length > 0) { + // Assume the first standard matched is the main one for now + return data.map(report => validateCertificateAgainstStandard(report, standardsContext[0] as Standard)); + } + return data; } catch (e) { if (e instanceof Error) { throw e; @@ -262,7 +244,7 @@ export const analyzeWithOpenAI = async (file: File, apiKey: string, model: strin reader.readAsDataURL(file); }); - const prompt = buildAnalysisPrompt(standardsContext); + const prompt = buildAnalysisPrompt(); const response = await fetch('https://api.openai.com/v1/chat/completions', { method: 'POST', @@ -302,7 +284,11 @@ export const analyzeWithOpenAI = async (file: File, apiKey: string, model: strin throw new Error("Resposta vazia da OpenAI"); } - return cleanAndParseJson(content) as ReportData[]; + const dataJson = cleanAndParseJson(content) as ReportData[]; + if (standardsContext && standardsContext.length > 0) { + return dataJson.map(report => validateCertificateAgainstStandard(report, standardsContext[0] as Standard)); + } + return dataJson; }; export const analyzeCertificate = async (options: AnalyzeOptions): Promise => { @@ -334,7 +320,7 @@ export const analyzeWithOllama = async (file: File, endpoint: string, model: str reader.readAsDataURL(file); }); - const prompt = buildAnalysisPrompt(standardsContext); + const prompt = buildAnalysisPrompt(); let url = `${endpoint}/api/chat`; @@ -392,7 +378,11 @@ export const analyzeWithOllama = async (file: File, endpoint: string, model: str throw new Error("Resposta vazia do Ollama/OpenWebUI"); } - return cleanAndParseJson(content) as ReportData[]; + const dataJson = cleanAndParseJson(content) as ReportData[]; + if (standardsContext && standardsContext.length > 0) { + return dataJson.map(report => validateCertificateAgainstStandard(report, standardsContext[0] as Standard)); + } + return dataJson; }; export const analyzeWithOpenRouter = async (file: File, apiKey: string, model: string = 'openai/gpt-4o', standardsContext?: any[]): Promise => { @@ -406,7 +396,7 @@ export const analyzeWithOpenRouter = async (file: File, apiKey: string, model: s reader.readAsDataURL(file); }); - const prompt = buildAnalysisPrompt(standardsContext); + const prompt = buildAnalysisPrompt(); const response = await fetch('https://openrouter.ai/api/v1/chat/completions', { method: 'POST', @@ -452,7 +442,11 @@ export const analyzeWithOpenRouter = async (file: File, apiKey: string, model: s throw new Error("Resposta vazia do OpenRouter"); } - return cleanAndParseJson(content) as ReportData[]; + const dataJson = cleanAndParseJson(content) as ReportData[]; + if (standardsContext && standardsContext.length > 0) { + return dataJson.map(report => validateCertificateAgainstStandard(report, standardsContext[0] as Standard)); + } + return dataJson; }; export const buildStandardWithAI = async ( @@ -463,32 +457,42 @@ export const buildStandardWithAI = async ( const prompt = `Você é um engenheiro de materiais sênior. O usuário solicitou a criação de uma tabela de limites para a norma técnica: "${standardName}". A categoria deste material é descrita como: "${categoryDescription}". -Sua tarefa é acessar seu conhecimento sobre esta norma e gerar os limites estritos (mínimos e máximos) exigidos. +Sua tarefa é acessar seu conhecimento sobre esta norma e gerar os limites estritos, incluindo as variações por faixa de espessura (bitola), grupos de perfil e tolerâncias de análise de produto. RESPONDA APENAS UM JSON VÁLIDO no seguinte formato EXATO: { - "codigo": "Nome oficial da norma (ex: ASTM A36/A36M-19)", - "produto_especifico": "Descreva o tipo de produto que essa norma cobre", + "id_norma": "id_unico_da_norma_sem_espacos", + "codigo": "Nome oficial da norma (ex: ASTM A572/A572M)", + "grau": "Grau se aplicável (ex: 50)", + "produto_especifico": "Descreva o tipo de produto", + "limites_quimicos_corrida": { + "produtos_gerais": [ + { + "espessura_max_mm": valor_numerico_ou_null, + "carbono_max": valor_numerico, + "manganes_max": valor_numerico + } + ] + }, + "tolerancias_analise_produto_astma6": { + "carbono": { + "tabela": [ + { "limite_corrida_ate": 0.25, "tolerancia_mais": 0.03 } + ] + } + }, "parametros_controle": { "mecanicos": { - "nome_do_parametro_1": valor_numerico, - "nome_do_parametro_2": valor_numerico - }, - "quimicos": { - "nome_do_parametro": valor_numerico - }, - "fisicos": { - "nome_do_parametro": valor_numerico + "limite_escoamento_min": valor_numerico } } } Regras Cruciais: -1. USE APENAS NÚMEROS (floats/ints) nos valores. NUNCA use strings como "0.25" ou "250". Use 0.25 e 250. -2. Se a norma não especifica um limite máximo ou mínimo para um elemento, coloque null (sem aspas). -3. O nome da chave dentro de mecânicos/químicos/físicos deve ser em minúsculo, sem acentos, com underscores, e DEVE conter a palavra "_min_" ou "_max_" (ex: "limite_escoamento_min_mpa", "carbono_max_percentual"). Se a norma define uma FAIXA (um mínimo e um máximo) para o mesmo elemento, você DEVE criar DUAS chaves separadas (ex: "columbio_min_percentual" e "columbio_max_percentual"). NUNCA combine min e max na mesma chave. -4. Crie APENAS os grupos (mecanicos, quimicos, fisicos) que fazem sentido para a norma solicitada. -5. DEVOLVA APENAS O JSON, sem nenhuma formatação ou texto explicativo ao redor.`; +1. USE APENAS NÚMEROS (floats/ints) nos valores. NUNCA use strings como "0.25". +2. Estruture as matrizes de limite químico considerando as faixas de espessura de acordo com a norma. +3. Inclua a chave tolerancias_analise_produto_astma6 se aplicável. +4. DEVOLVA APENAS O JSON, sem nenhuma formatação ou texto explicativo ao redor.`; try { let resultJson = ''; diff --git a/services/validationEngine.ts b/services/validationEngine.ts new file mode 100644 index 0000000..54cad2f --- /dev/null +++ b/services/validationEngine.ts @@ -0,0 +1,217 @@ +import { ReportData, Standard, ComplianceItem } from '../types'; + +/** + * Extracts a numeric value from a string like "0.22%", "415 MPa", etc. + */ +function extractNumber(val: string): number | null { + if (!val) return null; + const match = val.match(/-?\d+(\.\d+)?/); + return match ? parseFloat(match[0]) : null; +} + +/** + * Parses the product string to try and find a thickness in mm. + * E.g., "CHAPA 50MM", "PLATE 25.4 MM" + */ +function parseThickness(productDesc: string): number { + if (!productDesc) return 0; + const match = productDesc.match(/(\d+(\.\d+)?)\s*(mm)/i); + if (match) return parseFloat(match[1]); + return 0; // Default or unknown +} + +function normalizeElementName(element: string): string { + const map: Record = { + 'c': 'carbono', + 'carbon': 'carbono', + 'carbono': 'carbono', + 'mn': 'manganes', + 'manganese': 'manganes', + 'manganes': 'manganes', + 'si': 'silicio', + 'silicon': 'silicio', + 'silicio': 'silicio', + 'p': 'fosforo', + 'phosphorus': 'fosforo', + 'fosforo': 'fosforo', + 's': 'enxofre', + 'sulfur': 'enxofre', + 'enxofre': 'enxofre', + 'cu': 'cobre', + 'copper': 'cobre', + 'cobre': 'cobre', + 'ni': 'niquel', + 'nickel': 'niquel', + 'niquel': 'niquel', + 'cr': 'cromo', + 'chromium': 'cromo', + 'cromo': 'cromo', + 'mo': 'molibdenio', + 'molybdenum': 'molibdenio', + 'molibdenio': 'molibdenio', + 'v': 'vanadio', + 'vanadium': 'vanadio', + 'vanadio': 'vanadio', + 'nb': 'niobio', + 'columbium': 'niobio', + 'niobium': 'niobio', + 'niobio': 'niobio', + 'ti': 'titanio', + 'titanium': 'titanio', + 'titanio': 'titanio', + 'al': 'aluminio', + 'aluminum': 'aluminio', + 'aluminio': 'aluminio' + }; + + const lower = element.toLowerCase().trim(); + // Remove content in parentheses, e.g. "C (Carbon)" -> "c" + const clean = lower.replace(/\(.*?\)/g, '').trim(); + return map[clean] || clean; +} + +function getTolerance(elementName: string, productVal: number, thickness: number, norm: Standard): number { + if (!norm.tolerancias_analise_produto_astma6) return 0; + + const tolRules = norm.tolerancias_analise_produto_astma6[elementName]; + if (!tolRules) return 0; + + if (tolRules.tabela && tolRules.tabela.length > 0) { + for (const faixa of tolRules.tabela) { + if (faixa.espessura_ate_mm && thickness <= faixa.espessura_ate_mm) { + return faixa.tolerancia_mais || 0; + } + if (faixa.espessura_maior_que_mm && thickness > faixa.espessura_maior_que_mm) { + return faixa.tolerancia_mais || 0; + } + if (faixa.limite_corrida_ate && productVal <= faixa.limite_corrida_ate) { + return faixa.tolerancia_mais || 0; + } + } + } + + return tolRules.tolerancia_mais || 0; +} + +export function validateCertificateAgainstStandard(extractedReport: ReportData, standard: Standard): ReportData { + const report = JSON.parse(JSON.stringify(extractedReport)) as ReportData; + const thickness = parseThickness(report.identification.product); + + let allOk = true; + + // Encontrar limites baseados no produto (simplificado: pegamos o primeiro grupo que atende a espessura, ou o flat limits) + let activeLimits: Record = {}; + + if (standard.limites_quimicos_corrida) { + const grupos = Object.values(standard.limites_quimicos_corrida)[0] || []; + for (const limite of grupos) { + if (!limite.espessura_max_mm || thickness <= limite.espessura_max_mm) { + // Encontrou a faixa certa + for (const [key, val] of Object.entries(limite)) { + if (typeof val === 'number') { + activeLimits[key] = val; + } + } + break; // Pega o primeiro correspondente + } + } + } else if (standard.parametros_controle?.quimicos) { + activeLimits = { ...standard.parametros_controle.quimicos }; + } + + // Validate Chemical + if (report.compliance.chemical) { + for (const item of report.compliance.chemical) { + if (!item.element) continue; + + const normElement = normalizeElementName(item.element); + const val = extractNumber(item.certificate); + + if (val !== null) { + // Look for max limit + const maxLimit = activeLimits[`${normElement}_max`] || activeLimits[normElement]; + const minLimit = activeLimits[`${normElement}_min`]; + + let status: 'OK' | 'FALHA' = 'OK'; + let normStr = []; + + // Product tolerance + const tolerance = getTolerance(normElement, maxLimit as number || 0, thickness, standard); + + if (minLimit !== undefined && minLimit !== null) { + normStr.push(`Min: ${minLimit}`); + if (val < (minLimit - tolerance)) status = 'FALHA'; + } + + if (maxLimit !== undefined && maxLimit !== null) { + normStr.push(`Max: ${maxLimit}`); + if (val > (maxLimit + tolerance)) status = 'FALHA'; + } + + if (normStr.length > 0) { + item.norm = normStr.join(' - '); + } else { + item.norm = 'Não especificado'; + } + + item.status = status; + if (status === 'FALHA') allOk = false; + } + } + } + + // Validate Mechanical + const mechLimits = standard.parametros_controle?.mecanicos || {}; + if (report.compliance.mechanical) { + for (const item of report.compliance.mechanical) { + if (!item.property) continue; + + const val = extractNumber(item.certificate); + if (val !== null) { + const propLower = item.property.toLowerCase(); + let maxLimit: number | null = null; + let minLimit: number | null = null; + + if (propLower.includes('yield') || propLower.includes('escoamento')) { + minLimit = mechLimits['limite_escoamento_min'] || mechLimits['yield_strength_min'] || null; + maxLimit = mechLimits['limite_escoamento_max'] || mechLimits['yield_strength_max'] || null; + } else if (propLower.includes('tensile') || propLower.includes('resistencia')) { + minLimit = mechLimits['limite_resistencia_min'] || mechLimits['tensile_strength_min'] || null; + maxLimit = mechLimits['limite_resistencia_max'] || mechLimits['tensile_strength_max'] || null; + } else if (propLower.includes('elongation') || propLower.includes('alongamento')) { + minLimit = mechLimits['alongamento_min'] || mechLimits['elongation_min'] || null; + } + + let status: 'OK' | 'FALHA' = 'OK'; + let normStr = []; + + if (minLimit !== null) { + normStr.push(`Min: ${minLimit}`); + if (val < minLimit) status = 'FALHA'; + item.differencePercentage = `${val >= minLimit ? '+' : ''}${(((val - minLimit) / minLimit) * 100).toFixed(1)}%`; + } + + if (maxLimit !== null) { + normStr.push(`Max: ${maxLimit}`); + if (val > maxLimit) status = 'FALHA'; + } + + if (normStr.length > 0) { + item.norm = normStr.join(' - '); + } else { + item.norm = 'Não especificado'; + } + + item.status = status; + if (status === 'FALHA') allOk = false; + } + } + } + + report.compliance.status = allOk ? 'CONFORME' : 'NÃO CONFORME'; + if (!allOk) { + report.compliance.nonComplianceReason = 'Valores fora dos limites da norma técnica considerando bitola e tolerâncias de produto.'; + } + + return report; +} diff --git a/types.ts b/types.ts index 17db5bc..48c70a5 100644 --- a/types.ts +++ b/types.ts @@ -46,3 +46,46 @@ export interface ReportData { overPerformance: OverPerformanceItem[]; equivalents: EquivalentItem[]; } + +// --- NEW STANDARD DEFINITIONS --- +export interface ToleranciaTabela { + limite_corrida_ate?: number; + espessura_ate_mm?: number; + espessura_maior_que_mm?: number; + tolerancia_mais?: number; + tolerancia_menos?: number; +} + +export interface RegraTolerancia { + regra?: string; + tabela?: ToleranciaTabela[]; + tolerancia_mais?: number; + tolerancia_menos?: number; +} + +export interface ToleranciasAnaliseProduto { + [elemento: string]: RegraTolerancia; +} + +export interface LimitesQuimicos { + espessura_max_mm?: number; + grupos_astm_a6?: number[]; + nota_exemplo?: string; + [elemento: string]: number | string | number[] | undefined; +} + +export interface Standard { + id_norma: string; + codigo: string; + grau?: string; + produto_especifico?: string; + limites_quimicos_corrida?: { + [grupo_produto: string]: LimitesQuimicos[]; + }; + tolerancias_analise_produto_astma6?: ToleranciasAnaliseProduto; + parametros_controle?: { + mecanicos?: Record; + quimicos?: Record; + fisicos?: Record; + }; +}