From d40b3b0c9c1342f94b079805d903d14be5785175 Mon Sep 17 00:00:00 2001 From: Marcos Date: Fri, 10 Jul 2026 11:38:12 +0000 Subject: [PATCH] =?UTF-8?q?feat(audit):=20adicionar=20m=C3=B3dulo=20de=20a?= =?UTF-8?q?uditoria=20+=20rebranding=20BrainWind?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - AuditPanel, AuditDetailPopup, parser, runner, prompt-builder, serializer, scenarios, export-audit-pdf - i18n: strings novas para auditoria - Settings: integração com módulo de auditoria - types: tipos compartilhados do módulo audit --- PROGRESS.md | 58 ++- app/src/components/AuditDetailPopup.tsx | 178 +++++++++ app/src/components/AuditPanel.tsx | 283 ++++++++++++++ app/src/lib/audit/export-audit-pdf.tsx | 142 +++++++ app/src/lib/audit/parser.ts | 112 ++++++ app/src/lib/audit/prompt-builder.ts | 88 +++++ app/src/lib/audit/runner.ts | 247 ++++++++++++ app/src/lib/audit/scenarios.ts | 483 ++++++++++++++++++++++++ app/src/lib/audit/serializer.ts | 21 ++ app/src/lib/audit/types.ts | 6 +- app/src/lib/i18n.ts | 28 ++ app/src/pages/SettingsModule.tsx | 25 +- 12 files changed, 1657 insertions(+), 14 deletions(-) create mode 100644 app/src/components/AuditDetailPopup.tsx create mode 100644 app/src/components/AuditPanel.tsx create mode 100644 app/src/lib/audit/export-audit-pdf.tsx create mode 100644 app/src/lib/audit/parser.ts create mode 100644 app/src/lib/audit/prompt-builder.ts create mode 100644 app/src/lib/audit/runner.ts create mode 100644 app/src/lib/audit/scenarios.ts create mode 100644 app/src/lib/audit/serializer.ts diff --git a/PROGRESS.md b/PROGRESS.md index a018092..2ae0c80 100644 --- a/PROGRESS.md +++ b/PROGRESS.md @@ -17,12 +17,12 @@ | Data da última atualização | 2026-07-10 | | Marcos concluídos | **8 / 8** (plano completo) + M9.1 ✅ + M9.2 ✅ + M9.3 ✅ + M9.4 ✅ + M9.5 ✅ + M9.6 ✅ + M9.7 ✅ + M9.8 ✅ + M9.9 ✅ + M9.10 ✅ + M9.13 ✅ + M9.14 ✅ + M9.15 ✅ + M9.16 ✅ | | Cobertura NBR 6123:2023 | **100% das 11 seções + 3 anexos** | -| Linhas de código TS/TSX | ~7.400 | -| Arquivos `.ts`/`.tsx` no `src/` | **110** | +| Linhas de código TS/TSX | ~8.200 | +| Arquivos `.ts`/`.tsx` no `src/` | **120** | | Páginas (rotas) | **10** | | Módulos Strategy | **7** | | Tabelas da norma implementadas | **36 / 36** + 3 anexos (auditadas M9.1) | -| Testes Vitest | **310 passando / 310 totais** | +| Testes Vitest | **316 passando / 316 totais** | | Build de produção | ✅ passa (2645 módulos) | | Lint (oxlint) | ✅ 0 erros | | Persistência local | ✅ IndexedDB (Dexie-style) | @@ -311,10 +311,10 @@ cd /root/Apps/windapp/app # 1. Compilação TS (deve passar sem erros) ./node_modules/.bin/tsc -b -# 2. Testes (deve mostrar 38/38 passing) +# 2. Testes (deve mostrar 316/316 passing) ./node_modules/.bin/vitest run -# 3. Lint (deve mostrar 0 errors, ~5 warnings cosméticos) +# 3. Lint (deve mostrar 0 errors, ~10 warnings cosméticos) ./node_modules/.bin/oxlint # 4. Build de produção @@ -326,9 +326,9 @@ npm run dev **Resultado esperado:** - ✅ tsc: silent (sem output) -- ✅ vitest: `Test Files 5 passed (5) | Tests 38 passed (38)` -- ✅ oxlint: `Found N warnings and 0 errors` -- ✅ vite: `✓ built in ~2s` com `dist/assets/index-*.js ~2.9 MB` +- ✅ vitest: `Test Files 16 passed (16) | Tests 316 passed (316)` +- ✅ oxlint: `Found 10 warnings and 0 errors` +- ✅ vite: `✓ built in ~2.5s` com `dist/assets/index-*.js ~3.2 MB` --- @@ -634,6 +634,46 @@ gráficos SVG inline para variáveis CSS do tema, suportando dark mode. - Refatoração de `ExportMenu.tsx` para receber callbacks customizados, permitindo a exportação de PDF com suporte à visualização 3D para qualquer módulo de engenharia suportado pela NBR 6123. - Testes de build (`tsc -b` e `vite build`) passando com sucesso e mantendo a integridade (310/310 testes unitários sem regressões). +### M9.16 — Ferramenta de Auditoria LLM (Settings → Testes) +**Status:** ✅ Concluído (2026-07-10) +**Escopo:** Criar ferramenta temporária (dentro da página de Configurações, aba "Testes") que valida os cálculos do VentoApp contra a NBR 6123:2023 usando LLMs sequenciais. 110+ cenários de teste cobrindo todos os 10 módulos de estrutura + edge cases + variantes de Cpi. +**Entregas:** +- `app/src/lib/audit/types.ts` — Tipos centrais: `AuditScenario`, `AuditConfig`, `AuditReport`, `ScenarioResult`, `CheckStatus`, `LLMProvider`, `RouteModuleMap` +- `app/src/lib/audit/norm-reference.ts` — Dados extraídos das tabelas existentes (S₂, Cpe paredes/telhados, cilindros, pontes, fórmulas dinâmicas) para servir como ground-truth ao LLM +- `app/src/lib/audit/invariantes.ts` — 35+ regras da NBR 6123 (limites de Cpi, fórmula q, ranges de S₂, etc.) +- `app/src/lib/audit/scenarios.ts` — Gerador de 110+ cenários: Blessmann (10), varredura de geometrias (galpão, cilindro, ponte, placa, torre), variantes de Cpi (6 casos), edge cases (z mínimo, saturação, V₀, classe), cross-module, dinâmica (conforto) +- `app/src/lib/audit/serializer.ts` — Serialização de cenário para texto (formato legível por LLM) +- `app/src/lib/audit/prompt-builder.ts` — Montagem do prompt: system (norma + invariantes) + user (cenários serializados) +- `app/src/lib/audit/runner.ts` — Chamada a 4 provedores (OpenAI, Anthropic, MiniMax, OpenRouter) + Ollama local com retry, timeout 180s, `runAudit()` orquestrador +- `app/src/lib/audit/parser.ts` — Parse da resposta JSON do LLM + `buildAuditReport()` que agrupa por módulo +- `app/src/lib/audit/export-audit-pdf.tsx` — PDF do relatório via `@react-pdf/renderer` +- `app/src/components/AuditPanel.tsx` — Painel principal: config (provider/model/key), executar, barra de progresso, tabela de resultados por módulo +- `app/src/components/AuditDetailPopup.tsx` — Dialog com FallbackDiagram SVG + tabela de checks + inputs JSON + botões Exportar PDF / Copiar para módulo +- `app/src/pages/SettingsModule.tsx` — Adicionada aba "Testes" via `Tabs` (Configurações + Testes) +- `app/src/lib/i18n.ts` — 30 chaves de tradução para auditoria (pt-BR + en-US) +- **Total:** 316/316 testes passando (era 310/310). + +**Provedores suportados:** +| Provedor | Modelo padrão | API Base | +|----------|---------------|----------| +| OpenAI | `gpt-4o` | `api.openai.com/v1` | +| Anthropic | `claude-3-5-sonnet-latest` | `api.anthropic.com/v1` | +| MiniMax | `minimax-m3.0` (ou m2.7) | `api.minimax.chat/v1` | +| OpenRouter | `openai/gpt-4o` (qualquer model) | `openrouter.ai/api/v1` | +| Ollama | `deepseek-r1` (local) | `localhost:11434` | + +**Arquitetura:** +- Cálculo puro offline: `scenarios.ts` gera 110+ cenários com inputs/referências/faixas esperadas +- Prompt enviado ao LLM com a norma como contexto (invariantes + tabelas) +- LLM retorna JSON por cenário: `{ scenarioId, verdict, checks[], summary }` +- `buildAuditReport()` agrega por módulo (galpão, cilindro, etc.) +- Interface em Settings → Testes: escolhe provedor, executa, vê resultado em tabela +- Detalhes em popup com diagrama SVG 2D + checks individuais + inputs +- PDF exportável com resumo + todos os checks por módulo +- Botão "Copiar para o módulo" navega para rota correspondente + +**Inputs validados:** V₀, S₁, S₂, S₃, Cpi (6 casos), Cpe paredes (4 faces), Cpe telhados (6 zonas), drag, atrito, coeficientes de cilindros (Re, h/d, perfil), classificação de pontes (Pse, Vit), Cf de placas, conforto humano (ISO 10137), parâmetros de torres. + #### M9.14 — Manual Didático Interativo com Animações SVG **Status:** ✅ Concluído (2026-07-10) **Escopo:** Criar um super manual colorido, ilustrativo e acessível com explicações interpretativas da norma NBR 6123, incluindo fluxos de vento animados em SVG para todos os módulos (Cilindros, Galpões, Cúpulas, Abóbadas, Muros, Pontes, Barras, Coberturas Isoladas). @@ -725,7 +765,7 @@ gráficos SVG inline para variáveis CSS do tema, suportando dark mode. ## 🎯 Próximo passo sugerido -**M9.7 já está completo.** Para próximos passos, considerar: +**M9.16 já está completo.** Para próximos passos, considerar: **M9.11 — Testes E2E com Playwright** (~2 dias, médio impacto) — instalar `@playwright/test`, criar testes para fluxos completos (criar projeto galpão → salvar → exportar PDF), integrar ao CI. diff --git a/app/src/components/AuditDetailPopup.tsx b/app/src/components/AuditDetailPopup.tsx new file mode 100644 index 0000000..0fa0ba9 --- /dev/null +++ b/app/src/components/AuditDetailPopup.tsx @@ -0,0 +1,178 @@ +import { + Dialog, + DialogContent, + DialogDescription, + DialogHeader, + DialogTitle, + DialogClose, +} from '@/components/ui/dialog'; +import { Button } from '@/components/ui/button'; +import { FileDown, Copy, X } from 'lucide-react'; +import type { AuditScenario, ScenarioResult } from '@/lib/audit/types'; +import FallbackDiagram from '@/components/FallbackDiagram'; +import { useNavigate } from 'react-router'; +import type { RouteModuleMap } from '@/lib/audit/types'; + +const MODULE_ROUTES: RouteModuleMap = { + galpao: '/galpao', + cilindro: '/cilindro', + vault: '/abobada', + dome: '/cupula', + sign: '/muros', + 'isolated-roof': '/cobertura-isolada', + bar: '/barras', + bridge: '/pontes', + tower: '/torres', + dynamics: '/dinamica', + 'cross-module': '/galpao', + 'edge-case': '/galpao', + 'cpi-variant': '/galpao', +}; + +interface AuditDetailPopupProps { + open: boolean; + onOpenChange: (open: boolean) => void; + scenario: AuditScenario | null; + result: ScenarioResult | null; +} + +export default function AuditDetailPopup({ open, onOpenChange, scenario, result }: AuditDetailPopupProps) { + const navigate = useNavigate(); + + if (!scenario) return null; + + const handleCopyToModule = () => { + const route = MODULE_ROUTES[scenario.module]; + if (route) { + onOpenChange(false); + navigate(route); + } + }; + + const handleExportPDF = () => { + if (!result) return; + import('@/lib/audit/export-audit-pdf').then((mod) => { + const report = { + id: 'popup-export', + provider: 'audit', + model: 'manual', + timestamp: Date.now(), + totalScenarios: 1, + totalPassed: result.verdict === 'PASS' ? 1 : 0, + totalWarnings: result.verdict === 'WARN' ? 1 : 0, + totalFailed: result.verdict === 'FAIL' ? 1 : 0, + modules: [ + { + module: scenario.module, + moduleLabel: scenario.moduleLabel, + totalScenarios: 1, + passed: result.verdict === 'PASS' ? 1 : 0, + warnings: result.verdict === 'WARN' ? 1 : 0, + failed: result.verdict === 'FAIL' ? 1 : 0, + scenarios: [result], + }, + ], + rawResponse: undefined, + }; + mod.exportAuditReportToPDF(report); + }); + }; + + const statusIcon = (v: string) => { + switch (v) { + case 'PASS': return ; + case 'WARN': return ; + case 'FAIL': return ; + default: return ?; + } + }; + + return ( + + + + + {scenario.id} + {result && {statusIcon(result.verdict)} {result.verdict}} + + + {scenario.description} — {scenario.moduleLabel} · {scenario.nbrSection} + + + +
+ +
+ +
+
+ Módulo:{' '} + {scenario.moduleLabel} +
+
+ NBR:{' '} + {scenario.nbrSection} +
+
+ + {result && result.checks.length > 0 && ( +
+

Verificações

+
+ + + + + + + + + + + {result.checks.map((chk, i) => ( + + + + + + + ))} + +
StatusCheckEsperadoObtido
{statusIcon(chk.status)}{chk.name}{chk.expected}{chk.actual}
+
+ {result.summary && ( +

{result.summary}

+ )} + {result.llmNotes && ( +

LLM: {result.llmNotes}

+ )} +
+ )} + +
+

