diff --git a/app/src/components/AuditPanel.tsx b/app/src/components/AuditPanel.tsx index c7c949f..2357946 100644 --- a/app/src/components/AuditPanel.tsx +++ b/app/src/components/AuditPanel.tsx @@ -11,7 +11,7 @@ import { SelectValue, } from '@/components/ui/select'; import { Play, FileDown, Loader2, AlertCircle } from 'lucide-react'; -import type { AuditConfig, AuditScenario, ScenarioResult, AuditReport, ModuleType } from '@/lib/audit/types'; +import type { AuditConfig, AuditScenario, ScenarioResult, AuditReport } from '@/lib/audit/types'; import { MODULE_LABELS } from '@/lib/audit/types'; import { generateAllScenarios } from '@/lib/audit/scenarios'; import AuditDetailPopup from './AuditDetailPopup'; diff --git a/app/src/lib/audit/export-audit-pdf.tsx b/app/src/lib/audit/export-audit-pdf.tsx index 9deb514..f0637dd 100644 --- a/app/src/lib/audit/export-audit-pdf.tsx +++ b/app/src/lib/audit/export-audit-pdf.tsx @@ -65,6 +65,33 @@ function statusColor(s: CheckStatus): string { } } +function sanitizeForPDF(text: string | undefined): string { + if (!text) return ''; + return text + .replace(/φ/g, 'phi') + .replace(/ϕ/g, 'phi') + .replace(/α/g, 'alpha') + .replace(/θ/g, 'theta') + .replace(/β/g, 'beta') + .replace(/γ/g, 'gamma') + .replace(/Δ/g, 'Delta') + .replace(/—/g, '-') + .replace(/–/g, '-') + .replace(/“/g, '"') + .replace(/”/g, '"') + .replace(/‘/g, "'") + .replace(/’/g, "'") + .replace(/Æ/g, 'phi') // Fix specifically the one seen in user screenshot + .replace(/≤/g, '<=') + .replace(/≥/g, '>=') + .replace(/≈/g, '~') + .replace(/×/g, 'x') + .replace(/÷/g, '/') + .replace(/²/g, '^2') + .replace(/³/g, '^3') + .replace(/°/g, ' deg'); +} + interface ReportPDFProps { report: AuditReport; } @@ -119,27 +146,27 @@ function AuditReportDocument({ report }: ReportPDFProps) { return ( - {sc.scenarioId} — {sc.verdict === 'PASS' ? '✓' : sc.verdict === 'WARN' ? '⚠' : '✗'} {summaryTranslated} + {sanitizeForPDF(sc.scenarioId)} — {sc.verdict === 'PASS' ? '✓' : sc.verdict === 'WARN' ? '⚠' : '✗'} {sanitizeForPDF(summaryTranslated)} {sc.enunciado_problema && ( - Enunciado: {sc.enunciado_problema} + Enunciado: {sanitizeForPDF(sc.enunciado_problema)} )} {sc.tabelas_nbr_consultadas && ( - Tabelas: {sc.tabelas_nbr_consultadas} + Tabelas: {sanitizeForPDF(sc.tabelas_nbr_consultadas)} )} {sc.resultado_app && ( - Avaliação do App: {sc.resultado_app} + Avaliação do App: {sanitizeForPDF(sc.resultado_app)} )} {sc.calculation_steps && ( - CoT: {sc.calculation_steps.substring(0, 300)}{sc.calculation_steps.length > 300 ? '...' : ''} + CoT: {sanitizeForPDF(sc.calculation_steps.substring(0, 300))}{sc.calculation_steps.length > 300 ? '...' : ''} )} {sc.checks.map((chk, i) => ( {chk.status} - {chk.name} - {chk.actual} ({chk.expected}) + {sanitizeForPDF(chk.name)} + {sanitizeForPDF(chk.actual)} ({sanitizeForPDF(chk.expected)}) ))} diff --git a/app/src/lib/audit/runner.ts b/app/src/lib/audit/runner.ts index c840979..7daadcc 100644 --- a/app/src/lib/audit/runner.ts +++ b/app/src/lib/audit/runner.ts @@ -185,7 +185,7 @@ async function callOpenRouter( onProgress?: (msg: string) => void, signal?: AbortSignal, ): Promise { - onProgress?.('Enviando para OpenRouter...'); + onProgress?.('Enviando para OpenRouter (streaming)...'); let url = config.baseUrl || 'https://openrouter.ai/api/v1/chat/completions'; if (config.baseUrl && !config.baseUrl.includes('/chat/completions')) { url = config.baseUrl.replace(/\/$/, '') + '/chat/completions'; @@ -197,6 +197,7 @@ async function callOpenRouter( { role: 'user' as const, content: userPrompt }, ], temperature: 0, + stream: true, }; const response = await fetchWithTimeout(url, { method: 'POST', @@ -208,15 +209,43 @@ async function callOpenRouter( }, body: JSON.stringify(body), }, 600000, signal); + if (!response.ok) { const errText = await response.text(); throw new Error(`OpenRouter HTTP ${response.status}: ${errText}`); } - const data = await response.json() as { choices?: { message?: { content?: string } }[]; error?: { message?: string } }; - if (data.error) throw new Error(`OpenRouter error: ${data.error.message}`); - const content = data.choices?.[0]?.message?.content; - if (!content) throw new Error('OpenRouter returned empty response'); - return content; + + if (!response.body) throw new Error('OpenRouter response body is null'); + + const reader = response.body.getReader(); + const decoder = new TextDecoder('utf-8'); + let fullText = ''; + let buffer = ''; + + while (true) { + const { done, value } = await reader.read(); + if (done) break; + buffer += decoder.decode(value, { stream: true }); + const lines = buffer.split('\n'); + buffer = lines.pop() || ''; + for (const rawLine of lines) { + const line = rawLine.trim(); + if (!line.startsWith('data: ')) continue; + const jsonStr = line.replace(/^data: /, '').trim(); + if (jsonStr === '[DONE]') continue; + try { + const parsed = JSON.parse(jsonStr); + // OpenRouter typically sends reasoning tokens or content in delta + const delta = parsed.choices?.[0]?.delta; + if (delta?.content) fullText += delta.content; + } catch (e) { + // ignore incomplete JSON + } + } + } + + if (!fullText) throw new Error('OpenRouter returned empty streaming response'); + return fullText; } async function callOllama( @@ -292,6 +321,10 @@ export async function runAudit( } catch (e) { chunkError = e instanceof Error ? e : new Error(String(e)); console.warn(`Erro no chunk ${i} (tentativa ${retry}):`, chunkError); + if (retry < maxRetriesPerChunk) { + // Backoff de 3 segundos antes da próxima tentativa + await new Promise(r => setTimeout(r, 3000)); + } } } diff --git a/app/src/pages/CertificatePage.tsx b/app/src/pages/CertificatePage.tsx index 2776333..70a47fb 100644 --- a/app/src/pages/CertificatePage.tsx +++ b/app/src/pages/CertificatePage.tsx @@ -1,8 +1,7 @@ import { useState } from 'react'; -import { ShieldCheck, FileText, CheckCircle2, ChevronRight, Download, Bot, FileCheck, X } from 'lucide-react'; -import { Card, CardContent, CardHeader, CardTitle, CardDescription } from '@/components/ui/card'; +import { ShieldCheck, FileText, CheckCircle2, Download, Bot, FileCheck } from 'lucide-react'; +import { Card, CardContent, CardTitle, CardDescription } from '@/components/ui/card'; import { Button } from '@/components/ui/button'; -import { Badge } from '@/components/ui/badge'; import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription, DialogClose } from '@/components/ui/dialog'; const rawLaudos = import.meta.glob('/public/laudos/*.pdf', { eager: true, query: '?url', import: 'default' });