diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..0a8eace --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,189 @@ +# AGENTS.md — decompile-ai + +## Project Overview + +**decompile-ai** is a backend service for binary analysis automation. It accepts uploaded binaries, runs them through multiple analysis engines (IDA Pro, Ghidra, DiE) inside Docker containers, and provides AI-powered chat over the decompiled code via Ollama + RAG (pgvector embeddings). + +- **Java 25**, **Spring Boot 4.0.6**, **Maven** wrapper (`./mvnw`) +- **PostgreSQL** + **Flyway** migrations; **H2** in-memory for tests +- **RabbitMQ** for async job dispatch +- **Spring Modulith** for module boundaries, event publication, and architecture verification +- **Spring AI** (Ollama) for chat and embedding generation +- **Spotless** (Google Java Format) for code formatting + +## Build & Run Commands + +```bash +# Compile +./mvnw compile + +# Run tests +./mvnw test + +# Run a specific test class +./mvnw test -Dtest=ChatServiceTest + +# Package +./mvnw package -DskipTests + +# Run the app locally (needs PostgreSQL + RabbitMQ) +./mvnw spring-boot:run + +# Format code +./mvnw spotless:apply + +# Check formatting (CI-style) +./mvnw spotless:check +``` + +## Project Structure + +``` +src/main/java/ai/decompile/ +├── DecompileAiApplication.java # Spring Boot entry point +├── WebConfig.java # CORS configuration +├── workspace/ # Workspace, Project, Binary CRUD +│ ├── controller/ +│ ├── event/ # Domain events (BinaryUploadedEvent) +│ ├── model/{dto,entity,repository,specification}/ +│ └── service/ +├── analysis/ # Static analysis results (functions, xrefs, labels) +│ ├── controller/ +│ ├── event/ # StaticAnalysisRequestedEvent, StaticAnalysisCompletedEvent +│ ├── model/{dto,entity,repository}/ +│ └── service/ +├── engine/ # Analysis engines: IDA v5, Ghidra (Docker-managed) +│ ├── config/ +│ ├── model/ +│ └── service/ +├── die/ # DiE (Detect It Easy) file type detection (Docker) +├── docker/ # Docker container management via docker-java +├── job/ # Async job queue: create, dispatch, execute, track status +│ ├── controller/ +│ ├── messaging/ # RabbitMQ publisher + listener +│ ├── model/{dto,entity,enums,repository,specification}/ +│ └── service/handler/ # JobType handlers (AnalyzeFile, StaticAnalysis, Embedding) +├── ai/ # AI chat + RAG embeddings (Spring AI + Ollama) +│ ├── config/ +│ ├── controller/ +│ ├── model/{dto,entity,jdbc,repository}/ +│ └── service/ +└── common/ # Shared config and exceptions (NotFoundException, ConflictException) + └── exception/ +``` + +## Coding Conventions + +### Null Checking +Always use `java.util.Objects.isNull()` and `java.util.Objects.nonNull()` — **never** use raw `== null` or `!= null`. + +```java +// Correct +if (Objects.isNull(value)) { ... } +if (Objects.nonNull(value)) { ... } + +// Wrong +if (value == null) { ... } +if (value != null) { ... } +``` + +### If Statements +Always use braces, even for single-line bodies. Always include a blank line after the closing `}` of an `if` block (before the next statement). + +```java +// Correct +if (condition) { + doSomething(); +} + +nextThing(); + +// Wrong +if (condition) doSomething(); + +// Wrong (no blank line after if) +if (condition) { + doSomething(); +} +nextThing(); +``` + +### Classes and Records +No blank line between the class/record opening brace and the first member. + +```java +// Correct +public class Job { + @Id + @GeneratedValue(strategy = GenerationType.UUID) + private UUID id; + +// Correct +public record ChatRequest(@NotBlank String content) {} + +// Wrong (blank line after brace) +public class Job { + + @Id + private UUID id; +``` + +### Type Inference (var) +Use `var` for local variables whenever the type is obvious from the right-hand side. + +```java +// Correct +var session = chatService.createSession(binaryId, projectId, model); +var messages = new ArrayList(); +var binary = binaryService.getBinary(binaryId); + +// Acceptable (type not obvious from RHS, or lambda target typing needed) +StreamingChatModel streamingModel = resolveStreamingChatModel(session.getChatModel()); +``` + +### Dependency Injection +Constructor injection via Lombok `@RequiredArgsConstructor`. Declare dependencies as `private final`. + +```java +@Service +@RequiredArgsConstructor +public class MyService { + private final DependencyA depA; + private final DependencyB depB; +} +``` + +### Transactions +- `@Transactional` on write operations +- `@Transactional(readOnly = true)` on read-only queries +- `@Transactional(propagation = Propagation.REQUIRES_NEW)` for independent sub-transactions + +### Testing +- **JUnit 5** + **Mockito** (unit tests) +- **MockMvc** + H2 (integration tests) +- Unit tests: `@ExtendWith(MockitoExtension.class)`, `@Mock` / `@InjectMocks` +- Integration tests: `@SpringBootTest`, `@ActiveProfiles("test")` +- Test class naming: `*Test.java` (unit), `*IntegrationTest.java` (integration) +- Static imports for assertions: `assertEquals(...)`, `assertThrows(...)`, `assertTrue(...)` + +### Logging +Lombok `@Log4j2` on all non-trivial classes. Use parameterized messages: + +```java +log.info("Processing binary={} with engine={}", binaryId, engineName); +log.error("Failed to stream chat for session {}", sessionId, error); +``` + +### Domain Events (Spring Modulith) +Event listeners use `@ApplicationModuleListener`. Events are published via `ApplicationEventPublisher.publishEvent()`. + +### Flyway Migrations +- Files in `src/main/resources/db/migration/`, naming: `V001__description.sql` +- DDL changes go through Flyway; Hibernate `ddl-auto=validate` only + +## Design Principles + +- **DRY** — Extract repeated logic. The refactoring of `ChatController.sendMessage()` is the canonical example: duplicated error formatting was extracted into reusable `event()` and `errorEvent()` helpers. +- **SOLID** — Each module (workspace, analysis, engine, job, ai) has a single responsibility. Dependencies between modules are explicitly declared in `package-info.java`. +- **Clean Code** — Small methods, meaningful names, no comments explaining *what* (the code does that), only comments explaining *why* when non-obvious. +- **Module boundaries** — Cross-module communication via domain events, not direct service calls. Use `@NamedInterface` for fine-grained intra-module access control. diff --git a/docs/ai/api-design.md b/docs/ai/api-design.md new file mode 100644 index 0000000..f43b552 --- /dev/null +++ b/docs/ai/api-design.md @@ -0,0 +1,404 @@ +# API Design — Módulo AI + +## Endpoints + +Todos os endpoints são prefixados com o contexto da aplicação e seguem as convenções REST do projeto (OpenAPI 3.1 via SpringDoc). + +| Método | Path | Descrição | Tag | +|---|---|---|---| +| `POST` | `/binaries/{binaryId}/chat/sessions` | Criar nova sessão de chat | Chat | +| `GET` | `/binaries/{binaryId}/chat/sessions` | Listar sessões do binário | Chat | +| `GET` | `/chat/sessions/{sessionId}` | Detalhes da sessão | Chat | +| `DELETE` | `/chat/sessions/{sessionId}` | Deletar sessão | Chat | +| `GET` | `/chat/sessions/{sessionId}/messages` | Histórico de mensagens | Chat | +| `POST` | `/chat/sessions/{sessionId}/messages` | Enviar mensagem (SSE stream) | Chat | + +--- + +## 1. Criar Sessão + +### `POST /binaries/{binaryId}/chat/sessions` + +Cria uma nova sessão de chat vinculada a um binário. + +**Query Parameters**: + +| Parâmetro | Tipo | Obrigatório | Default | Descrição | +|---|---|---|---|---| +| `model` | String | Não | `app.ai.default-chat-provider` | Modelo LLM: `gemma4:12b`, `gemma4:e4b`, `deepseek-chat` | + +**Request Body**: Vazio + +**Response** (`201 Created`): + +```json +{ + "id": "550e8400-e29b-41d4-a716-446655440000", + "binaryId": "660e8400-e29b-41d4-a716-446655440001", + "projectId": "770e8400-e29b-41d4-a716-446655440002", + "title": "malware.exe — 2026-06-08T15:30:00Z", + "chatModel": "gemma4:12b", + "messageCount": 0, + "createdAt": "2026-06-08T15:30:00Z", + "updatedAt": "2026-06-08T15:30:00Z" +} +``` + +**Erros**: + +| Código | Condição | +|---|---| +| `404` | Binário não encontrado | +| `400` | Modelo não suportado (ex: `?model=invalid-model`) | + +**Exemplo curl**: + +```bash +curl -X POST "http://localhost:8080/binaries/{binaryId}/chat/sessions?model=deepseek-chat" +``` + +--- + +## 2. Listar Sessões + +### `GET /binaries/{binaryId}/chat/sessions` + +Lista todas as sessões de chat de um binário, ordenadas por data de atualização (mais recente primeiro). + +**Response** (`200 OK`): + +```json +[ + { + "id": "550e8400-...", + "binaryId": "660e8400-...", + "projectId": "770e8400-...", + "title": "Analisando funções de rede", + "chatModel": "deepseek-chat", + "messageCount": 12, + "createdAt": "2026-06-08T15:30:00Z", + "updatedAt": "2026-06-08T16:45:00Z" + }, + { + "id": "550e8401-...", + "binaryId": "660e8400-...", + "projectId": "770e8400-...", + "title": "Investigando crypto", + "chatModel": "gemma4:12b", + "messageCount": 5, + "createdAt": "2026-06-08T14:00:00Z", + "updatedAt": "2026-06-08T14:20:00Z" + } +] +``` + +**Exemplo curl**: + +```bash +curl "http://localhost:8080/binaries/{binaryId}/chat/sessions" +``` + +--- + +## 3. Detalhes da Sessão + +### `GET /chat/sessions/{sessionId}` + +Retorna os detalhes de uma sessão específica. + +**Response** (`200 OK`): + +```json +{ + "id": "550e8400-...", + "binaryId": "660e8400-...", + "projectId": "770e8400-...", + "title": "Analisando funções de rede", + "chatModel": "deepseek-chat", + "messageCount": 12, + "createdAt": "2026-06-08T15:30:00Z", + "updatedAt": "2026-06-08T16:45:00Z" +} +``` + +**Erros**: `404` se sessão não encontrada. + +--- + +## 4. Deletar Sessão + +### `DELETE /chat/sessions/{sessionId}` + +Deleta a sessão e todas as suas mensagens (cascade). + +**Response**: `204 No Content` + +**Exemplo curl**: + +```bash +curl -X DELETE "http://localhost:8080/chat/sessions/{sessionId}" +``` + +--- + +## 5. Histórico de Mensagens + +### `GET /chat/sessions/{sessionId}/messages` + +Retorna o histórico de mensagens da sessão, ordenado cronologicamente (mais antigo primeiro). + +**Response** (`200 OK`): + +```json +[ + { + "id": "aa0e8400-...", + "sessionId": "550e8400-...", + "role": "USER", + "content": "Quais funções lidam com rede?", + "tokenCount": 12, + "createdAt": "2026-06-08T15:30:30Z" + }, + { + "id": "bb0e8400-...", + "sessionId": "550e8400-...", + "role": "ASSISTANT", + "content": "O binário importa funções de rede da WS2_32.dll. As funções sub_401200 e sub_401300 utilizam send() e recv()...", + "tokenCount": 85, + "createdAt": "2026-06-08T15:30:35Z" + } +] +``` + +**Erros**: `404` se sessão não encontrada. + +--- + +## 6. Enviar Mensagem (SSE Streaming) + +### `POST /chat/sessions/{sessionId}/messages` + +Envia uma mensagem do usuário e retorna a resposta da LLM em streaming via Server-Sent Events (SSE). + +**Headers**: + +``` +Content-Type: application/json +Accept: text/event-stream +``` + +**Request Body**: + +```json +{ + "content": "Quais funções nesse binário lidam com criptografia?" +} +``` + +**Response**: `200 OK` com `Content-Type: text/event-stream` + +### Protocolo SSE + +O stream consiste em uma sequência de eventos `data:`: + +#### Evento: `chunk` + +Fragmento de texto da resposta, enviado assim que gerado pela LLM: + +``` +data: {"type":"chunk","content":"O binário"} + +data: {"type":"chunk","content":" utiliza "} + +data: {"type":"chunk","content":"as seguintes"} + +data: {"type":"chunk","content":" funções"} + +data: {"type":"chunk","content":" relacionadas"} + +data: {"type":"chunk","content":" a criptografia:"} + +data: {"type":"chunk","content":"\n\n1. **sub_405000**"} + +data: {"type":"chunk","content":" em 0x405000"} + +... +``` + +#### Evento: `done` + +Indica o fim do stream, com metadados da mensagem: + +``` +data: {"type":"done","messageId":"cc0e8400-e29b-...","sessionId":"550e8400-...","tokenCount":142} +``` + +#### Evento: `error` + +Indica um erro durante o processamento: + +``` +data: {"type":"error","code":"EMBEDDING_FAILED","message":"Failed to generate embedding: Ollama connection refused"} +``` + +Após um evento `error`, a conexão é fechada. Nenhum evento `done` é enviado. + +### Tipos de Evento SSE + +| `type` | Descrição | Campos | +|---|---|---| +| `chunk` | Fragmento de texto da resposta | `content: String` | +| `done` | Stream concluído com sucesso | `messageId: UUID`, `sessionId: UUID`, `tokenCount: Integer` | +| `error` | Erro durante o processamento | `code: String`, `message: String` | + +### Fluxo Interno Durante o SSE + +``` +1. Salva UserMessage (role=USER) no banco +2. Embedda a query do usuário (qwen3-embedding) +3. Busca similaridade no pgvector (top-K chunks) +4. (Opcional) Expande call graph +5. Constrói system prompt com contexto RAG +6. Monta lista de mensagens: [SystemPrompt, history, UserMessage] +7. Chama StreamingChatClient da LLM +8. Para cada token gerado: envia SSE "chunk" +9. Ao finalizar: salva AssistantMessage (role=ASSISTANT) no banco +10. Envia SSE "done" com messageId + tokenCount +11. Se erro em qualquer passo: envia SSE "error" e fecha conexão +``` + +### Exemplo curl (com streaming) + +```bash +curl -X POST "http://localhost:8080/chat/sessions/{sessionId}/messages" \ + -H "Content-Type: application/json" \ + -H "Accept: text/event-stream" \ + -d '{"content": "Explique a função sub_401000"}' +``` + +### Implementação no Controller + +```java +@PostMapping(value = "/chat/sessions/{sessionId}/messages", + produces = MediaType.TEXT_EVENT_STREAM_VALUE) +@Operation(summary = "Send a message and stream the AI response", + tags = {"Chat"}) +public Flux> sendMessage( + @PathVariable UUID sessionId, + @Valid @RequestBody ChatRequest request) { + + return chatService.chat(sessionId, request.content()) + .map(token -> ServerSentEvent.builder() + .data("{\"type\":\"chunk\",\"content\":\"" + escapeJson(token) + "\"}") + .build()) + .concatWith(chatService.finalizeMessage(sessionId) + .map(msg -> ServerSentEvent.builder() + .data("{\"type\":\"done\",\"messageId\":\"" + msg.id() + "\"," + + "\"sessionId\":\"" + sessionId + "\"," + + "\"tokenCount\":" + msg.tokenCount() + "}") + .build())) + .onErrorResume(e -> Flux.just( + ServerSentEvent.builder() + .data("{\"type\":\"error\",\"code\":\"INTERNAL\"," + + "\"message\":\"" + escapeJson(e.getMessage()) + "\"}") + .build())); +} +``` + +--- + +## DTOs + +### `ChatRequest` + +```java +public record ChatRequest( + @NotBlank @Size(min = 1, max = 4000) + String content +) {} +``` + +### `ChatResponse` (evento `done`) + +```java +public record ChatResponse( + UUID messageId, + UUID sessionId, + int tokenCount +) {} +``` + +### `SessionResponse` + +```java +public record SessionResponse( + UUID id, + UUID binaryId, + UUID projectId, + String title, + String chatModel, + int messageCount, + Instant createdAt, + Instant updatedAt +) {} +``` + +### `MessageResponse` + +```java +public record MessageResponse( + UUID id, + UUID sessionId, + String role, + String content, + Integer tokenCount, + Instant createdAt +) {} +``` + +### `StreamChunk` (evento SSE `chunk`) + +```java +public record StreamChunk( + String type, // "chunk", "done", "error" + String content // null para "done" e "error" +) {} +``` + +--- + +## Tratamento de Erros + +| Código HTTP | `type` SSE | Condição | +|---|---|---| +| `400` | — | Request body inválido (content vazio, >4000 chars) | +| `404` | — | Sessão não encontrada | +| `404` | — | Binário não encontrado | +| `503` | `error` | Ollama indisponível (embeddings ou chat) | +| `503` | `error` | DeepSeek API indisponível | +| `500` | `error` | Erro interno (pgvector, RabbitMQ, etc.) | +| `402` | `error` | DeepSeek API: saldo insuficiente / quota excedida | + +### Resposta de erro padrão (não-SSE) + +```json +{ + "status": 404, + "message": "Chat session with id '550e8400-...' not found", + "timestamp": "2026-06-08T15:30:00Z" +} +``` + +--- + +## OpenAPI / Swagger + +Todos os endpoints são documentados com `@Operation` e aparecem no Swagger UI (`/swagger-ui.html`). + +**Tag**: `Chat` + +**Schemas**: +- `ChatRequest` +- `SessionResponse` +- `MessageResponse` +- `StreamChunk` (não documentado como schema, pois é SSE) diff --git a/docs/ai/architecture.md b/docs/ai/architecture.md new file mode 100644 index 0000000..f9e0658 --- /dev/null +++ b/docs/ai/architecture.md @@ -0,0 +1,281 @@ +# Arquitetura do Módulo de IA + +## Visão Geral + +O módulo `ai/` implementa um sistema de **Retrieval-Augmented Generation (RAG)** que permite ao usuário interagir com uma LLM através de um chat, fazendo perguntas sobre funções, estruturas, protocolos de rede e outros aspectos de um binário que passou por análise estática. + +O sistema combina **busca semântica via embeddings** (pgvector) com **navegação estruturada via call graph** (xrefs) para fornecer contexto relevante à LLM durante a geração de respostas. + +## Stack Tecnológica + +| Componente | Tecnologia | Papel | +|---|---|---| +| **Framework** | Spring Boot 4.0.6 + Spring Modulith 2.0.6 | Base da aplicação, injeção de dependências, eventos | +| **AI Framework** | Spring AI 1.0.x | Abstração de LLM providers, embeddings, chat streaming | +| **Chat Local** | Gemma 4 12B (via Ollama) | LLM para chat executando localmente, 256K contexto | +| **Chat Cloud** | DeepSeek (`deepseek-chat`) | LLM alternativa via API cloud (OpenAI-compatible) | +| **Embeddings** | Qwen3 Embedding 8B (via Ollama) | Geração de embeddings para busca semântica, dims 32–4096 | +| **Vector Store** | pgvector (PostgreSQL 18) | Armazenamento e busca por similaridade de cosseno | +| **Vector Index** | HNSW (Hierarchical Navigable Small World) | Índice para busca vetorial rápida | +| **Message Broker** | RabbitMQ 4 | Processamento assíncrono de jobs de embedding | +| **Arquitetura** | Spring Modulith | Módulo desacoplado com eventos de domínio | + +## Diagrama de Componentes + +``` +┌──────────────────────────────────────────────────────────────────────────┐ +│ ai.decompile.ai (Módulo AI) │ +│ │ +│ ┌──────────────────────┐ ┌──────────────────────┐ │ +│ │ EmbeddingService │ │ ChatService │ │ +│ │ │ │ │ │ +│ │ • embed(text) │ │ • createSession() │ │ +│ │ • embedBatch() │ │ • chat(sessionId, │ │ +│ │ • indexBinary(id) │ │ message) │ │ +│ │ • searchSimilar() │ │ • getHistory() │ │ +│ │ • deleteByBinary() │ │ • deleteSession() │ │ +│ └────────┬─────────────┘ └──────────┬───────────┘ │ +│ │ │ │ +│ ┌────────┴─────────────────────────────┴──────────────────┐ │ +│ │ RagService │ │ +│ │ │ │ +│ │ • retrieveContext(binaryId, query) → List │ │ +│ │ • buildSystemPrompt(chunks, binaryMeta) → String │ │ +│ │ • expandCallGraph(functionId, hops) → List │ │ +│ │ • deduplicateAndRank(results) → List │ │ +│ └────────┬──────────────────────────────────────────────────┘ │ +│ │ │ +│ ┌────────┴────────────────┬──────────────────────────────┐ │ +│ │ EmbeddingChunkRepo │ ChatSessionRepo │ │ +│ │ • findSimilar(vector, │ • findByBinaryId() │ │ +│ │ binaryId, topK) │ • findById() │ │ +│ │ • deleteByBinaryId() │ │ │ +│ │ │ ChatMessageRepo │ │ +│ │ │ • findBySessionId() │ │ +│ └──────────────────────────┴──────────────────────────────┘ │ +│ │ +│ ┌──────────────────────────────────────────────────────────┐ │ +│ │ ChatController (REST + SSE) │ │ +│ │ │ │ +│ │ POST /binaries/{id}/chat/sessions — criar sessão │ │ +│ │ GET /binaries/{id}/chat/sessions — listar sessões │ │ +│ │ GET /chat/sessions/{id}/messages — histórico │ │ +│ │ POST /chat/sessions/{id}/messages — enviar (SSE) │ │ +│ │ DELETE /chat/sessions/{id} — deletar sessão │ │ +│ └──────────────────────────────────────────────────────────┘ │ +└──────────────────────────────────────────────────────────────────────────┘ + +┌──────────────────────────────────────────────────────────────────────────┐ +│ Infraestrutura Externa │ +│ │ +│ ┌──────────────┐ ┌──────────────┐ ┌──────────────┐ │ +│ │ Ollama │ │ DeepSeek │ │ PostgreSQL │ │ +│ │ (localhost) │ │ (Cloud API) │ │ (pgvector) │ │ +│ │ │ │ │ │ │ │ +│ │ qwen3-embed │ │ deepseek- │ │ embedding_ │ │ +│ │ gemma4:12b │ │ chat │ │ chunk │ │ +│ └──────────────┘ └──────────────┘ │ chat_session │ │ +│ │ chat_message │ │ +│ ┌──────────────┐ └──────────────┘ │ +│ │ RabbitMQ │ │ +│ │ │ │ +│ │ decompile. │ │ +│ │ jobs.queue │ │ +│ └──────────────┘ │ +└──────────────────────────────────────────────────────────────────────────┘ +``` + +## Decisões Arquiteturais + +### 1. Uso do Spring AI como Camada de Abstração + +**Decisão**: Utilizar Spring AI 1.0.x como camada de abstração para providers de LLM e embeddings. + +**Justificativa**: +- Fornece interfaces padronizadas (`ChatClient`, `StreamingChatClient`, `EmbeddingModel`) +- Suporte nativo a streaming via `Flux` (Reactor) +- Auto-configuração para Ollama e OpenAI (DeepSeek é compatível com API da OpenAI) +- Integração com ecossistema Spring Boot + +**Trade-off**: Spring AI é relativamente novo e pode ter breaking changes entre versões. Mitigamos encapsulando os chamados diretos atrás dos nossos serviços (`EmbeddingService`, `ChatService`). + +### 2. Embedding Store Próprio (não Spring AI VectorStore) + +**Decisão**: Implementar repositório pgvector próprio (`EmbeddingChunkRepository`) em vez de usar `spring-ai-pgvector-store`. + +**Justificativa**: +- Schema altamente específico ao domínio (FK para `binary_id`, `source_id` para `static_function.id`) +- Necessidade de campos de metadados customizados em `jsonb` +- Semântica de deleção específica (hard delete por `binary_id` na re-indexação) +- Suporte a múltiplas estratégias de retrieval: busca semântica (pgvector `<=>`), busca textual (ILIKE fallback), call graph expansion + +### 2.1 Hybrid Retrieval + +**Decisão**: Pipeline de retrieval com três camadas: semântica → textual fallback → call graph. + +**Fluxo**: +1. **Semântica**: pgvector cosine similarity (top-K) +2. **Fallback**: se < 40% do topK preenchido, busca textual via `LIKE` nos chunks +3. **Call Graph**: detecta funções/endereços na query, expande 1-hop via xrefs, inclui assembly inline + +**Justificativa**: +- Queries com nomes específicos de função (`sub_f5`, `0x401000`) frequentemente falham na busca semântica (nome curto, sem contexto) +- O fallback textual captura menções diretas ao nome da função nos chunks +- A expansão por call graph garante que a função requisitada SEMPRE apareça no contexto, mesmo sem embedding + +### 3. Indexação Assíncrona via Job Separado + +**Decisão**: Geração de embeddings como job assíncrono (`GENERATE_EMBEDDINGS`) via RabbitMQ, desacoplado do job de análise estática. + +**Justificativa**: +- Análise estática e embedding são operações com perfis de falha diferentes +- Permite re-indexar sem re-analisar (ex: trocar modelo de embedding) +- Permite retry independente se a API de embedding falhar +- Segue o padrão arquitetural já estabelecido (ANALYZE_FILE, STATIC_ANALYSIS) + +### 4. Multi-Provider com Seleção por Sessão + +**Decisão**: Suporte a múltiplos providers de LLM (Ollama/Gemma4 + DeepSeek) com seleção no momento da criação da sessão. + +**Justificativa**: +- Usuário pode escolher entre privacidade/local (Ollama) ou qualidade/cloud (DeepSeek) +- Providers são "pluggable" — adicionar novo provider requer apenas implementar a interface e registrar +- A seleção é persistida na `chat_session.chat_model` e usada em todas as mensagens daquela sessão + +### 5. Streaming via Server-Sent Events (SSE) + +**Decisão**: Respostas do chat utilizam SSE para streaming de tokens. + +**Justificativa**: +- UX muito melhor que esperar resposta completa +- Nativo do Spring WebMVC / Reactor (`Flux>`) +- Compatível com `StreamingChatClient` do Spring AI +- Fácil de consumir no frontend (`EventSource` API) + +### 6. Chunking por Função (não por tamanho fixo) + +**Decisão**: Cada função estática vira um chunk de embedding. Funções muito grandes são truncadas em `chunk-max-chars` (default 3000). + +**Justificativa**: +- Alinha com a unidade semântica natural da análise de binários (a função) +- Permite que metadados de call graph (callers/callees) enriqueçam cada chunk +- Facilita a expansão via navegação de xrefs (saber exatamente qual função foi recuperada) +- Simplifica o tracking (`source_type=STATIC_FUNCTION`, `source_id=function.id`) + +## Módulos Spring Modulith + +### Dependências do módulo `ai/` + +``` +ai/ + ├── allowedDependencies: + │ ├── workspace::entities (Binary, Project para relações FK) + │ ├── analysis::entities (StaticFunction, StaticAnalysis, etc.) + │ ├── analysis::services (AnalysisService para buscar funções/xrefs) + │ └── common (exceções, configurações compartilhadas) +``` + +### Dependências do módulo `job/` (atualizado) + +``` +job/ + ├── allowedDependencies (novos): + │ ├── ... + │ ├── ai::services (EmbeddingService para o handler de embeddings) + │ ├── ai::dto (tipos de dados do módulo AI) + │ └── ai::events (eventos publicados pelo módulo AI) +``` + +### Dependências do módulo `analysis/` (atualizado) + +``` +analysis/ + ├── allowedDependencies (novos): + │ ├── ... + │ └── ai::events (para publicar StaticAnalysisCompletedEvent) + └── NOTA: o evento será definido no analysis/event para evitar dependência circular +``` + +## Fluxo de Eventos Inter-Módulos + +``` +workspace analysis job ai + │ │ │ │ + │ BinaryUp- │ │ │ + │ loadedEvent───┤ │ │ + │ │ │ │ + │ │ StaticAnalysisRequestedEvent │ + │ ├───────────────►│ │ + │ │ │ STATIC_ │ + │ │ │ ANALYSIS │ + │ │ │ (handler) │ + │ │ │ │ + │ │◄───────────────┤ saveResult() │ + │ │ │ │ + │ │ StaticAnalysisCompletedEvent │ + │ ├────────────────────────────────►│ + │ │ │ │ + │ │ │ GENERATE_ │ + │ │ │ EMBEDDINGS │ + │ │ │ (handler)────►│ + │ │ │ │ + │ │ │◄───────────────│ + │ │ │ indexBinary() │ +``` + +> **Nota**: O `StaticAnalysisCompletedEvent` será definido em `analysis.event` e o subscriber residirá em `job.service` (padrão consistente com `StaticAnalysisRequestedEvent` + `StaticAnalysisEventSubscriber`). + +## Configuração Multi-Provider + +O módulo suporta dois providers de chat e um provider de embeddings, todos abstraídos pelo Spring AI: + +``` + ┌─────────────────────┐ + │ ChatClientProvider │ (interface própria) + │ │ + │ getClient(model)─────┤ + └──────────┬──────────┘ + │ + ┌───────────────┼───────────────┐ + │ │ + ┌──────────┴──────────┐ ┌──────────────┴──────────┐ + │ DeepSeekChatClient │ │ OllamaChatClient │ + │ (OpenAiChatModel) │ │ (OllamaChatModel) │ + │ │ │ │ + │ base-url: │ │ base-url: │ + │ api.deepseek.com │ │ localhost:11434 │ + │ model: deepseek-chat│ │ model: gemma4:12b │ + └─────────────────────┘ └─────────────────────────┘ + + ┌─────────────────────┐ + │ EmbeddingModel │ (Spring AI interface) + │ │ + │ embed(docs) │ + └──────────┬──────────┘ + │ + ┌──────────┴──────────┐ + │ OllamaEmbeddingModel │ + │ │ + │ base-url: │ + │ localhost:11434 │ + │ model: qwen3-embedding│ + └──────────────────────┘ +``` + +## Considerações de Performance + +| Aspecto | Estratégia | +|---|---| +| **Embedding em batch** | Funções enviadas em lotes de 20 (`app.ai.embedding.batch-size`) | +| **Busca vetorial** | Índice HNSW com `vector_cosine_ops` — sub-100ms para milhares de vetores | +| **Contexto da LLM** | Truncagem de chunks + histórico para caber em `max-context-tokens` (8000 default) | +| **Streaming** | Tokens fluem via SSE assim que gerados, sem buffer completo | +| **Conexões Ollama** | Pool de conexões HTTP gerenciado pelo Spring AI | +| **Virtual Threads** | Habilitados (`spring.threads.virtual.enabled: true`) — I/O de rede não bloqueia | + +## Segurança + +- API keys (DeepSeek) armazenadas exclusivamente em `.env`, nunca no repositório +- `.env` listado no `.gitignore` +- Nenhuma chave ou segredo em plaintext no `application.yml` +- Variáveis de ambiente referenciadas como `${DEEPSEEK_API_KEY}` com fallback vazio diff --git a/docs/ai/configuration.md b/docs/ai/configuration.md new file mode 100644 index 0000000..07da206 --- /dev/null +++ b/docs/ai/configuration.md @@ -0,0 +1,438 @@ +# Guia de Configuração — Módulo AI + +## Pré-requisitos + +| Componente | Como obter | Status | +|---|---|---| +| **Ollama** (host) | `curl -fsSL https://ollama.com/install.sh \| sh` | Local, necessário | +| **qwen3-embedding** | `ollama pull qwen3-embedding:latest` | Modelo de embedding | +| **gemma4:12b** | `ollama pull gemma4:12b` | LLM para chat local | +| **DeepSeek API key** | [platform.deepseek.com](https://platform.deepseek.com) | LLM cloud (opcional) | +| **PostgreSQL + pgvector** | `docker compose up -d postgres` | Já configurado | +| **RabbitMQ** | `docker compose up -d rabbit-mq` | Já configurado | + +## 1. Setup do Ollama (Host) + +### Instalação + +```bash +# Linux +curl -fsSL https://ollama.com/install.sh | sh + +# Verificar se está rodando +ollama --version +curl http://localhost:11434/api/tags +``` + +### Download dos Modelos + +```bash +# Embedding (4.7 GB) +ollama pull qwen3-embedding:latest + +# Chat local (7.6 GB) +ollama pull gemma4:12b + +# Alternativa menor para máquinas com pouca RAM (9.6 GB, MoE) +# ollama pull gemma4:e4b + +# Verificar modelos instalados +ollama list +``` + +**Hardware recomendado**: + +| Modelo | RAM mínima | RAM recomendada | GPU VRAM (opcional) | +|---|---|---|---| +| `qwen3-embedding:latest` (8B) | 8 GB | 16 GB | 6 GB | +| `gemma4:12b` | 10 GB | 16 GB | 8 GB | +| `gemma4:e4b` | 6 GB | 12 GB | 6 GB | + +### Configuração do Ollama + +Por padrão, o Ollama escuta em `localhost:11434`. Se precisar acessar de outro host: + +```bash +# Editar /etc/systemd/system/ollama.service ou usar env var +export OLLAMA_HOST=0.0.0.0:11434 +``` + +Para otimizar performance com GPU (se disponível): + +```bash +# Verificar se GPU está sendo usada +ollama run gemma4:12b --verbose +# Saída: "total duration: ..." — procure por "eval rate: XX tokens/s" +``` + +## 2. Arquivo `.env` + +Criar na raiz do projeto (`backend/.env`): + +```bash +# ============================================================ +# Decompile-AI — Environment Variables +# ============================================================ + +# --- LLM Providers --- + +# DeepSeek API Key (obter em https://platform.deepseek.com) +DEEPSEEK_API_KEY=sk-your-api-key-here + +# --- Override de modelos (opcional) --- + +# Provedor padrão de chat: ollama | deepseek +AI_DEFAULT_CHAT_PROVIDER=ollama + +# Modelo de chat Ollama (padrão: gemma4:12b) +# AI_CHAT_MODEL_OLLAMA=gemma4:12b + +# Modelo de chat DeepSeek (padrão: deepseek-chat) +# AI_CHAT_MODEL_DEEPSEEK=deepseek-chat + +# Modelo de embedding (padrão: qwen3-embedding:latest) +# AI_EMBEDDING_MODEL=qwen3-embedding:latest + +# Dimensão do embedding (padrão: 1024, max 4096 para qwen3) +# AI_EMBEDDING_DIMENSION=1024 + +# --- Database --- + +# Sobrescrever credenciais do Postgres se necessário +# SPRING_DATASOURCE_URL=jdbc:postgresql://localhost:5432/decompile_ai +# SPRING_DATASOURCE_USERNAME=decompile_ai +# SPRING_DATASOURCE_PASSWORD=decompile_ai +``` + +**Importante**: Adicione `.env` ao `.gitignore`: + +```bash +echo ".env" >> .gitignore +``` + +## 3. Carregamento do `.env` + +O Spring Boot precisa saber como carregar o `.env`. Opções: + +### Opção A: `spring.config.import` (nativo Spring Boot) + +Adicionar ao `application.yml`: + +```yaml +spring: + config: + import: optional:file:.env[.properties] +``` + +Isso carrega o `.env` como um arquivo de propriedades. **Limitação**: não suporta substituição de variáveis inline como `${VAR}` dentro de outras variáveis no `.env`, mas funciona bem para definir valores. + +### Opção B: Plugin `dotenv` (recomendado para desenvolvimento) + +Adicionar dependência ao `pom.xml`: + +```xml + + me.paulschwarz + spring-dotenv + 4.0.0 + +``` + +Suporta interpolação de variáveis e convenção `.env` padrão. + +### Opção C: Export manual (desenvolvimento) + +```bash +set -a && source .env && set +a +./mvnw spring-boot:run +``` + +## 4. `application.yml` — Configuração do Módulo AI + +### Blocos a adicionar ao arquivo existente + +```yaml +spring: + # ... configurações existentes (datasource, jpa, flyway, rabbitmq) ... + + ai: + ollama: + base-url: http://localhost:11434 + embedding: + options: + model: ${AI_EMBEDDING_MODEL:qwen3-embedding:latest} + chat: + options: + model: ${AI_CHAT_MODEL_OLLAMA:gemma4:12b} + temperature: 0.3 # respostas determinísticas para análise técnica + top-p: 0.95 + top-k: 64 + openai: + base-url: https://api.deepseek.com + api-key: ${DEEPSEEK_API_KEY} + chat: + options: + model: ${AI_CHAT_MODEL_DEEPSEEK:deepseek-chat} + temperature: 0.3 + max-tokens: 4096 + +app: + # ... configurações existentes (storage, rabbitmq, docker, die, engines) ... + + ai: + # Provedor padrão de chat quando a sessão não especifica modelo + default-chat-provider: ${AI_DEFAULT_CHAT_PROVIDER:ollama} + + # --- Configurações de Embedding --- + embedding: + # Dimensão do vetor (qwen3-embedding suporta 32-4096) + # DEVE corresponder à coluna vector(N) na migration V008 + dimension: ${AI_EMBEDDING_DIMENSION:1024} + # Tamanho máximo do chunk em caracteres + chunk-max-chars: 3000 + # Quantas funções processar por batch na API de embedding + batch-size: 20 + + # --- Configurações de RAG --- + rag: + # Quantos chunks retornar na busca semântica + top-k: 10 + # Similaridade mínima para incluir um chunk (0.0 a 1.0) + similarity-threshold: 0.6 + # Máximo de tokens de contexto enviados à LLM + max-context-tokens: 8000 + + # --- Configurações de Chat --- + chat: + # Quantas mensagens de histórico incluir no prompt + max-history-messages: 20 + # Temperatura padrão para geração + temperature: 0.3 +``` + +### Configuração completa do `application.yml` (após merge) + +```yaml +spring: + threads: + virtual: + enabled: true + application: + name: decompile-ai + config: + import: optional:file:.env[.properties] + datasource: + url: jdbc:postgresql://localhost:5432/decompile_ai + username: decompile_ai + password: decompile_ai + jpa: + hibernate: + ddl-auto: validate + flyway: + enabled: true + rabbitmq: + host: localhost + port: 5672 + username: guest + password: guest + servlet: + multipart: + max-file-size: 500MB + max-request-size: 500MB + ai: + ollama: + base-url: http://localhost:11434 + embedding: + options: + model: ${AI_EMBEDDING_MODEL:qwen3-embedding:latest} + chat: + options: + model: ${AI_CHAT_MODEL_OLLAMA:gemma4:12b} + temperature: 0.3 + top-p: 0.95 + top-k: 64 + openai: + base-url: https://api.deepseek.com + api-key: ${DEEPSEEK_API_KEY} + chat: + options: + model: ${AI_CHAT_MODEL_DEEPSEEK:deepseek-chat} + temperature: 0.3 + max-tokens: 4096 + +app: + storage: + upload-dir: ./uploads/binaries + rabbitmq: + exchange: decompile.jobs + queue: decompile.jobs.queue + routing-key: decompile.jobs.created + docker: + enabled: true + die: + image: decompile-ai/diec:latest + timeout-seconds: 60 + docker-host: unix:///var/run/docker.sock + engines: + ida5: + image: decompile-ai/ida5:latest + timeout-seconds: 300 + ghidra: + image: decompile-ai/ghidra:latest + timeout-seconds: 600 + ai: + default-chat-provider: ${AI_DEFAULT_CHAT_PROVIDER:ollama} + embedding: + dimension: ${AI_EMBEDDING_DIMENSION:1024} + chunk-max-chars: 3000 + batch-size: 20 + rag: + top-k: 10 + similarity-threshold: 0.6 + max-context-tokens: 8000 + chat: + max-history-messages: 20 + temperature: 0.3 + +logging: + level: + org.springframework.modulith: DEBUG + ai.decompile.ai: DEBUG # Logs do módulo AI + org.springframework.ai: INFO # Logs do Spring AI +``` + +## 5. `docker-compose.yml` (sem alterações) + +O `docker-compose.yml` atual já atende todos os requisitos: + +```yaml +services: + postgres: + container_name: decompile-ai-postgres + image: pgvector/pgvector:pg18 # PostgreSQL 18 + pgvector nativo + environment: + - POSTGRES_USER=decompile_ai + - POSTGRES_PASSWORD=decompile_ai + - POSTGRES_DB=decompile_ai + ports: + - "5432:5432" + volumes: + - postgres:/var/lib/postgresql + networks: + - decompile-ai-network + + rabbit-mq: + container_name: decompile-ai-rabbitmq + image: rabbitmq:4-management + ports: + - "5672:5672" + - "15672:15672" + networks: + - decompile-ai-network + +networks: + decompile-ai-network: + driver: bridge + +volumes: + postgres: +``` + +**Não adicionamos Ollama ao docker-compose** porque ele roda no host. + +## 6. Startup + +### Ordem de inicialização + +```bash +# 1. Infraestrutura Docker +docker compose up -d postgres rabbit-mq + +# 2. Verificar Ollama no host +ollama list +# Deve mostrar: qwen3-embedding:latest, gemma4:12b + +# 3. Configurar .env +cp .env.example .env +# Editar com a DEEPSEEK_API_KEY + +# 4. Iniciar o backend +./mvnw spring-boot:run + +# 5. Verificar health +curl http://localhost:8080/actuator/health +``` + +### Verificação de saúde dos componentes AI + +```bash +# Ollama +curl http://localhost:11434/api/tags | jq '.models[].name' + +# Testar embedding +curl http://localhost:11434/api/embed -d '{ + "model": "qwen3-embedding:latest", + "input": "test" +}' | jq '.embeddings[0] | length' +# Deve retornar 1024 (ou dimensão configurada) + +# Testar chat local +curl http://localhost:11434/api/chat -d '{ + "model": "gemma4:12b", + "messages": [{"role": "user", "content": "Hello"}], + "stream": false +}' | jq '.message.content' +``` + +## 7. Troubleshooting + +### Ollama não responde + +```bash +# Verificar se está rodando +systemctl status ollama +# ou +ps aux | grep ollama + +# Reiniciar se necessário +systemctl restart ollama +``` + +### Erro: "model not found" + +```bash +# Listar modelos disponíveis +ollama list + +# Baixar modelo faltante +ollama pull qwen3-embedding:latest +ollama pull gemma4:12b +``` + +### Erro: "DeepSeek API key not configured" + +- Verificar se `.env` existe e contém `DEEPSEEK_API_KEY=sk-...` +- Verificar se `spring.config.import: optional:file:.env[.properties]` está no `application.yml` +- Alternativa: `export DEEPSEEK_API_KEY=sk-...` no terminal + +### Erro: "relation 'vector_store' does not exist" + +- Verificar se Flyway rodou a migration V008: `SELECT * FROM flyway_schema_history;` +- Rodar manualmente se necessário: `./mvnw flyway:migrate` + +### Erro: "column 'embedding' type vector not supported" + +- Verificar se a extensão pgvector está ativada: `SELECT * FROM pg_extension WHERE extname = 'vector';` +- Ativar manualmente: `CREATE EXTENSION IF NOT EXISTS vector;` + +### Performance lenta nos embeddings + +- Reduzir `batch-size` no `application.yml` +- Verificar se o Ollama está usando GPU: `ollama run qwen3-embedding --verbose` e checar `eval rate` +- Sem GPU, o modelo 8B pode ser lento para centenas de funções (esperar 2-5 minutos) + +### Contexto da LLM estourando + +- Reduzir `max-context-tokens` em `app.ai.rag` +- Reduzir `max-history-messages` em `app.ai.chat` +- Aumentar `chunk-max-chars` (para incluir menos chunks maiores) diff --git a/docs/ai/data-model.md b/docs/ai/data-model.md new file mode 100644 index 0000000..dd8f87f --- /dev/null +++ b/docs/ai/data-model.md @@ -0,0 +1,369 @@ +# Modelo de Dados — Módulo AI + +## Diagrama Entidade-Relacionamento + +``` +┌─────────────────────────────────────────────────────────────┐ +│ TABELAS EXISTENTES │ +│ │ +│ binaries ──────┬── static_analysis ──┬── static_function │ +│ (workspace) │ ├── static_label │ +│ │ └── static_xref │ +│ │ │ +│ └── jobs │ +└─────────────────────────────────────────────────────────────┘ + │ + │ FK: binary_id + ▼ +┌─────────────────────────────────────────────────────────────┐ +│ NOVAS TABELAS (V008) │ +│ │ +│ embedding_chunk ──── chat_session ──── chat_message │ +│ │ │ │ +│ │ │ FK: binary_id → binaries │ +│ │ │ FK: project_id → projects │ +│ │ │ │ +│ │ FK: binary_id │ │ +│ │ → binaries │ │ +│ │ │ │ +│ │ FK: source_id │ │ +│ │ → opcional │ │ +│ │ (static_func) │ │ +└─────────────────────────────────────────────────────────────┘ +``` + +## Migration V008 — DDL Completo + +```sql +-- V008__ai_embedding_and_chat.sql + +-- Garantir que a extensão pgvector está ativada +CREATE EXTENSION IF NOT EXISTS vector; + +-- ============================================================ +-- Tabela: embedding_chunk +-- Armazena chunks de texto embeddados para busca semântica +-- ============================================================ +CREATE TABLE embedding_chunk ( + id UUID PRIMARY KEY, + + -- Referência ao binário (obrigatória) + binary_id UUID NOT NULL REFERENCES binaries(id) ON DELETE CASCADE, + + -- Tipo de chunk: FUNCTION, METADATA + chunk_type VARCHAR(30) NOT NULL, + + -- Tipo da fonte: STATIC_FUNCTION, IMPORT, STRING (extensível) + source_type VARCHAR(30), + + -- FK opcional para a entidade fonte (ex: static_function.id) + source_id UUID, + + -- Texto completo que foi embeddado + content TEXT NOT NULL, + + -- Vetor de embedding (dimensão configurável, default 4096) + -- qwen3-embedding:latest (8B) retorna 4096 dimensões + embedding vector(4096), + + -- Metadados flexíveis em JSON: + -- { + -- "function_name": "sub_401000", + -- "address": "0x401000", + -- "callers": ["WinMain", "sub_402300"], + -- "callees": ["CreateFileA", "WriteFile"], + -- "label_count": 2 + -- } + metadata JSONB, + + -- Contagem estimada de tokens (para controle de contexto) + token_count INTEGER, + + -- Timestamp de criação + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +-- Índice para busca por binário (filtro de escopo) +CREATE INDEX idx_embedding_chunk_binary ON embedding_chunk(binary_id); + +-- Índice para lookup por fonte (ex: achar chunk de uma função específica) +CREATE INDEX idx_embedding_chunk_source ON embedding_chunk(source_type, source_id); + +-- Índice de busca textual (fallback para queries específicas) +CREATE INDEX idx_embedding_chunk_content ON embedding_chunk + USING gin (content gin_trgm_ops); + +-- NOTA: Não há índice vetorial. HNSW e IVFFlat do pgvector limitados a 2000d. +-- Sequential scan é rápido para < 10K vetores de 4096d. + +-- ============================================================ +-- Tabela: chat_session +-- Representa uma sessão de conversa com o chat IA +-- ============================================================ +CREATE TABLE chat_session ( + id UUID PRIMARY KEY, + + -- Binário em análise (escopo da conversa) + binary_id UUID NOT NULL REFERENCES binaries(id) ON DELETE CASCADE, + + -- Projeto pai (para navegação/breadcrumb) + project_id UUID NOT NULL REFERENCES projects(id) ON DELETE CASCADE, + + -- Título da sessão (ex: "Analisando funções de rede") + title VARCHAR(255), + + -- Modelo de chat usado nesta sessão + -- Ex: "gemma4:12b", "deepseek-chat", "gemma4:e4b" + chat_model VARCHAR(100) NOT NULL, + + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +CREATE INDEX idx_chat_session_binary ON chat_session(binary_id); + +-- ============================================================ +-- Tabela: chat_message +-- Mensagens individuais dentro de uma sessão +-- ============================================================ +CREATE TABLE chat_message ( + id UUID PRIMARY KEY, + + -- Sessão pai + session_id UUID NOT NULL REFERENCES chat_session(id) ON DELETE CASCADE, + + -- Papel do emissor: USER, ASSISTANT, SYSTEM + role VARCHAR(20) NOT NULL, + + -- Conteúdo textual da mensagem + content TEXT NOT NULL, + + -- Estimativa de tokens (para tracking de uso) + token_count INTEGER, + + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +-- Índice para buscar histórico em ordem cronológica +CREATE INDEX idx_chat_message_session ON chat_message(session_id, created_at); +``` + +## Entidades JPA + +### `EmbeddingChunk` + +```java +@Entity +@Table(name = "embedding_chunk") +@Getter @Setter @NoArgsConstructor @AllArgsConstructor @Builder +public class EmbeddingChunk { + + @Id + @GeneratedValue(strategy = GenerationType.UUID) + private UUID id; + + @Column(name = "binary_id", nullable = false) + private UUID binaryId; + + @Column(name = "chunk_type", nullable = false, length = 30) + private String chunkType; + // Valores: "FUNCTION", "METADATA" + + @Column(name = "source_type", length = 30) + private String sourceType; + // Valores: "STATIC_FUNCTION", null (para METADATA) + + @Column(name = "source_id") + private UUID sourceId; + // FK lógica para static_function.id (sem constraint de FK no banco + // porque pode ser null ou apontar para tabelas diferentes no futuro) + + @Column(nullable = false, columnDefinition = "TEXT") + private String content; + + @JdbcTypeCode(SqlTypes.VECTOR) + @Column(name = "embedding", columnDefinition = "vector(1024)") + private float[] embedding; + + @JdbcTypeCode(SqlTypes.JSON) + @Column(name = "metadata", columnDefinition = "jsonb") + private String metadata; + // JSON string: {"function_name":"...", "address":"...", "callers":[...], ...} + + @Column(name = "token_count") + private Integer tokenCount; + + @CreationTimestamp + @Column(name = "created_at", nullable = false, updatable = false) + private Instant createdAt; +} +``` + +**Notas sobre o mapeamento**: +- `embedding` usa `@JdbcTypeCode(SqlTypes.VECTOR)` — requer Hibernate 6.2+ (Spring Boot 4.0.6 inclui) +- `metadata` é `String` com `@JdbcTypeCode(SqlTypes.JSON)` — Hibernate converte de/para JSONB automaticamente +- `sourceId` é FK lógica (não constraint no banco) para permitir flexibilidade futura (apontar para outras tabelas além de `static_function`) + +### `ChatSession` + +```java +@Entity +@Table(name = "chat_session") +@Getter @Setter @NoArgsConstructor @AllArgsConstructor @Builder +public class ChatSession { + + @Id + @GeneratedValue(strategy = GenerationType.UUID) + private UUID id; + + @Column(name = "binary_id", nullable = false) + private UUID binaryId; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "binary_id", insertable = false, updatable = false) + private Binary binary; + + @Column(name = "project_id", nullable = false) + private UUID projectId; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "project_id", insertable = false, updatable = false) + private Project project; + + @Column(length = 255) + private String title; + + @Column(name = "chat_model", nullable = false, length = 100) + private String chatModel; + + @OneToMany(mappedBy = "session", cascade = CascadeType.ALL, orphanRemoval = true) + @Builder.Default + private List messages = new ArrayList<>(); + + @CreationTimestamp + @Column(name = "created_at", nullable = false, updatable = false) + private Instant createdAt; + + @UpdateTimestamp + @Column(name = "updated_at", nullable = false) + private Instant updatedAt; +} +``` + +### `ChatMessage` + +```java +@Entity +@Table(name = "chat_message") +@Getter @Setter @NoArgsConstructor @AllArgsConstructor @Builder +public class ChatMessage { + + @Id + @GeneratedValue(strategy = GenerationType.UUID) + private UUID id; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "session_id", nullable = false) + private ChatSession session; + + @Column(nullable = false, length = 20) + private String role; // "USER", "ASSISTANT", "SYSTEM" + + @Column(nullable = false, columnDefinition = "TEXT") + private String content; + + @Column(name = "token_count") + private Integer tokenCount; + + @CreationTimestamp + @Column(name = "created_at", nullable = false, updatable = false) + private Instant createdAt; +} +``` + +## Relacionamentos + +``` +chat_session (1) ────────── (N) chat_message + │ │ + │ FK binary_id → binaries │ FK session_id → chat_session + │ FK project_id → projects │ + │ │ + └── cascade: ALL, orphanRemoval └── cascade: ALL, orphanRemoval + (deletar sessão deleta (deletar sessão deleta + todas as mensagens) todas as mensagens) + +embedding_chunk (N) ────────── (1) binaries + │ + │ FK binary_id → binaries + │ ON DELETE CASCADE (deletar binário deleta todos os chunks) + │ + └── source_id: FK lógica para static_function.id + (não é constraint; usado para join manual) +``` + +## Índices e Performance + +### Índices + +| Índice | Coluna(s) | Uso | +|---|---|---| +| `idx_embedding_chunk_binary` | `binary_id` | Filtrar chunks por binário (todas as queries) | +| `idx_embedding_chunk_source` | `source_type, source_id` | Lookup por fonte (ex: achar chunk de uma função específica) | +| `idx_embedding_chunk_content` | `content` (GIN trgm) | Busca textual de fallback (`ILIKE '%term%'`) | +| `idx_chat_session_binary` | `binary_id` | Listar sessões de um binário | +| `idx_chat_message_session` | `session_id, created_at` | Buscar histórico em ordem cronológica | + +> **Performance**: Sem índice vetorial (HNSW/IVFFlat limitados a 2000d). Para < 10K vetores de 4096d, sequential scan < 100ms. + +## Coluna `metadata` (JSONB) + +O campo `metadata` armazena informações estruturadas que não fazem parte do texto embeddado mas são úteis para filtragem e exibição: + +```json +{ + "function_name": "sub_401000", + "address": "0x401000", + "callers": ["WinMain", "sub_402300"], + "callees": ["CreateFileA", "WriteFile", "CloseHandle"], + "label_count": 2, + "assembly_length": 1247, + "has_decompiled_code": false +} +``` + +Para chunks do tipo `METADATA`: + +```json +{ + "filename": "malware.exe", + "format": "PE32", + "architecture": "x86", + "compiler": "MSVC 19.0", + "function_count": 342 +} +``` + +Vantagens do JSONB sobre colunas separadas: +- Flexível: adicionar campos sem migration +- Indexável: `CREATE INDEX ... ON embedding_chunk USING gin (metadata jsonb_path_ops)` se necessário +- Permite queries como: `SELECT * FROM embedding_chunk WHERE metadata->>'function_name' = 'WinMain'` + +## Políticas de Deleção + +| Operação | Comportamento | +|---|---| +| Deletar binário | `ON DELETE CASCADE` → deleta todos os `embedding_chunk`, `chat_session` e `chat_message` | +| Deletar projeto | `ON DELETE CASCADE` via binário → cascata completa | +| Re-analisar binário | Hard delete de `embedding_chunk` via `deleteByBinaryId()` antes de reindexar | +| Deletar sessão | `orphanRemoval = true` → deleta todas as `chat_message` | + +## Relação com o Schema Existente + +A migration V008 é aditiva — não altera nenhuma tabela existente. As novas tabelas referenciam: +- `binaries(id)` — FK com CASCADE +- `projects(id)` — FK com CASCADE + +O `source_id` em `embedding_chunk` referencia logicamente `static_function.id`, mas **sem constraint de FK** para: +- Permitir valores nulos (chunk METADATA não tem source) +- Permitir referências futuras a outras tabelas (ex: tabela de strings, tabela de imports) diff --git a/docs/ai/frontend-guide.md b/docs/ai/frontend-guide.md new file mode 100644 index 0000000..f8311cf --- /dev/null +++ b/docs/ai/frontend-guide.md @@ -0,0 +1,671 @@ +# Guia de Implementação Frontend — Chat IA (RAG) + +## Visão Geral + +Este documento descreve como integrar o frontend com a API de chat IA do Decompile-AI. O fluxo consiste em: + +1. Após upload + análise estática de um binário, embeddings são gerados automaticamente +2. O usuário cria uma sessão de chat vinculada ao binário +3. Mensagens são enviadas e a resposta da LLM é recebida via **Server-Sent Events (SSE)** +4. O backend faz RAG automaticamente: busca contexto relevante no binário e injeta no prompt + +## Stack Recomendada (Frontend) + +| Tecnologia | Propósito | +|---|---| +| **EventSource** ou **fetch + ReadableStream** | Consumir SSE stream | +| **React** (ou framework similar) | UI components | +| **AbortController** | Cancelar stream em andamento | +| **@microsoft/fetch-event-source** | Lib opcional para SSE com POST + cancelamento | + +--- + +## 1. Endpoints da API + +### 1.1 Criar Sessão de Chat + +``` +POST /binaries/{binaryId}/chat/sessions?projectId={projectId}&model={model} +``` + +**Query Params**: + +| Parâmetro | Tipo | Obrigatório | Descrição | +|---|---|---|---| +| `projectId` | UUID | SIM | ID do projeto pai | +| `model` | String | Não | Modelo LLM (`gemma4:12b` padrão) | + +**Response** (`201 Created`): + +```json +{ + "id": "550e8400-e29b-41d4-a716-446655440000", + "binaryId": "660e8400-e29b-41d4-a716-446655440001", + "projectId": "770e8400-e29b-41d4-a716-446655440002", + "title": "malware.exe — Chat", + "chatModel": "gemma4:12b", + "messageCount": 0, + "createdAt": "2026-06-08T15:30:00Z", + "updatedAt": "2026-06-08T15:30:00Z" +} +``` + +**Erros**: + +| Código | Significado | +|---|---| +| `404` | Binário/projeto não encontrado | +| `400` | Modelo indisponível (Ollama não está rodando) | +| `400` | `projectId` ausente | + +### 1.2 Listar Sessões do Binário + +``` +GET /binaries/{binaryId}/chat/sessions +``` + +**Response** (`200 OK`): + +```json +[ + { + "id": "550e8400-...", + "binaryId": "660e8400-...", + "projectId": "770e8400-...", + "title": "malware.exe — Chat", + "chatModel": "gemma4:12b", + "messageCount": 5, + "createdAt": "2026-06-08T15:30:00Z", + "updatedAt": "2026-06-08T16:00:00Z" + } +] +``` + +Ordenado por `updatedAt` decrescente (sessão mais recente primeiro). + +### 1.3 Ver Detalhes da Sessão + +``` +GET /chat/sessions/{sessionId} +``` + +Mesmo formato de resposta que o item 1.1. + +### 1.4 Deletar Sessão + +``` +DELETE /chat/sessions/{sessionId} +``` + +**Response**: `204 No Content`. + +> Deletar a sessão remove **todas** as mensagens associadas (cascade). + +### 1.5 Histórico de Mensagens + +``` +GET /chat/sessions/{sessionId}/messages +``` + +**Response** (`200 OK`): + +```json +[ + { + "id": "aa0e8400-...", + "sessionId": "550e8400-...", + "role": "USER", + "content": "Quais funções lidam com rede?", + "tokenCount": 12, + "createdAt": "2026-06-08T15:30:30Z" + }, + { + "id": "bb0e8400-...", + "sessionId": "550e8400-...", + "role": "ASSISTANT", + "content": "O binário importa funções de rede da WS2_32.dll...", + "tokenCount": 85, + "createdAt": "2026-06-08T15:30:35Z" + } +] +``` + +Ordenado por `createdAt` crescente (mais antigo primeiro). + +### 1.6 Enviar Mensagem (SSE Streaming) + +``` +POST /chat/sessions/{sessionId}/messages +Content-Type: application/json +Accept: text/event-stream +``` + +**Request Body**: + +```json +{ + "content": "Quais funções nesse binário lidam com criptografia?" +} +``` + +| Campo | Tipo | Validação | +|---|---|---| +| `content` | String | 1–4000 caracteres, obrigatório | + +**Response**: `200 OK` com `Content-Type: text/event-stream` + +--- + +## 2. Protocolo SSE (Server-Sent Events) + +O stream é uma sequência de eventos `data:` enviados ao longo da conexão HTTP. + +### 2.1 Tipos de Evento + +| `type` | Quando | Campos | +|---|---|---| +| `chunk` | Token de texto gerado pela LLM | `content: String` | +| `done` | Stream concluído com sucesso | `sessionId: UUID` | +| `error` | Erro durante o processamento | `message: String` | + +### 2.2 Exemplo de Stream + +``` +data: {"type":"chunk","content":"O binário"} + +data: {"type":"chunk","content":" utiliza "} + +data: {"type":"chunk","content":"as seguintes"} + +data: {"type":"chunk","content":" funções"} + +data: {"type":"chunk","content":" para criptografia:"} + +data: {"type":"chunk","content":"\n\n1. **sub_405000**"} + +... + +data: {"type":"done","sessionId":"550e8400-e29b-..."} +``` + +### 2.3 Evento de Erro + +``` +data: {"type":"error","message":"Ollama is not available. Install ollama and pull gemma4:12b."} +``` + +Após um evento `error`, a conexão é fechada. Nenhum evento `done` é enviado. + +### 2.4 Notas sobre SSE + +- A conexão é mantida aberta até o stream terminar (`done` ou `error`) +- Tokens podem chegar com latência variável (a LLM gera em tempo real) +- Cada evento `data:` contém uma linha JSON válida +- O frontend deve acumular os `chunk.content` para montar a resposta completa +- O backend **já salva** a resposta completa no banco ao final — o frontend não precisa reenviar + +--- + +## 3. Fluxo de Implementação + +### 3.1 Diagrama de Sequência + +``` +Frontend Backend Ollama + │ │ │ + │ POST /chat/sessions (criar) │ │ + ├────────────────────────────────►│ │ + │◄────────────────────────────────┤ sessionId │ + │ │ │ + │ POST /chat/sessions/{id}/messages (SSE) │ + ├────────────────────────────────►│ │ + │ │ embed query │ + │ ├──────────────────────────────►│ + │ │◄──────────────────────────────┤ + │ │ │ + │ │ pgvector search (RAG) │ + │ │ build system prompt │ + │ │ │ + │ │ stream LLM │ + │ ├──────────────────────────────►│ + │◄──── data: {"type":"chunk"...}──┤◄──────────────────────────────┤ + │◄──── data: {"type":"chunk"...}──┤◄──────────────────────────────┤ + │◄──── data: {"type":"chunk"...}──┤◄──────────────────────────────┤ + │◄──── data: {"type":"done"...}───┤ │ + │ │ │ + │ │ save assistant message │ + │ │ │ + │ GET /chat/sessions/{id}/messages (refresh) │ + ├────────────────────────────────►│ │ + │◄────────────────────────────────┤ full history │ +``` + +### 3.2 Estrutura de Componentes (React) + +``` +BinaryDetailPage + ├── ChatPanel ← componente principal do chat + │ ├── ChatSessionList ← barra lateral: lista de sessões + │ │ ├── ChatSessionItem ← cada sessão (título, modelo, msg count) + │ │ └── NewSessionButton ← botão "Nova conversa" + │ │ + │ └── ChatWindow ← área principal de conversa + │ ├── ChatMessageList ← scroll area com mensagens + │ │ ├── UserMessage ← bolha do usuário + │ │ └── AssistantMessage ← bolha do assistente (com markdown) + │ │ + │ ├── StreamingMessage ← mensagem sendo gerada (cursor piscando) + │ └── ChatInput ← textarea + botão enviar +``` + +### 3.3 Exemplo de Hook: `useChatSession` + +```typescript +interface ChatSession { + id: string; + binaryId: string; + projectId: string; + title: string; + chatModel: string; + messageCount: number; + createdAt: string; + updatedAt: string; +} + +interface ChatMessage { + id: string; + sessionId: string; + role: 'USER' | 'ASSISTANT'; + content: string; + tokenCount: number; + createdAt: string; +} + +function useChatSession(binaryId: string, projectId: string) { + const [sessions, setSessions] = useState([]); + const [activeSessionId, setActiveSessionId] = useState(null); + const [messages, setMessages] = useState([]); + const [streamingContent, setStreamingContent] = useState(''); + const [isStreaming, setIsStreaming] = useState(false); + const abortRef = useRef(null); + + // Listar sessões + const loadSessions = async () => { + const res = await fetch(`/binaries/${binaryId}/chat/sessions`); + const data = await res.json(); + setSessions(data); + }; + + // Criar nova sessão + const createSession = async (model: string = 'gemma4:12b') => { + const res = await fetch( + `/binaries/${binaryId}/chat/sessions?projectId=${projectId}&model=${model}`, + { method: 'POST' } + ); + const session = await res.json(); + setSessions(prev => [session, ...prev]); + setActiveSessionId(session.id); + setMessages([]); + return session; + }; + + // Carregar histórico + const loadMessages = async (sessionId: string) => { + const res = await fetch(`/chat/sessions/${sessionId}/messages`); + const data = await res.json(); + setMessages(data); + }; + + // Enviar mensagem (SSE streaming) + const sendMessage = async (content: string) => { + if (!activeSessionId) return; + + // Adiciona mensagem do usuário ao estado local (otimista) + const userMsg: ChatMessage = { + id: crypto.randomUUID(), + sessionId: activeSessionId, + role: 'USER', + content, + tokenCount: 0, + createdAt: new Date().toISOString(), + }; + setMessages(prev => [...prev, userMsg]); + setStreamingContent(''); + setIsStreaming(true); + + const controller = new AbortController(); + abortRef.current = controller; + + try { + const response = await fetch( + `/chat/sessions/${activeSessionId}/messages`, + { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'Accept': 'text/event-stream', + }, + body: JSON.stringify({ content }), + signal: controller.signal, + } + ); + + const reader = response.body!.getReader(); + const decoder = new TextDecoder(); + let fullContent = ''; + let buffer = ''; + + while (true) { + const { done, value } = await reader.read(); + if (done) break; + + buffer += decoder.decode(value, { stream: true }); + const lines = buffer.split('\n'); + buffer = lines.pop() || ''; + + for (const line of lines) { + if (!line.startsWith('data: ')) continue; + const event = JSON.parse(line.slice(6)); + + if (event.type === 'chunk') { + fullContent += event.content; + setStreamingContent(fullContent); + } else if (event.type === 'done') { + setIsStreaming(false); + // Recarrega histórico real para pegar IDs do backend + await loadMessages(activeSessionId); + } else if (event.type === 'error') { + setIsStreaming(false); + throw new Error(event.message); + } + } + } + } catch (err: any) { + if (err.name !== 'AbortError') { + setIsStreaming(false); + console.error('Chat error:', err); + } + } + }; + + // Cancelar stream + const cancelStream = () => { + abortRef.current?.abort(); + setIsStreaming(false); + }; + + // Deletar sessão + const deleteSession = async (sessionId: string) => { + await fetch(`/chat/sessions/${sessionId}`, { method: 'DELETE' }); + setSessions(prev => prev.filter(s => s.id !== sessionId)); + if (activeSessionId === sessionId) { + setActiveSessionId(null); + setMessages([]); + } + }; + + // Selecionar sessão + const selectSession = async (sessionId: string) => { + setActiveSessionId(sessionId); + await loadMessages(sessionId); + }; + + return { + sessions, + activeSessionId, + messages, + streamingContent, + isStreaming, + loadSessions, + createSession, + selectSession, + sendMessage, + cancelStream, + deleteSession, + }; +} +``` + +### 3.4 Componente de Chat (React) + +```tsx +function ChatWindow({ + messages, + streamingContent, + isStreaming, + onSend, + onCancel, +}: { + messages: ChatMessage[]; + streamingContent: string; + isStreaming: boolean; + onSend: (content: string) => void; + onCancel: () => void; +}) { + const [input, setInput] = useState(''); + const scrollRef = useRef(null); + + useEffect(() => { + scrollRef.current?.scrollIntoView({ behavior: 'smooth' }); + }, [messages, streamingContent]); + + const handleSubmit = (e: React.FormEvent) => { + e.preventDefault(); + if (!input.trim() || isStreaming) return; + onSend(input.trim()); + setInput(''); + }; + + return ( +
+
+ {messages.map(msg => ( + + ))} + {isStreaming && ( + + )} +
+
+ +
+