Merge pull request #1 from jonaspachecoometas/claude/analyze-project-0mXjP
Contenização do Suite
This commit is contained in:
commit
8ed4863685
|
|
@ -0,0 +1,73 @@
|
||||||
|
# ─── Arcádia Suite — Variáveis de Ambiente ────────────────────────────────────
|
||||||
|
# Copie para .env e preencha os valores.
|
||||||
|
# NUNCA commite o arquivo .env real.
|
||||||
|
|
||||||
|
# ── Banco de dados ────────────────────────────────────────────────────────────
|
||||||
|
DATABASE_URL=postgresql://arcadia:arcadia123@localhost:5432/arcadia
|
||||||
|
PGHOST=localhost
|
||||||
|
PGPORT=5432
|
||||||
|
PGUSER=arcadia
|
||||||
|
PGPASSWORD=arcadia123
|
||||||
|
PGDATABASE=arcadia
|
||||||
|
|
||||||
|
# ── Aplicação ─────────────────────────────────────────────────────────────────
|
||||||
|
NODE_ENV=development
|
||||||
|
PORT=5000
|
||||||
|
SESSION_SECRET=troque-por-string-aleatoria-segura-em-producao
|
||||||
|
SSO_SECRET=arcadia-sso-secret-2024-plus-integration-key-secure
|
||||||
|
|
||||||
|
# ── Docker Mode (desativa spawn de processos filhos) ─────────────────────────
|
||||||
|
# Em produção com Docker, defina como "true"
|
||||||
|
DOCKER_MODE=false
|
||||||
|
|
||||||
|
# ── URLs dos microserviços Python ─────────────────────────────────────────────
|
||||||
|
# Em Docker: use nome do serviço (ex: http://contabil:8003)
|
||||||
|
# Em desenvolvimento local: use localhost
|
||||||
|
CONTABIL_PYTHON_URL=http://localhost:8003
|
||||||
|
BI_PYTHON_URL=http://localhost:8004
|
||||||
|
AUTOMATION_PYTHON_URL=http://localhost:8005
|
||||||
|
FISCO_PYTHON_URL=http://localhost:8002
|
||||||
|
PYTHON_SERVICE_URL=http://localhost:8001
|
||||||
|
|
||||||
|
# ── IA — OpenAI ───────────────────────────────────────────────────────────────
|
||||||
|
# Deixe vazio se usar apenas Ollama (soberania total)
|
||||||
|
OPENAI_API_KEY=
|
||||||
|
|
||||||
|
# ── IA — LiteLLM (proxy unificado) ───────────────────────────────────────────
|
||||||
|
# Em Docker: http://litellm:4000 | Em dev local: http://localhost:4000
|
||||||
|
LITELLM_BASE_URL=http://localhost:4000
|
||||||
|
LITELLM_API_KEY=arcadia-internal
|
||||||
|
|
||||||
|
# ── IA — Ollama (LLMs locais) ─────────────────────────────────────────────────
|
||||||
|
OLLAMA_BASE_URL=http://localhost:11434
|
||||||
|
|
||||||
|
# ── Open WebUI ────────────────────────────────────────────────────────────────
|
||||||
|
WEBUI_SECRET_KEY=troque-por-string-aleatoria-segura
|
||||||
|
|
||||||
|
# ── Arcádia Plus (Laravel/Fiscal) ─────────────────────────────────────────────
|
||||||
|
PLUS_URL=http://localhost:8080
|
||||||
|
PLUS_PORT=8080
|
||||||
|
PLUS_API_TOKEN=
|
||||||
|
|
||||||
|
# ── Superset (BI avançado) ────────────────────────────────────────────────────
|
||||||
|
SUPERSET_SECRET_KEY=troque-por-string-aleatoria-segura
|
||||||
|
SUPERSET_PORT=8088
|
||||||
|
|
||||||
|
# ── Redis ─────────────────────────────────────────────────────────────────────
|
||||||
|
REDIS_URL=redis://localhost:6379
|
||||||
|
|
||||||
|
# ── Domínio (produção) ────────────────────────────────────────────────────────
|
||||||
|
DOMAIN=seudominio.com.br
|
||||||
|
|
||||||
|
# ── Integrações externas (opcional) ──────────────────────────────────────────
|
||||||
|
# ERPNext
|
||||||
|
ERPNEXT_URL=
|
||||||
|
ERPNEXT_API_KEY=
|
||||||
|
ERPNEXT_API_SECRET=
|
||||||
|
|
||||||
|
# GitHub (para sync de código)
|
||||||
|
GITHUB_TOKEN=
|
||||||
|
GITHUB_REPO=
|
||||||
|
|
||||||
|
# WhatsApp (Baileys — sessões salvas no banco)
|
||||||
|
# Nenhuma variável adicional necessária — configurado via interface
|
||||||
|
|
@ -0,0 +1,438 @@
|
||||||
|
# Arcádia Suite — Auditoria Completa e Plano de Execução
|
||||||
|
**Data:** 2026-03-13 | **Versão:** 1.0 | **Auditores:** Claude Code (4 agentes paralelos)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. NÚMEROS DO SISTEMA
|
||||||
|
|
||||||
|
| Dimensão | Métrica |
|
||||||
|
|----------|---------|
|
||||||
|
| Tabelas no schema | **337** |
|
||||||
|
| Migration formal | **1 arquivo (4.602 linhas) — cobre todas as tabelas** |
|
||||||
|
| Tabelas COM tenantId | **~147 (44%)** |
|
||||||
|
| Tabelas SEM tenantId | **~190 (56%) — risco de isolamento** |
|
||||||
|
| Tabelas sem timestamps | **~40** |
|
||||||
|
| Tabelas sem insert schema | **36 (11%)** |
|
||||||
|
| Módulos backend auditados | **25 módulos / 400+ endpoints** |
|
||||||
|
| Páginas frontend auditadas | **64 páginas** |
|
||||||
|
| Páginas reais e funcionais | **48 (75%)** |
|
||||||
|
| Páginas parciais | **12 (19%)** |
|
||||||
|
| Páginas placeholder | **4 (6%)** |
|
||||||
|
| Ferramentas Manus definidas | **79** |
|
||||||
|
| Credenciais hardcoded | **3 locais** |
|
||||||
|
| Rotas sem autenticação (4 módulos) | **102+ endpoints expostos** |
|
||||||
|
| Serviços Python com CORS aberto | **6 de 6** |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. SCORE POR DIMENSÃO
|
||||||
|
|
||||||
|
| Dimensão | Score | Status |
|
||||||
|
|----------|-------|--------|
|
||||||
|
| Funcionalidade | **7/10** | 75% real, 25% parcial/placeholder |
|
||||||
|
| Segurança | **3/10** | CORS aberto, auth ausente, credentials hardcoded |
|
||||||
|
| Multi-tenancy | **4/10** | 56% das tabelas sem isolamento |
|
||||||
|
| Inteligência (IA) | **5/10** | Manus funciona, embeddings vazios, ciclo quebrado |
|
||||||
|
| Integrações ERP | **4/10** | 25 conectores definidos, zero chamadas reais |
|
||||||
|
| Infraestrutura | **2/10** | No Replit, sem containers, sem CI/CD |
|
||||||
|
| Qualidade de código | **5/10** | Sem testes, sem logging estruturado, sem rate limit |
|
||||||
|
| Maturidade de produto | **6/10** | Boa visão, execução inconsistente entre módulos |
|
||||||
|
|
||||||
|
**Score geral: 4.5/10 — Excelente para MVP. Precisa de fundação antes de escalar.**
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. ACHADOS CRÍTICOS (🔴 Resolver antes de qualquer deploy público)
|
||||||
|
|
||||||
|
### SEC-01 — XOS: 100% das rotas sem autenticação e sem tenantId
|
||||||
|
```
|
||||||
|
server/xos/routes.ts — 40+ endpoints:
|
||||||
|
GET /api/xos/contacts, /companies, /deals, /tickets, /conversations
|
||||||
|
→ Qualquer request não autenticado lista dados de qualquer tenant
|
||||||
|
```
|
||||||
|
**Risco:** Vazamento total de dados de CRM, tickets e conversas entre clientes.
|
||||||
|
|
||||||
|
### SEC-02 — LMS: 8+ rotas sem autenticação
|
||||||
|
```
|
||||||
|
server/lms/routes.ts:
|
||||||
|
GET /api/lms/courses → público (todos os tenants)
|
||||||
|
POST /api/lms/courses → qualquer pessoa cria cursos
|
||||||
|
POST /api/lms/:id/enroll → matrícula sem login
|
||||||
|
```
|
||||||
|
|
||||||
|
### SEC-03 — Quality: 50+ rotas sem autenticação
|
||||||
|
```
|
||||||
|
server/quality/routes.ts — amostras, laudos, RNCs, documentos, treinamentos → todos públicos
|
||||||
|
```
|
||||||
|
|
||||||
|
### SEC-04 — Credenciais hardcoded em produção
|
||||||
|
```
|
||||||
|
server/metaset/routes.ts:7-8:
|
||||||
|
ADMIN_EMAIL = "admin@arcadia.app" ← aparece em logs
|
||||||
|
ADMIN_PASSWORD = "Arcadia2026!BI" ← exposição crítica
|
||||||
|
|
||||||
|
server/auth.ts:34:
|
||||||
|
SESSION_SECRET = "arcadia-browser-secret-key-2024" ← fallback fraco
|
||||||
|
```
|
||||||
|
|
||||||
|
### SEC-05 — CORS aberto com credentials em TODOS os serviços Python
|
||||||
|
```
|
||||||
|
server/python/{automation,bi,contabil,fisco,people,bi_analysis}_service.py:
|
||||||
|
allow_origins=["*"] + allow_credentials=True
|
||||||
|
|
||||||
|
→ Viola spec CORS (Chrome/Firefox rejeitam e o sistema quebra em prod)
|
||||||
|
→ Qualquer site pode fazer requisições autenticadas se bypassed
|
||||||
|
```
|
||||||
|
|
||||||
|
### SEC-06 — `/api/tenants` exposto sem autenticação
|
||||||
|
```
|
||||||
|
server/routes.ts:139 — GET /api/tenants → PUBLIC
|
||||||
|
→ Qualquer pessoa enumera todos os clientes do Arcádia
|
||||||
|
```
|
||||||
|
|
||||||
|
### SEC-07 — Manus com ferramentas perigosas sem sandboxing
|
||||||
|
```
|
||||||
|
server/manus/tools.ts — 79 ferramentas, incluindo:
|
||||||
|
shell → qualquer comando no servidor
|
||||||
|
write_file → escreve qualquer arquivo (incluindo .env)
|
||||||
|
read_file → lê qualquer arquivo
|
||||||
|
python_execute → código Python arbitrário
|
||||||
|
|
||||||
|
BLOCKED_COMMANDS: rm, sudo, wget (bom)
|
||||||
|
ALLOWED_COMMANDS: npm, git, node ← ainda podem executar código arbitrário
|
||||||
|
```
|
||||||
|
|
||||||
|
### SEC-08 — Zero timeout em chamadas externas críticas
|
||||||
|
```
|
||||||
|
manus/service.ts → OpenAI sem timeout → hang indefinido
|
||||||
|
crm/frappe-service.ts:45 → fetch sem timeout → hang indefinido
|
||||||
|
→ Cenário: OpenAI lento → requests acumulam → servidor trava
|
||||||
|
```
|
||||||
|
|
||||||
|
### SEC-09 — PipelineOrchestrator com risco de loop infinito
|
||||||
|
```
|
||||||
|
server/blackboard/PipelineOrchestrator.ts:171:
|
||||||
|
setInterval(async () => { ... }, 2000ms)
|
||||||
|
→ operação async sem guard → múltiplas execuções simultâneas
|
||||||
|
→ agentes tentando processar a mesma task em loop
|
||||||
|
```
|
||||||
|
|
||||||
|
### SEC-10 — 190 tabelas sem isolamento multi-tenant
|
||||||
|
**Tabelas críticas:** `knowledge_base`, `conversations`, `chatThreads`, `chatMessages`,
|
||||||
|
`whatsappContacts`, `whatsappMessages`, `whatsappSessions`, `whatsappTickets`,
|
||||||
|
`manusRuns`, `workspacePages`, `pageBlocks`, `activityFeed`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. ACHADOS DE ALTO IMPACTO (🟠 Funcionalidade core quebrada)
|
||||||
|
|
||||||
|
### FUNC-01 — Knowledge Graph: embeddings vazios — ciclo de aprendizado quebrado
|
||||||
|
O campo `embedding` em `graph_nodes` existe mas nunca é preenchido. A busca semântica do Manus retorna vazio. O sistema não aprende de nenhuma interação.
|
||||||
|
|
||||||
|
### FUNC-02 — AppCenter e Marketplace não se conversam
|
||||||
|
48 apps hardcoded no AppCenter. Marketplace tem billing e subscriptions funcionando. Nenhuma relação entre os dois — qualquer tenant vê todos os 58 apps independente do plano.
|
||||||
|
|
||||||
|
### FUNC-03 — 4 páginas placeholder das mais estratégicas
|
||||||
|
```
|
||||||
|
CentralApis.tsx → PLACEHOLDER (central de integrações)
|
||||||
|
ApiHub.tsx → PLACEHOLDER (hub de APIs)
|
||||||
|
Agent.tsx → PLACEHOLDER (página do agente principal)
|
||||||
|
ArcadiaNext.tsx → PLACEHOLDER
|
||||||
|
```
|
||||||
|
|
||||||
|
### FUNC-04 — 25 conectores ERP definidos, zero fazem chamadas reais
|
||||||
|
A Central de API tem UI completa com logs e métricas. Todos os dados são mockados. TOTVS, SAP, Omie, PIX — nenhum conecta de verdade.
|
||||||
|
|
||||||
|
### FUNC-05 — Python services não suportam uvicorn module-style (Docker)
|
||||||
|
`docker/python-entrypoint.sh` chama `python -m uvicorn server.python.X:app`, mas os arquivos usam `if __name__ == "__main__": uvicorn.run(app)`. Containers Python não sobem.
|
||||||
|
|
||||||
|
### FUNC-06 — Process Compass sem inteligência AI
|
||||||
|
PDCA, SWOT, Canvas modelados corretamente. Zero chamadas ao Manus. 100% manual.
|
||||||
|
|
||||||
|
### FUNC-07 — WhatsApp auto-reply só em memória
|
||||||
|
```
|
||||||
|
server/whatsapp/service.ts:50, 60-70
|
||||||
|
autoReplyConfig: Map (in-memory)
|
||||||
|
→ Restart = perda total de configuração sem aviso ao cliente
|
||||||
|
```
|
||||||
|
|
||||||
|
### FUNC-08 — Dois sistemas de comunicação duplicados e divergentes
|
||||||
|
```
|
||||||
|
Legacy (sem tenantId): whatsapp_*, chat_*, conversations, messages
|
||||||
|
Moderno (com tenantId): comm_*, xosConversations, crmThreads
|
||||||
|
→ Rota de dados inconsistente, manutenção dupla
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. ACHADOS DE MÉDIO IMPACTO (🟡 Qualidade e maturidade)
|
||||||
|
|
||||||
|
### QUAL-01 — Zero testes automatizados em todo o repositório
|
||||||
|
### QUAL-02 — Sem paginação real (límites hardcoded: `LIMIT 100`)
|
||||||
|
### QUAL-03 — Sem error boundaries globais no React
|
||||||
|
### QUAL-04 — Sem rate limiting nas rotas da API
|
||||||
|
### QUAL-05 — Logging não estruturado (só console.log/console.error)
|
||||||
|
### QUAL-06 — 36 tabelas sem insert schema (11% do schema)
|
||||||
|
### QUAL-07 — 40+ tabelas sem timestamps createdAt/updatedAt
|
||||||
|
### QUAL-08 — Tokens WhatsApp no filesystem sem criptografia
|
||||||
|
### QUAL-09 — Connection pooling ausente nos serviços Python (nova conexão por request)
|
||||||
|
### QUAL-10 — Governance com endpoints públicos sem decisão documentada
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. ACHADOS DE ORGANIZAÇÃO (🔵 Arquitetura e estrutura)
|
||||||
|
|
||||||
|
### ARQ-01 — ERP integrations em 4 lugares diferentes
|
||||||
|
```
|
||||||
|
server/erpnext/ → adapter ERPNext
|
||||||
|
server/crm/frappe-service.ts → sync Frappe
|
||||||
|
server/api-central/ → conectores REST
|
||||||
|
server/migration/ → importação one-time
|
||||||
|
→ Deveriam estar: server/integrations/erp/
|
||||||
|
```
|
||||||
|
|
||||||
|
### ARQ-02 — Migration module escondido como tool técnica
|
||||||
|
Estratégico para onboarding de clientes mas sem fluxo guiado para consultores.
|
||||||
|
|
||||||
|
### ARQ-03 — Metabase e Superset sem estratégia definida
|
||||||
|
Dois sistemas de BI sem critério de quando usar cada um.
|
||||||
|
|
||||||
|
### ARQ-04 — IDE.tsx é só um wrapper `<Suspense>`
|
||||||
|
Nenhuma integração com backend de projetos ou contexto de desenvolvimento.
|
||||||
|
|
||||||
|
### ARQ-05 — Boolean/integer/varchar misturados para campos de status
|
||||||
|
Inconsistência: `isActive` (boolean) vs `is_active` (integer 0/1) vs `status` (varchar).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 7. STATUS POR MÓDULO
|
||||||
|
|
||||||
|
### Backend (25 módulos auditados)
|
||||||
|
|
||||||
|
| Módulo | Rotas | Auth | Multi-tenant | Status |
|
||||||
|
|--------|-------|------|--------------|--------|
|
||||||
|
| Automations | CRUD completo | ✅ | ✅ | ✅ Completo |
|
||||||
|
| BI | 30+ endpoints | ✅ | ✅ | ✅ Completo |
|
||||||
|
| Blackboard | Pipeline completo | ✅ | ✅ | ✅ Completo |
|
||||||
|
| Communities | 15+ endpoints | ✅ | ✅ | ✅ Completo |
|
||||||
|
| CRM | CRUD completo | ✅ | ✅ | ✅ Completo |
|
||||||
|
| DevAgent/IDE | File+code ops | ✅ | N/A | ✅ Completo |
|
||||||
|
| Engine Room | Health+control | ✅ | N/A | ✅ Completo |
|
||||||
|
| Financeiro | 40+ endpoints | ✅ | ✅ | ✅ Completo |
|
||||||
|
| Governance | 20+ endpoints | ⚠️ alguns públicos | ✅ | ⚠️ Revisar |
|
||||||
|
| Graph (NOVO) | CRUD + search | ✅ | ✅ | ✅ Adicionado |
|
||||||
|
| LMS | 20+ endpoints | ❌ 8+ sem auth | ⚠️ | 🔴 Corrigir |
|
||||||
|
| Lowcode | 30+ endpoints | ⚠️ intencional? | ✅ | ⚠️ Revisar |
|
||||||
|
| Marketplace | CRUD subs | ⚠️ público | N/A | ⚠️ Revisar |
|
||||||
|
| MetaSet | 12 endpoints | ⚠️ credentials | ✅ | 🔴 Corrigir |
|
||||||
|
| Migration | 12 endpoints | ✅ | ✅ | ✅ Completo |
|
||||||
|
| Para | 30+ endpoints | ✅ | ✅ | ✅ Completo |
|
||||||
|
| People | CRUD+proxy | ✅ | ✅ | ✅ Completo |
|
||||||
|
| Production | 60+ endpoints | ✅ | ✅ | ✅ Completo |
|
||||||
|
| Quality | 50+ endpoints | ❌ maioria sem auth | ⚠️ | 🔴 Corrigir |
|
||||||
|
| Retail | CRUD completo | ✅ | ✅ | ✅ Completo |
|
||||||
|
| Support | 25+ endpoints | ✅ | ✅ | ✅ Completo |
|
||||||
|
| Valuation | 25+ endpoints | ✅ | ✅ | ✅ Completo |
|
||||||
|
| XOS | 40+ endpoints | ❌ ZERO auth | ❌ sem tenant | 🔴 Crítico |
|
||||||
|
| ERPNext | 10 endpoints | ✅ | N/A | ✅ Completo |
|
||||||
|
| GitHub | 9 endpoints | ⚠️ parcial | N/A | ⚠️ Revisar |
|
||||||
|
|
||||||
|
### Frontend (64 páginas auditadas)
|
||||||
|
|
||||||
|
| Grupo | Páginas | Status |
|
||||||
|
|-------|---------|--------|
|
||||||
|
| ✅ Completas com API real | 48 | AppCenter, Automations, BI, Canvas, Communities, Financeiro, Knowledge, LMS, Marketplace, Migration, People, ProcessCompass, Production, Quality, Retail, Scientist, Support, Valuation, WhatsApp, XOS (todos), Cockpit, e mais 25 |
|
||||||
|
| ⚠️ Parciais | 12 | IDE, CRM, DevCenter, ERP, Admin, Plus, Retail, Contabil, Fisco, Chat, WorkflowBuilder, MetabaseProxy |
|
||||||
|
| ❌ Placeholder | 4 | ArcadiaNext, Agent, ApiHub, CentralApis |
|
||||||
|
|
||||||
|
### Schema (337 tabelas)
|
||||||
|
|
||||||
|
| Módulo | Tabelas | tenantId | Status |
|
||||||
|
|--------|---------|----------|--------|
|
||||||
|
| Retail | 40 | ✅ | Completo |
|
||||||
|
| Valuation/PDCA | 30 | ✅ | Completo |
|
||||||
|
| Production/Compass | 35 | ✅ | Completo |
|
||||||
|
| CRM | 28 | ✅ | Completo |
|
||||||
|
| XOS | 25 | ⚠️ parcial | Corrigir |
|
||||||
|
| Fiscal | 18 | ✅ | Completo |
|
||||||
|
| HR/People | 15 | ✅ | Completo |
|
||||||
|
| Quality | 10 | ⚠️ | Corrigir |
|
||||||
|
| Financeiro | 7 | ✅ | Completo |
|
||||||
|
| Comunicação legacy | 30+ | ❌ | Corrigir |
|
||||||
|
| Comunicação moderna | 9 | ✅ | Completo |
|
||||||
|
| LMS | ⚠️ parcial | N/A | lms_courses criado dinamicamente |
|
||||||
|
| Knowledge/AI | 10+ | ⚠️ parcial | Corrigir |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 8. PLANO DE EXECUÇÃO — SPRINTS
|
||||||
|
|
||||||
|
### 🔴 Sprint S — Segurança (Pré-requisito absoluto) — 1 semana
|
||||||
|
|
||||||
|
| # | Tarefa | Arquivo | Esforço |
|
||||||
|
|---|--------|---------|---------|
|
||||||
|
| S1 | Auth em todas as rotas XOS (40+) | `server/xos/routes.ts` | M |
|
||||||
|
| S2 | Auth em LMS (8 rotas) | `server/lms/routes.ts` | P |
|
||||||
|
| S3 | Auth em Quality (50+ rotas) | `server/quality/routes.ts` | M |
|
||||||
|
| S4 | Remover credentials hardcoded; SESSION_SECRET obrigatório | `server/auth.ts`, `server/metaset/routes.ts` | P |
|
||||||
|
| S5 | CORS: `["*"]` → `[APP_URL]` nos 6 serviços Python | `server/python/*.py` | P |
|
||||||
|
| S6 | Proteger `/api/tenants` | `server/routes.ts:139` | P |
|
||||||
|
| S7 | `requires_approval` + audit log nas tools `shell` e `write_file` | `server/manus/tools.ts` | M |
|
||||||
|
| S8 | Timeout 30s + retry 3x em OpenAI e Frappe | `server/manus/service.ts`, `server/crm/frappe-service.ts` | P |
|
||||||
|
| S9 | Guard anti-overlap no setInterval do PipelineOrchestrator | `server/blackboard/PipelineOrchestrator.ts:171` | P |
|
||||||
|
| S10 | tenantId nas tabelas de comunicação legacy | `shared/schema.ts` | G |
|
||||||
|
|
||||||
|
**Esforço:** P=Pequeno(<2h) M=Médio(2-4h) G=Grande(4-8h)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 🟠 Sprint 0 — Deploy no Coolify — 1 semana (paralelo ao Sprint S)
|
||||||
|
|
||||||
|
| # | Tarefa | Esforço |
|
||||||
|
|---|--------|---------|
|
||||||
|
| 0.1 | Corrigir Python services para exportar `app` (uvicorn module) | P |
|
||||||
|
| 0.2 | Testar `docker compose up` local — todos sobem e `/health` retorna 200 | M |
|
||||||
|
| 0.3 | Exportar banco do Replit (`pg_dump`) antes de encerrar o plano | P |
|
||||||
|
| 0.4 | Deploy no Coolify com domínio real + SSL automático | M |
|
||||||
|
| 0.5 | Persistir config auto-reply WhatsApp no banco | P |
|
||||||
|
| 0.6 | Validar: NF-e, Manus, WhatsApp funcionando em produção | M |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 🟠 Sprint 1 — Fechar o Ciclo de Inteligência — 2 semanas
|
||||||
|
|
||||||
|
| # | Tarefa | Esforço |
|
||||||
|
|---|--------|---------|
|
||||||
|
| 1.1 | Subir serviço de embeddings com pgvector | P |
|
||||||
|
| 1.2 | Popular embeddings com histórico de `learnedInteractions` | M |
|
||||||
|
| 1.3 | Configurar LiteLLM (OpenAI → Ollama fallback) | P |
|
||||||
|
| 1.4 | Validar ciclo: Manus aprende → armazena → recupera em contexto | M |
|
||||||
|
| 1.5 | Adicionar insert schemas nas 36 tabelas faltantes | M |
|
||||||
|
| 1.6 | Padronizar timestamps nas 40+ tabelas sem createdAt/updatedAt | M |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 🟠 Sprint 2 — App Store Real — 1 semana
|
||||||
|
|
||||||
|
| # | Tarefa | Esforço |
|
||||||
|
|---|--------|---------|
|
||||||
|
| 2.1 | Endpoint `GET /api/marketplace/my-apps` por tenant | P |
|
||||||
|
| 2.2 | AppCenter consulta subscriptions → apps aparecem/somem por plano | M |
|
||||||
|
| 2.3 | Apps sem subscription → cadeado + CTA de ativar inline | M |
|
||||||
|
| 2.4 | Billing engine: MRR calculado por tenant automaticamente | M |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 🟡 Sprint 3 — Process Compass com IA — 2 semanas
|
||||||
|
|
||||||
|
| # | Tarefa | Esforço |
|
||||||
|
|---|--------|---------|
|
||||||
|
| 3.1 | Brief automático: notas brutas → Manus extrai ações/responsáveis/prazos | M |
|
||||||
|
| 3.2 | Diagnóstico AI: Manus + BI cruzam dados → relatório de empresa | G |
|
||||||
|
| 3.3 | Health score automático de projeto (PDCA completude + prazo) | M |
|
||||||
|
| 3.4 | Gerador de proposta via Scientist (DOCX/PDF) | G |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 🟡 Sprint 4 — Integrações ERP Reais — 3 semanas
|
||||||
|
|
||||||
|
| # | Tarefa | Esforço |
|
||||||
|
|---|--------|---------|
|
||||||
|
| 4.1 | Reorganizar `server/integrations/erp/` (unificar 4 módulos) | G |
|
||||||
|
| 4.2 | Adaptador Omie (REST mais simples) | G |
|
||||||
|
| 4.3 | Adaptador TOTVS RM (segundo mais comum PMEs BR) | G |
|
||||||
|
| 4.4 | UI de onboarding: ERP → espelhamento → BI em 5 passos | G |
|
||||||
|
| 4.5 | Dashboard de saúde das integrações (sync status, erros) | M |
|
||||||
|
| 4.6 | Completar páginas placeholder: `CentralApis.tsx`, `ApiHub.tsx`, `Agent.tsx` | G |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 🟢 Sprint 5 — Soberania de IA — 1 semana
|
||||||
|
|
||||||
|
| # | Tarefa | Esforço |
|
||||||
|
|---|--------|---------|
|
||||||
|
| 5.1 | Ollama + Open WebUI no Coolify (`--profile ai`) | P |
|
||||||
|
| 5.2 | Baixar modelos: Llama 3.3, Qwen 2.5 Coder | P |
|
||||||
|
| 5.3 | RAG no Open WebUI conectado ao Knowledge Graph | M |
|
||||||
|
| 5.4 | Manus → LiteLLM em vez de OpenAI direto | M |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 🔵 Sprint 6 — Qualidade de Engenharia — 2 semanas
|
||||||
|
|
||||||
|
| # | Tarefa | Esforço |
|
||||||
|
|---|--------|---------|
|
||||||
|
| 6.1 | Rate limiting nas rotas da API (`express-rate-limit`) | P |
|
||||||
|
| 6.2 | Logging estruturado com Winston (JSON + correlation ID) | M |
|
||||||
|
| 6.3 | Paginação em todas as listagens principais | G |
|
||||||
|
| 6.4 | Error boundaries globais no React | M |
|
||||||
|
| 6.5 | Primeiros testes de integração (auth, fiscal, Manus) | G |
|
||||||
|
| 6.6 | Connection pooling nos serviços Python | M |
|
||||||
|
| 6.7 | Consolidar sistemas de comunicação duplicados | G |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 🟢 Sprint 7 — ERPNext como Container de Regras — 2 semanas
|
||||||
|
|
||||||
|
| # | Tarefa | Esforço |
|
||||||
|
|---|--------|---------|
|
||||||
|
| 7.1 | Container ERPNext no Coolify (MariaDB separado) | M |
|
||||||
|
| 7.2 | Configurar empresa + plano de contas BR | M |
|
||||||
|
| 7.3 | Bridge de autenticação (API Key por tenant) | M |
|
||||||
|
| 7.4 | Expandir `server/erpnext/`: GL, Estoque, RH, Projetos | G |
|
||||||
|
| 7.5 | Primeiro caso de uso real: pedido → lançamento GL no ERPNext | G |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 9. LINHA DO TEMPO
|
||||||
|
|
||||||
|
```
|
||||||
|
Semanas → 1 2 3 4 5 6 7 8 9 10 11 12 13 14
|
||||||
|
┌─────────────────────────────────────────────────────────────────────
|
||||||
|
Sprint S │████ ████ Segurança (obrigatório)
|
||||||
|
Sprint 0 │████ ████ Deploy Coolify
|
||||||
|
Sprint 1 │ ████ ████ Inteligência + Schema
|
||||||
|
Sprint 2 │ ████ App Store Real
|
||||||
|
Sprint 3 │ ████ ████ Process Compass IA
|
||||||
|
Sprint 4 │ ████ ████ ████ Integrações ERP reais
|
||||||
|
Sprint 5 │ ████ Soberania IA
|
||||||
|
Sprint 6 │ ████ ████ Qualidade
|
||||||
|
Sprint 7 │ ████ ████ ERPNext
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 10. TOP 10 — MAIORES ALAVANCAS POR IMPACTO
|
||||||
|
|
||||||
|
| Rank | O que fazer | Impacto | Esforço |
|
||||||
|
|------|-------------|---------|---------|
|
||||||
|
| 1 | Autenticar rotas XOS, LMS, Quality | Segurança crítica | Baixo |
|
||||||
|
| 2 | Corrigir CORS nos serviços Python | Segurança crítica | Baixo |
|
||||||
|
| 3 | Deploy no Coolify com Docker | Infra — tudo depende disso | Médio |
|
||||||
|
| 4 | Fechar ciclo de embeddings (pgvector) | IA aprende de verdade | Médio |
|
||||||
|
| 5 | Conectar Marketplace → AppCenter | Modelo de negócio funciona | Médio |
|
||||||
|
| 6 | Adaptador Omie real na Central de API | Primeira integração ERP real | Médio |
|
||||||
|
| 7 | tenantId nas tabelas de comunicação | Isolamento de dados correto | Alto |
|
||||||
|
| 8 | Brief automático no Process Compass | Produto para consultores | Médio |
|
||||||
|
| 9 | Timeout + retry em chamadas externas | Estabilidade em produção | Baixo |
|
||||||
|
| 10 | Completar Agent.tsx, CentralApis.tsx | Páginas estratégicas com UI | Alto |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 11. DECISÕES ARQUITETURAIS PENDENTES
|
||||||
|
|
||||||
|
Estas decisões precisam de resposta antes de codificar:
|
||||||
|
|
||||||
|
| # | Decisão | Opções |
|
||||||
|
|---|---------|--------|
|
||||||
|
| D1 | BI padrão do sistema? | Metabase (já existe) vs Superset (no docker-compose) |
|
||||||
|
| D2 | LMS usa tabelas dinâmicas ou schema.ts? | Hoje cria tabelas em runtime — inconsistente |
|
||||||
|
| D3 | Sistema de comunicação canônico? | Legacy whatsapp_* vs moderno comm_* vs XOS |
|
||||||
|
| D4 | Marketplace é público ou requer auth? | Hoje público (intencional?) |
|
||||||
|
| D5 | Governance/Lowcode são públicos? | Sem auth (intencional para parceiros?) |
|
||||||
|
| D6 | Usuários são globais ou por tenant? | Hoje global (users sem tenantId) — documentar |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
*Documento gerado em 2026-03-13 via auditoria paralela com 4 agentes Claude Code.*
|
||||||
|
*Cobre: 25 módulos backend, 64 páginas frontend, 337 tabelas de schema, segurança e integrações.*
|
||||||
|
|
@ -0,0 +1,33 @@
|
||||||
|
# ─── Stage 1: Build ───────────────────────────────────────────────────────────
|
||||||
|
FROM node:20-alpine AS builder
|
||||||
|
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
COPY package*.json ./
|
||||||
|
RUN npm ci --ignore-scripts
|
||||||
|
|
||||||
|
COPY . .
|
||||||
|
RUN npm run build
|
||||||
|
|
||||||
|
# ─── Stage 2: Production ──────────────────────────────────────────────────────
|
||||||
|
FROM node:20-alpine AS runner
|
||||||
|
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
ENV NODE_ENV=production
|
||||||
|
|
||||||
|
# Deps de produção apenas
|
||||||
|
COPY package*.json ./
|
||||||
|
RUN npm ci --omit=dev --ignore-scripts
|
||||||
|
|
||||||
|
# Artefatos do build
|
||||||
|
COPY --from=builder /app/dist ./dist
|
||||||
|
COPY --from=builder /app/shared ./shared
|
||||||
|
COPY --from=builder /app/migrations ./migrations
|
||||||
|
|
||||||
|
EXPOSE 5000
|
||||||
|
|
||||||
|
HEALTHCHECK --interval=30s --timeout=10s --start-period=40s --retries=3 \
|
||||||
|
CMD wget -qO- http://localhost:5000/api/health || exit 1
|
||||||
|
|
||||||
|
CMD ["node", "dist/index.cjs"]
|
||||||
|
|
@ -0,0 +1,47 @@
|
||||||
|
# Dockerfile compartilhado para todos os microserviços Python
|
||||||
|
# Uso: docker build -f Dockerfile.python --build-arg SERVICE=contabil .
|
||||||
|
# Ou via docker-compose com a variável SERVICE_NAME
|
||||||
|
|
||||||
|
FROM python:3.11-slim
|
||||||
|
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
# Dependências de sistema necessárias para psycopg2, signxml e playwright
|
||||||
|
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||||
|
gcc \
|
||||||
|
libpq-dev \
|
||||||
|
libxml2-dev \
|
||||||
|
libxslt-dev \
|
||||||
|
&& rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
|
# Instalar dependências Python
|
||||||
|
COPY pyproject.toml ./
|
||||||
|
RUN pip install --no-cache-dir ".[all]" 2>/dev/null || pip install --no-cache-dir \
|
||||||
|
fastapi>=0.128.0 \
|
||||||
|
uvicorn>=0.40.0 \
|
||||||
|
psycopg2-binary>=2.9.11 \
|
||||||
|
pandas>=2.3.3 \
|
||||||
|
numpy>=2.4.1 \
|
||||||
|
pydantic>=2.12.5 \
|
||||||
|
python-dotenv \
|
||||||
|
requests \
|
||||||
|
httpx
|
||||||
|
|
||||||
|
# Copiar código dos serviços
|
||||||
|
COPY server/python/ ./server/python/
|
||||||
|
COPY shared/ ./shared/
|
||||||
|
|
||||||
|
# SERVICE_NAME deve ser: contabil | bi | automation | fisco | people
|
||||||
|
ENV SERVICE_NAME=contabil
|
||||||
|
ENV SERVICE_PORT=8000
|
||||||
|
|
||||||
|
# Script de entrada que seleciona o serviço correto
|
||||||
|
COPY docker/python-entrypoint.sh /entrypoint.sh
|
||||||
|
RUN chmod +x /entrypoint.sh
|
||||||
|
|
||||||
|
EXPOSE ${SERVICE_PORT}
|
||||||
|
|
||||||
|
HEALTHCHECK --interval=30s --timeout=10s --start-period=20s --retries=3 \
|
||||||
|
CMD python -c "import urllib.request; urllib.request.urlopen('http://localhost:${SERVICE_PORT}/health')" || exit 1
|
||||||
|
|
||||||
|
ENTRYPOINT ["/entrypoint.sh"]
|
||||||
|
|
@ -0,0 +1,212 @@
|
||||||
|
# ─── Arcádia Suite — Produção (Coolify) ───────────────────────────────────────
|
||||||
|
# Este arquivo é usado pelo Coolify para deploy automático.
|
||||||
|
# NÃO inclui volumes de código-fonte — só artefatos de build.
|
||||||
|
# Configurar no Coolify: Environment Variables para todas as vars ${...}
|
||||||
|
|
||||||
|
name: arcadia-prod
|
||||||
|
|
||||||
|
services:
|
||||||
|
|
||||||
|
# ── Banco de dados com pgvector ─────────────────────────────────────────────
|
||||||
|
db:
|
||||||
|
image: pgvector/pgvector:pg16
|
||||||
|
restart: always
|
||||||
|
environment:
|
||||||
|
POSTGRES_DB: ${PGDATABASE:-arcadia}
|
||||||
|
POSTGRES_USER: ${PGUSER:-arcadia}
|
||||||
|
POSTGRES_PASSWORD: ${PGPASSWORD}
|
||||||
|
volumes:
|
||||||
|
- pgdata:/var/lib/postgresql/data
|
||||||
|
- ./docker/init-pgvector.sql:/docker-entrypoint-initdb.d/01-pgvector.sql
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD-SHELL", "pg_isready -U ${PGUSER:-arcadia}"]
|
||||||
|
interval: 10s
|
||||||
|
timeout: 5s
|
||||||
|
retries: 10
|
||||||
|
networks:
|
||||||
|
- arcadia-internal
|
||||||
|
|
||||||
|
# ── Redis ────────────────────────────────────────────────────────────────────
|
||||||
|
redis:
|
||||||
|
image: redis:7-alpine
|
||||||
|
restart: always
|
||||||
|
command: redis-server --maxmemory 256mb --maxmemory-policy allkeys-lru
|
||||||
|
volumes:
|
||||||
|
- redis_data:/data
|
||||||
|
networks:
|
||||||
|
- arcadia-internal
|
||||||
|
|
||||||
|
# ── App principal ─────────────────────────────────────────────────────────
|
||||||
|
app:
|
||||||
|
build:
|
||||||
|
context: .
|
||||||
|
dockerfile: Dockerfile
|
||||||
|
restart: always
|
||||||
|
environment:
|
||||||
|
NODE_ENV: production
|
||||||
|
PORT: 5000
|
||||||
|
DATABASE_URL: postgresql://${PGUSER:-arcadia}:${PGPASSWORD}@db:5432/${PGDATABASE:-arcadia}
|
||||||
|
REDIS_URL: redis://redis:6379
|
||||||
|
DOCKER_MODE: "true"
|
||||||
|
CONTABIL_PYTHON_URL: http://contabil:8003
|
||||||
|
BI_PYTHON_URL: http://bi:8004
|
||||||
|
AUTOMATION_PYTHON_URL: http://automation:8005
|
||||||
|
FISCO_PYTHON_URL: http://fisco:8002
|
||||||
|
PYTHON_SERVICE_URL: http://embeddings:8001
|
||||||
|
SESSION_SECRET: ${SESSION_SECRET}
|
||||||
|
SSO_SECRET: ${SSO_SECRET}
|
||||||
|
OPENAI_API_KEY: ${OPENAI_API_KEY:-}
|
||||||
|
LITELLM_BASE_URL: http://litellm:4000
|
||||||
|
LITELLM_API_KEY: ${LITELLM_API_KEY}
|
||||||
|
ports:
|
||||||
|
- "5000:5000"
|
||||||
|
depends_on:
|
||||||
|
db:
|
||||||
|
condition: service_healthy
|
||||||
|
redis:
|
||||||
|
condition: service_started
|
||||||
|
networks:
|
||||||
|
- arcadia-internal
|
||||||
|
- arcadia-public
|
||||||
|
labels:
|
||||||
|
- "traefik.enable=true"
|
||||||
|
- "traefik.http.routers.arcadia.rule=Host(`${DOMAIN}`)"
|
||||||
|
- "traefik.http.routers.arcadia.tls=true"
|
||||||
|
- "traefik.http.routers.arcadia.tls.certresolver=letsencrypt"
|
||||||
|
|
||||||
|
# ── Microserviços Python ─────────────────────────────────────────────────────
|
||||||
|
contabil:
|
||||||
|
build:
|
||||||
|
context: .
|
||||||
|
dockerfile: Dockerfile.python
|
||||||
|
restart: always
|
||||||
|
environment:
|
||||||
|
SERVICE_NAME: contabil
|
||||||
|
SERVICE_PORT: 8003
|
||||||
|
CONTABIL_PORT: 8003
|
||||||
|
DATABASE_URL: postgresql://${PGUSER:-arcadia}:${PGPASSWORD}@db:5432/${PGDATABASE:-arcadia}
|
||||||
|
depends_on:
|
||||||
|
db:
|
||||||
|
condition: service_healthy
|
||||||
|
networks:
|
||||||
|
- arcadia-internal
|
||||||
|
|
||||||
|
bi:
|
||||||
|
build:
|
||||||
|
context: .
|
||||||
|
dockerfile: Dockerfile.python
|
||||||
|
restart: always
|
||||||
|
environment:
|
||||||
|
SERVICE_NAME: bi
|
||||||
|
SERVICE_PORT: 8004
|
||||||
|
BI_PORT: 8004
|
||||||
|
DATABASE_URL: postgresql://${PGUSER:-arcadia}:${PGPASSWORD}@db:5432/${PGDATABASE:-arcadia}
|
||||||
|
depends_on:
|
||||||
|
db:
|
||||||
|
condition: service_healthy
|
||||||
|
networks:
|
||||||
|
- arcadia-internal
|
||||||
|
|
||||||
|
automation:
|
||||||
|
build:
|
||||||
|
context: .
|
||||||
|
dockerfile: Dockerfile.python
|
||||||
|
restart: always
|
||||||
|
environment:
|
||||||
|
SERVICE_NAME: automation
|
||||||
|
SERVICE_PORT: 8005
|
||||||
|
AUTOMATION_PORT: 8005
|
||||||
|
DATABASE_URL: postgresql://${PGUSER:-arcadia}:${PGPASSWORD}@db:5432/${PGDATABASE:-arcadia}
|
||||||
|
depends_on:
|
||||||
|
db:
|
||||||
|
condition: service_healthy
|
||||||
|
networks:
|
||||||
|
- arcadia-internal
|
||||||
|
|
||||||
|
fisco:
|
||||||
|
build:
|
||||||
|
context: .
|
||||||
|
dockerfile: Dockerfile.python
|
||||||
|
restart: always
|
||||||
|
environment:
|
||||||
|
SERVICE_NAME: fisco
|
||||||
|
SERVICE_PORT: 8002
|
||||||
|
FISCO_PORT: 8002
|
||||||
|
DATABASE_URL: postgresql://${PGUSER:-arcadia}:${PGPASSWORD}@db:5432/${PGDATABASE:-arcadia}
|
||||||
|
depends_on:
|
||||||
|
db:
|
||||||
|
condition: service_healthy
|
||||||
|
networks:
|
||||||
|
- arcadia-internal
|
||||||
|
|
||||||
|
embeddings:
|
||||||
|
build:
|
||||||
|
context: .
|
||||||
|
dockerfile: Dockerfile.python
|
||||||
|
restart: always
|
||||||
|
environment:
|
||||||
|
SERVICE_NAME: embeddings
|
||||||
|
SERVICE_PORT: 8001
|
||||||
|
DATABASE_URL: postgresql://${PGUSER:-arcadia}:${PGPASSWORD}@db:5432/${PGDATABASE:-arcadia}
|
||||||
|
depends_on:
|
||||||
|
db:
|
||||||
|
condition: service_healthy
|
||||||
|
networks:
|
||||||
|
- arcadia-internal
|
||||||
|
|
||||||
|
# ── LiteLLM (proxy de LLM) ───────────────────────────────────────────────────
|
||||||
|
litellm:
|
||||||
|
image: ghcr.io/berriai/litellm:main-latest
|
||||||
|
restart: always
|
||||||
|
volumes:
|
||||||
|
- ./docker/litellm-config.yaml:/app/config.yaml
|
||||||
|
command: ["--config", "/app/config.yaml", "--port", "4000"]
|
||||||
|
environment:
|
||||||
|
OPENAI_API_KEY: ${OPENAI_API_KEY:-}
|
||||||
|
LITELLM_MASTER_KEY: ${LITELLM_API_KEY}
|
||||||
|
networks:
|
||||||
|
- arcadia-internal
|
||||||
|
|
||||||
|
# ── Ollama (LLMs locais — ativar se o servidor tiver RAM suficiente) ──────────
|
||||||
|
ollama:
|
||||||
|
image: ollama/ollama:latest
|
||||||
|
restart: always
|
||||||
|
volumes:
|
||||||
|
- ollama_models:/root/.ollama
|
||||||
|
networks:
|
||||||
|
- arcadia-internal
|
||||||
|
profiles: [ai]
|
||||||
|
|
||||||
|
# ── Open WebUI ───────────────────────────────────────────────────────────────
|
||||||
|
open-webui:
|
||||||
|
image: ghcr.io/open-webui/open-webui:main
|
||||||
|
restart: always
|
||||||
|
environment:
|
||||||
|
OLLAMA_BASE_URL: http://ollama:11434
|
||||||
|
WEBUI_SECRET_KEY: ${WEBUI_SECRET_KEY}
|
||||||
|
volumes:
|
||||||
|
- open_webui_data:/app/backend/data
|
||||||
|
depends_on:
|
||||||
|
- ollama
|
||||||
|
networks:
|
||||||
|
- arcadia-internal
|
||||||
|
- arcadia-public
|
||||||
|
labels:
|
||||||
|
- "traefik.enable=true"
|
||||||
|
- "traefik.http.routers.webui.rule=Host(`ai.${DOMAIN}`)"
|
||||||
|
- "traefik.http.routers.webui.tls=true"
|
||||||
|
- "traefik.http.routers.webui.tls.certresolver=letsencrypt"
|
||||||
|
- "traefik.http.services.webui.loadbalancer.server.port=8080"
|
||||||
|
profiles: [ai]
|
||||||
|
|
||||||
|
networks:
|
||||||
|
arcadia-internal:
|
||||||
|
driver: bridge
|
||||||
|
arcadia-public:
|
||||||
|
driver: bridge
|
||||||
|
|
||||||
|
volumes:
|
||||||
|
pgdata:
|
||||||
|
redis_data:
|
||||||
|
ollama_models:
|
||||||
|
open_webui_data:
|
||||||
|
|
@ -0,0 +1,233 @@
|
||||||
|
# ─── Arcádia Suite — Desenvolvimento Local ────────────────────────────────────
|
||||||
|
# Uso: docker compose up
|
||||||
|
# Para subir com IA local: docker compose --profile ai up
|
||||||
|
|
||||||
|
name: arcadia-dev
|
||||||
|
|
||||||
|
services:
|
||||||
|
|
||||||
|
# ── Banco de dados com pgvector ─────────────────────────────────────────────
|
||||||
|
db:
|
||||||
|
image: pgvector/pgvector:pg16
|
||||||
|
restart: unless-stopped
|
||||||
|
environment:
|
||||||
|
POSTGRES_DB: arcadia
|
||||||
|
POSTGRES_USER: arcadia
|
||||||
|
POSTGRES_PASSWORD: arcadia123
|
||||||
|
volumes:
|
||||||
|
- pgdata:/var/lib/postgresql/data
|
||||||
|
- ./docker/init-pgvector.sql:/docker-entrypoint-initdb.d/01-pgvector.sql
|
||||||
|
ports:
|
||||||
|
- "5432:5432"
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD-SHELL", "pg_isready -U arcadia -d arcadia"]
|
||||||
|
interval: 10s
|
||||||
|
timeout: 5s
|
||||||
|
retries: 5
|
||||||
|
|
||||||
|
# ── Redis (filas de jobs) ────────────────────────────────────────────────────
|
||||||
|
redis:
|
||||||
|
image: redis:7-alpine
|
||||||
|
restart: unless-stopped
|
||||||
|
ports:
|
||||||
|
- "6379:6379"
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD", "redis-cli", "ping"]
|
||||||
|
interval: 10s
|
||||||
|
timeout: 5s
|
||||||
|
retries: 5
|
||||||
|
|
||||||
|
# ── App principal (Node.js + React) ─────────────────────────────────────────
|
||||||
|
app:
|
||||||
|
build:
|
||||||
|
context: .
|
||||||
|
dockerfile: Dockerfile
|
||||||
|
target: builder # usa stage builder em dev (hot reload via volume)
|
||||||
|
restart: unless-stopped
|
||||||
|
command: npx tsx server/index.ts
|
||||||
|
environment:
|
||||||
|
NODE_ENV: development
|
||||||
|
PORT: 5000
|
||||||
|
DATABASE_URL: postgresql://arcadia:arcadia123@db:5432/arcadia
|
||||||
|
REDIS_URL: redis://redis:6379
|
||||||
|
DOCKER_MODE: "true" # desativa spawn de processos filhos
|
||||||
|
CONTABIL_PYTHON_URL: http://contabil:8003
|
||||||
|
BI_PYTHON_URL: http://bi:8004
|
||||||
|
AUTOMATION_PYTHON_URL: http://automation:8005
|
||||||
|
FISCO_PYTHON_URL: http://fisco:8002
|
||||||
|
PYTHON_SERVICE_URL: http://embeddings:8001
|
||||||
|
SESSION_SECRET: ${SESSION_SECRET:-arcadia-dev-secret-change-in-prod}
|
||||||
|
SSO_SECRET: ${SSO_SECRET:-arcadia-sso-secret-2024-plus-integration-key-secure}
|
||||||
|
OPENAI_API_KEY: ${OPENAI_API_KEY:-}
|
||||||
|
LITELLM_BASE_URL: http://litellm:4000
|
||||||
|
LITELLM_API_KEY: ${LITELLM_API_KEY:-arcadia-internal}
|
||||||
|
ports:
|
||||||
|
- "5000:5000"
|
||||||
|
volumes:
|
||||||
|
- .:/app
|
||||||
|
- /app/node_modules
|
||||||
|
- /app/dist
|
||||||
|
depends_on:
|
||||||
|
db:
|
||||||
|
condition: service_healthy
|
||||||
|
redis:
|
||||||
|
condition: service_healthy
|
||||||
|
contabil:
|
||||||
|
condition: service_started
|
||||||
|
bi:
|
||||||
|
condition: service_started
|
||||||
|
|
||||||
|
# ── Microserviço Contábil (Python) ──────────────────────────────────────────
|
||||||
|
contabil:
|
||||||
|
build:
|
||||||
|
context: .
|
||||||
|
dockerfile: Dockerfile.python
|
||||||
|
restart: unless-stopped
|
||||||
|
environment:
|
||||||
|
SERVICE_NAME: contabil
|
||||||
|
SERVICE_PORT: 8003
|
||||||
|
CONTABIL_PORT: 8003
|
||||||
|
DATABASE_URL: postgresql://arcadia:arcadia123@db:5432/arcadia
|
||||||
|
ports:
|
||||||
|
- "8003:8003"
|
||||||
|
depends_on:
|
||||||
|
db:
|
||||||
|
condition: service_healthy
|
||||||
|
|
||||||
|
# ── Microserviço BI Engine (Python) ─────────────────────────────────────────
|
||||||
|
bi:
|
||||||
|
build:
|
||||||
|
context: .
|
||||||
|
dockerfile: Dockerfile.python
|
||||||
|
restart: unless-stopped
|
||||||
|
environment:
|
||||||
|
SERVICE_NAME: bi
|
||||||
|
SERVICE_PORT: 8004
|
||||||
|
BI_PORT: 8004
|
||||||
|
DATABASE_URL: postgresql://arcadia:arcadia123@db:5432/arcadia
|
||||||
|
ports:
|
||||||
|
- "8004:8004"
|
||||||
|
depends_on:
|
||||||
|
db:
|
||||||
|
condition: service_healthy
|
||||||
|
|
||||||
|
# ── Microserviço Automações (Python) ────────────────────────────────────────
|
||||||
|
automation:
|
||||||
|
build:
|
||||||
|
context: .
|
||||||
|
dockerfile: Dockerfile.python
|
||||||
|
restart: unless-stopped
|
||||||
|
environment:
|
||||||
|
SERVICE_NAME: automation
|
||||||
|
SERVICE_PORT: 8005
|
||||||
|
AUTOMATION_PORT: 8005
|
||||||
|
DATABASE_URL: postgresql://arcadia:arcadia123@db:5432/arcadia
|
||||||
|
ports:
|
||||||
|
- "8005:8005"
|
||||||
|
depends_on:
|
||||||
|
db:
|
||||||
|
condition: service_healthy
|
||||||
|
|
||||||
|
# ── Microserviço Fiscal (Python) ────────────────────────────────────────────
|
||||||
|
fisco:
|
||||||
|
build:
|
||||||
|
context: .
|
||||||
|
dockerfile: Dockerfile.python
|
||||||
|
restart: unless-stopped
|
||||||
|
environment:
|
||||||
|
SERVICE_NAME: fisco
|
||||||
|
SERVICE_PORT: 8002
|
||||||
|
FISCO_PORT: 8002
|
||||||
|
DATABASE_URL: postgresql://arcadia:arcadia123@db:5432/arcadia
|
||||||
|
ports:
|
||||||
|
- "8002:8002"
|
||||||
|
depends_on:
|
||||||
|
db:
|
||||||
|
condition: service_healthy
|
||||||
|
|
||||||
|
# ── Serviço de Embeddings (pgvector via Python) ──────────────────────────────
|
||||||
|
embeddings:
|
||||||
|
build:
|
||||||
|
context: .
|
||||||
|
dockerfile: Dockerfile.python
|
||||||
|
restart: unless-stopped
|
||||||
|
environment:
|
||||||
|
SERVICE_NAME: embeddings
|
||||||
|
SERVICE_PORT: 8001
|
||||||
|
DATABASE_URL: postgresql://arcadia:arcadia123@db:5432/arcadia
|
||||||
|
ports:
|
||||||
|
- "8001:8001"
|
||||||
|
depends_on:
|
||||||
|
db:
|
||||||
|
condition: service_healthy
|
||||||
|
|
||||||
|
# ── Apache Superset (BI avançado) ────────────────────────────────────────────
|
||||||
|
superset:
|
||||||
|
image: apache/superset:4.1.0
|
||||||
|
restart: unless-stopped
|
||||||
|
environment:
|
||||||
|
SUPERSET_SECRET_KEY: ${SUPERSET_SECRET_KEY:-superset-secret-change-in-prod}
|
||||||
|
DATABASE_URL: postgresql://arcadia:arcadia123@db:5432/arcadia_superset
|
||||||
|
ports:
|
||||||
|
- "8088:8088"
|
||||||
|
depends_on:
|
||||||
|
db:
|
||||||
|
condition: service_healthy
|
||||||
|
profiles: [bi]
|
||||||
|
|
||||||
|
# ─────────────── PERFIL: ai (Soberania de IA) ────────────────────────────────
|
||||||
|
|
||||||
|
# ── Ollama (LLMs locais) ─────────────────────────────────────────────────────
|
||||||
|
ollama:
|
||||||
|
image: ollama/ollama:latest
|
||||||
|
restart: unless-stopped
|
||||||
|
volumes:
|
||||||
|
- ollama_models:/root/.ollama
|
||||||
|
ports:
|
||||||
|
- "11434:11434"
|
||||||
|
profiles: [ai]
|
||||||
|
# Para GPU NVIDIA: adicione `deploy.resources.reservations.devices`
|
||||||
|
# deploy:
|
||||||
|
# resources:
|
||||||
|
# reservations:
|
||||||
|
# devices:
|
||||||
|
# - driver: nvidia
|
||||||
|
# count: 1
|
||||||
|
# capabilities: [gpu]
|
||||||
|
|
||||||
|
# ── Open WebUI (interface para devs/consultores) ─────────────────────────────
|
||||||
|
open-webui:
|
||||||
|
image: ghcr.io/open-webui/open-webui:main
|
||||||
|
restart: unless-stopped
|
||||||
|
environment:
|
||||||
|
OLLAMA_BASE_URL: http://ollama:11434
|
||||||
|
WEBUI_SECRET_KEY: ${WEBUI_SECRET_KEY:-webui-secret-change-in-prod}
|
||||||
|
DEFAULT_MODELS: llama3.3,qwen2.5-coder
|
||||||
|
ENABLE_RAG_WEB_SEARCH: "true"
|
||||||
|
ports:
|
||||||
|
- "3001:8080"
|
||||||
|
volumes:
|
||||||
|
- open_webui_data:/app/backend/data
|
||||||
|
depends_on:
|
||||||
|
- ollama
|
||||||
|
profiles: [ai]
|
||||||
|
|
||||||
|
# ── LiteLLM (proxy unificado de LLMs) ───────────────────────────────────────
|
||||||
|
litellm:
|
||||||
|
image: ghcr.io/berriai/litellm:main-latest
|
||||||
|
restart: unless-stopped
|
||||||
|
volumes:
|
||||||
|
- ./docker/litellm-config.yaml:/app/config.yaml
|
||||||
|
command: ["--config", "/app/config.yaml", "--port", "4000", "--detailed_debug"]
|
||||||
|
environment:
|
||||||
|
OPENAI_API_KEY: ${OPENAI_API_KEY:-}
|
||||||
|
OLLAMA_BASE_URL: http://ollama:11434
|
||||||
|
LITELLM_MASTER_KEY: ${LITELLM_API_KEY:-arcadia-internal}
|
||||||
|
ports:
|
||||||
|
- "4000:4000"
|
||||||
|
profiles: [ai]
|
||||||
|
|
||||||
|
volumes:
|
||||||
|
pgdata:
|
||||||
|
ollama_models:
|
||||||
|
open_webui_data:
|
||||||
|
|
@ -0,0 +1,11 @@
|
||||||
|
-- Inicialização do PostgreSQL com extensão pgvector
|
||||||
|
-- Executado automaticamente pelo container na primeira inicialização
|
||||||
|
|
||||||
|
-- Habilitar extensão pgvector para busca por similaridade de embeddings
|
||||||
|
CREATE EXTENSION IF NOT EXISTS vector;
|
||||||
|
|
||||||
|
-- Habilitar extensão pg_trgm para busca textual rápida
|
||||||
|
CREATE EXTENSION IF NOT EXISTS pg_trgm;
|
||||||
|
|
||||||
|
-- Habilitar uuid-ossp para geração de UUIDs
|
||||||
|
CREATE EXTENSION IF NOT EXISTS "uuid-ossp";
|
||||||
|
|
@ -0,0 +1,55 @@
|
||||||
|
# LiteLLM — Proxy unificado de LLMs para o Arcádia Suite
|
||||||
|
# Documentação: https://docs.litellm.ai/docs/proxy/configs
|
||||||
|
|
||||||
|
model_list:
|
||||||
|
|
||||||
|
# ── OpenAI (quando disponível) ───────────────────────────────────────────────
|
||||||
|
- model_name: gpt-4o
|
||||||
|
litellm_params:
|
||||||
|
model: openai/gpt-4o
|
||||||
|
api_key: os.environ/OPENAI_API_KEY
|
||||||
|
|
||||||
|
- model_name: gpt-4o-mini
|
||||||
|
litellm_params:
|
||||||
|
model: openai/gpt-4o-mini
|
||||||
|
api_key: os.environ/OPENAI_API_KEY
|
||||||
|
|
||||||
|
# ── Ollama (LLMs locais — soberania) ─────────────────────────────────────────
|
||||||
|
- model_name: llama3.3
|
||||||
|
litellm_params:
|
||||||
|
model: ollama/llama3.3
|
||||||
|
api_base: os.environ/OLLAMA_BASE_URL
|
||||||
|
|
||||||
|
- model_name: qwen2.5-coder
|
||||||
|
litellm_params:
|
||||||
|
model: ollama/qwen2.5-coder:7b
|
||||||
|
api_base: os.environ/OLLAMA_BASE_URL
|
||||||
|
|
||||||
|
- model_name: deepseek-r1
|
||||||
|
litellm_params:
|
||||||
|
model: ollama/deepseek-r1:7b
|
||||||
|
api_base: os.environ/OLLAMA_BASE_URL
|
||||||
|
|
||||||
|
# ── Modelo padrão: tenta OpenAI, fallback para Ollama ───────────────────────
|
||||||
|
- model_name: arcadia-default
|
||||||
|
litellm_params:
|
||||||
|
model: openai/gpt-4o-mini
|
||||||
|
api_key: os.environ/OPENAI_API_KEY
|
||||||
|
model_info:
|
||||||
|
fallbacks: ["llama3.3"]
|
||||||
|
|
||||||
|
router_settings:
|
||||||
|
routing_strategy: least-busy
|
||||||
|
fallbacks:
|
||||||
|
- {"gpt-4o": ["llama3.3"]}
|
||||||
|
- {"gpt-4o-mini": ["qwen2.5-coder"]}
|
||||||
|
- {"arcadia-default": ["llama3.3"]}
|
||||||
|
|
||||||
|
litellm_settings:
|
||||||
|
drop_params: true
|
||||||
|
request_timeout: 120
|
||||||
|
set_verbose: false
|
||||||
|
|
||||||
|
general_settings:
|
||||||
|
master_key: os.environ/LITELLM_MASTER_KEY
|
||||||
|
database_url: os.environ/DATABASE_URL
|
||||||
|
|
@ -0,0 +1,51 @@
|
||||||
|
#!/bin/sh
|
||||||
|
# Entrypoint dos microserviços Python
|
||||||
|
# Seleciona o script correto baseado em SERVICE_NAME
|
||||||
|
|
||||||
|
set -e
|
||||||
|
|
||||||
|
SERVICE_NAME=${SERVICE_NAME:-contabil}
|
||||||
|
SERVICE_PORT=${SERVICE_PORT:-8000}
|
||||||
|
|
||||||
|
echo "[entrypoint] Iniciando serviço: $SERVICE_NAME na porta $SERVICE_PORT"
|
||||||
|
|
||||||
|
case "$SERVICE_NAME" in
|
||||||
|
contabil)
|
||||||
|
exec python -m uvicorn server.python.contabil_service:app \
|
||||||
|
--host 0.0.0.0 \
|
||||||
|
--port "$SERVICE_PORT" \
|
||||||
|
--workers 2
|
||||||
|
;;
|
||||||
|
bi)
|
||||||
|
exec python -m uvicorn server.python.bi_engine:app \
|
||||||
|
--host 0.0.0.0 \
|
||||||
|
--port "$SERVICE_PORT" \
|
||||||
|
--workers 2
|
||||||
|
;;
|
||||||
|
automation)
|
||||||
|
exec python -m uvicorn server.python.automation_engine:app \
|
||||||
|
--host 0.0.0.0 \
|
||||||
|
--port "$SERVICE_PORT" \
|
||||||
|
--workers 1
|
||||||
|
;;
|
||||||
|
fisco)
|
||||||
|
exec python -m uvicorn server.python.fisco_service:app \
|
||||||
|
--host 0.0.0.0 \
|
||||||
|
--port "$SERVICE_PORT" \
|
||||||
|
--workers 2
|
||||||
|
;;
|
||||||
|
people)
|
||||||
|
exec python -m uvicorn server.python.people_service:app \
|
||||||
|
--host 0.0.0.0 \
|
||||||
|
--port "$SERVICE_PORT" \
|
||||||
|
--workers 2
|
||||||
|
;;
|
||||||
|
embeddings)
|
||||||
|
exec python server/python/embeddings_service.py
|
||||||
|
;;
|
||||||
|
*)
|
||||||
|
echo "[entrypoint] ERRO: SERVICE_NAME desconhecido: $SERVICE_NAME"
|
||||||
|
echo "Valores válidos: contabil | bi | automation | fisco | people | embeddings"
|
||||||
|
exit 1
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
|
@ -0,0 +1,213 @@
|
||||||
|
import { Router, Request, Response } from "express";
|
||||||
|
import { insertGraphNodeSchema, insertGraphEdgeSchema, insertKnowledgeBaseSchema } from "@shared/schema";
|
||||||
|
import * as graphService from "./service";
|
||||||
|
|
||||||
|
const router = Router();
|
||||||
|
|
||||||
|
// Middleware de autenticação
|
||||||
|
router.use((req: Request, res: Response, next) => {
|
||||||
|
if (!req.isAuthenticated()) {
|
||||||
|
return res.status(401).json({ error: "Não autenticado" });
|
||||||
|
}
|
||||||
|
next();
|
||||||
|
});
|
||||||
|
|
||||||
|
const tenantId = (req: Request): number | undefined =>
|
||||||
|
(req.user as any)?.tenantId ?? undefined;
|
||||||
|
|
||||||
|
// ─── Nodes ────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
router.get("/nodes", async (req: Request, res: Response) => {
|
||||||
|
try {
|
||||||
|
const type = req.query.type as string | undefined;
|
||||||
|
const limit = req.query.limit ? parseInt(req.query.limit as string) : 100;
|
||||||
|
const nodes = await graphService.getNodes(tenantId(req), type, limit);
|
||||||
|
res.json(nodes);
|
||||||
|
} catch (error: any) {
|
||||||
|
res.status(500).json({ error: error.message });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
router.get("/nodes/:id", async (req: Request, res: Response) => {
|
||||||
|
try {
|
||||||
|
const node = await graphService.getNodeById(parseInt(req.params.id));
|
||||||
|
if (!node) return res.status(404).json({ error: "Nó não encontrado" });
|
||||||
|
res.json(node);
|
||||||
|
} catch (error: any) {
|
||||||
|
res.status(500).json({ error: error.message });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
router.post("/nodes", async (req: Request, res: Response) => {
|
||||||
|
try {
|
||||||
|
const parsed = insertGraphNodeSchema.safeParse({
|
||||||
|
...req.body,
|
||||||
|
tenantId: tenantId(req),
|
||||||
|
});
|
||||||
|
if (!parsed.success) {
|
||||||
|
return res.status(400).json({ error: parsed.error.flatten() });
|
||||||
|
}
|
||||||
|
const node = await graphService.createNode(parsed.data);
|
||||||
|
res.status(201).json(node);
|
||||||
|
} catch (error: any) {
|
||||||
|
res.status(500).json({ error: error.message });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
router.put("/nodes/:id", async (req: Request, res: Response) => {
|
||||||
|
try {
|
||||||
|
const node = await graphService.updateNode(parseInt(req.params.id), req.body);
|
||||||
|
if (!node) return res.status(404).json({ error: "Nó não encontrado" });
|
||||||
|
res.json(node);
|
||||||
|
} catch (error: any) {
|
||||||
|
res.status(500).json({ error: error.message });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
router.delete("/nodes/:id", async (req: Request, res: Response) => {
|
||||||
|
try {
|
||||||
|
const deleted = await graphService.deleteNode(parseInt(req.params.id));
|
||||||
|
if (!deleted) return res.status(404).json({ error: "Nó não encontrado" });
|
||||||
|
res.json({ success: true });
|
||||||
|
} catch (error: any) {
|
||||||
|
res.status(500).json({ error: error.message });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// ─── Batch de Nodes ───────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
router.post("/nodes/batch", async (req: Request, res: Response) => {
|
||||||
|
try {
|
||||||
|
const { nodes } = req.body as { nodes: any[] };
|
||||||
|
if (!Array.isArray(nodes)) {
|
||||||
|
return res.status(400).json({ error: "Campo 'nodes' deve ser um array" });
|
||||||
|
}
|
||||||
|
const created = await Promise.all(
|
||||||
|
nodes.map((n) =>
|
||||||
|
graphService.createNode({ ...n, tenantId: tenantId(req) })
|
||||||
|
)
|
||||||
|
);
|
||||||
|
res.status(201).json(created);
|
||||||
|
} catch (error: any) {
|
||||||
|
res.status(500).json({ error: error.message });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// ─── Edges ────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
router.get("/edges", async (req: Request, res: Response) => {
|
||||||
|
try {
|
||||||
|
const sourceId = req.query.sourceId ? parseInt(req.query.sourceId as string) : undefined;
|
||||||
|
const targetId = req.query.targetId ? parseInt(req.query.targetId as string) : undefined;
|
||||||
|
const edges = await graphService.getEdges(sourceId, targetId);
|
||||||
|
res.json(edges);
|
||||||
|
} catch (error: any) {
|
||||||
|
res.status(500).json({ error: error.message });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
router.post("/edges", async (req: Request, res: Response) => {
|
||||||
|
try {
|
||||||
|
const parsed = insertGraphEdgeSchema.safeParse(req.body);
|
||||||
|
if (!parsed.success) {
|
||||||
|
return res.status(400).json({ error: parsed.error.flatten() });
|
||||||
|
}
|
||||||
|
const edge = await graphService.createEdge(parsed.data);
|
||||||
|
res.status(201).json(edge);
|
||||||
|
} catch (error: any) {
|
||||||
|
res.status(500).json({ error: error.message });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
router.delete("/edges/:id", async (req: Request, res: Response) => {
|
||||||
|
try {
|
||||||
|
const deleted = await graphService.deleteEdge(parseInt(req.params.id));
|
||||||
|
if (!deleted) return res.status(404).json({ error: "Aresta não encontrada" });
|
||||||
|
res.json({ success: true });
|
||||||
|
} catch (error: any) {
|
||||||
|
res.status(500).json({ error: error.message });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// ─── Knowledge Base ───────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
router.get("/knowledge", async (req: Request, res: Response) => {
|
||||||
|
try {
|
||||||
|
const category = req.query.category as string | undefined;
|
||||||
|
const search = req.query.search as string | undefined;
|
||||||
|
const entries = await graphService.getKnowledgeEntries(category, search);
|
||||||
|
res.json(entries);
|
||||||
|
} catch (error: any) {
|
||||||
|
res.status(500).json({ error: error.message });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
router.get("/knowledge/:id", async (req: Request, res: Response) => {
|
||||||
|
try {
|
||||||
|
const entry = await graphService.getKnowledgeEntry(parseInt(req.params.id));
|
||||||
|
if (!entry) return res.status(404).json({ error: "Entrada não encontrada" });
|
||||||
|
res.json(entry);
|
||||||
|
} catch (error: any) {
|
||||||
|
res.status(500).json({ error: error.message });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
router.post("/knowledge", async (req: Request, res: Response) => {
|
||||||
|
try {
|
||||||
|
const parsed = insertKnowledgeBaseSchema.safeParse(req.body);
|
||||||
|
if (!parsed.success) {
|
||||||
|
return res.status(400).json({ error: parsed.error.flatten() });
|
||||||
|
}
|
||||||
|
const entry = await graphService.createKnowledgeEntry(parsed.data);
|
||||||
|
res.status(201).json(entry);
|
||||||
|
} catch (error: any) {
|
||||||
|
res.status(500).json({ error: error.message });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
router.put("/knowledge/:id", async (req: Request, res: Response) => {
|
||||||
|
try {
|
||||||
|
const entry = await graphService.updateKnowledgeEntry(parseInt(req.params.id), req.body);
|
||||||
|
if (!entry) return res.status(404).json({ error: "Entrada não encontrada" });
|
||||||
|
res.json(entry);
|
||||||
|
} catch (error: any) {
|
||||||
|
res.status(500).json({ error: error.message });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
router.delete("/knowledge/:id", async (req: Request, res: Response) => {
|
||||||
|
try {
|
||||||
|
const deleted = await graphService.deleteKnowledgeEntry(parseInt(req.params.id));
|
||||||
|
if (!deleted) return res.status(404).json({ error: "Entrada não encontrada" });
|
||||||
|
res.json({ success: true });
|
||||||
|
} catch (error: any) {
|
||||||
|
res.status(500).json({ error: error.message });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// ─── Busca Semântica ──────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
router.post("/search", async (req: Request, res: Response) => {
|
||||||
|
try {
|
||||||
|
const { query, n_results = 5 } = req.body as { query: string; n_results?: number };
|
||||||
|
if (!query) return res.status(400).json({ error: "Campo 'query' é obrigatório" });
|
||||||
|
|
||||||
|
const result = await graphService.semanticSearch(query, n_results);
|
||||||
|
res.json(result);
|
||||||
|
} catch (error: any) {
|
||||||
|
res.status(500).json({ error: error.message });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// ─── Grafo completo para visualização ────────────────────────────────────────
|
||||||
|
|
||||||
|
router.get("/visualization", async (req: Request, res: Response) => {
|
||||||
|
try {
|
||||||
|
const data = await graphService.getGraphData(tenantId(req));
|
||||||
|
res.json(data);
|
||||||
|
} catch (error: any) {
|
||||||
|
res.status(500).json({ error: error.message });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
export default router;
|
||||||
|
|
@ -0,0 +1,242 @@
|
||||||
|
import { db } from "../../db/index";
|
||||||
|
import { eq, desc, and, sql, ilike, or } from "drizzle-orm";
|
||||||
|
import {
|
||||||
|
graphNodes,
|
||||||
|
graphEdges,
|
||||||
|
knowledgeBase,
|
||||||
|
learnedInteractions,
|
||||||
|
insertGraphNodeSchema,
|
||||||
|
insertGraphEdgeSchema,
|
||||||
|
insertKnowledgeBaseSchema,
|
||||||
|
type GraphNode,
|
||||||
|
type GraphEdge,
|
||||||
|
type KnowledgeBaseEntry,
|
||||||
|
type InsertGraphNode,
|
||||||
|
type InsertGraphEdge,
|
||||||
|
type InsertKnowledgeBaseEntry,
|
||||||
|
} from "@shared/schema";
|
||||||
|
|
||||||
|
const EMBEDDINGS_URL = process.env.PYTHON_SERVICE_URL || "http://localhost:8001";
|
||||||
|
|
||||||
|
// ─── Nodes ────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export async function getNodes(tenantId?: number, type?: string, limit = 100): Promise<GraphNode[]> {
|
||||||
|
const conditions = [];
|
||||||
|
if (tenantId) conditions.push(eq(graphNodes.tenantId, tenantId));
|
||||||
|
if (type) conditions.push(eq(graphNodes.type, type));
|
||||||
|
|
||||||
|
return db
|
||||||
|
.select()
|
||||||
|
.from(graphNodes)
|
||||||
|
.where(conditions.length ? and(...conditions) : undefined)
|
||||||
|
.orderBy(desc(graphNodes.createdAt))
|
||||||
|
.limit(limit);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getNodeById(id: number): Promise<GraphNode | undefined> {
|
||||||
|
const [node] = await db.select().from(graphNodes).where(eq(graphNodes.id, id));
|
||||||
|
return node;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function createNode(data: InsertGraphNode): Promise<GraphNode> {
|
||||||
|
const [node] = await db.insert(graphNodes).values(data).returning();
|
||||||
|
|
||||||
|
// Indexar embedding em background (não bloqueia a resposta)
|
||||||
|
const content = typeof data.data === "object" ? JSON.stringify(data.data) : String(data.data);
|
||||||
|
indexNodeEmbedding(node.id, content, data.type).catch(() => {});
|
||||||
|
|
||||||
|
return node;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function updateNode(id: number, data: Partial<InsertGraphNode>): Promise<GraphNode | undefined> {
|
||||||
|
const [node] = await db
|
||||||
|
.update(graphNodes)
|
||||||
|
.set({ ...data, updatedAt: new Date() })
|
||||||
|
.where(eq(graphNodes.id, id))
|
||||||
|
.returning();
|
||||||
|
return node;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function deleteNode(id: number): Promise<boolean> {
|
||||||
|
const result = await db.delete(graphNodes).where(eq(graphNodes.id, id));
|
||||||
|
return (result.rowCount ?? 0) > 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Edges ────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export async function getEdges(sourceId?: number, targetId?: number): Promise<GraphEdge[]> {
|
||||||
|
const conditions = [];
|
||||||
|
if (sourceId) conditions.push(eq(graphEdges.sourceId, sourceId));
|
||||||
|
if (targetId) conditions.push(eq(graphEdges.targetId, targetId));
|
||||||
|
|
||||||
|
return db
|
||||||
|
.select()
|
||||||
|
.from(graphEdges)
|
||||||
|
.where(conditions.length ? and(...conditions) : undefined)
|
||||||
|
.orderBy(desc(graphEdges.createdAt));
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function createEdge(data: InsertGraphEdge): Promise<GraphEdge> {
|
||||||
|
const [edge] = await db.insert(graphEdges).values(data).returning();
|
||||||
|
return edge;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function deleteEdge(id: number): Promise<boolean> {
|
||||||
|
const result = await db.delete(graphEdges).where(eq(graphEdges.id, id));
|
||||||
|
return (result.rowCount ?? 0) > 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Knowledge Base ───────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export async function getKnowledgeEntries(category?: string, search?: string): Promise<KnowledgeBaseEntry[]> {
|
||||||
|
const conditions = [];
|
||||||
|
if (category) conditions.push(eq(knowledgeBase.category, category));
|
||||||
|
if (search) {
|
||||||
|
conditions.push(
|
||||||
|
or(
|
||||||
|
ilike(knowledgeBase.title, `%${search}%`),
|
||||||
|
ilike(knowledgeBase.content, `%${search}%`)
|
||||||
|
)!
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return db
|
||||||
|
.select()
|
||||||
|
.from(knowledgeBase)
|
||||||
|
.where(conditions.length ? and(...conditions) : undefined)
|
||||||
|
.orderBy(desc(knowledgeBase.createdAt))
|
||||||
|
.limit(50);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getKnowledgeEntry(id: number): Promise<KnowledgeBaseEntry | undefined> {
|
||||||
|
const [entry] = await db.select().from(knowledgeBase).where(eq(knowledgeBase.id, id));
|
||||||
|
return entry;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function createKnowledgeEntry(data: InsertKnowledgeBaseEntry): Promise<KnowledgeBaseEntry> {
|
||||||
|
const [entry] = await db.insert(knowledgeBase).values(data).returning();
|
||||||
|
|
||||||
|
// Indexar no serviço de embeddings
|
||||||
|
indexKnowledgeEmbedding(entry.id, entry.title + "\n" + entry.content, entry.category).catch(() => {});
|
||||||
|
|
||||||
|
return entry;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function updateKnowledgeEntry(
|
||||||
|
id: number,
|
||||||
|
data: Partial<InsertKnowledgeBaseEntry>
|
||||||
|
): Promise<KnowledgeBaseEntry | undefined> {
|
||||||
|
const [entry] = await db.update(knowledgeBase).set(data).where(eq(knowledgeBase.id, id)).returning();
|
||||||
|
return entry;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function deleteKnowledgeEntry(id: number): Promise<boolean> {
|
||||||
|
const result = await db.delete(knowledgeBase).where(eq(knowledgeBase.id, id));
|
||||||
|
return (result.rowCount ?? 0) > 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Busca Semântica ──────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export async function semanticSearch(
|
||||||
|
query: string,
|
||||||
|
nResults = 5
|
||||||
|
): Promise<{ results: any[]; source: "embeddings" | "text_fallback" }> {
|
||||||
|
// Tenta busca vetorial no serviço de embeddings Python
|
||||||
|
try {
|
||||||
|
const response = await fetch(`${EMBEDDINGS_URL}/embeddings/search`, {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({ query, n_results: nResults }),
|
||||||
|
signal: AbortSignal.timeout(5000),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (response.ok) {
|
||||||
|
const data = await response.json();
|
||||||
|
return { results: data.results || [], source: "embeddings" };
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// Serviço de embeddings indisponível — fallback para busca textual
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fallback: busca textual simples no banco
|
||||||
|
const textResults = await db
|
||||||
|
.select()
|
||||||
|
.from(knowledgeBase)
|
||||||
|
.where(
|
||||||
|
or(
|
||||||
|
ilike(knowledgeBase.title, `%${query}%`),
|
||||||
|
ilike(knowledgeBase.content, `%${query}%`)
|
||||||
|
)!
|
||||||
|
)
|
||||||
|
.limit(nResults);
|
||||||
|
|
||||||
|
// Complementa com interações aprendidas
|
||||||
|
const interactionResults = await db
|
||||||
|
.select()
|
||||||
|
.from(learnedInteractions)
|
||||||
|
.where(
|
||||||
|
or(
|
||||||
|
ilike(learnedInteractions.question, `%${query}%`),
|
||||||
|
ilike(learnedInteractions.answer, `%${query}%`)
|
||||||
|
)!
|
||||||
|
)
|
||||||
|
.orderBy(desc(learnedInteractions.createdAt))
|
||||||
|
.limit(nResults);
|
||||||
|
|
||||||
|
return {
|
||||||
|
results: [
|
||||||
|
...textResults.map((r) => ({ type: "knowledge", score: 0.7, data: r })),
|
||||||
|
...interactionResults.map((r) => ({ type: "interaction", score: 0.6, data: r })),
|
||||||
|
],
|
||||||
|
source: "text_fallback",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Grafo Completo para Visualização ────────────────────────────────────────
|
||||||
|
|
||||||
|
export async function getGraphData(tenantId?: number): Promise<{ nodes: GraphNode[]; edges: GraphEdge[] }> {
|
||||||
|
const nodes = await getNodes(tenantId, undefined, 200);
|
||||||
|
const nodeIds = nodes.map((n) => n.id);
|
||||||
|
|
||||||
|
if (nodeIds.length === 0) return { nodes: [], edges: [] };
|
||||||
|
|
||||||
|
const edges = await db
|
||||||
|
.select()
|
||||||
|
.from(graphEdges)
|
||||||
|
.where(
|
||||||
|
or(
|
||||||
|
sql`${graphEdges.sourceId} = ANY(${sql.raw(`ARRAY[${nodeIds.join(",")}]`)})`,
|
||||||
|
sql`${graphEdges.targetId} = ANY(${sql.raw(`ARRAY[${nodeIds.join(",")}]`)})`
|
||||||
|
)!
|
||||||
|
);
|
||||||
|
|
||||||
|
return { nodes, edges };
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Helpers Privados ────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
async function indexNodeEmbedding(nodeId: number, content: string, type: string) {
|
||||||
|
await fetch(`${EMBEDDINGS_URL}/embeddings/add`, {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({
|
||||||
|
doc_id: `node_${nodeId}`,
|
||||||
|
document: content,
|
||||||
|
metadata: { type: "graph_node", node_type: type, node_id: nodeId },
|
||||||
|
}),
|
||||||
|
signal: AbortSignal.timeout(10000),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function indexKnowledgeEmbedding(entryId: number, content: string, category: string) {
|
||||||
|
await fetch(`${EMBEDDINGS_URL}/embeddings/add`, {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({
|
||||||
|
doc_id: `kb_${entryId}`,
|
||||||
|
document: content,
|
||||||
|
metadata: { type: "knowledge_base", category, entry_id: entryId },
|
||||||
|
}),
|
||||||
|
signal: AbortSignal.timeout(10000),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
@ -201,10 +201,15 @@ function startNodeService(name: string, scriptPath: string, port: number) {
|
||||||
|
|
||||||
export { managedServices, restartManagedService, stopManagedService, getManagedServiceInfo, getManagedServiceLogs, startNodeService };
|
export { managedServices, restartManagedService, stopManagedService, getManagedServiceInfo, getManagedServiceLogs, startNodeService };
|
||||||
|
|
||||||
|
// Em modo Docker cada serviço roda como container independente — não spawnar processos filhos
|
||||||
|
if (!process.env.DOCKER_MODE || process.env.DOCKER_MODE === "false") {
|
||||||
startPythonService("contabil", path.join(process.cwd(), "server/python/contabil_service.py"), 8003);
|
startPythonService("contabil", path.join(process.cwd(), "server/python/contabil_service.py"), 8003);
|
||||||
startPythonService("bi", path.join(process.cwd(), "server/python/bi_engine.py"), 8004);
|
startPythonService("bi", path.join(process.cwd(), "server/python/bi_engine.py"), 8004);
|
||||||
startPythonService("automation", path.join(process.cwd(), "server/python/automation_engine.py"), 8005);
|
startPythonService("automation", path.join(process.cwd(), "server/python/automation_engine.py"), 8005);
|
||||||
startNodeService("communication", path.join(process.cwd(), "server/communication/engine.ts"), 8006);
|
startNodeService("communication", path.join(process.cwd(), "server/communication/engine.ts"), 8006);
|
||||||
|
} else {
|
||||||
|
console.log("[services] DOCKER_MODE=true — microserviços rodando como containers independentes");
|
||||||
|
}
|
||||||
|
|
||||||
function startShellService(name: string, scriptPath: string, port: number) {
|
function startShellService(name: string, scriptPath: string, port: number) {
|
||||||
const existing = managedServices.get(name);
|
const existing = managedServices.get(name);
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,250 @@
|
||||||
|
"""
|
||||||
|
Serviço de Embeddings com pgvector
|
||||||
|
|
||||||
|
Expõe endpoints compatíveis com o que o learning/service.ts espera:
|
||||||
|
POST /embeddings/add — adiciona documento com embedding
|
||||||
|
POST /embeddings/search — busca por similaridade semântica
|
||||||
|
GET /health — healthcheck
|
||||||
|
|
||||||
|
Usa pgvector (PostgreSQL) em vez de ChromaDB — sem dependência extra.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import os
|
||||||
|
import json
|
||||||
|
import logging
|
||||||
|
from contextlib import asynccontextmanager
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
|
import psycopg2
|
||||||
|
import psycopg2.extras
|
||||||
|
from fastapi import FastAPI, HTTPException
|
||||||
|
from fastapi.middleware.cors import CORSMiddleware
|
||||||
|
from pydantic import BaseModel
|
||||||
|
|
||||||
|
logging.basicConfig(level=logging.INFO)
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
DATABASE_URL = os.getenv("DATABASE_URL", "postgresql://arcadia:arcadia123@localhost:5432/arcadia")
|
||||||
|
PORT = int(os.getenv("SERVICE_PORT", "8001"))
|
||||||
|
EMBEDDING_DIM = 1536 # OpenAI text-embedding-3-small
|
||||||
|
|
||||||
|
|
||||||
|
# ─── Modelos Pydantic ──────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
class AddDocumentRequest(BaseModel):
|
||||||
|
doc_id: str
|
||||||
|
document: str
|
||||||
|
metadata: Optional[dict] = {}
|
||||||
|
|
||||||
|
class SearchRequest(BaseModel):
|
||||||
|
query: str
|
||||||
|
n_results: int = 5
|
||||||
|
filter_type: Optional[str] = None
|
||||||
|
|
||||||
|
|
||||||
|
# ─── Setup do banco ────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
def get_conn():
|
||||||
|
return psycopg2.connect(DATABASE_URL)
|
||||||
|
|
||||||
|
|
||||||
|
def setup_database():
|
||||||
|
"""Cria a tabela de embeddings e instala pgvector se não existir."""
|
||||||
|
try:
|
||||||
|
conn = get_conn()
|
||||||
|
cur = conn.cursor()
|
||||||
|
|
||||||
|
cur.execute("CREATE EXTENSION IF NOT EXISTS vector;")
|
||||||
|
cur.execute("""
|
||||||
|
CREATE TABLE IF NOT EXISTS document_embeddings (
|
||||||
|
id SERIAL PRIMARY KEY,
|
||||||
|
doc_id TEXT NOT NULL UNIQUE,
|
||||||
|
document TEXT NOT NULL,
|
||||||
|
metadata JSONB DEFAULT '{}',
|
||||||
|
embedding vector(1536),
|
||||||
|
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
||||||
|
);
|
||||||
|
""")
|
||||||
|
cur.execute("""
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_embeddings_vector
|
||||||
|
ON document_embeddings
|
||||||
|
USING ivfflat (embedding vector_cosine_ops)
|
||||||
|
WITH (lists = 100);
|
||||||
|
""")
|
||||||
|
conn.commit()
|
||||||
|
cur.close()
|
||||||
|
conn.close()
|
||||||
|
logger.info("✅ Tabela document_embeddings pronta com pgvector")
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Erro no setup do banco: {e}")
|
||||||
|
|
||||||
|
|
||||||
|
# ─── Embedding via LiteLLM/OpenAI ─────────────────────────────────────────────
|
||||||
|
|
||||||
|
def generate_embedding(text: str) -> Optional[list]:
|
||||||
|
"""
|
||||||
|
Gera embedding via LiteLLM proxy (que pode usar OpenAI ou Ollama).
|
||||||
|
Retorna None se falhar — documentos sem embedding ainda são indexados (busca textual).
|
||||||
|
"""
|
||||||
|
litellm_url = os.getenv("LITELLM_BASE_URL", "http://litellm:4000")
|
||||||
|
api_key = os.getenv("LITELLM_API_KEY", "arcadia-internal")
|
||||||
|
|
||||||
|
try:
|
||||||
|
import urllib.request
|
||||||
|
import urllib.error
|
||||||
|
|
||||||
|
payload = json.dumps({
|
||||||
|
"model": "text-embedding-3-small",
|
||||||
|
"input": text[:8000] # limite de tokens
|
||||||
|
}).encode()
|
||||||
|
|
||||||
|
req = urllib.request.Request(
|
||||||
|
f"{litellm_url}/v1/embeddings",
|
||||||
|
data=payload,
|
||||||
|
headers={
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
"Authorization": f"Bearer {api_key}"
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
with urllib.request.urlopen(req, timeout=15) as response:
|
||||||
|
result = json.loads(response.read())
|
||||||
|
return result["data"][0]["embedding"]
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning(f"Embedding não gerado (usando busca textual como fallback): {e}")
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
# ─── App FastAPI ───────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
@asynccontextmanager
|
||||||
|
async def lifespan(app: FastAPI):
|
||||||
|
setup_database()
|
||||||
|
yield
|
||||||
|
|
||||||
|
app = FastAPI(
|
||||||
|
title="Arcádia Embeddings Service",
|
||||||
|
description="Serviço de embeddings com pgvector para busca semântica",
|
||||||
|
version="1.0.0",
|
||||||
|
lifespan=lifespan
|
||||||
|
)
|
||||||
|
|
||||||
|
app.add_middleware(
|
||||||
|
CORSMiddleware,
|
||||||
|
allow_origins=["*"],
|
||||||
|
allow_methods=["*"],
|
||||||
|
allow_headers=["*"],
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/health")
|
||||||
|
def health():
|
||||||
|
return {"status": "ok", "service": "embeddings"}
|
||||||
|
|
||||||
|
|
||||||
|
@app.post("/embeddings/add")
|
||||||
|
def add_document(req: AddDocumentRequest):
|
||||||
|
"""Adiciona ou atualiza um documento com embedding."""
|
||||||
|
embedding = generate_embedding(req.document)
|
||||||
|
|
||||||
|
try:
|
||||||
|
conn = get_conn()
|
||||||
|
cur = conn.cursor()
|
||||||
|
|
||||||
|
if embedding:
|
||||||
|
cur.execute("""
|
||||||
|
INSERT INTO document_embeddings (doc_id, document, metadata, embedding, updated_at)
|
||||||
|
VALUES (%s, %s, %s, %s, CURRENT_TIMESTAMP)
|
||||||
|
ON CONFLICT (doc_id) DO UPDATE SET
|
||||||
|
document = EXCLUDED.document,
|
||||||
|
metadata = EXCLUDED.metadata,
|
||||||
|
embedding = EXCLUDED.embedding,
|
||||||
|
updated_at = CURRENT_TIMESTAMP
|
||||||
|
""", (req.doc_id, req.document, json.dumps(req.metadata), str(embedding)))
|
||||||
|
else:
|
||||||
|
cur.execute("""
|
||||||
|
INSERT INTO document_embeddings (doc_id, document, metadata, updated_at)
|
||||||
|
VALUES (%s, %s, %s, CURRENT_TIMESTAMP)
|
||||||
|
ON CONFLICT (doc_id) DO UPDATE SET
|
||||||
|
document = EXCLUDED.document,
|
||||||
|
metadata = EXCLUDED.metadata,
|
||||||
|
updated_at = CURRENT_TIMESTAMP
|
||||||
|
""", (req.doc_id, req.document, json.dumps(req.metadata)))
|
||||||
|
|
||||||
|
conn.commit()
|
||||||
|
cur.close()
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
return {
|
||||||
|
"success": True,
|
||||||
|
"doc_id": req.doc_id,
|
||||||
|
"has_embedding": embedding is not None
|
||||||
|
}
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Erro ao adicionar documento: {e}")
|
||||||
|
raise HTTPException(status_code=500, detail=str(e))
|
||||||
|
|
||||||
|
|
||||||
|
@app.post("/embeddings/search")
|
||||||
|
def search_documents(req: SearchRequest):
|
||||||
|
"""Busca documentos similares por embedding ou texto."""
|
||||||
|
query_embedding = generate_embedding(req.query)
|
||||||
|
|
||||||
|
try:
|
||||||
|
conn = get_conn()
|
||||||
|
cur = conn.cursor(cursor_factory=psycopg2.extras.RealDictCursor)
|
||||||
|
|
||||||
|
if query_embedding:
|
||||||
|
# Busca vetorial por similaridade coseno
|
||||||
|
cur.execute("""
|
||||||
|
SELECT
|
||||||
|
doc_id,
|
||||||
|
document,
|
||||||
|
metadata,
|
||||||
|
1 - (embedding <=> %s::vector) AS similarity
|
||||||
|
FROM document_embeddings
|
||||||
|
WHERE embedding IS NOT NULL
|
||||||
|
ORDER BY embedding <=> %s::vector
|
||||||
|
LIMIT %s
|
||||||
|
""", (str(query_embedding), str(query_embedding), req.n_results))
|
||||||
|
else:
|
||||||
|
# Fallback: full-text search com LIKE
|
||||||
|
cur.execute("""
|
||||||
|
SELECT
|
||||||
|
doc_id,
|
||||||
|
document,
|
||||||
|
metadata,
|
||||||
|
0.5 AS similarity
|
||||||
|
FROM document_embeddings
|
||||||
|
WHERE document ILIKE %s
|
||||||
|
ORDER BY updated_at DESC
|
||||||
|
LIMIT %s
|
||||||
|
""", (f"%{req.query}%", req.n_results))
|
||||||
|
|
||||||
|
rows = cur.fetchall()
|
||||||
|
cur.close()
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
return {
|
||||||
|
"results": [dict(r) for r in rows],
|
||||||
|
"query": req.query,
|
||||||
|
"method": "vector" if query_embedding else "text_fallback"
|
||||||
|
}
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Erro na busca: {e}")
|
||||||
|
raise HTTPException(status_code=500, detail=str(e))
|
||||||
|
|
||||||
|
|
||||||
|
@app.post("/documents/add")
|
||||||
|
def add_document_compat(req: AddDocumentRequest):
|
||||||
|
"""Alias compatível com o formato do python-service/."""
|
||||||
|
return add_document(req)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
import uvicorn
|
||||||
|
uvicorn.run(app, host="0.0.0.0", port=PORT)
|
||||||
|
|
@ -58,6 +58,7 @@ import blackboardRoutes from "./blackboard/routes";
|
||||||
import pipelineRoutes from "./blackboard/pipelineRoutes";
|
import pipelineRoutes from "./blackboard/pipelineRoutes";
|
||||||
import { startAllAgents } from "./blackboard/agents";
|
import { startAllAgents } from "./blackboard/agents";
|
||||||
import { loadModuleRoutes } from "./modules/loader";
|
import { loadModuleRoutes } from "./modules/loader";
|
||||||
|
import graphRoutes from "./graph/routes";
|
||||||
|
|
||||||
export async function registerRoutes(
|
export async function registerRoutes(
|
||||||
httpServer: Server,
|
httpServer: Server,
|
||||||
|
|
@ -83,6 +84,7 @@ export async function registerRoutes(
|
||||||
registerAutomationRoutes(app);
|
registerAutomationRoutes(app);
|
||||||
registerAutomationEngineRoutes(app);
|
registerAutomationEngineRoutes(app);
|
||||||
registerBiRoutes(app);
|
registerBiRoutes(app);
|
||||||
|
app.use("/api/graph", graphRoutes);
|
||||||
registerBiEngineRoutes(app);
|
registerBiEngineRoutes(app);
|
||||||
registerMetaSetRoutes(app);
|
registerMetaSetRoutes(app);
|
||||||
registerCommEngineRoutes(app);
|
registerCommEngineRoutes(app);
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue