feat: containerização completa + Knowledge Graph API + embeddings pgvector

Infraestrutura Docker:
- Dockerfile multi-stage para app Node.js
- Dockerfile.python genérico para todos os microserviços Python
- docker-compose.yml para desenvolvimento local (com perfis: bi, ai)
- docker-compose.prod.yml para deploy no Coolify com Traefik
- docker/init-pgvector.sql — instala extensão vector no PostgreSQL
- docker/python-entrypoint.sh — seleciona serviço pelo SERVICE_NAME
- docker/litellm-config.yaml — proxy LLM com fallback OpenAI → Ollama
- .env.example com todas as variáveis documentadas

Soberania de IA:
- Ollama + Open WebUI como perfil 'ai' no docker-compose
- LiteLLM como proxy unificado (OpenAI ↔ Ollama com fallback automático)

Knowledge Graph (rotas faltantes implementadas):
- server/graph/routes.ts — CRUD completo de nodes, edges, knowledge base + busca semântica
- server/graph/service.ts — camada de serviço com indexação automática de embeddings
- server/python/embeddings_service.py — FastAPI com pgvector para busca vetorial

Correções de infraestrutura:
- server/index.ts — DOCKER_MODE=true desativa spawn de processos filhos
- server/routes.ts — registra /api/graph/* no Express

https://claude.ai/code/session_01DinH3VcgbAv1d9MqnNxzdb
This commit is contained in:
Claude 2026-03-13 10:14:33 +00:00
parent 44dacedd90
commit 9322bf570f
No known key found for this signature in database
13 changed files with 1431 additions and 4 deletions

73
.env.example Normal file
View File

@ -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

33
Dockerfile Normal file
View File

@ -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"]

47
Dockerfile.python Normal file
View File

@ -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"]

212
docker-compose.prod.yml Normal file
View File

@ -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:

233
docker-compose.yml Normal file
View File

@ -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:

11
docker/init-pgvector.sql Normal file
View File

@ -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";

View File

@ -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

View File

@ -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

213
server/graph/routes.ts Normal file
View File

@ -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;

242
server/graph/service.ts Normal file
View File

@ -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),
});
}

View File

@ -201,10 +201,15 @@ function startNodeService(name: string, scriptPath: string, port: number) {
export { managedServices, restartManagedService, stopManagedService, getManagedServiceInfo, getManagedServiceLogs, startNodeService };
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("automation", path.join(process.cwd(), "server/python/automation_engine.py"), 8005);
startNodeService("communication", path.join(process.cwd(), "server/communication/engine.ts"), 8006);
// 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("bi", path.join(process.cwd(), "server/python/bi_engine.py"), 8004);
startPythonService("automation", path.join(process.cwd(), "server/python/automation_engine.py"), 8005);
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) {
const existing = managedServices.get(name);

View File

@ -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)

View File

@ -58,6 +58,7 @@ import blackboardRoutes from "./blackboard/routes";
import pipelineRoutes from "./blackboard/pipelineRoutes";
import { startAllAgents } from "./blackboard/agents";
import { loadModuleRoutes } from "./modules/loader";
import graphRoutes from "./graph/routes";
export async function registerRoutes(
httpServer: Server,
@ -83,6 +84,7 @@ export async function registerRoutes(
registerAutomationRoutes(app);
registerAutomationEngineRoutes(app);
registerBiRoutes(app);
app.use("/api/graph", graphRoutes);
registerBiEngineRoutes(app);
registerMetaSetRoutes(app);
registerCommEngineRoutes(app);