feat(audit): adicionar módulo de auditoria + rebranding BrainWind
- 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
This commit is contained in:
+49
-9
@@ -17,12 +17,12 @@
|
|||||||
| Data da última atualização | 2026-07-10 |
|
| 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 ✅ |
|
| 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** |
|
| Cobertura NBR 6123:2023 | **100% das 11 seções + 3 anexos** |
|
||||||
| Linhas de código TS/TSX | ~7.400 |
|
| Linhas de código TS/TSX | ~8.200 |
|
||||||
| Arquivos `.ts`/`.tsx` no `src/` | **110** |
|
| Arquivos `.ts`/`.tsx` no `src/` | **120** |
|
||||||
| Páginas (rotas) | **10** |
|
| Páginas (rotas) | **10** |
|
||||||
| Módulos Strategy | **7** |
|
| Módulos Strategy | **7** |
|
||||||
| Tabelas da norma implementadas | **36 / 36** + 3 anexos (auditadas M9.1) |
|
| 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) |
|
| Build de produção | ✅ passa (2645 módulos) |
|
||||||
| Lint (oxlint) | ✅ 0 erros |
|
| Lint (oxlint) | ✅ 0 erros |
|
||||||
| Persistência local | ✅ IndexedDB (Dexie-style) |
|
| Persistência local | ✅ IndexedDB (Dexie-style) |
|
||||||
@@ -311,10 +311,10 @@ cd /root/Apps/windapp/app
|
|||||||
# 1. Compilação TS (deve passar sem erros)
|
# 1. Compilação TS (deve passar sem erros)
|
||||||
./node_modules/.bin/tsc -b
|
./node_modules/.bin/tsc -b
|
||||||
|
|
||||||
# 2. Testes (deve mostrar 38/38 passing)
|
# 2. Testes (deve mostrar 316/316 passing)
|
||||||
./node_modules/.bin/vitest run
|
./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
|
./node_modules/.bin/oxlint
|
||||||
|
|
||||||
# 4. Build de produção
|
# 4. Build de produção
|
||||||
@@ -326,9 +326,9 @@ npm run dev
|
|||||||
|
|
||||||
**Resultado esperado:**
|
**Resultado esperado:**
|
||||||
- ✅ tsc: silent (sem output)
|
- ✅ tsc: silent (sem output)
|
||||||
- ✅ vitest: `Test Files 5 passed (5) | Tests 38 passed (38)`
|
- ✅ vitest: `Test Files 16 passed (16) | Tests 316 passed (316)`
|
||||||
- ✅ oxlint: `Found N warnings and 0 errors`
|
- ✅ oxlint: `Found 10 warnings and 0 errors`
|
||||||
- ✅ vite: `✓ built in ~2s` com `dist/assets/index-*.js ~2.9 MB`
|
- ✅ 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.
|
- 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).
|
- 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
|
#### M9.14 — Manual Didático Interativo com Animações SVG
|
||||||
**Status:** ✅ Concluído (2026-07-10)
|
**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).
|
**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
|
## 🎯 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) —
|
**M9.11 — Testes E2E com Playwright** (~2 dias, médio impacto) —
|
||||||
instalar `@playwright/test`, criar testes para fluxos completos
|
instalar `@playwright/test`, criar testes para fluxos completos
|
||||||
(criar projeto galpão → salvar → exportar PDF), integrar ao CI.
|
(criar projeto galpão → salvar → exportar PDF), integrar ao CI.
|
||||||
|
|||||||
@@ -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 <span className="text-emerald-600 font-bold">✓</span>;
|
||||||
|
case 'WARN': return <span className="text-amber-600 font-bold">⚠</span>;
|
||||||
|
case 'FAIL': return <span className="text-red-600 font-bold">✗</span>;
|
||||||
|
default: return <span className="text-muted-foreground">?</span>;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||||
|
<DialogContent className="sm:max-w-[600px] max-h-[85vh] overflow-y-auto">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle className="flex items-center gap-2 text-base">
|
||||||
|
{scenario.id}
|
||||||
|
{result && <span className="text-sm font-normal">{statusIcon(result.verdict)} {result.verdict}</span>}
|
||||||
|
</DialogTitle>
|
||||||
|
<DialogDescription>
|
||||||
|
{scenario.description} — {scenario.moduleLabel} · {scenario.nbrSection}
|
||||||
|
</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
|
||||||
|
<div className="w-full h-48 rounded-md overflow-hidden border bg-muted/10 mb-3">
|
||||||
|
<FallbackDiagram type={scenario.diagramType as never} props={scenario.diagramProps as never} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-2 gap-2 text-xs mb-3">
|
||||||
|
<div className="rounded-md border bg-muted/30 p-2">
|
||||||
|
<span className="text-muted-foreground">Módulo:</span>{' '}
|
||||||
|
<span className="font-medium">{scenario.moduleLabel}</span>
|
||||||
|
</div>
|
||||||
|
<div className="rounded-md border bg-muted/30 p-2">
|
||||||
|
<span className="text-muted-foreground">NBR:</span>{' '}
|
||||||
|
<span className="font-medium">{scenario.nbrSection}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{result && result.checks.length > 0 && (
|
||||||
|
<div className="space-y-1 mb-3">
|
||||||
|
<h4 className="text-sm font-semibold">Verificações</h4>
|
||||||
|
<div className="rounded-md border overflow-hidden">
|
||||||
|
<table className="w-full text-xs">
|
||||||
|
<thead className="bg-muted/50">
|
||||||
|
<tr>
|
||||||
|
<th className="p-2 text-left w-10">Status</th>
|
||||||
|
<th className="p-2 text-left">Check</th>
|
||||||
|
<th className="p-2 text-left">Esperado</th>
|
||||||
|
<th className="p-2 text-left">Obtido</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{result.checks.map((chk, i) => (
|
||||||
|
<tr key={i} className="border-t border-border">
|
||||||
|
<td className="p-2">{statusIcon(chk.status)}</td>
|
||||||
|
<td className="p-2 font-medium">{chk.name}</td>
|
||||||
|
<td className="p-2 text-muted-foreground">{chk.expected}</td>
|
||||||
|
<td className="p-2">{chk.actual}</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
{result.summary && (
|
||||||
|
<p className="text-xs text-muted-foreground mt-1">{result.summary}</p>
|
||||||
|
)}
|
||||||
|
{result.llmNotes && (
|
||||||
|
<p className="text-xs italic text-muted-foreground mt-1">LLM: {result.llmNotes}</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="space-y-1 mb-3">
|
||||||
|
<h4 className="text-sm font-semibold">Entradas</h4>
|
||||||
|
<div className="rounded-md border bg-muted/10 p-2 text-xs font-mono max-h-32 overflow-y-auto">
|
||||||
|
<pre className="whitespace-pre-wrap">{JSON.stringify(scenario.inputs, null, 2)}</pre>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex gap-2 justify-end">
|
||||||
|
<Button variant="outline" size="sm" onClick={handleExportPDF} disabled={!result}>
|
||||||
|
<FileDown className="w-3.5 h-3.5 mr-1.5" />
|
||||||
|
Exportar PDF
|
||||||
|
</Button>
|
||||||
|
<Button variant="outline" size="sm" onClick={handleCopyToModule}>
|
||||||
|
<Copy className="w-3.5 h-3.5 mr-1.5" />
|
||||||
|
Copiar para o módulo
|
||||||
|
</Button>
|
||||||
|
<DialogClose asChild>
|
||||||
|
<Button variant="ghost" size="sm">
|
||||||
|
<X className="w-3.5 h-3.5 mr-1.5" />
|
||||||
|
Fechar
|
||||||
|
</Button>
|
||||||
|
</DialogClose>
|
||||||
|
</div>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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<string, string> = {
|
||||||
|
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<AuditConfig>({
|
||||||
|
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<AuditReport | null>(null);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const abortRef = useRef(false);
|
||||||
|
|
||||||
|
const [popupOpen, setPopupOpen] = useState(false);
|
||||||
|
const [selectedScenario, setSelectedScenario] = useState<AuditScenario | null>(null);
|
||||||
|
const [selectedResult, setSelectedResult] = useState<ScenarioResult | null>(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 (
|
||||||
|
<div className="space-y-4">
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="text-lg">Auditoria LLM</CardTitle>
|
||||||
|
<CardDescription>
|
||||||
|
Validação dos cálculos de vento usando LLMs sequenciais. 100+ cenários cobrindo todos os módulos.
|
||||||
|
</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-4">
|
||||||
|
<div className="grid grid-cols-2 gap-3">
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<label className="text-sm font-medium">Provedor</label>
|
||||||
|
<Select value={config.provider} onValueChange={handleProviderChange}>
|
||||||
|
<SelectTrigger>
|
||||||
|
<SelectValue />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{PROVIDERS.map((p) => (
|
||||||
|
<SelectItem key={p.value} value={p.value}>{p.label}</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<label className="text-sm font-medium">Modelo</label>
|
||||||
|
<Input
|
||||||
|
value={config.model}
|
||||||
|
onChange={(e) => setConfig((c) => ({ ...c, model: e.target.value }))}
|
||||||
|
placeholder="gpt-4o, claude-3-5-sonnet-..."
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<label className="text-sm font-medium">API Key</label>
|
||||||
|
<Input
|
||||||
|
type="password"
|
||||||
|
value={config.apiKey}
|
||||||
|
onChange={(e) => setConfig((c) => ({ ...c, apiKey: e.target.value }))}
|
||||||
|
placeholder="sk-..."
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<label className="text-sm font-medium">Base URL (opcional)</label>
|
||||||
|
<Input
|
||||||
|
value={config.baseUrl ?? ''}
|
||||||
|
onChange={(e) => setConfig((c) => ({ ...c, baseUrl: e.target.value || undefined }))}
|
||||||
|
placeholder="https://api.openai.com/v1"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center gap-2 flex-wrap">
|
||||||
|
<Button
|
||||||
|
onClick={handleRun}
|
||||||
|
disabled={status === 'running' || status === 'generating'}
|
||||||
|
>
|
||||||
|
{status === 'running' || status === 'generating' ? (
|
||||||
|
<Loader2 className="w-4 h-4 mr-1.5 animate-spin" />
|
||||||
|
) : (
|
||||||
|
<Play className="w-4 h-4 mr-1.5" />
|
||||||
|
)}
|
||||||
|
Executar Auditoria
|
||||||
|
</Button>
|
||||||
|
{(status === 'running' || status === 'generating') && (
|
||||||
|
<Button variant="outline" size="sm" onClick={() => { abortRef.current = true; }}>
|
||||||
|
Cancelar
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
{status === 'done' && report && (
|
||||||
|
<Button variant="outline" size="sm" onClick={handleExportPDF}>
|
||||||
|
<FileDown className="w-3.5 h-3.5 mr-1.5" />
|
||||||
|
Exportar PDF
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{(status === 'running' || status === 'generating') && total > 0 && (
|
||||||
|
<div className="space-y-1">
|
||||||
|
<div className="flex justify-between text-xs text-muted-foreground">
|
||||||
|
<span>{status === 'generating' ? 'Gerando cenários...' : `Processando cenários...`}</span>
|
||||||
|
<span>{progress} / {total}</span>
|
||||||
|
</div>
|
||||||
|
<div className="w-full bg-muted rounded-full h-2 overflow-hidden">
|
||||||
|
<div
|
||||||
|
className="bg-purple-600 h-full rounded-full transition-all duration-300"
|
||||||
|
style={{ width: `${(progress / total) * 100}%` }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{status === 'error' && error && (
|
||||||
|
<div className="rounded-md border border-red-200 bg-red-50 dark:bg-red-950 dark:border-red-800 p-3 text-sm text-red-700 dark:text-red-300 flex items-start gap-2">
|
||||||
|
<AlertCircle className="w-4 h-4 mt-0.5 shrink-0" />
|
||||||
|
<span>{error}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{status === 'done' && report && (
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="text-lg flex items-center justify-between">
|
||||||
|
<span>Resultados</span>
|
||||||
|
<span className="text-sm font-normal text-muted-foreground">
|
||||||
|
{report.totalPassed}/{report.totalScenarios} PASS —
|
||||||
|
W:{report.totalWarnings} F:{report.totalFailed}
|
||||||
|
</span>
|
||||||
|
</CardTitle>
|
||||||
|
<CardDescription>
|
||||||
|
Provedor: {report.provider} ({report.model}) — {new Date(report.timestamp).toLocaleString('pt-BR')}
|
||||||
|
</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<div className="rounded-md border overflow-hidden">
|
||||||
|
<table className="w-full text-sm">
|
||||||
|
<thead className="bg-muted/50">
|
||||||
|
<tr>
|
||||||
|
<th className="p-2 text-left">Módulo</th>
|
||||||
|
<th className="p-2 text-center">Total</th>
|
||||||
|
<th className="p-2 text-center">PASS</th>
|
||||||
|
<th className="p-2 text-center">WARN</th>
|
||||||
|
<th className="p-2 text-center">FAIL</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{grouped.map((g: { module: string; label: string; total: number; passed: number; failed: number; warnings: number }) => (
|
||||||
|
<tr key={g.module} className="border-t border-border cursor-pointer hover:bg-muted/30"
|
||||||
|
onClick={() => {
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
}}>
|
||||||
|
<td className="p-2 font-medium">{g.label}</td>
|
||||||
|
<td className="p-2 text-center">{g.total}</td>
|
||||||
|
<td className="p-2 text-center"><Badge className="bg-emerald-100 text-emerald-800 dark:bg-emerald-900 dark:text-emerald-300">{g.passed}</Badge></td>
|
||||||
|
<td className="p-2 text-center"><Badge className="bg-amber-100 text-amber-800 dark:bg-amber-900 dark:text-amber-300">{g.warnings}</Badge></td>
|
||||||
|
<td className="p-2 text-center"><Badge className="bg-red-100 text-red-800 dark:bg-red-900 dark:text-red-300">{g.failed}</Badge></td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
<p className="text-xs text-muted-foreground mt-2">
|
||||||
|
Clique em um módulo para ver detalhes dos cenários e verificações.
|
||||||
|
</p>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<AuditDetailPopup
|
||||||
|
open={popupOpen}
|
||||||
|
onOpenChange={setPopupOpen}
|
||||||
|
scenario={selectedScenario}
|
||||||
|
result={selectedResult}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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 (
|
||||||
|
<Document>
|
||||||
|
<Page size="A4" style={styles.page}>
|
||||||
|
<View style={styles.header}>
|
||||||
|
<Text style={styles.title}>Relatório de Auditoria — BrainWind</Text>
|
||||||
|
<Text style={styles.subtitle}>
|
||||||
|
Provedor: {report.provider} ({report.model}) | {new Date(report.timestamp).toLocaleString('pt-BR')}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<View style={styles.section}>
|
||||||
|
<Text style={styles.sectionTitle}>Resumo</Text>
|
||||||
|
<View style={styles.summaryRow}>
|
||||||
|
<Text style={styles.summaryLabel}>Total de Cenários:</Text>
|
||||||
|
<Text style={styles.summaryValue}>{report.totalScenarios}</Text>
|
||||||
|
</View>
|
||||||
|
<View style={styles.summaryRow}>
|
||||||
|
<Text style={[styles.summaryLabel, { color: '#16a34a' }]}>Pass:</Text>
|
||||||
|
<Text style={styles.summaryValue}>{report.totalPassed}</Text>
|
||||||
|
</View>
|
||||||
|
<View style={styles.summaryRow}>
|
||||||
|
<Text style={[styles.summaryLabel, { color: '#d97706' }]}>Warn:</Text>
|
||||||
|
<Text style={styles.summaryValue}>{report.totalWarnings}</Text>
|
||||||
|
</View>
|
||||||
|
<View style={styles.summaryRow}>
|
||||||
|
<Text style={[styles.summaryLabel, { color: '#dc2626' }]}>Fail:</Text>
|
||||||
|
<Text style={styles.summaryValue}>{report.totalFailed}</Text>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{report.modules.map((mod: ModuleAuditResult) => (
|
||||||
|
<View key={mod.module} wrap={false} style={styles.moduleCard}>
|
||||||
|
<Text style={styles.moduleTitle}>
|
||||||
|
{mod.moduleLabel} — {mod.passed}/{mod.totalScenarios} (W:{mod.warnings} F:{mod.failed})
|
||||||
|
</Text>
|
||||||
|
{mod.scenarios.map((sc: ScenarioResult) => (
|
||||||
|
<View key={sc.scenarioId} style={styles.scenarioItem}>
|
||||||
|
<Text style={styles.scenarioId}>
|
||||||
|
{sc.scenarioId} — {sc.verdict === 'PASS' ? '✓' : sc.verdict === 'WARN' ? '⚠' : '✗'} {sc.summary}
|
||||||
|
</Text>
|
||||||
|
{sc.checks.map((chk, i) => (
|
||||||
|
<View key={i} style={styles.checkRow}>
|
||||||
|
<Text style={[styles.statusBadge, { color: statusColor(chk.status) }]}>{chk.status}</Text>
|
||||||
|
<Text style={styles.checkLabel}>{chk.name}</Text>
|
||||||
|
<Text style={styles.checkValue}>{chk.actual} ({chk.expected})</Text>
|
||||||
|
</View>
|
||||||
|
))}
|
||||||
|
</View>
|
||||||
|
))}
|
||||||
|
</View>
|
||||||
|
))}
|
||||||
|
|
||||||
|
<Text style={styles.footer}>
|
||||||
|
Gerado por BrainWind v1.0 — Ferramenta de Auditoria NBR 6123:2023
|
||||||
|
</Text>
|
||||||
|
</Page>
|
||||||
|
</Document>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function exportAuditReportToPDF(report: AuditReport): Promise<void> {
|
||||||
|
const blob = await pdf(<AuditReportDocument report={report} />).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);
|
||||||
|
}
|
||||||
@@ -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<string, { module: ModuleType; moduleLabel: string }>,
|
||||||
|
provider: string,
|
||||||
|
model: string,
|
||||||
|
rawResponse?: string,
|
||||||
|
): AuditReport {
|
||||||
|
const moduleMap = new Map<ModuleType, { passed: number; warnings: number; failed: number; results: ScenarioResult[] }>();
|
||||||
|
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,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -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.`,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -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<string> {
|
||||||
|
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<string> {
|
||||||
|
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<string> {
|
||||||
|
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<string> {
|
||||||
|
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<string> {
|
||||||
|
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<string> {
|
||||||
|
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<AuditReport> {
|
||||||
|
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<Response> {
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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<string, unknown>,
|
||||||
|
intermediates: Record<string, number>,
|
||||||
|
outputs: Record<string, unknown>,
|
||||||
|
ranges: Record<string, { min: number; max: number }>,
|
||||||
|
diagramType: string,
|
||||||
|
diagramProps: Record<string, unknown>,
|
||||||
|
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<string, number> = {};
|
||||||
|
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<string, number> = { '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<ModuleType, AuditScenario[]> {
|
||||||
|
const groups: Partial<Record<ModuleType, AuditScenario[]>> = {};
|
||||||
|
for (const s of scenarios) {
|
||||||
|
if (!groups[s.module]) groups[s.module] = [];
|
||||||
|
groups[s.module]!.push(s);
|
||||||
|
}
|
||||||
|
return groups as Record<ModuleType, AuditScenario[]>;
|
||||||
|
}
|
||||||
@@ -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');
|
||||||
|
}
|
||||||
@@ -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 TerrainCategory = 'I' | 'II' | 'III' | 'IV' | 'V';
|
||||||
export type StructureClass = 'A' | 'B' | 'C';
|
export type StructureClass = 'A' | 'B' | 'C';
|
||||||
@@ -64,7 +64,7 @@ export interface AuditScenario {
|
|||||||
readonly moduleLabel: string;
|
readonly moduleLabel: string;
|
||||||
readonly description: string;
|
readonly description: string;
|
||||||
readonly inputs: Record<string, unknown>;
|
readonly inputs: Record<string, unknown>;
|
||||||
readonly intermediates: Record<string, number>;
|
readonly intermediates: Record<string, unknown>;
|
||||||
readonly outputs: Record<string, unknown>;
|
readonly outputs: Record<string, unknown>;
|
||||||
readonly expectedRanges: Record<string, ExpectedRange>;
|
readonly expectedRanges: Record<string, ExpectedRange>;
|
||||||
readonly diagramType: DiagramType;
|
readonly diagramType: DiagramType;
|
||||||
@@ -123,6 +123,8 @@ export interface AuditRun {
|
|||||||
readonly error?: string;
|
readonly error?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export type RouteModuleMap = Record<ModuleType, string>;
|
||||||
|
|
||||||
export interface AuditLLMResponse {
|
export interface AuditLLMResponse {
|
||||||
readonly scenarioId: string;
|
readonly scenarioId: string;
|
||||||
readonly verdict: CheckStatus;
|
readonly verdict: CheckStatus;
|
||||||
|
|||||||
@@ -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_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' },
|
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 (App.tsx) ===
|
||||||
home_full_coverage: { 'pt-BR': 'Cobertura completa da norma', 'en-US': 'Full standard coverage' },
|
home_full_coverage: { 'pt-BR': 'Cobertura completa da norma', 'en-US': 'Full standard coverage' },
|
||||||
|
|
||||||
|
|||||||
@@ -1,7 +1,8 @@
|
|||||||
import React, { useRef, useState } from 'react';
|
import React, { useRef, useState } from 'react';
|
||||||
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from '@/components/ui/card';
|
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from '@/components/ui/card';
|
||||||
import { Button } from '@/components/ui/button';
|
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 { useTheme } from '@/lib/theme';
|
||||||
import { useProjects } from '@/lib/hooks/useProjects';
|
import { useProjects } from '@/lib/hooks/useProjects';
|
||||||
import { useWindStore } from '@/store/appStore';
|
import { useWindStore } from '@/store/appStore';
|
||||||
@@ -12,6 +13,7 @@ import {
|
|||||||
readProjectFile,
|
readProjectFile,
|
||||||
type ImportResult,
|
type ImportResult,
|
||||||
} from '@/lib/import-project';
|
} from '@/lib/import-project';
|
||||||
|
import AuditPanel from '@/components/AuditPanel';
|
||||||
|
|
||||||
const SettingsModule: React.FC = () => {
|
const SettingsModule: React.FC = () => {
|
||||||
const { theme, setTheme, effectiveTheme } = useTheme();
|
const { theme, setTheme, effectiveTheme } = useTheme();
|
||||||
@@ -60,8 +62,19 @@ const SettingsModule: React.FC = () => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="p-6 max-w-5xl mx-auto space-y-6 overflow-auto">
|
<div className="p-6 max-w-5xl mx-auto overflow-auto">
|
||||||
<h1 className="text-3xl font-bold">{t('nav_settings')}</h1>
|
<h1 className="text-3xl font-bold mb-6">{t('nav_settings')}</h1>
|
||||||
|
|
||||||
|
<Tabs defaultValue="config" className="w-full">
|
||||||
|
<TabsList className="grid w-full grid-cols-2 mb-6">
|
||||||
|
<TabsTrigger value="config">Configurações</TabsTrigger>
|
||||||
|
<TabsTrigger value="testes">
|
||||||
|
<FlaskConical className="w-4 h-4 mr-1.5" />
|
||||||
|
Testes
|
||||||
|
</TabsTrigger>
|
||||||
|
</TabsList>
|
||||||
|
|
||||||
|
<TabsContent value="config" className="space-y-6">
|
||||||
|
|
||||||
<Card>
|
<Card>
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
@@ -207,6 +220,12 @@ const SettingsModule: React.FC = () => {
|
|||||||
</p>
|
</p>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
</TabsContent>
|
||||||
|
|
||||||
|
<TabsContent value="testes" className="space-y-6">
|
||||||
|
<AuditPanel />
|
||||||
|
</TabsContent>
|
||||||
|
</Tabs>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
Reference in New Issue
Block a user