Deploy Inicial do SteelCheck com Docker Build Automatizado

This commit is contained in:
2026-06-23 00:22:53 +00:00
parent 8fc62aeb0f
commit e82656e5c2
5 changed files with 111 additions and 87 deletions
+15 -11
View File
@@ -18,7 +18,7 @@ const App: React.FC = () => {
const [provider, setProvider] = useState<AIProvider>('gemini'); const [provider, setProvider] = useState<AIProvider>('gemini');
const [model, setModel] = useState<string>('gemini-2.5-flash'); const [model, setModel] = useState<string>('gemini-2.5-flash');
const [hasKey, setHasKey] = useState<boolean>(false); const [hasKey, setHasKey] = useState<boolean>(false);
const [reportData, setReportData] = useState<ReportData | null>(null); const [reportsData, setReportsData] = useState<ReportData[] | null>(null);
const [isLoading, setIsLoading] = useState(false); const [isLoading, setIsLoading] = useState(false);
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
@@ -40,7 +40,7 @@ const App: React.FC = () => {
const handleFileChange = useCallback((selectedFile: File | null) => { const handleFileChange = useCallback((selectedFile: File | null) => {
setFile(selectedFile); setFile(selectedFile);
if (!selectedFile) { if (!selectedFile) {
setReportData(null); setReportsData(null);
setError(null); setError(null);
} }
}, []); }, []);
@@ -79,7 +79,7 @@ const App: React.FC = () => {
} }
setIsLoading(true); setIsLoading(true);
setError(null); setError(null);
setReportData(null); setReportsData(null);
try { try {
const data = await analyzeCertificate({ const data = await analyzeCertificate({
provider, provider,
@@ -88,7 +88,7 @@ const App: React.FC = () => {
file, file,
endpoint: provider === 'ollama' ? endpoint : undefined endpoint: provider === 'ollama' ? endpoint : undefined
}); });
setReportData(data); setReportsData(data);
} catch (err) { } catch (err) {
if (err instanceof Error) { if (err instanceof Error) {
if (err.message.includes('API key') || err.message.includes('not valid') || err.message.includes('invalid')) { if (err.message.includes('API key') || err.message.includes('not valid') || err.message.includes('invalid')) {
@@ -107,7 +107,7 @@ const App: React.FC = () => {
const handleReset = useCallback(() => { const handleReset = useCallback(() => {
setFile(null); setFile(null);
setReportData(null); setReportsData(null);
setIsLoading(false); setIsLoading(false);
setError(null); setError(null);
}, []); }, []);
@@ -120,8 +120,8 @@ const App: React.FC = () => {
}, [handleReset]); }, [handleReset]);
const handleExport = (action: 'preview' | 'download') => { const handleExport = (action: 'preview' | 'download') => {
if (reportData) { if (reportsData && reportsData.length > 0) {
const fileName = `Relatorio_SteelCheck_${reportData.identification.certificateNumber || 'analise'}`; const fileName = `Relatorio_SteelCheck_${reportsData[0].identification.certificateNumber || 'analise'}`;
exportAsPdf('printable-report-container', fileName, action); exportAsPdf('printable-report-container', fileName, action);
} }
}; };
@@ -134,7 +134,7 @@ const App: React.FC = () => {
<main className="container mx-auto p-4 sm:p-6 md:p-8 max-w-7xl"> <main className="container mx-auto p-4 sm:p-6 md:p-8 max-w-7xl">
{!hasKey ? ( {!hasKey ? (
<ApiKeySetup onKeySave={handleKeySave} /> <ApiKeySetup onKeySave={handleKeySave} />
) : !reportData ? ( ) : !reportsData ? (
<div className="max-w-2xl mx-auto pt-8"> <div className="max-w-2xl mx-auto pt-8">
<div className="bg-white/70 dark:bg-slate-800/60 backdrop-blur-md border border-white/20 dark:border-slate-700/50 shadow-xl p-6 sm:p-10 rounded-2xl"> <div className="bg-white/70 dark:bg-slate-800/60 backdrop-blur-md border border-white/20 dark:border-slate-700/50 shadow-xl p-6 sm:p-10 rounded-2xl">
<div className="text-center mb-10"> <div className="text-center mb-10">
@@ -191,14 +191,18 @@ const App: React.FC = () => {
Baixar Relatório Baixar Relatório
</button> </button>
</div> </div>
<ReportDisplay report={reportData} /> <div className="flex flex-col gap-8">
{reportsData.map((report, idx) => (
<ReportDisplay key={idx} report={report} />
))}
</div>
</div> </div>
)} )}
</main> </main>
{reportData && ( {reportsData && (
<div className="absolute -left-[9999px] top-0 opacity-0" aria-hidden="true"> <div className="absolute -left-[9999px] top-0 opacity-0" aria-hidden="true">
<PrintableReport report={reportData} /> <PrintableReport reports={reportsData} />
</div> </div>
)} )}
</div> </div>
+9 -9
View File
@@ -3,22 +3,22 @@ import type { ReportData } from '../types';
import { ReportDisplay } from './ReportDisplay'; import { ReportDisplay } from './ReportDisplay';
interface PrintableReportProps { interface PrintableReportProps {
report: ReportData; reports: ReportData[];
} }
export const PrintableReport: React.FC<PrintableReportProps> = ({ report }) => { export const PrintableReport: React.FC<PrintableReportProps> = ({ reports }) => {
// To ensure the generated PDF is strictly in light mode, we can wrap it in a container
// that forces light theme classes, but since html2canvas evaluates computed styles,
// it's best to ensure it renders offscreen without "dark" class in its hierarchy.
// The 'light' class can help if tailwind 'dark' strategy is class-based.
return ( return (
<div <div
id="printable-report-container" id="printable-report-container"
data-report={JSON.stringify(report)} data-report={JSON.stringify(reports)}
className="light bg-white text-slate-900" className="light bg-white text-slate-900 flex flex-col gap-[20mm]"
style={{ width: '210mm', minHeight: '297mm', background: 'white' }} style={{ width: '210mm', background: 'white' }}
> >
{reports.map((report, idx) => (
<div key={idx} className="report-page" style={{ minHeight: '297mm' }}>
<ReportDisplay report={report} /> <ReportDisplay report={report} />
</div> </div>
))}
</div>
); );
}; };
+4 -4
View File
@@ -141,9 +141,9 @@ export const ReportDisplay: React.FC<ReportDisplayProps> = ({ report }) => {
return ( return (
<tr key={i}> <tr key={i}>
<td className="py-2.5 px-2 truncate"> <td className="py-2.5 px-2 truncate">
<span className="font-semibold block text-slate-800 dark:text-slate-200" title={item.property}>{item.property}</span> <span className="font-medium text-[10px] block text-slate-800 dark:text-slate-200" title={item.property}>{item.property}</span>
</td> </td>
<td className="py-2.5 px-1 font-mono font-bold text-slate-700 dark:text-slate-300 truncate" title={item.certificate}>{item.certificate}</td> <td className="py-2.5 px-1 font-mono font-bold text-[10px] text-slate-700 dark:text-slate-300 truncate" title={item.certificate}>{item.certificate}</td>
<td className="py-2.5 px-1 text-center truncate"> <td className="py-2.5 px-1 text-center truncate">
{item.differencePercentage ? ( {item.differencePercentage ? (
<span className={`text-[9px] font-bold ${diffColor}`}>{item.differencePercentage}</span> <span className={`text-[9px] font-bold ${diffColor}`}>{item.differencePercentage}</span>
@@ -191,9 +191,9 @@ export const ReportDisplay: React.FC<ReportDisplayProps> = ({ report }) => {
<tbody className="divide-y divide-slate-50 dark:divide-slate-800/50 font-mono"> <tbody className="divide-y divide-slate-50 dark:divide-slate-800/50 font-mono">
{compliance.chemical.map((item, i) => ( {compliance.chemical.map((item, i) => (
<tr key={i}> <tr key={i}>
<td className="py-2 px-2 font-sans font-semibold text-slate-800 dark:text-slate-200 truncate" title={item.element}>{item.element}</td> <td className="py-2 px-2 font-sans font-medium text-[10px] text-slate-800 dark:text-slate-200 truncate" title={item.element}>{item.element}</td>
<td className="py-2 px-1 text-slate-400 dark:text-slate-500 text-[9px] leading-tight truncate" title={item.norm}>{item.norm}</td> <td className="py-2 px-1 text-slate-400 dark:text-slate-500 text-[9px] leading-tight truncate" title={item.norm}>{item.norm}</td>
<td className="py-2 px-1 font-bold text-slate-700 dark:text-slate-300 truncate" title={item.certificate}>{item.certificate}</td> <td className="py-2 px-1 font-bold text-[10px] text-slate-700 dark:text-slate-300 truncate" title={item.certificate}>{item.certificate}</td>
<td className="py-2 px-1 text-right"> <td className="py-2 px-1 text-right">
<span className={`inline-flex items-center text-[9px] font-bold px-1.5 py-0.5 rounded ${statusClass(item.status)}`}> <span className={`inline-flex items-center text-[9px] font-bold px-1.5 py-0.5 rounded ${statusClass(item.status)}`}>
{item.status} {item.status}
+42 -32
View File
@@ -39,11 +39,14 @@ const cleanAndParseJson = (jsonString: string): any => {
try { try {
const parsed = JSON.parse(cleanedString); const parsed = JSON.parse(cleanedString);
// Se o modelo aninhou os dados dentro de uma chave "report" let finalData = parsed;
if (parsed.report && !parsed.identification) { // Se o modelo aninhou os dados dentro de uma chave "report" ou "reports"
return parsed.report; if (parsed.reports) {
finalData = parsed.reports;
} else if (parsed.report && !parsed.identification) {
finalData = parsed.report;
} }
return parsed; return Array.isArray(finalData) ? finalData : [finalData];
} catch (error) { } catch (error) {
console.error("Failed to parse cleaned JSON:", jsonString); console.error("Failed to parse cleaned JSON:", jsonString);
throw new Error("A resposta da IA não estava no formato JSON esperado."); throw new Error("A resposta da IA não estava no formato JSON esperado.");
@@ -143,25 +146,31 @@ const reportSchema = {
required: ["confidence", "identification", "compliance", "overPerformance", "equivalents"] required: ["confidence", "identification", "compliance", "overPerformance", "equivalents"]
}; };
const PROMPT_BASE = `Você é o "SteelCheck", um especialista sênior em engenharia de materiais, metalurgia e controle de qualidade, com profundo conhecimento em aços estruturais (ASTM, EN, DIN), parafusos (ASTM, ISO), consumíveis de soldagem (AWS, ISO) e revestimentos industriais (ISO, NACE, SSPC). const arraySchema = {
type: Type.ARRAY,
items: reportSchema
};
Sua tarefa é analisar o Certificado de Qualidade fornecido e gerar um "Relatório de Análise Técnica" em formato JSON, seguindo rigorosamente o schema fornecido. const PROMPT_BASE = `Você é o "SteelCheck", um especialista sênior em engenharia de materiais, metalurgia e controle de qualidade.
**Instrução Crítica:** Verifique a orientação do documento. Se estiver rotacionado, ajuste para a posição correta antes de extrair dados. Sua tarefa é analisar o Certificado de Qualidade fornecido e gerar um "Relatório de Análise Técnica" em formato JSON.
**Instrução Crítica 1 (Orientação):** A imagem PODE estar rotacionada (deitada ou de cabeça para baixo). Compense mentalmente a orientação correta antes de ler as tabelas.
**Instrução Crítica 2 (Múltiplos Lotes/Corridas):** Se o certificado contiver MÚLTIPLOS LOTES (Batches) ou MÚLTIPLAS CORRIDAS (Heats) com resultados INDEPENDENTES, gere múltiplos relatórios! O JSON final DEVE SER UM ARRAY contendo 1 objeto para cada lote/corrida independente encontrado.
Execute: Execute:
1. **Extração de Dados (OCR):** Extraia todos os dados primários do certificado. 1. Extração de Dados: Leia a imagem atentamente, respeitando as colunas.
2. **Identificação de Normas:** Identifique a(s) norma(s) que o certificado alega atender. 2. Identificação de Normas.
3. **Análise de Conformidade:** Compare os resultados com os requisitos mínimos e máximos das normas. Determine status como "OK" ou "FALHA" para cada item e "CONFORME" ou "NÃO CONFORME" geral. 3. Análise de Conformidade: Compare resultados x requisitos.
4. **Propriedades Mecânicas (mechanical):** Tabela de propriedades extraídas (ex: Yield Strength, Tensile Strength, Alongamento). Para CADA propriedade, calcule o percentual de quanto o material analisado é superior (ex: "+15%") ou inferior (ex: "-5%") em resistência ao material normativo teórico, e preencha no campo "differencePercentage". 4. Propriedades Mecânicas (mechanical): Calcule o "differencePercentage" (+X% ou -X%) em relação ao teórico.
5. **Análise de Equivalência:** Liste materiais equivalentes de outras normas (EN, DIN, JIS, NBR, etc.). 5. Análise de Equivalência e Grau de Confiança.
6. **Grau de Confiança:** Forneça um % de confiança baseado na qualidade do documento.
Retorne APENAS o objeto JSON, sem formatação adicional, seguindo EXATAMENTE esta estrutura de chaves (em inglês): Retorne APENAS o JSON, sem markdown. DEVE SER UM ARRAY DE OBJETOS:
[
{ {
"confidence": 95, "confidence": 95,
"identification": { "identification": {
"product": "", "standards": "", "manufacturer": "", "certificateNumber": "", "certificateDate": "", "batches": "", "heats": "", "quantity": "" "product": "", "standards": "", "manufacturer": "", "certificateNumber": "", "certificateDate": "", "batches": "LOTE X", "heats": "CORRIDA Y", "quantity": ""
}, },
"compliance": { "compliance": {
"status": "CONFORME ou NÃO CONFORME", "status": "CONFORME ou NÃO CONFORME",
@@ -171,9 +180,10 @@ Retorne APENAS o objeto JSON, sem formatação adicional, seguindo EXATAMENTE es
}, },
"overPerformance": [ { "property": "", "value": "" } ], "overPerformance": [ { "property": "", "value": "" } ],
"equivalents": [ { "system": "", "norm": "" } ] "equivalents": [ { "system": "", "norm": "" } ]
}`; }
]`;
export const analyzeWithGemini = async (file: File, apiKey: string, model: string = 'gemini-2.5-flash'): Promise<ReportData> => { export const analyzeWithGemini = async (file: File, apiKey: string, model: string = 'gemini-2.5-flash'): Promise<ReportData[]> => {
if (!apiKey) { if (!apiKey) {
throw new Error("A chave de API do Gemini não foi fornecida."); throw new Error("A chave de API do Gemini não foi fornecida.");
} }
@@ -186,7 +196,7 @@ export const analyzeWithGemini = async (file: File, apiKey: string, model: strin
contents: { parts: [imagePart, { text: PROMPT_BASE }] }, contents: { parts: [imagePart, { text: PROMPT_BASE }] },
config: { config: {
responseMimeType: "application/json", responseMimeType: "application/json",
responseSchema: reportSchema, responseSchema: arraySchema,
temperature: 0.1, temperature: 0.1,
} }
}); });
@@ -194,7 +204,7 @@ export const analyzeWithGemini = async (file: File, apiKey: string, model: strin
try { try {
const text = response.text; const text = response.text;
const data = cleanAndParseJson(text); const data = cleanAndParseJson(text);
return data as ReportData; return data as ReportData[];
} catch (e) { } catch (e) {
if (e instanceof Error) { if (e instanceof Error) {
throw e; throw e;
@@ -203,7 +213,7 @@ export const analyzeWithGemini = async (file: File, apiKey: string, model: strin
} }
}; };
export const analyzeWithOpenAI = async (file: File, apiKey: string, model: string = 'gpt-4o'): Promise<ReportData> => { export const analyzeWithOpenAI = async (file: File, apiKey: string, model: string = 'gpt-4o'): Promise<ReportData[]> => {
if (!apiKey) { if (!apiKey) {
throw new Error("A chave de API da OpenAI não foi fornecida."); throw new Error("A chave de API da OpenAI não foi fornecida.");
} }
@@ -252,10 +262,10 @@ export const analyzeWithOpenAI = async (file: File, apiKey: string, model: strin
throw new Error("Resposta vazia da OpenAI"); throw new Error("Resposta vazia da OpenAI");
} }
return cleanAndParseJson(content) as ReportData; return cleanAndParseJson(content) as ReportData[];
}; };
export const analyzeWithAnthropic = async (file: File, apiKey: string, model: string = 'claude-3-sonnet-20240229'): Promise<ReportData> => { export const analyzeWithAnthropic = async (file: File, apiKey: string, model: string = 'claude-3-sonnet-20240229'): Promise<ReportData[]> => {
if (!apiKey) { if (!apiKey) {
throw new Error("A chave de API da Anthropic não foi fornecida."); throw new Error("A chave de API da Anthropic não foi fornecida.");
} }
@@ -311,10 +321,10 @@ export const analyzeWithAnthropic = async (file: File, apiKey: string, model: st
throw new Error("Resposta vazia da Anthropic"); throw new Error("Resposta vazia da Anthropic");
} }
return cleanAndParseJson(content) as ReportData; return cleanAndParseJson(content) as ReportData[];
}; };
export const analyzeWithAzure = async (file: File, apiKey: string, endpoint: string, deployment: string): Promise<ReportData> => { export const analyzeWithAzure = async (file: File, apiKey: string, endpoint: string, deployment: string): Promise<ReportData[]> => {
if (!apiKey || !endpoint) { if (!apiKey || !endpoint) {
throw new Error("A chave de API e endpoint do Azure são necessários."); throw new Error("A chave de API e endpoint do Azure são necessários.");
} }
@@ -363,10 +373,10 @@ export const analyzeWithAzure = async (file: File, apiKey: string, endpoint: str
throw new Error("Resposta vazia do Azure"); throw new Error("Resposta vazia do Azure");
} }
return cleanAndParseJson(content) as ReportData; return cleanAndParseJson(content) as ReportData[];
}; };
export const analyzeCertificate = async (options: AnalyzeOptions): Promise<ReportData> => { export const analyzeCertificate = async (options: AnalyzeOptions): Promise<ReportData[]> => {
const { provider, apiKey, model, file, endpoint } = options; const { provider, apiKey, model, file, endpoint } = options;
switch (provider) { switch (provider) {
@@ -389,7 +399,7 @@ export const analyzeCertificate = async (options: AnalyzeOptions): Promise<Repor
} }
}; };
export const analyzeWithOllama = async (file: File, endpoint: string, model: string = 'llama3.2-vision'): Promise<ReportData> => { export const analyzeWithOllama = async (file: File, endpoint: string, model: string = 'llama3.2-vision'): Promise<ReportData[]> => {
if (!endpoint) { if (!endpoint) {
throw new Error("O endpoint do Ollama é necessário. Configure o endereço da sua VPS."); throw new Error("O endpoint do Ollama é necessário. Configure o endereço da sua VPS.");
} }
@@ -456,10 +466,10 @@ export const analyzeWithOllama = async (file: File, endpoint: string, model: str
throw new Error("Resposta vazia do Ollama/OpenWebUI"); throw new Error("Resposta vazia do Ollama/OpenWebUI");
} }
return cleanAndParseJson(content) as ReportData; return cleanAndParseJson(content) as ReportData[];
}; };
export const analyzeWithOpenRouter = async (file: File, apiKey: string, model: string = 'google/gemini-2.5-flash'): Promise<ReportData> => { export const analyzeWithOpenRouter = async (file: File, apiKey: string, model: string = 'google/gemini-2.5-flash'): Promise<ReportData[]> => {
if (!apiKey) { if (!apiKey) {
throw new Error("A chave de API do OpenRouter não foi fornecida."); throw new Error("A chave de API do OpenRouter não foi fornecida.");
} }
@@ -508,10 +518,10 @@ export const analyzeWithOpenRouter = async (file: File, apiKey: string, model: s
throw new Error("Resposta vazia do OpenRouter"); throw new Error("Resposta vazia do OpenRouter");
} }
return cleanAndParseJson(content) as ReportData; return cleanAndParseJson(content) as ReportData[];
}; };
export const analyzeWithMinimax = async (file: File, apiKey: string, model: string = 'minimax-2.7'): Promise<ReportData> => { export const analyzeWithMinimax = async (file: File, apiKey: string, model: string = 'minimax-2.7'): Promise<ReportData[]> => {
if (!apiKey) { if (!apiKey) {
throw new Error("A chave de API do MiniMax não foi fornecida."); throw new Error("A chave de API do MiniMax não foi fornecida.");
} }
@@ -558,5 +568,5 @@ export const analyzeWithMinimax = async (file: File, apiKey: string, model: stri
throw new Error("Resposta vazia do MiniMax"); throw new Error("Resposta vazia do MiniMax");
} }
return cleanAndParseJson(content) as ReportData; return cleanAndParseJson(content) as ReportData[];
}; };
+23 -13
View File
@@ -2,18 +2,30 @@ import { jsPDF } from 'jspdf';
import html2canvas from 'html2canvas'; import html2canvas from 'html2canvas';
export const exportAsPdf = async (elementId: string, fileName: string, action: 'preview' | 'download'): Promise<void> => { export const exportAsPdf = async (elementId: string, fileName: string, action: 'preview' | 'download'): Promise<void> => {
const element = document.getElementById(elementId); const container = document.getElementById(elementId);
if (!element) { if (!container) {
console.error(`Element with id ${elementId} not found.`); console.error(`Element with id ${elementId} not found.`);
return; return;
} }
try { const pages = container.querySelectorAll('.report-page');
// We temporarily make the element visible to html2canvas if it's hidden by CSS if (pages.length === 0) {
// by cloning it or just capturing it directly (html2canvas can capture off-screen if it's positioned absolute) console.error('No report pages found');
return;
}
const canvas = await html2canvas(element, { try {
const pdf = new jsPDF({
orientation: 'portrait',
unit: 'mm',
format: 'a4',
});
for (let i = 0; i < pages.length; i++) {
const pageEl = pages[i] as HTMLElement;
const canvas = await html2canvas(pageEl, {
scale: 2, // Higher resolution scale: 2, // Higher resolution
useCORS: true, useCORS: true,
logging: false, logging: false,
@@ -22,17 +34,15 @@ export const exportAsPdf = async (elementId: string, fileName: string, action: '
const imgData = canvas.toDataURL('image/jpeg', 0.95); const imgData = canvas.toDataURL('image/jpeg', 0.95);
// A4 dimensions in mm: 210 x 297
const pdf = new jsPDF({
orientation: 'portrait',
unit: 'mm',
format: 'a4',
});
const pdfWidth = pdf.internal.pageSize.getWidth(); const pdfWidth = pdf.internal.pageSize.getWidth();
const pdfHeight = (canvas.height * pdfWidth) / canvas.width; const pdfHeight = (canvas.height * pdfWidth) / canvas.width;
if (i > 0) {
pdf.addPage();
}
pdf.addImage(imgData, 'JPEG', 0, 0, pdfWidth, pdfHeight); pdf.addImage(imgData, 'JPEG', 0, 0, pdfWidth, pdfHeight);
}
if (action === 'download') { if (action === 'download') {
pdf.save(`${fileName}.pdf`); pdf.save(`${fileName}.pdf`);