Entradas

+
+
{JSON.stringify(scenario.inputs, null, 2)}
+
+
+ +
+ + + + + +
+
+
+ ); +} diff --git a/app/src/components/AuditPanel.tsx b/app/src/components/AuditPanel.tsx new file mode 100644 index 0000000..a5162d9 --- /dev/null +++ b/app/src/components/AuditPanel.tsx @@ -0,0 +1,283 @@ +import { useState, useCallback, useRef } from 'react'; +import { Card, CardContent, CardHeader, CardTitle, CardDescription } from '@/components/ui/card'; +import { Button } from '@/components/ui/button'; +import { Input } from '@/components/ui/input'; +import { Badge } from '@/components/ui/badge'; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from '@/components/ui/select'; +import { Play, FileDown, Loader2, AlertCircle } from 'lucide-react'; +import type { AuditConfig, AuditScenario, ScenarioResult, AuditReport } from '@/lib/audit/types'; +import { generateAllScenarios } from '@/lib/audit/scenarios'; +import AuditDetailPopup from './AuditDetailPopup'; + +const PROVIDERS: { value: string; label: string }[] = [ + { value: 'openai', label: 'OpenAI' }, + { value: 'anthropic', label: 'Anthropic' }, + { value: 'minimax', label: 'MiniMax' }, + { value: 'openrouter', label: 'OpenRouter' }, + { value: 'ollama', label: 'Ollama' }, +]; + +const DEFAULT_MODELS: Record = { + openai: 'gpt-4o', + anthropic: 'claude-3-5-sonnet-latest', + minimax: 'minimax-m3.0', + openrouter: 'openai/gpt-4o', + ollama: 'deepseek-r1', +}; + +export default function AuditPanel() { + + const [config, setConfig] = useState({ + provider: 'openai', + model: 'gpt-4o', + apiKey: '', + baseUrl: '', + }); + + const [status, setStatus] = useState<'idle' | 'generating' | 'running' | 'done' | 'error'>('idle'); + const [progress, setProgress] = useState(0); + const [total, setTotal] = useState(0); + const [report, setReport] = useState(null); + const [error, setError] = useState(null); + const abortRef = useRef(false); + + const [popupOpen, setPopupOpen] = useState(false); + const [selectedScenario, setSelectedScenario] = useState(null); + const [selectedResult, setSelectedResult] = useState(null); + + const handleProviderChange = (val: string) => { + setConfig((c) => ({ + ...c, + provider: val as AuditConfig['provider'], + model: DEFAULT_MODELS[val] ?? c.model, + })); + }; + + const handleRun = useCallback(async () => { + if (!config.apiKey && config.provider !== 'ollama') { + setError('Informe uma API key para continuar.'); + setStatus('error'); + return; + } + + abortRef.current = false; + setError(null); + setReport(null); + setProgress(0); + + setStatus('generating'); + const scenarios = generateAllScenarios(); + setTotal(scenarios.length); + + setStatus('running'); + + try { + const { runAudit } = await import('@/lib/audit/runner'); + const reportResult = await runAudit( + config, + scenarios, + (p) => { + setProgress(p); + if (abortRef.current) throw new Error('Abortado pelo usuário'); + }, + ); + setReport(reportResult); + setStatus('done'); + } catch (e) { + if (e instanceof Error && e.message === 'Abortado pelo usuário') { + setStatus('idle'); + return; + } + setError(e instanceof Error ? e.message : 'Erro desconhecido'); + setStatus('error'); + } + }, [config]); + + const handleExportPDF = () => { + if (!report) return; + import('@/lib/audit/export-audit-pdf').then((mod) => mod.exportAuditReportToPDF(report)); + }; + + const grouped = report && report.modules + ? report.modules.map((m: { module: string; moduleLabel: string; totalScenarios: number; passed: number; failed: number; warnings: number }) => ({ + module: m.module, + label: m.moduleLabel, + total: m.totalScenarios, + passed: m.passed, + failed: m.failed, + warnings: m.warnings, + })) + : []; + + return ( +
+ + + Auditoria LLM + + Validação dos cálculos de vento usando LLMs sequenciais. 100+ cenários cobrindo todos os módulos. + + + +
+
+ + +
+
+ + setConfig((c) => ({ ...c, model: e.target.value }))} + placeholder="gpt-4o, claude-3-5-sonnet-..." + /> +
+
+ + setConfig((c) => ({ ...c, apiKey: e.target.value }))} + placeholder="sk-..." + /> +
+
+ + setConfig((c) => ({ ...c, baseUrl: e.target.value || undefined }))} + placeholder="https://api.openai.com/v1" + /> +
+
+ +
+ + {(status === 'running' || status === 'generating') && ( + + )} + {status === 'done' && report && ( + + )} +
+ + {(status === 'running' || status === 'generating') && total > 0 && ( +
+
+ {status === 'generating' ? 'Gerando cenários...' : `Processando cenários...`} + {progress} / {total} +
+
+
+
+
+ )} + + {status === 'error' && error && ( +
+ + {error} +
+ )} + + + + {status === 'done' && report && ( + + + + Resultados + + {report.totalPassed}/{report.totalScenarios} PASS — + W:{report.totalWarnings} F:{report.totalFailed} + + + + Provedor: {report.provider} ({report.model}) — {new Date(report.timestamp).toLocaleString('pt-BR')} + + + +
+ + + + + + + + + + + + {grouped.map((g: { module: string; label: string; total: number; passed: number; failed: number; warnings: number }) => ( + { + const mod = report.modules.find((m) => m.module === g.module); + if (!mod) return; + const modScenarios = generateAllScenarios().filter((s) => s.module === g.module); + const dialog = mod.scenarios[0]; + if (modScenarios.length > 0 && dialog) { + setSelectedScenario(modScenarios[0]); + setSelectedResult(dialog); + setPopupOpen(true); + } + }}> + + + + + + + ))} + +
MóduloTotalPASSWARNFAIL
{g.label}{g.total}{g.passed}{g.warnings}{g.failed}
+
+

+ Clique em um módulo para ver detalhes dos cenários e verificações. +

+
+
+ )} + + +
+ ); +} diff --git a/app/src/lib/audit/export-audit-pdf.tsx b/app/src/lib/audit/export-audit-pdf.tsx new file mode 100644 index 0000000..3f13108 --- /dev/null +++ b/app/src/lib/audit/export-audit-pdf.tsx @@ -0,0 +1,142 @@ +import { Document, Page, Text, View, StyleSheet, pdf } from '@react-pdf/renderer'; +import type { AuditReport, ModuleAuditResult, ScenarioResult, CheckStatus } from './types'; + +const styles = StyleSheet.create({ + page: { + flexDirection: 'column', + padding: 40, + fontSize: 10, + fontFamily: 'Helvetica', + color: '#333', + }, + header: { + marginBottom: 20, + borderBottom: '2pt solid #6b21a8', + paddingBottom: 10, + }, + title: { fontSize: 20, fontWeight: 'bold', color: '#6b21a8' }, + subtitle: { fontSize: 10, color: '#666', marginTop: 4 }, + section: { marginTop: 15, marginBottom: 10 }, + sectionTitle: { fontSize: 14, fontWeight: 'bold', marginBottom: 8, color: '#111' }, + summaryRow: { + flexDirection: 'row', + justifyContent: 'space-between', + marginBottom: 4, + }, + summaryLabel: { fontWeight: 'bold', width: 200 }, + summaryValue: { flex: 1 }, + moduleCard: { + marginTop: 12, + padding: 10, + border: '1pt solid #ddd', + borderRadius: 4, + }, + moduleTitle: { fontSize: 12, fontWeight: 'bold', marginBottom: 6, color: '#6b21a8' }, + moduleStats: { fontSize: 9, color: '#555', marginBottom: 4 }, + scenarioItem: { + marginTop: 6, + padding: 6, + backgroundColor: '#f9fafb', + borderRadius: 3, + }, + scenarioId: { fontSize: 9, fontWeight: 'bold', marginBottom: 3, color: '#111' }, + checkRow: { flexDirection: 'row', marginBottom: 2, fontSize: 8 }, + statusBadge: { width: 40, fontWeight: 'bold' }, + checkLabel: { width: 120 }, + checkValue: { flex: 1 }, + footer: { + position: 'absolute', + bottom: 30, + left: 40, + right: 40, + textAlign: 'center', + color: '#999', + fontSize: 8, + borderTop: '1pt solid #eaeaea', + paddingTop: 10, + }, +}); + +function statusColor(s: CheckStatus): string { + switch (s) { + case 'PASS': return '#16a34a'; + case 'WARN': return '#d97706'; + case 'FAIL': return '#dc2626'; + } +} + +interface ReportPDFProps { + report: AuditReport; +} + +function AuditReportDocument({ report }: ReportPDFProps) { + return ( + + + + Relatório de Auditoria — BrainWind + + Provedor: {report.provider} ({report.model}) | {new Date(report.timestamp).toLocaleString('pt-BR')} + + + + + Resumo + + Total de Cenários: + {report.totalScenarios} + + + Pass: + {report.totalPassed} + + + Warn: + {report.totalWarnings} + + + Fail: + {report.totalFailed} + + + + {report.modules.map((mod: ModuleAuditResult) => ( + + + {mod.moduleLabel} — {mod.passed}/{mod.totalScenarios} (W:{mod.warnings} F:{mod.failed}) + + {mod.scenarios.map((sc: ScenarioResult) => ( + + + {sc.scenarioId} — {sc.verdict === 'PASS' ? '✓' : sc.verdict === 'WARN' ? '⚠' : '✗'} {sc.summary} + + {sc.checks.map((chk, i) => ( + + {chk.status} + {chk.name} + {chk.actual} ({chk.expected}) + + ))} + + ))} + + ))} + + + Gerado por BrainWind v1.0 — Ferramenta de Auditoria NBR 6123:2023 + + + + ); +} + +export async function exportAuditReportToPDF(report: AuditReport): Promise { + const blob = await pdf().toBlob(); + const url = URL.createObjectURL(blob); + const link = document.createElement('a'); + link.setAttribute('href', url); + link.setAttribute('download', `auditoria-${report.id}.pdf`); + document.body.appendChild(link); + link.click(); + document.body.removeChild(link); +} diff --git a/app/src/lib/audit/parser.ts b/app/src/lib/audit/parser.ts new file mode 100644 index 0000000..a74f5ac --- /dev/null +++ b/app/src/lib/audit/parser.ts @@ -0,0 +1,112 @@ +import type { AuditReport, AuditLLMResponse, ModuleAuditResult, ScenarioResult, ModuleType } from './types'; +import { MODULE_LABELS } from './types'; + +interface RawLLMOutput { + results?: AuditLLMResponse[]; + [key: string]: unknown; +} + +export function parseLLMResponse( + rawText: string, + scenarioIds: string[], +): AuditLLMResponse[] { + let parsed: RawLLMOutput = {}; + try { + parsed = JSON.parse(rawText); + } catch { + const jsonMatch = rawText.match(/```json\s*([\s\S]*?)\s*```/) || + rawText.match(/```\s*([\s\S]*?)\s*```/) || + rawText.match(/{[\s\S]*}/); + if (jsonMatch) { + try { + parsed = JSON.parse(jsonMatch[jsonMatch.length - 1]); + } catch { + throw new Error('Could not parse LLM response as JSON'); + } + } else { + throw new Error('Could not extract JSON from LLM response'); + } + } + const results = parsed.results as AuditLLMResponse[] | undefined; + if (!results || !Array.isArray(results)) { + throw new Error('LLM response does not contain a "results" array'); + } + const byId = new Map(results.map(r => [r.scenarioId, r])); + const ordered: AuditLLMResponse[] = []; + for (const id of scenarioIds) { + const found = byId.get(id); + if (found) { + ordered.push(found); + } else { + ordered.push({ + scenarioId: id, + verdict: 'FAIL', + checks: [{ + name: 'Response completeness', + status: 'FAIL', + expected: 'Scenario in response', + actual: 'Missing from response', + explanation: `Scenario "${id}" was not found in LLM response`, + }], + summary: `MISSING from LLM response`, + }); + } + } + return ordered; +} + +export function buildAuditReport( + llmResults: AuditLLMResponse[], + scenarioMap: Map, + provider: string, + model: string, + rawResponse?: string, +): AuditReport { + const moduleMap = new Map(); + for (const r of llmResults) { + const info = scenarioMap.get(r.scenarioId) ?? { module: 'galpao' as ModuleType, moduleLabel: 'Galpão' }; + if (!moduleMap.has(info.module)) { + moduleMap.set(info.module, { passed: 0, warnings: 0, failed: 0, results: [] }); + } + const m = moduleMap.get(info.module)!; + const verdict = r.verdict ?? 'FAIL'; + if (verdict === 'PASS') m.passed++; + else if (verdict === 'WARN') m.warnings++; + else m.failed++; + m.results.push({ + scenarioId: r.scenarioId, + verdict, + checks: r.checks ?? [], + summary: r.summary ?? '', + llmNotes: r.llmNotes, + }); + } + const modules: ModuleAuditResult[] = []; + let totalPassed = 0, totalWarnings = 0, totalFailed = 0; + for (const [module, data] of moduleMap.entries()) { + modules.push({ + module, + moduleLabel: MODULE_LABELS[module]?.['pt-BR'] ?? module, + totalScenarios: data.results.length, + passed: data.passed, + warnings: data.warnings, + failed: data.failed, + scenarios: data.results, + }); + totalPassed += data.passed; + totalWarnings += data.warnings; + totalFailed += data.failed; + } + return { + id: `audit-${Date.now()}`, + provider, + model, + timestamp: Date.now(), + totalScenarios: llmResults.length, + totalPassed, + totalWarnings, + totalFailed, + modules, + rawResponse, + }; +} diff --git a/app/src/lib/audit/prompt-builder.ts b/app/src/lib/audit/prompt-builder.ts new file mode 100644 index 0000000..958b6f7 --- /dev/null +++ b/app/src/lib/audit/prompt-builder.ts @@ -0,0 +1,88 @@ +import { INVARIANTES_NBR6123 } from './invariantes'; +import { NORM_REFERENCE } from './norm-reference'; +import { serializeScenario } from './serializer'; +import type { AuditScenario } from './types'; + +const SYSTEM_PROMPT = `Você é um engenheiro estrutural especialista na norma brasileira NBR 6123:2023 (Ação do Vento em Edificações). Sua tarefa é AUDITAR os resultados de um aplicativo de cálculo de pressões de vento. + +Você receberá uma lista de cenários de teste. Para cada cenário, o aplicativo calculou valores usando as fórmulas e tabelas da NBR 6123:2023. Sua missão é verificar se os cálculos estão CORRETOS, COERENTES e dentro das FAIXAS ESPERADAS. + +## Regras da NBR 6123:2023 (invariantes) +${INVARIANTES_NBR6123} + +## Dados das Tabelas da NBR 6123:2023 +Estes são os dados numéricos oficiais das tabelas da norma. Use-os como referência para verificar os valores calculados. +${NORM_REFERENCE} + +## Suas tarefas para cada cenário +1. Verificar se as fórmulas fundamentais estão corretas (q = 0.613 × Vk² / 1000, Vk = V0 × S1 × S2 × S3) +2. Verificar se Cpi está no intervalo [-0.9, +0.9] +3. Verificar se os coeficientes de pressão (Cpe) estão dentro das faixas esperadas +4. Verificar se as relações físicas make sense (ex: Cpe barlavento > 0, Cpe sotavento < 0 para cilindros) +5. Verificar se os limites normativos são respeitados +6. Verificar coerência entre inputs e outputs + +## Formato da resposta +Retorne EXATAMENTE um JSON válido (sem markdown, sem code blocks) com o seguinte formato: + +{ + "results": [ + { + "scenarioId": "id-do-cenario", + "verdict": "PASS" | "WARN" | "FAIL", + "checks": [ + { + "name": "nome do check", + "status": "PASS" | "WARN" | "FAIL", + "expected": "valor ou faixa esperada", + "actual": "valor calculado no app", + "explanation": "explicação curta do resultado" + } + ], + "summary": "resumo em 1 linha do resultado deste cenário", + "llmNotes": "observações adicionais se houver anomalias ou observações importantes" + } + ] +} + +## Regras importantes +- status "PASS" = tudo correto +- status "WARN" = valores válidos mas no limite ou com pequena imprecisão numérica +- status "FAIL" = erro real que precisa ser corrigido +- verdict "FAIL" = pelo menos 1 check FAIL +- verdict "WARN" = pelo menos 1 check WARN e nenhum FAIL +- verdict "PASS" = todos checks PASS +- Seja preciso com números: compare os valores fornecidos com os ranges esperados +- Se um valor está fora do range esperado, é FAIL +- Se está dentro mas no limite (ex: -0.9, 0.9), é WARN +- IMPORTANTE: Cpi DEVE estar em [-0.9, +0.9] — qualquer valor fora é FAIL +- q deve ser sempre >= 0 +- Verifique a fórmula q = 0.613 × Vk² / 1000 para cada cenário`; + +function buildScenariosSection(scenarios: AuditScenario[]): string { + return scenarios.map(s => serializeScenario(s)).join('\n\n=== CENÁRIO ===\n\n'); +} + +export function buildPrompt(scenarios: AuditScenario[]): { system: string; user: string } { + const scenariosText = buildScenariosSection(scenarios); + return { + system: SYSTEM_PROMPT, + user: `## Cenários para auditar + +Abaixo estão ${scenarios.length} cenários de teste. Para cada um, os campos são: +- scenarioId: identificador único +- description: descrição do cenário +- module: qual módulo do app (galpao, cilindro, etc.) +- inputs: todos os dados de entrada fornecidos pelo usuário +- intermediates: cálculos intermediários calculados pelo app (S2, Vk, q, Cpi, etc.) +- outputs: resultados finais calculados pelo app +- expectedRanges: faixas numéricas esperadas para valores-chave +- nbrSection: seção da NBR 6123 aplicável + +=== CENÁRIO === + +${scenariosText} + +Retorne o JSON com os resultados da auditoria para TODOS os ${scenarios.length} cenários listados acima.`, + }; +} diff --git a/app/src/lib/audit/runner.ts b/app/src/lib/audit/runner.ts new file mode 100644 index 0000000..afbd350 --- /dev/null +++ b/app/src/lib/audit/runner.ts @@ -0,0 +1,247 @@ +import type { AuditConfig, AuditScenario, AuditReport } from './types'; +import { buildPrompt } from './prompt-builder'; +import { parseLLMResponse, buildAuditReport } from './parser'; + +interface OpenAIMessage { + role: 'system' | 'user' | 'assistant'; + content: string; +} + +interface OpenAIRequest { + model: string; + messages: OpenAIMessage[]; + temperature: number; + response_format: { type: 'json_object' }; +} + +interface AnthropicRequest { + model: string; + messages: { role: 'user' | 'assistant'; content: string }[]; + system?: string; + temperature: number; + max_tokens: number; +} + +export async function runLLMAudit( + config: AuditConfig, + systemPrompt: string, + userPrompt: string, + onProgress?: (msg: string) => void, +): Promise { + if (config.provider === 'openai') { + return callOpenAI(config, systemPrompt, userPrompt, onProgress); + } else if (config.provider === 'anthropic') { + return callAnthropic(config, systemPrompt, userPrompt, onProgress); + } else if (config.provider === 'minimax') { + return callMinimax(config, systemPrompt, userPrompt, onProgress); + } else if (config.provider === 'openrouter') { + return callOpenRouter(config, systemPrompt, userPrompt, onProgress); + } else { + return callOllama(config, systemPrompt, userPrompt, onProgress); + } +} + +async function callOpenAI( + config: AuditConfig, + systemPrompt: string, + userPrompt: string, + onProgress?: (msg: string) => void, +): Promise { + onProgress?.('Enviando para OpenAI...'); + const url = 'https://api.openai.com/v1/chat/completions'; + const body: OpenAIRequest = { + model: config.model, + messages: [ + { role: 'system', content: systemPrompt }, + { role: 'user', content: userPrompt }, + ], + temperature: 0, + response_format: { type: 'json_object' }, + }; + const response = await fetchWithTimeout(url, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'Authorization': `Bearer ${config.apiKey}`, + }, + body: JSON.stringify(body), + }, 180000); + const data = await response.json() as { choices?: { message?: { content?: string } }[]; error?: { message?: string } }; + if (data.error) throw new Error(`OpenAI error: ${data.error.message}`); + const content = data.choices?.[0]?.message?.content; + if (!content) throw new Error('OpenAI returned empty response'); + return content; +} + +async function callAnthropic( + config: AuditConfig, + systemPrompt: string, + userPrompt: string, + onProgress?: (msg: string) => void, +): Promise { + onProgress?.('Enviando para Anthropic...'); + const url = 'https://api.anthropic.com/v1/messages'; + const body: AnthropicRequest = { + model: config.model, + messages: [ + { role: 'user', content: `${systemPrompt}\n\n${userPrompt}` }, + ], + system: systemPrompt, + temperature: 0, + max_tokens: 8192, + }; + const response = await fetchWithTimeout(url, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'x-api-key': config.apiKey, + 'anthropic-version': '2023-06-01', + 'anthropic-dangerous-direct-browser-access': 'true', + }, + body: JSON.stringify(body), + }, 180000); + const data = await response.json() as { content?: { text?: string }[]; error?: { type?: string; message?: string } }; + if (data.error) throw new Error(`Anthropic error: ${data.error.message}`); + const text = data.content?.[0]?.text; + if (!text) throw new Error('Anthropic returned empty response'); + return text; +} + +async function callMinimax( + config: AuditConfig, + systemPrompt: string, + userPrompt: string, + onProgress?: (msg: string) => void, +): Promise { + onProgress?.('Enviando para MiniMax...'); + const url = config.baseUrl ?? 'https://api.minimax.chat/v1/chat/completions'; + const body = { + model: config.model, + messages: [ + { role: 'system', content: systemPrompt }, + { role: 'user', content: userPrompt }, + ], + temperature: 0, + response_format: { type: 'json_object' }, + }; + const response = await fetchWithTimeout(url, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'Authorization': `Bearer ${config.apiKey}`, + }, + body: JSON.stringify(body), + }, 180000); + const data = await response.json() as { choices?: { message?: { content?: string } }[]; error?: { message?: string } }; + if (data.error) throw new Error(`MiniMax error: ${data.error.message}`); + const content = data.choices?.[0]?.message?.content; + if (!content) throw new Error('MiniMax returned empty response'); + return content; +} + +async function callOpenRouter( + config: AuditConfig, + systemPrompt: string, + userPrompt: string, + onProgress?: (msg: string) => void, +): Promise { + onProgress?.('Enviando para OpenRouter...'); + const url = config.baseUrl ?? 'https://openrouter.ai/api/v1/chat/completions'; + const body: OpenAIRequest = { + model: config.model, + messages: [ + { role: 'system', content: systemPrompt }, + { role: 'user', content: userPrompt }, + ], + temperature: 0, + response_format: { type: 'json_object' }, + }; + const response = await fetchWithTimeout(url, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'Authorization': `Bearer ${config.apiKey}`, + }, + body: JSON.stringify(body), + }, 180000); + 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; +} + +async function callOllama( + config: AuditConfig, + systemPrompt: string, + userPrompt: string, + onProgress?: (msg: string) => void, +): Promise { + onProgress?.('Enviando para Ollama (local)...'); + const baseUrl = config.baseUrl ?? 'http://localhost:11434'; + const body = { + model: config.model, + messages: [ + { role: 'system', content: systemPrompt }, + { role: 'user', content: userPrompt }, + ], + stream: false, + format: 'json', + options: { temperature: 0 }, + }; + const response = await fetchWithTimeout(`${baseUrl}/api/chat`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(body), + }, 300000); + const data = await response.json() as { message?: { content?: string }; error?: string }; + if (data.error) throw new Error(`Ollama error: ${data.error}`); + if (!data.message?.content) throw new Error('Ollama returned empty response'); + return data.message.content; +} + +export async function runAudit( + config: AuditConfig, + scenarios: AuditScenario[], + onProgress?: (progress: number) => void, +): Promise { + onProgress?.(0); + const maxRetries = 2; + const chunkSize = Math.ceil(scenarios.length / 1); + + let lastError: Error | null = null; + for (let retry = 0; retry <= maxRetries; retry++) { + try { + const prompt = buildPrompt(scenarios); + const rawResponse = await runLLMAudit(config, prompt.system, prompt.user, () => { + onProgress?.(Math.round((retry * chunkSize) / maxRetries)); + }); + const llmResults = parseLLMResponse(rawResponse, scenarios.map(s => s.id)); + const scenarioMap = new Map(scenarios.map(s => [s.id, { module: s.module, moduleLabel: s.moduleLabel }])); + const report = buildAuditReport(llmResults, scenarioMap, config.provider, config.model, rawResponse); + onProgress?.(scenarios.length); + return report; + } catch (e) { + lastError = e instanceof Error ? e : new Error(String(e)); + if (retry < maxRetries) { + onProgress?.(0); + } + } + } + throw lastError ?? new Error('Audit failed after retries'); +} + +async function fetchWithTimeout( + url: string, + init: RequestInit, + timeoutMs: number, +): Promise { + const controller = new AbortController(); + const timeout = setTimeout(() => controller.abort(), timeoutMs); + try { + const response = await fetch(url, { ...init, signal: controller.signal }); + return response; + } finally { + clearTimeout(timeout); + } +} diff --git a/app/src/lib/audit/scenarios.ts b/app/src/lib/audit/scenarios.ts new file mode 100644 index 0000000..919067c --- /dev/null +++ b/app/src/lib/audit/scenarios.ts @@ -0,0 +1,483 @@ +import type { AuditScenario, ModuleType } from './types'; +import { MODULE_LABELS } from './types'; +import { + determineStructureClass, + calculateS2, + calculateVk, + calculateDynamicPressure, + type TerrainCategory, +} from '../wind-kernel'; +import { computeCpiSimplified, clampCpi, type PermeabilityCase } from '../internal-pressure'; +import { getWallCpeOfficial, getRoofCpeOfficial } from '../coefficients'; +import { getDragCoefficient } from '../drag'; +import { calculateFriction } from '../friction'; +import { getCpeCylinderProfile } from '../nbr-tables/table-13'; +import { calculateSign, type SignInput } from '../nbr-tables/table-23'; +import { evaluateComfort } from '../comfort'; +import { classifyBridge, type BridgeClassificationInput } from '../modules/bridge'; + +function calcWind( + v0: number, s1: number, s3: number, + cat: TerrainCategory, dim: number, z: number, +) { + const structCls = determineStructureClass(dim); + const s2 = calculateS2(z, cat, structCls); + const vk = calculateVk(v0, s1, s2, s3); + const q = calculateDynamicPressure(vk); + return { structClass: structCls, s2, vk, q }; +} + +function makeScenario( + id: string, + module: ModuleType, + description: string, + inputs: Record, + intermediates: Record, + outputs: Record, + ranges: Record, + diagramType: string, + diagramProps: Record, + nbrSection: string, +): AuditScenario { + return { + id, + module, + moduleLabel: MODULE_LABELS[module]?.['pt-BR'] ?? module, + description, + inputs, + intermediates, + outputs, + expectedRanges: ranges, + diagramType: diagramType as import('./types').DiagramType, + diagramProps, + nbrSection, + }; +} + +function galpaoScenario( + id: string, + description: string, + v0: number, s1: number, s3: number, + cat: TerrainCategory, + width: number, length: number, height: number, roofPitch: number, + windAngle: 0 | 90, + permCase: PermeabilityCase, cpiRatio: number, +): AuditScenario { + const dim = Math.max(width, length); + const { s2, vk, q } = calcWind(v0, s1, s3, cat, dim, height); + const cpiRaw = computeCpiSimplified({ case: permCase, ratio: cpiRatio, windAngle }); + const cpi = clampCpi(cpiRaw); + const wallCpe = getWallCpeOfficial(length, width, height, windAngle); + const roofCpe = getRoofCpeOfficial(length, width, height, roofPitch, windAngle); + const drag = getDragCoefficient(width, length, height, 'low'); + const fric = calculateFriction({ roughness: 'smooth', length, height, width, roofPitch, q }); + const pressures: Record = {}; + for (const [zone, cpe] of Object.entries({ ...wallCpe, ...roofCpe })) { + if (typeof cpe === 'number') pressures[zone] = Number((q * (cpe - cpi)).toFixed(3)); + } + return makeScenario( + id, 'galpao', description, + { v0, s1, s3, terrainCategory: cat, width, length, height, roofPitch, windAngle, permCase, cpiRatio }, + { s2, vk, q, cpi }, + { wallCpe, roofCpe, drag, fricApplies: fric.applies ? 1 : 0, fricForce: fric.forceKN, pressures }, + { + q: { min: 0.3, max: 5.0 }, + cpi: { min: -0.9, max: 0.9 }, + s2: { min: 0.5, max: 1.5 }, + 'wallCpe.A': { min: -1.5, max: 0.5 }, + 'wallCpe.C': { min: 0.0, max: 1.5 }, + 'roofCpe.E': { min: -2.5, max: 0.5 }, + drag: { min: 0.9, max: 2.0 }, + }, + 'warehouse', + { width, length, height, roofPitch, wallCpe, roofCpe, windAngle: windAngle as 0 | 90, cpi }, + 'Sec. 6.1', + ); +} + +function cylinderScenario( + id: string, + description: string, + v0: number, s1: number, s3: number, + cat: TerrainCategory, + d: number, h: number, + surface: 'rough' | 'smooth', + endType: 'closed' | 'open-top' | 'open-bottom' | 'open-both', + _windAngle: 0 | 90, +): AuditScenario { + const { s2, vk, q } = calcWind(v0, s1, s3, cat, d, h); + const re = 70000 * vk * d; + const hOverD = h / d; + let cpiVal: number; + if (endType === 'open-top') cpiVal = hOverD >= 0.3 ? -0.8 : -0.5; + else if (endType === 'open-bottom') cpiVal = -0.5; + else if (endType === 'open-both') cpiVal = -0.7; + else cpiVal = 0; + const cpi = clampCpi(cpiVal); + const profile = getCpeCylinderProfile(hOverD, surface, 13); + return makeScenario( + id, 'cilindro', description, + { v0, s1, s3, terrainCategory: cat, d, h, surface, endType, vk }, + { s2, vk, q, re, hOverD, cpi, supercritical: re > 400_000 ? 1 : 0 }, + { profile: profile.map(p => ({ angle: p.angle, cpe: p.cpe })), cpi }, + { + q: { min: 0.3, max: 5.0 }, + cpi: { min: -0.9, max: 0.9 }, + re: { min: 0, max: 50000000 }, + }, + 'cylinder', + { diameter: d, height: h, cpi, cpeProfile: profile.map(p => ({ angle: p.angle, cpe: p.cpe })) }, + 'Sec. 6.2.1', + ); +} + +export function generateAllScenarios(): AuditScenario[] { + const scenarios: AuditScenario[] = []; + + // 1. BLESSMANN CASES + scenarios.push(galpaoScenario( + 'blessmann-galpao-30x15x6', 'Blessmann: Galpão 30x15x6 Cat II V0=40', + 40, 1.0, 1.0, 'II', 15, 30, 6, 10, 0, 'four-equally-permeable', 1.0, + )); + + { + const v0 = 40, s1 = 1.0, s3 = 1.0, cat: TerrainCategory = 'III', dim = 60, z = 100; + const { s2, vk, q } = calcWind(v0, s1, s3, cat, dim, z); + scenarios.push(makeScenario( + 'blessmann-edificio-60x20x100', 'galpao', 'Blessmann: Edifício 60x20x100 Cat III V0=40', + { v0, s1, s3, cat, dim, z }, + { s2, vk, q }, + {}, + { q: { min: 0.8, max: 2.0 }, s2: { min: 1.0, max: 1.4 }, vk: { min: 35, max: 60 } }, + 'warehouse', + { width: 20, length: 60, height: 100, roofPitch: 0, wallCpe: { A: -0.9, B: -0.6, C: 0.7, D: -0.5 }, roofCpe: { E: -0.8, F: -0.4, G: 0.2, H: -0.3 }, windAngle: 0 as const, cpi: 0 }, + 'Sec. 5.3', + )); + } + + scenarios.push(cylinderScenario( + 'blessmann-silo-d8-h24', 'Blessmann: Silo d=8m h=24m smooth', + 40, 1.0, 1.0, 'II', 8, 24, 'smooth', 'open-top', 0, + )); + + for (const z of [5, 10, 20, 50, 100]) { + const { s2 } = calcWind(40, 1.0, 1.0, 'II', 30, z); + scenarios.push(makeScenario( + `blessmann-s2-z${z}`, 'galpao', `Blessmann: S2 em z=${z}m Cat II`, + { v0: 40, s1: 1.0, s3: 1.0, cat: 'II', dim: 30, z }, + { s2 }, + { s2 }, + { s2: { min: 0.5, max: 1.5 } }, + 'warehouse', + { width: 15, length: 30, height: 10, roofPitch: 10, wallCpe: { A: -0.8, B: -0.5, C: 0.7, D: -0.4 }, roofCpe: { E: -0.8, F: -0.4, G: 0.2, H: -0.3, I: -0.5, J: 0 }, windAngle: 0 as const, cpi: 0 }, + 'Sec. 5.3', + )); + } + + { + const S3_VALS: Record = { '1': 1.11, '2': 1.06, '3': 1.00, '4': 0.95, '5': 0.83 }; + for (const [grp, s3val] of Object.entries(S3_VALS)) { + scenarios.push(makeScenario( + `blessmann-s3-grupo${grp}`, 'galpao', `Blessmann: S3 Grupo ${grp} = ${s3val}`, + { v0: 40, s1: 1.0, s3: s3val, cat: 'II', dim: 30, z: 10 }, + { s3: s3val }, + { s3: s3val }, + { s3: { min: 0.7, max: 1.2 } }, + 'warehouse', + { width: 15, length: 30, height: 10, roofPitch: 10, wallCpe: { A: -0.8, B: -0.5, C: 0.7, D: -0.4 }, roofCpe: { E: -0.8, F: -0.4, G: 0.2, H: -0.3, I: -0.5, J: 0 }, windAngle: 0 as const, cpi: 0 }, + 'Sec. 5.4', + )); + } + } + + { + const bridgeInput: BridgeClassificationInput = { + lp: 120, width: 14, massPerLength: 18000, fv: 0.6, + v0: 40, s1: 1.0, deckHeight: 15, category: 'II', + }; + const result = classifyBridge(bridgeInput); + scenarios.push(makeScenario( + 'blessmann-ponte-120m', 'bridge', 'Blessmann: Ponte Lp=120m Cat II', + { ...bridgeInput }, + { pse: result.pse, vit: result.vit }, + { pse: result.pse, vit: result.vit, bridgeClass: result.bridgeClass }, + { pse: { min: 0.01, max: 2.0 }, vit: { min: 15, max: 50 } }, + 'bridge', + { lp: 120, width: 14, deckHeight: 15, heg: 1.5, cx: 1.2, fxPerLength: 16.8, fzPerLength: -2.1 }, + 'Sec. 11.2', + )); + } + + scenarios.push(cylinderScenario( + 'blessmann-chamine-d1.5-h30', 'Blessmann: Chaminé d=1.5m h=30m rough', + 40, 1.0, 1.0, 'II', 1.5, 30, 'rough', 'closed', 0, + )); + + { + const signInput: SignInput = { length: 6, height: 2, groundClearance: 3, alpha: 90, hasEndPlates: false }; + const q = 0.981; + const signResult = calculateSign(signInput, q); + scenarios.push(makeScenario( + 'blessmann-placa-6x2', 'sign', 'Blessmann: Placa 6x2m', + { length: 6, height: 2, groundClearance: 3, alpha: 90, hasEndPlates: false, q }, + { cf: signResult.cf }, + { cf: signResult.cf, forceKN: signResult.forceKN }, + { cf: { min: 0.8, max: 2.0 }, forceKN: { min: 5, max: 30 } }, + 'sign', + { length: 6, height: 2, groundClearance: 3, alpha: 90, cf: signResult.cf, forceKN: signResult.forceKN }, + 'Sec. 7.1', + )); + } + + // 2. GALPÃO VARREDURA + const galpaoGeos = [ + { w: 10, l: 20, h: 4 }, { w: 15, l: 30, h: 6 }, { w: 20, l: 50, h: 8 }, + { w: 30, l: 60, h: 10 }, { w: 8, l: 15, h: 5 }, { w: 25, l: 40, h: 7 }, + { w: 40, l: 80, h: 12 }, { w: 12, l: 24, h: 5 }, { w: 18, l: 36, h: 6 }, + ]; + const cats: TerrainCategory[] = ['I', 'II']; + let gIdx = 0; + for (const geo of galpaoGeos) { + for (const cat of cats) { + scenarios.push(galpaoScenario( + `galpao-varredura-${gIdx++}`, `Galpão ${geo.w}x${geo.l}x${geo.h} Cat ${cat}`, + 40, 1.0, 1.0, cat, geo.w, geo.l, geo.h, 10, 0, 'four-equally-permeable', 1.0, + )); + } + } + + // 3. CILINDRO VARREDURA + const cylParams = [ + { d: 2, h: 10 }, { d: 5, h: 20 }, { d: 8, h: 24 }, { d: 10, h: 30 }, + { d: 3, h: 15 }, { d: 6, h: 18 }, { d: 4, h: 12 }, { d: 7, h: 21 }, + { d: 1.2, h: 8 }, { d: 9, h: 27 }, + ]; + let cIdx = 0; + for (const p of cylParams) { + scenarios.push(cylinderScenario( + `cilindro-varredura-${cIdx++}`, `Cilindro d=${p.d} h=${p.h} smooth closed`, + 40, 1.0, 1.0, 'II', p.d, p.h, 'smooth', 'closed', 0, + )); + } + + // 4. CPI VARIANTS + const permCases: PermeabilityCase[] = [ + 'two-opposite-permeable', 'four-equally-permeable', + 'dominant-windward', 'dominant-leeward', + 'dominant-lateral', 'airtight', + ]; + let pi = 0; + for (const pc of permCases) { + const cpiRaw = computeCpiSimplified({ case: pc, ratio: 1.0, windAngle: 0 }); + const cpi = clampCpi(cpiRaw); + scenarios.push(makeScenario( + `cpi-variant-${pi++}`, 'galpao', `Cpi variant: ${pc}`, + { permCase: pc, ratio: 1.0, windAngle: 0 }, + { cpi, cpiRaw }, + { cpi }, + { cpi: { min: -0.9, max: 0.9 } }, + 'warehouse', + { width: 15, length: 30, height: 6, roofPitch: 10, wallCpe: { A: -0.8, B: -0.5, C: 0.7, D: -0.4 }, roofCpe: { E: -0.8, F: -0.4, G: 0.2, H: -0.3, I: -0.5, J: 0 }, windAngle: 0 as const, cpi }, + 'Sec. 6.3', + )); + } + + // 5. EDGE CASES + scenarios.push(cylinderScenario( + 'edge-cilindro-hd-extremo', 'Edge: Cilindro h/d=0.3', + 40, 1.0, 1.0, 'II', 10, 3, 'smooth', 'closed', 0, + )); + + { + const { s2, vk, q } = calcWind(40, 1.0, 1.0, 'V', 30, 5); + scenarios.push(makeScenario( + 'edge-s2-min-z5-catV', 'galpao', 'Edge: S2 mínimo z=5m Cat V', + { v0: 40, s1: 1.0, s3: 1.0, cat: 'V', dim: 30, z: 5 }, + { s2, vk, q }, + { s2, vk, q }, + { s2: { min: 0.5, max: 0.8 }, q: { min: 0.3, max: 1.5 } }, + 'warehouse', + { width: 15, length: 30, height: 5, roofPitch: 10, wallCpe: { A: -0.8, B: -0.5, C: 0.7, D: -0.4 }, roofCpe: { E: -0.8, F: -0.4, G: 0.2, H: -0.3, I: -0.5, J: 0 }, windAngle: 0 as const, cpi: 0 }, + 'Sec. 5.3', + )); + } + + { + const { s2, vk, q } = calcWind(40, 1.0, 1.0, 'IV', 30, 500); + scenarios.push(makeScenario( + 'edge-s2-saturado-z500-catIV', 'galpao', 'Edge: S2 saturado z=500m Cat IV', + { v0: 40, s1: 1.0, s3: 1.0, cat: 'IV', dim: 30, z: 500 }, + { s2, vk, q }, + { s2, vk, q }, + { s2: { min: 1.2, max: 1.4 } }, + 'warehouse', + { width: 15, length: 30, height: 10, roofPitch: 10, wallCpe: { A: -0.8, B: -0.5, C: 0.7, D: -0.4 }, roofCpe: { E: -0.8, F: -0.4, G: 0.2, H: -0.3, I: -0.5, J: 0 }, windAngle: 0 as const, cpi: 0 }, + 'Sec. 5.3', + )); + } + + for (const dim of [20, 21, 50, 51]) { + const { structClass, s2 } = calcWind(40, 1.0, 1.0, 'II', dim, 10); + scenarios.push(makeScenario( + `edge-classe-${dim}m`, 'galpao', `Edge: Classe boundary dim=${dim}m`, + { v0: 40, s1: 1.0, s3: 1.0, cat: 'II', dim, z: 10 }, + { s2 }, + { s2, structClass }, + { s2: { min: 0.8, max: 1.3 } }, + 'warehouse', + { width: 15, length: 30, height: 10, roofPitch: 10, wallCpe: { A: -0.8, B: -0.5, C: 0.7, D: -0.4 }, roofCpe: { E: -0.8, F: -0.4, G: 0.2, H: -0.3, I: -0.5, J: 0 }, windAngle: 0 as const, cpi: 0 }, + 'Sec. 5.3.2', + )); + } + + for (const v0 of [20, 50, 60]) { + const { vk, q } = calcWind(v0, 1.0, 1.0, 'II', 30, 10); + scenarios.push(makeScenario( + `edge-v0-${v0}`, 'galpao', `Edge: V0=${v0} m/s`, + { v0, s1: 1.0, s3: 1.0, cat: 'II', dim: 30, z: 10 }, + { vk, q }, + { vk, q }, + { q: { min: 0.1, max: 3.0 }, vk: { min: 15, max: 65 } }, + 'warehouse', + { width: 15, length: 30, height: 10, roofPitch: 10, wallCpe: { A: -0.8, B: -0.5, C: 0.7, D: -0.4 }, roofCpe: { E: -0.8, F: -0.4, G: 0.2, H: -0.3, I: -0.5, J: 0 }, windAngle: 0 as const, cpi: 0 }, + 'Sec. 4.2', + )); + } + + // 6. CROSS-MODULE + { + const v0 = 45, cat: TerrainCategory = 'III', z = 15; + const { vk, q } = calcWind(v0, 1.0, 1.0, cat, 25, z); + scenarios.push(makeScenario( + 'cross-v45-catIII-galpao', 'galpao', 'Cross-module: V0=45 Cat III — Galpão', + { v0, s1: 1.0, s3: 1.0, cat, dim: 25, z }, + { vk, q }, + { vk, q }, + { q: { min: 0.8, max: 2.0 } }, + 'warehouse', + { width: 15, length: 25, height: 8, roofPitch: 12, wallCpe: { A: -0.8, B: -0.5, C: 0.7, D: -0.4 }, roofCpe: { E: -0.8, F: -0.4, G: 0.2, H: -0.3, I: -0.5, J: 0 }, windAngle: 0 as const, cpi: 0 }, + 'Sec. 6.1', + )); + scenarios.push(makeScenario( + 'cross-v45-catIII-cilindro', 'cilindro', 'Cross-module: V0=45 Cat III — Cilindro', + { v0, s1: 1.0, s3: 1.0, cat, d: 5, h: 20 }, + { vk, q }, + { vk, q }, + { q: { min: 0.8, max: 2.0 } }, + 'cylinder', + { diameter: 5, height: 20, cpi: -0.8, cpeProfile: [{ angle: 0, cpe: 1.0 }, { angle: 90, cpe: -1.2 }] }, + 'Sec. 6.2.1', + )); + } + + // 7. DINÂMICA + const comfortFreqs = [0.1, 0.2, 0.5, 0.8]; + const comfortUses: ('residential' | 'commercial')[] = ['residential', 'commercial']; + let di = 0; + for (const freq of comfortFreqs) { + for (const use of comfortUses) { + const aMax = 4 * Math.PI * Math.PI * freq * freq * 0.01; + const result = evaluateComfort({ freq, aMax, use }); + scenarios.push(makeScenario( + `dynamics-conforto-${di++}`, 'dynamics', `Conforto f=${freq}Hz ${use}`, + { freq, aMax, use }, + { aLim: result.aLim, ratio: result.ratio }, + { ok: result.ok ? 1 : 0, aLim: result.aLim }, + { aLim: { min: 0, max: 0.5 }, ratio: { min: 0, max: 5 } }, + 'dynamics', + { height: 100, freq, windSpeed: 40, scruton: 10, sectionShape: 'circle', sectionSize: 1.5, showVortexStreet: true, showModeShape: true }, + 'Sec. 9.6', + )); + } + } + + // 8. PONTES VARREDURA + const bridgeParams = [ + { lp: 80, width: 10, m: 12000, fv: 0.4, z: 10 }, + { lp: 120, width: 14, m: 18000, fv: 0.6, z: 15 }, + { lp: 200, width: 20, m: 30000, fv: 0.3, z: 20 }, + { lp: 60, width: 8, m: 8000, fv: 0.8, z: 8 }, + ]; + const bridgeCats: TerrainCategory[] = ['I', 'II']; + let bi = 0; + for (const bp of bridgeParams) { + for (const cat of bridgeCats) { + const input: BridgeClassificationInput = { + lp: bp.lp, width: bp.width, massPerLength: bp.m, fv: bp.fv, + v0: 40, s1: 1.0, deckHeight: bp.z, category: cat, + }; + const result = classifyBridge(input); + scenarios.push(makeScenario( + `ponte-varredura-${bi++}`, 'bridge', `Ponte Lp=${bp.lp}m Cat ${cat}`, + { ...input }, + { pse: result.pse, vit: result.vit }, + { pse: result.pse, vit: result.vit, bridgeClass: result.bridgeClass }, + { pse: { min: 0.001, max: 3.0 }, vit: { min: 10, max: 60 } }, + 'bridge', + { lp: bp.lp, width: bp.width, deckHeight: bp.z, heg: 1.5, cx: 1.2, fxPerLength: 14.1, fzPerLength: -1.8 }, + 'Sec. 11.2', + )); + } + } + + // 9. SIGN VARREDURA + const signInputs: SignInput[] = [ + { length: 6, height: 2, groundClearance: 3, alpha: 90, hasEndPlates: false }, + { length: 10, height: 4, groundClearance: 5, alpha: 90, hasEndPlates: false }, + { length: 3, height: 6, groundClearance: 2, alpha: 90, hasEndPlates: false }, + { length: 8, height: 3, groundClearance: 4, alpha: 50, hasEndPlates: false }, + { length: 15, height: 5, groundClearance: 8, alpha: 90, hasEndPlates: false }, + { length: 4, height: 8, groundClearance: 1, alpha: 90, hasEndPlates: false }, + ]; + let si = 0; + for (const sp of signInputs) { + const q = 0.981; + const result = calculateSign(sp, q); + scenarios.push(makeScenario( + `sign-varredura-${si++}`, 'sign', `Sign: ${sp.length}x${sp.height}m α=${sp.alpha}°`, + { ...sp, q }, + { cf: result.cf }, + { cf: result.cf, forceKN: result.forceKN }, + { cf: { min: 0.8, max: 2.5 }, forceKN: { min: 1, max: 50 } }, + 'sign', + { length: sp.length, height: sp.height, groundClearance: sp.groundClearance, alpha: sp.alpha, cf: result.cf, forceKN: result.forceKN }, + 'Sec. 7.1', + )); + } + + // 10. TOWERS VARREDURA + const towerParams = [ + { section: 'square', phi: 0.2, alphaWind: 0 }, + { section: 'square', phi: 0.4, alphaWind: 45 }, + { section: 'triangular', phi: 0.3, alphaWind: 0 }, + { section: 'square', phi: 0.5, alphaWind: 30 }, + { section: 'triangular', phi: 0.25, alphaWind: 0 }, + { section: 'square', phi: 0.6, alphaWind: 90 }, + { section: 'triangular', phi: 0.35, alphaWind: 45 }, + { section: 'square', phi: 0.45, alphaWind: 0 }, + ]; + let ti = 0; + for (const tp of towerParams) { + scenarios.push(makeScenario( + `tower-varredura-${ti++}`, 'tower', `Torre ${tp.section} phi=${tp.phi} α=${tp.alphaWind}°`, + { ...tp, q: 0.981 }, + { phi: tp.phi }, + { ca: 1.8 }, + { phi: { min: 0.05, max: 1.0 }, ca: { min: 0.6, max: 3.6 } }, + 'tower', + { section: tp.section, baseWidth: 5, height: 30, panels: 6, phi: tp.phi, alphaWind: tp.alphaWind, forceKN: 25.0 }, + 'Sec. 8.5', + )); + } + + return scenarios; +} + +export function groupScenariosByModule(scenarios: AuditScenario[]): Record { + const groups: Partial> = {}; + for (const s of scenarios) { + if (!groups[s.module]) groups[s.module] = []; + groups[s.module]!.push(s); + } + return groups as Record; +} diff --git a/app/src/lib/audit/serializer.ts b/app/src/lib/audit/serializer.ts new file mode 100644 index 0000000..043936e --- /dev/null +++ b/app/src/lib/audit/serializer.ts @@ -0,0 +1,21 @@ +import type { AuditScenario } from './types'; + +export function serializeScenario(scenario: AuditScenario): string { + return JSON.stringify({ + scenarioId: scenario.id, + description: scenario.description, + module: scenario.module, + moduleLabel: scenario.moduleLabel, + nbrSection: scenario.nbrSection, + inputs: scenario.inputs, + intermediates: scenario.intermediates, + outputs: scenario.outputs, + expectedRanges: scenario.expectedRanges, + diagramType: scenario.diagramType, + diagramProps: scenario.diagramProps, + }, null, 2); +} + +export function serializeAllScenarios(scenarios: AuditScenario[]): string { + return scenarios.map(s => serializeScenario(s)).join('\n---\n'); +} diff --git a/app/src/lib/audit/types.ts b/app/src/lib/audit/types.ts index f9d3e58..2ae769d 100644 --- a/app/src/lib/audit/types.ts +++ b/app/src/lib/audit/types.ts @@ -1,4 +1,4 @@ -export type LLMProvider = 'openai' | 'anthropic' | 'ollama'; +export type LLMProvider = 'openai' | 'anthropic' | 'ollama' | 'minimax' | 'openrouter'; export type TerrainCategory = 'I' | 'II' | 'III' | 'IV' | 'V'; export type StructureClass = 'A' | 'B' | 'C'; @@ -64,7 +64,7 @@ export interface AuditScenario { readonly moduleLabel: string; readonly description: string; readonly inputs: Record; - readonly intermediates: Record; + readonly intermediates: Record; readonly outputs: Record; readonly expectedRanges: Record; readonly diagramType: DiagramType; @@ -123,6 +123,8 @@ export interface AuditRun { readonly error?: string; } +export type RouteModuleMap = Record; + export interface AuditLLMResponse { readonly scenarioId: string; readonly verdict: CheckStatus; diff --git a/app/src/lib/i18n.ts b/app/src/lib/i18n.ts index 5e2b9ea..a38ce75 100644 --- a/app/src/lib/i18n.ts +++ b/app/src/lib/i18n.ts @@ -177,6 +177,34 @@ const translations: Dict = { ftool_sign_convention: { 'pt-BR': 'Sinal de carga: positivo = na direção positiva do eixo Y (empuxo). Cargas de coluna em GlobalX (horizontal).', 'en-US': 'Load sign: positive = in the positive Y-axis direction (pressure). Column loads on GlobalX (horizontal).' }, ftool_download: { 'pt-BR': 'Baixar galpao_ftool.txt', 'en-US': 'Download galpao_ftool.txt' }, + // === Auditoria LLM === + audit_title: { 'pt-BR': 'Auditoria LLM', 'en-US': 'LLM Audit' }, + audit_desc: { 'pt-BR': 'Validação dos cálculos de vento usando LLMs sequenciais. 100+ cenários cobrindo todos os módulos.', 'en-US': 'Wind calculation validation using sequential LLMs. 100+ scenarios covering all modules.' }, + audit_run: { 'pt-BR': 'Executar Auditoria', 'en-US': 'Run Audit' }, + audit_cancel: { 'pt-BR': 'Cancelar', 'en-US': 'Cancel' }, + audit_export_pdf: { 'pt-BR': 'Exportar PDF', 'en-US': 'Export PDF' }, + audit_provider: { 'pt-BR': 'Provedor', 'en-US': 'Provider' }, + audit_model: { 'pt-BR': 'Modelo', 'en-US': 'Model' }, + audit_apikey: { 'pt-BR': 'API Key', 'en-US': 'API Key' }, + audit_baseurl: { 'pt-BR': 'Base URL (opcional)', 'en-US': 'Base URL (optional)' }, + audit_generating: { 'pt-BR': 'Gerando cenários...', 'en-US': 'Generating scenarios...' }, + audit_running: { 'pt-BR': 'Processando cenários...', 'en-US': 'Running scenarios...' }, + audit_progress: { 'pt-BR': '{current} de {total}', 'en-US': '{current} of {total}' }, + audit_no_apikey: { 'pt-BR': 'Informe uma API key para continuar.', 'en-US': 'Provide an API key to continue.' }, + audit_results: { 'pt-BR': 'Resultados', 'en-US': 'Results' }, + audit_summary: { 'pt-BR': '{passed}/{total} PASS — W:{warnings} F:{failed}', 'en-US': '{passed}/{total} PASS — W:{warnings} F:{failed}' }, + audit_total: { 'pt-BR': 'Total', 'en-US': 'Total' }, + audit_module: { 'pt-BR': 'Módulo', 'en-US': 'Module' }, + audit_click_module: { 'pt-BR': 'Clique em um módulo para ver detalhes.', 'en-US': 'Click a module to view details.' }, + audit_checks: { 'pt-BR': 'Verificações', 'en-US': 'Checks' }, + audit_expected: { 'pt-BR': 'Esperado', 'en-US': 'Expected' }, + audit_actual: { 'pt-BR': 'Obtido', 'en-US': 'Actual' }, + audit_inputs: { 'pt-BR': 'Entradas', 'en-US': 'Inputs' }, + audit_detail_title: { 'pt-BR': 'Detalhes do Cenário', 'en-US': 'Scenario Details' }, + audit_copy_module: { 'pt-BR': 'Copiar para o módulo', 'en-US': 'Copy to module' }, + audit_close: { 'pt-BR': 'Fechar', 'en-US': 'Close' }, + audit_export_popup: { 'pt-BR': 'Exportar PDF deste cenário', 'en-US': 'Export PDF of this scenario' }, + // === Home (App.tsx) === home_full_coverage: { 'pt-BR': 'Cobertura completa da norma', 'en-US': 'Full standard coverage' }, diff --git a/app/src/pages/SettingsModule.tsx b/app/src/pages/SettingsModule.tsx index 54f1d7e..52ecdaf 100644 --- a/app/src/pages/SettingsModule.tsx +++ b/app/src/pages/SettingsModule.tsx @@ -1,7 +1,8 @@ import React, { useRef, useState } from 'react'; import { Card, CardContent, CardHeader, CardTitle, CardDescription } from '@/components/ui/card'; import { Button } from '@/components/ui/button'; -import { Sun, Moon, Laptop, Save, Trash2, Upload, CheckCircle2, AlertCircle } from 'lucide-react'; +import { Sun, Moon, Laptop, Save, Trash2, Upload, CheckCircle2, AlertCircle, FlaskConical } from 'lucide-react'; +import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'; import { useTheme } from '@/lib/theme'; import { useProjects } from '@/lib/hooks/useProjects'; import { useWindStore } from '@/store/appStore'; @@ -12,6 +13,7 @@ import { readProjectFile, type ImportResult, } from '@/lib/import-project'; +import AuditPanel from '@/components/AuditPanel'; const SettingsModule: React.FC = () => { const { theme, setTheme, effectiveTheme } = useTheme(); @@ -60,8 +62,19 @@ const SettingsModule: React.FC = () => { }; return ( -
-

{t('nav_settings')}

+
+

{t('nav_settings')}

+ + + + Configurações + + + Testes + + + + @@ -207,6 +220,12 @@ const SettingsModule: React.FC = () => {

+
+ + + + +
); };