feat(ai): add AI chat and RAG embedding module
Core AI module with chat sessions, message history, pgvector embeddings, semantic search via RAG, and Ollama integration for chat/embedding models. Includes: - Spring AI Ollama dependency (BOM 1.0.1) - Entity model: ChatSession, ChatMessage, EmbeddingChunk - Custom PostgreSQLVectorJdbcType for pgvector mapping - Services: ChatService, EmbeddingService, ChatContextBuilder - ChatController with SSE streaming endpoint - Flyway migration V008 (pgvector extension + tables) - AI configuration in application.yml
This commit is contained in:
parent
199345d497
commit
d01ed613fb
21
.env.example
Normal file
21
.env.example
Normal file
@ -0,0 +1,21 @@
|
|||||||
|
# ============================================================
|
||||||
|
# Decompile-AI — Environment Variables
|
||||||
|
# ============================================================
|
||||||
|
# Copy this file to .env and fill in your values.
|
||||||
|
# .env is git-ignored and never committed.
|
||||||
|
|
||||||
|
# --- LLM Provider ---
|
||||||
|
|
||||||
|
# Default chat provider (ollama)
|
||||||
|
AI_DEFAULT_CHAT_PROVIDER=ollama
|
||||||
|
|
||||||
|
# --- Model overrides (optional) ---
|
||||||
|
|
||||||
|
# Ollama chat model (default: gemma4:12b)
|
||||||
|
# AI_CHAT_MODEL_OLLAMA=gemma4:12b
|
||||||
|
|
||||||
|
# Ollama embedding model (default: qwen3-embedding:latest)
|
||||||
|
# AI_EMBEDDING_MODEL=qwen3-embedding:latest
|
||||||
|
|
||||||
|
# Embedding vector dimension (default: 4096)
|
||||||
|
# AI_EMBEDDING_DIMENSION=4096
|
||||||
63
pom.xml
63
pom.xml
@ -26,10 +26,11 @@
|
|||||||
<tag/>
|
<tag/>
|
||||||
<url/>
|
<url/>
|
||||||
</scm>
|
</scm>
|
||||||
<properties>
|
<properties>
|
||||||
<java.version>25</java.version>
|
<java.version>25</java.version>
|
||||||
<spring-modulith.version>2.0.6</spring-modulith.version>
|
<spring-modulith.version>2.0.6</spring-modulith.version>
|
||||||
</properties>
|
<spring-ai.version>1.0.1</spring-ai.version>
|
||||||
|
</properties>
|
||||||
<dependencies>
|
<dependencies>
|
||||||
<dependency>
|
<dependency>
|
||||||
<groupId>org.springframework.boot</groupId>
|
<groupId>org.springframework.boot</groupId>
|
||||||
@ -67,7 +68,6 @@
|
|||||||
<dependency>
|
<dependency>
|
||||||
<groupId>org.postgresql</groupId>
|
<groupId>org.postgresql</groupId>
|
||||||
<artifactId>postgresql</artifactId>
|
<artifactId>postgresql</artifactId>
|
||||||
<scope>runtime</scope>
|
|
||||||
</dependency>
|
</dependency>
|
||||||
<dependency>
|
<dependency>
|
||||||
<groupId>org.springframework.modulith</groupId>
|
<groupId>org.springframework.modulith</groupId>
|
||||||
@ -135,25 +135,42 @@
|
|||||||
<artifactId>docker-java-transport-httpclient5</artifactId>
|
<artifactId>docker-java-transport-httpclient5</artifactId>
|
||||||
<version>3.7.1</version>
|
<version>3.7.1</version>
|
||||||
</dependency>
|
</dependency>
|
||||||
<dependency>
|
<dependency>
|
||||||
<groupId>org.apache.commons</groupId>
|
<groupId>org.apache.commons</groupId>
|
||||||
<artifactId>commons-lang3</artifactId>
|
<artifactId>commons-lang3</artifactId>
|
||||||
<version>3.20.0</version>
|
<version>3.20.0</version>
|
||||||
<scope>compile</scope>
|
<scope>compile</scope>
|
||||||
</dependency>
|
</dependency>
|
||||||
</dependencies>
|
<dependency>
|
||||||
|
<groupId>org.springframework.ai</groupId>
|
||||||
|
<artifactId>spring-ai-ollama</artifactId>
|
||||||
|
<exclusions>
|
||||||
|
<exclusion>
|
||||||
|
<groupId>io.swagger.core.v3</groupId>
|
||||||
|
<artifactId>swagger-annotations-jakarta</artifactId>
|
||||||
|
</exclusion>
|
||||||
|
</exclusions>
|
||||||
|
</dependency>
|
||||||
|
</dependencies>
|
||||||
|
|
||||||
<dependencyManagement>
|
<dependencyManagement>
|
||||||
<dependencies>
|
<dependencies>
|
||||||
<dependency>
|
<dependency>
|
||||||
<groupId>org.springframework.modulith</groupId>
|
<groupId>org.springframework.modulith</groupId>
|
||||||
<artifactId>spring-modulith-bom</artifactId>
|
<artifactId>spring-modulith-bom</artifactId>
|
||||||
<version>${spring-modulith.version}</version>
|
<version>${spring-modulith.version}</version>
|
||||||
<type>pom</type>
|
<type>pom</type>
|
||||||
<scope>import</scope>
|
<scope>import</scope>
|
||||||
</dependency>
|
</dependency>
|
||||||
</dependencies>
|
<dependency>
|
||||||
</dependencyManagement>
|
<groupId>org.springframework.ai</groupId>
|
||||||
|
<artifactId>spring-ai-bom</artifactId>
|
||||||
|
<version>${spring-ai.version}</version>
|
||||||
|
<type>pom</type>
|
||||||
|
<scope>import</scope>
|
||||||
|
</dependency>
|
||||||
|
</dependencies>
|
||||||
|
</dependencyManagement>
|
||||||
|
|
||||||
<build>
|
<build>
|
||||||
<plugins>
|
<plugins>
|
||||||
|
|||||||
61
src/main/java/ai/decompile/ai/config/AiConfig.java
Normal file
61
src/main/java/ai/decompile/ai/config/AiConfig.java
Normal file
@ -0,0 +1,61 @@
|
|||||||
|
package ai.decompile.ai.config;
|
||||||
|
|
||||||
|
import io.micrometer.observation.ObservationRegistry;
|
||||||
|
import org.springframework.ai.model.tool.ToolCallingManager;
|
||||||
|
import org.springframework.ai.ollama.OllamaChatModel;
|
||||||
|
import org.springframework.ai.ollama.OllamaEmbeddingModel;
|
||||||
|
import org.springframework.ai.ollama.api.OllamaApi;
|
||||||
|
import org.springframework.ai.ollama.api.OllamaOptions;
|
||||||
|
import org.springframework.ai.ollama.management.ModelManagementOptions;
|
||||||
|
import org.springframework.beans.factory.annotation.Value;
|
||||||
|
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
|
||||||
|
import org.springframework.context.annotation.Bean;
|
||||||
|
import org.springframework.context.annotation.Configuration;
|
||||||
|
import org.springframework.resilience.annotation.EnableResilientMethods;
|
||||||
|
|
||||||
|
@Configuration
|
||||||
|
@EnableResilientMethods
|
||||||
|
@ConditionalOnProperty(name = "app.ai.enabled", havingValue = "true", matchIfMissing = true)
|
||||||
|
public class AiConfig {
|
||||||
|
@Value("${spring.ai.ollama.base-url:http://localhost:11434}")
|
||||||
|
private String ollamaBaseUrl;
|
||||||
|
|
||||||
|
@Value("${spring.ai.ollama.chat.options.model:gemma4:12b}")
|
||||||
|
private String ollamaChatModelName;
|
||||||
|
|
||||||
|
@Value("${spring.ai.ollama.chat.options.temperature:0.3}")
|
||||||
|
private double ollamaTemperature;
|
||||||
|
|
||||||
|
@Value("${spring.ai.ollama.embedding.options.model:qwen3-embedding:latest}")
|
||||||
|
private String ollamaEmbeddingModel;
|
||||||
|
|
||||||
|
@Bean
|
||||||
|
OllamaApi ollamaApi() {
|
||||||
|
return OllamaApi.builder().baseUrl(ollamaBaseUrl).build();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Bean
|
||||||
|
OllamaChatModel ollamaChatModel(OllamaApi ollamaApi, ObservationRegistry observationRegistry) {
|
||||||
|
return OllamaChatModel.builder()
|
||||||
|
.ollamaApi(ollamaApi)
|
||||||
|
.defaultOptions(
|
||||||
|
OllamaOptions.builder()
|
||||||
|
.model(ollamaChatModelName)
|
||||||
|
.temperature(ollamaTemperature)
|
||||||
|
.build())
|
||||||
|
.toolCallingManager(ToolCallingManager.builder().build())
|
||||||
|
.observationRegistry(observationRegistry)
|
||||||
|
.build();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Bean
|
||||||
|
OllamaEmbeddingModel ollamaEmbeddingModel(
|
||||||
|
OllamaApi ollamaApi, ObservationRegistry observationRegistry) {
|
||||||
|
return OllamaEmbeddingModel.builder()
|
||||||
|
.ollamaApi(ollamaApi)
|
||||||
|
.defaultOptions(OllamaOptions.builder().model(ollamaEmbeddingModel).build())
|
||||||
|
.observationRegistry(observationRegistry)
|
||||||
|
.modelManagementOptions(ModelManagementOptions.defaults())
|
||||||
|
.build();
|
||||||
|
}
|
||||||
|
}
|
||||||
115
src/main/java/ai/decompile/ai/controller/ChatController.java
Normal file
115
src/main/java/ai/decompile/ai/controller/ChatController.java
Normal file
@ -0,0 +1,115 @@
|
|||||||
|
package ai.decompile.ai.controller;
|
||||||
|
|
||||||
|
import ai.decompile.ai.model.dto.ChatRequest;
|
||||||
|
import ai.decompile.ai.model.dto.MessageResponse;
|
||||||
|
import ai.decompile.ai.model.dto.SessionResponse;
|
||||||
|
import ai.decompile.ai.service.ChatService;
|
||||||
|
import ai.decompile.ai.service.ChatSessionService;
|
||||||
|
import com.fasterxml.jackson.core.JsonProcessingException;
|
||||||
|
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||||
|
import io.swagger.v3.oas.annotations.Hidden;
|
||||||
|
import io.swagger.v3.oas.annotations.Operation;
|
||||||
|
import io.swagger.v3.oas.annotations.tags.Tag;
|
||||||
|
import jakarta.validation.Valid;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Map;
|
||||||
|
import java.util.UUID;
|
||||||
|
import lombok.RequiredArgsConstructor;
|
||||||
|
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
|
||||||
|
import org.springframework.http.HttpStatus;
|
||||||
|
import org.springframework.http.MediaType;
|
||||||
|
import org.springframework.http.ResponseEntity;
|
||||||
|
import org.springframework.http.codec.ServerSentEvent;
|
||||||
|
import org.springframework.web.bind.annotation.DeleteMapping;
|
||||||
|
import org.springframework.web.bind.annotation.GetMapping;
|
||||||
|
import org.springframework.web.bind.annotation.PathVariable;
|
||||||
|
import org.springframework.web.bind.annotation.PostMapping;
|
||||||
|
import org.springframework.web.bind.annotation.RequestBody;
|
||||||
|
import org.springframework.web.bind.annotation.RequestParam;
|
||||||
|
import org.springframework.web.bind.annotation.RestController;
|
||||||
|
import reactor.core.publisher.Flux;
|
||||||
|
import reactor.core.publisher.Mono;
|
||||||
|
|
||||||
|
@RestController
|
||||||
|
@RequiredArgsConstructor
|
||||||
|
@ConditionalOnProperty(name = "app.ai.enabled", havingValue = "true", matchIfMissing = true)
|
||||||
|
@Tag(name = "Chat", description = "AI-powered chat for binary analysis")
|
||||||
|
public class ChatController {
|
||||||
|
private final ChatService chatService;
|
||||||
|
private final ChatSessionService sessionService;
|
||||||
|
private final ObjectMapper objectMapper;
|
||||||
|
|
||||||
|
@PostMapping("/binaries/{binaryId}/chat/sessions")
|
||||||
|
@Operation(summary = "Create a new chat session for a binary")
|
||||||
|
public ResponseEntity<SessionResponse> createSession(
|
||||||
|
@PathVariable UUID binaryId,
|
||||||
|
@RequestParam(required = false) UUID projectId,
|
||||||
|
@RequestParam(required = false, defaultValue = "gemma4:12b") String model) {
|
||||||
|
if (projectId == null) {
|
||||||
|
return ResponseEntity.badRequest().build();
|
||||||
|
}
|
||||||
|
var session = sessionService.createSession(binaryId, projectId, model);
|
||||||
|
return ResponseEntity.status(HttpStatus.CREATED).body(SessionResponse.from(session));
|
||||||
|
}
|
||||||
|
|
||||||
|
@GetMapping("/binaries/{binaryId}/chat/sessions")
|
||||||
|
@Operation(summary = "List chat sessions for a binary")
|
||||||
|
public ResponseEntity<List<SessionResponse>> listSessions(@PathVariable UUID binaryId) {
|
||||||
|
var sessions = sessionService.getSessions(binaryId);
|
||||||
|
return ResponseEntity.ok(sessions.stream().map(SessionResponse::from).toList());
|
||||||
|
}
|
||||||
|
|
||||||
|
@GetMapping("/chat/sessions/{sessionId}")
|
||||||
|
@Operation(summary = "Get chat session details")
|
||||||
|
public ResponseEntity<SessionResponse> getSession(@PathVariable UUID sessionId) {
|
||||||
|
var session = sessionService.getSession(sessionId);
|
||||||
|
return ResponseEntity.ok(SessionResponse.from(session));
|
||||||
|
}
|
||||||
|
|
||||||
|
@DeleteMapping("/chat/sessions/{sessionId}")
|
||||||
|
@Operation(summary = "Delete a chat session and all its messages")
|
||||||
|
public ResponseEntity<Void> deleteSession(@PathVariable UUID sessionId) {
|
||||||
|
sessionService.deleteSession(sessionId);
|
||||||
|
return ResponseEntity.noContent().build();
|
||||||
|
}
|
||||||
|
|
||||||
|
@GetMapping("/chat/sessions/{sessionId}/messages")
|
||||||
|
@Operation(summary = "Get message history for a chat session")
|
||||||
|
public ResponseEntity<List<MessageResponse>> getMessages(@PathVariable UUID sessionId) {
|
||||||
|
var messages = sessionService.getMessages(sessionId);
|
||||||
|
return ResponseEntity.ok(messages.stream().map(MessageResponse::from).toList());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Hidden
|
||||||
|
@PostMapping(
|
||||||
|
value = "/chat/sessions/{sessionId}/messages",
|
||||||
|
produces = MediaType.TEXT_EVENT_STREAM_VALUE)
|
||||||
|
public Flux<ServerSentEvent<String>> sendMessage(
|
||||||
|
@PathVariable UUID sessionId, @Valid @RequestBody ChatRequest request) {
|
||||||
|
|
||||||
|
return chatService
|
||||||
|
.chat(sessionId, request.content())
|
||||||
|
.map(token -> event("chunk", "content", token))
|
||||||
|
.concatWith(Mono.just(event("done", "sessionId", sessionId.toString())))
|
||||||
|
.onErrorResume(e -> Flux.just(errorEvent(e)));
|
||||||
|
}
|
||||||
|
|
||||||
|
private ServerSentEvent<String> event(String type, String key, Object value) {
|
||||||
|
return ServerSentEvent.<String>builder().data(toJson(Map.of("type", type, key, value))).build();
|
||||||
|
}
|
||||||
|
|
||||||
|
private ServerSentEvent<String> errorEvent(Throwable e) {
|
||||||
|
String msg = e.getMessage() != null ? e.getMessage() : "Unknown error";
|
||||||
|
return ServerSentEvent.<String>builder()
|
||||||
|
.data(toJson(Map.of("type", "error", "message", msg)))
|
||||||
|
.build();
|
||||||
|
}
|
||||||
|
|
||||||
|
private String toJson(Object obj) {
|
||||||
|
try {
|
||||||
|
return objectMapper.writeValueAsString(obj);
|
||||||
|
} catch (JsonProcessingException e) {
|
||||||
|
return "{\"type\":\"error\",\"message\":\"JSON serialization failed\"}";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1 @@
|
|||||||
|
package ai.decompile.ai.controller;
|
||||||
6
src/main/java/ai/decompile/ai/model/dto/ChatRequest.java
Normal file
6
src/main/java/ai/decompile/ai/model/dto/ChatRequest.java
Normal file
@ -0,0 +1,6 @@
|
|||||||
|
package ai.decompile.ai.model.dto;
|
||||||
|
|
||||||
|
import jakarta.validation.constraints.NotBlank;
|
||||||
|
import jakarta.validation.constraints.Size;
|
||||||
|
|
||||||
|
public record ChatRequest(@NotBlank @Size(min = 1, max = 4000) String content) {}
|
||||||
18
src/main/java/ai/decompile/ai/model/dto/MessageResponse.java
Normal file
18
src/main/java/ai/decompile/ai/model/dto/MessageResponse.java
Normal file
@ -0,0 +1,18 @@
|
|||||||
|
package ai.decompile.ai.model.dto;
|
||||||
|
|
||||||
|
import ai.decompile.ai.model.entity.ChatMessage;
|
||||||
|
import java.time.Instant;
|
||||||
|
import java.util.UUID;
|
||||||
|
|
||||||
|
public record MessageResponse(
|
||||||
|
UUID id, UUID sessionId, String role, String content, Integer tokenCount, Instant createdAt) {
|
||||||
|
public static MessageResponse from(ChatMessage message) {
|
||||||
|
return new MessageResponse(
|
||||||
|
message.getId(),
|
||||||
|
message.getSession().getId(),
|
||||||
|
message.getRole(),
|
||||||
|
message.getContent(),
|
||||||
|
message.getTokenCount(),
|
||||||
|
message.getCreatedAt());
|
||||||
|
}
|
||||||
|
}
|
||||||
27
src/main/java/ai/decompile/ai/model/dto/SessionResponse.java
Normal file
27
src/main/java/ai/decompile/ai/model/dto/SessionResponse.java
Normal file
@ -0,0 +1,27 @@
|
|||||||
|
package ai.decompile.ai.model.dto;
|
||||||
|
|
||||||
|
import ai.decompile.ai.model.entity.ChatSession;
|
||||||
|
import java.time.Instant;
|
||||||
|
import java.util.UUID;
|
||||||
|
|
||||||
|
public record SessionResponse(
|
||||||
|
UUID id,
|
||||||
|
UUID binaryId,
|
||||||
|
UUID projectId,
|
||||||
|
String title,
|
||||||
|
String chatModel,
|
||||||
|
int messageCount,
|
||||||
|
Instant createdAt,
|
||||||
|
Instant updatedAt) {
|
||||||
|
public static SessionResponse from(ChatSession session) {
|
||||||
|
return new SessionResponse(
|
||||||
|
session.getId(),
|
||||||
|
session.getBinaryId(),
|
||||||
|
session.getProjectId(),
|
||||||
|
session.getTitle(),
|
||||||
|
session.getChatModel(),
|
||||||
|
session.getMessages() != null ? session.getMessages().size() : 0,
|
||||||
|
session.getCreatedAt(),
|
||||||
|
session.getUpdatedAt());
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1 @@
|
|||||||
|
package ai.decompile.ai.model.dto;
|
||||||
37
src/main/java/ai/decompile/ai/model/entity/ChatMessage.java
Normal file
37
src/main/java/ai/decompile/ai/model/entity/ChatMessage.java
Normal file
@ -0,0 +1,37 @@
|
|||||||
|
package ai.decompile.ai.model.entity;
|
||||||
|
|
||||||
|
import jakarta.persistence.*;
|
||||||
|
import java.time.Instant;
|
||||||
|
import java.util.UUID;
|
||||||
|
import lombok.*;
|
||||||
|
import org.hibernate.annotations.CreationTimestamp;
|
||||||
|
|
||||||
|
@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;
|
||||||
|
|
||||||
|
@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;
|
||||||
|
}
|
||||||
60
src/main/java/ai/decompile/ai/model/entity/ChatSession.java
Normal file
60
src/main/java/ai/decompile/ai/model/entity/ChatSession.java
Normal file
@ -0,0 +1,60 @@
|
|||||||
|
package ai.decompile.ai.model.entity;
|
||||||
|
|
||||||
|
import ai.decompile.workspace.model.entity.Binary;
|
||||||
|
import ai.decompile.workspace.model.entity.Project;
|
||||||
|
import jakarta.persistence.*;
|
||||||
|
import java.time.Instant;
|
||||||
|
import java.util.ArrayList;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.UUID;
|
||||||
|
import lombok.*;
|
||||||
|
import org.hibernate.annotations.CreationTimestamp;
|
||||||
|
import org.hibernate.annotations.UpdateTimestamp;
|
||||||
|
|
||||||
|
@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 private String title;
|
||||||
|
|
||||||
|
@Column(name = "chat_model", nullable = false, length = 100)
|
||||||
|
private String chatModel;
|
||||||
|
|
||||||
|
@OneToMany(
|
||||||
|
mappedBy = "session",
|
||||||
|
cascade = CascadeType.ALL,
|
||||||
|
orphanRemoval = true,
|
||||||
|
fetch = FetchType.LAZY)
|
||||||
|
@Builder.Default
|
||||||
|
private List<ChatMessage> 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;
|
||||||
|
}
|
||||||
@ -0,0 +1,55 @@
|
|||||||
|
package ai.decompile.ai.model.entity;
|
||||||
|
|
||||||
|
import ai.decompile.ai.model.jdbc.PostgreSQLVectorJdbcType;
|
||||||
|
import jakarta.persistence.*;
|
||||||
|
import java.time.Instant;
|
||||||
|
import java.util.UUID;
|
||||||
|
import lombok.*;
|
||||||
|
import org.hibernate.annotations.CreationTimestamp;
|
||||||
|
import org.hibernate.annotations.JdbcTypeCode;
|
||||||
|
import org.hibernate.annotations.JdbcTypeRegistration;
|
||||||
|
import org.hibernate.type.SqlTypes;
|
||||||
|
|
||||||
|
@Entity
|
||||||
|
@Table(name = "embedding_chunk")
|
||||||
|
@Getter
|
||||||
|
@Setter
|
||||||
|
@NoArgsConstructor
|
||||||
|
@AllArgsConstructor
|
||||||
|
@Builder
|
||||||
|
@JdbcTypeRegistration(PostgreSQLVectorJdbcType.class)
|
||||||
|
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;
|
||||||
|
|
||||||
|
@Column(name = "source_type", length = 30)
|
||||||
|
private String sourceType;
|
||||||
|
|
||||||
|
@Column(name = "source_id")
|
||||||
|
private UUID sourceId;
|
||||||
|
|
||||||
|
@Column(nullable = false, columnDefinition = "TEXT")
|
||||||
|
private String content;
|
||||||
|
|
||||||
|
@JdbcTypeCode(SqlTypes.VECTOR)
|
||||||
|
@Column(name = "embedding", columnDefinition = "vector(4096)")
|
||||||
|
private float[] embedding;
|
||||||
|
|
||||||
|
@JdbcTypeCode(SqlTypes.JSON)
|
||||||
|
@Column(name = "metadata", columnDefinition = "jsonb")
|
||||||
|
private String metadata;
|
||||||
|
|
||||||
|
@Column(name = "token_count")
|
||||||
|
private Integer tokenCount;
|
||||||
|
|
||||||
|
@CreationTimestamp
|
||||||
|
@Column(name = "created_at", nullable = false, updatable = false)
|
||||||
|
private Instant createdAt;
|
||||||
|
}
|
||||||
@ -0,0 +1 @@
|
|||||||
|
package ai.decompile.ai.model.entity;
|
||||||
@ -0,0 +1,115 @@
|
|||||||
|
package ai.decompile.ai.model.jdbc;
|
||||||
|
|
||||||
|
import java.sql.CallableStatement;
|
||||||
|
import java.sql.PreparedStatement;
|
||||||
|
import java.sql.ResultSet;
|
||||||
|
import java.sql.SQLException;
|
||||||
|
import java.sql.Types;
|
||||||
|
import java.util.Objects;
|
||||||
|
import org.hibernate.type.SqlTypes;
|
||||||
|
import org.hibernate.type.descriptor.ValueBinder;
|
||||||
|
import org.hibernate.type.descriptor.ValueExtractor;
|
||||||
|
import org.hibernate.type.descriptor.WrapperOptions;
|
||||||
|
import org.hibernate.type.descriptor.java.JavaType;
|
||||||
|
import org.hibernate.type.descriptor.jdbc.BasicBinder;
|
||||||
|
import org.hibernate.type.descriptor.jdbc.BasicExtractor;
|
||||||
|
import org.hibernate.type.descriptor.jdbc.JdbcType;
|
||||||
|
import org.postgresql.util.PGobject;
|
||||||
|
|
||||||
|
public class PostgreSQLVectorJdbcType implements JdbcType {
|
||||||
|
@Override
|
||||||
|
public int getJdbcTypeCode() {
|
||||||
|
return Types.OTHER;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public int getDefaultSqlTypeCode() {
|
||||||
|
return SqlTypes.VECTOR;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public String toString() {
|
||||||
|
return "PostgreSQLVectorJdbcType";
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public <X> ValueBinder<X> getBinder(JavaType<X> javaType) {
|
||||||
|
return new BasicBinder<>(javaType, this) {
|
||||||
|
@Override
|
||||||
|
protected void doBind(PreparedStatement st, X value, int index, WrapperOptions options)
|
||||||
|
throws SQLException {
|
||||||
|
var pgObj = new PGobject();
|
||||||
|
pgObj.setType("vector");
|
||||||
|
pgObj.setValue(formatVector((float[]) value));
|
||||||
|
st.setObject(index, pgObj);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
protected void doBind(CallableStatement st, X value, String name, WrapperOptions options)
|
||||||
|
throws SQLException {
|
||||||
|
var pgObj = new PGobject();
|
||||||
|
pgObj.setType("vector");
|
||||||
|
pgObj.setValue(formatVector((float[]) value));
|
||||||
|
st.setObject(name, pgObj);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public <X> ValueExtractor<X> getExtractor(JavaType<X> javaType) {
|
||||||
|
return new BasicExtractor<>(javaType, this) {
|
||||||
|
@Override
|
||||||
|
protected X doExtract(ResultSet rs, int paramIndex, WrapperOptions options)
|
||||||
|
throws SQLException {
|
||||||
|
return javaType.wrap(parseVector(rs.getString(paramIndex)), options);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
protected X doExtract(CallableStatement statement, int index, WrapperOptions options)
|
||||||
|
throws SQLException {
|
||||||
|
return javaType.wrap(parseVector(statement.getString(index)), options);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
protected X doExtract(CallableStatement statement, String name, WrapperOptions options)
|
||||||
|
throws SQLException {
|
||||||
|
return javaType.wrap(parseVector(statement.getString(name)), options);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
static float[] parseVector(String vectorStr) {
|
||||||
|
if (Objects.isNull(vectorStr) || vectorStr.isBlank()) {
|
||||||
|
return new float[0];
|
||||||
|
}
|
||||||
|
|
||||||
|
var trimmed = vectorStr.trim();
|
||||||
|
|
||||||
|
if (trimmed.startsWith("[") && trimmed.endsWith("]")) {
|
||||||
|
trimmed = trimmed.substring(1, trimmed.length() - 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
var parts = trimmed.split(",");
|
||||||
|
var result = new float[parts.length];
|
||||||
|
for (int i = 0; i < parts.length; i++) {
|
||||||
|
result[i] = Float.parseFloat(parts[i].trim());
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
static String formatVector(float[] vector) {
|
||||||
|
if (Objects.isNull(vector) || vector.length == 0) {
|
||||||
|
return "[]";
|
||||||
|
}
|
||||||
|
|
||||||
|
var sb = new StringBuilder("[");
|
||||||
|
for (int i = 0; i < vector.length; i++) {
|
||||||
|
if (i > 0) {
|
||||||
|
sb.append(",");
|
||||||
|
}
|
||||||
|
|
||||||
|
sb.append(vector[i]);
|
||||||
|
}
|
||||||
|
return sb.append("]").toString();
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,10 @@
|
|||||||
|
package ai.decompile.ai.model.repository;
|
||||||
|
|
||||||
|
import ai.decompile.ai.model.entity.ChatMessage;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.UUID;
|
||||||
|
import org.springframework.data.jpa.repository.JpaRepository;
|
||||||
|
|
||||||
|
public interface ChatMessageRepository extends JpaRepository<ChatMessage, UUID> {
|
||||||
|
List<ChatMessage> findBySessionIdOrderByCreatedAtAsc(UUID sessionId);
|
||||||
|
}
|
||||||
@ -0,0 +1,10 @@
|
|||||||
|
package ai.decompile.ai.model.repository;
|
||||||
|
|
||||||
|
import ai.decompile.ai.model.entity.ChatSession;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.UUID;
|
||||||
|
import org.springframework.data.jpa.repository.JpaRepository;
|
||||||
|
|
||||||
|
public interface ChatSessionRepository extends JpaRepository<ChatSession, UUID> {
|
||||||
|
List<ChatSession> findByBinaryIdOrderByUpdatedAtDesc(UUID binaryId);
|
||||||
|
}
|
||||||
@ -0,0 +1,65 @@
|
|||||||
|
package ai.decompile.ai.model.repository;
|
||||||
|
|
||||||
|
import ai.decompile.ai.model.entity.EmbeddingChunk;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.UUID;
|
||||||
|
import org.springframework.data.jpa.repository.JpaRepository;
|
||||||
|
import org.springframework.data.jpa.repository.Modifying;
|
||||||
|
import org.springframework.data.jpa.repository.Query;
|
||||||
|
import org.springframework.data.repository.query.Param;
|
||||||
|
import org.springframework.transaction.annotation.Transactional;
|
||||||
|
|
||||||
|
public interface EmbeddingChunkRepository extends JpaRepository<EmbeddingChunk, UUID> {
|
||||||
|
@Query(
|
||||||
|
value =
|
||||||
|
"""
|
||||||
|
SELECT * FROM embedding_chunk
|
||||||
|
WHERE binary_id = :binaryId
|
||||||
|
AND 1 - (embedding <=> CAST(:queryEmbedding AS vector)) > :threshold
|
||||||
|
ORDER BY embedding <=> CAST(:queryEmbedding AS vector)
|
||||||
|
LIMIT :limit
|
||||||
|
""",
|
||||||
|
nativeQuery = true)
|
||||||
|
List<EmbeddingChunk> findSimilar(
|
||||||
|
@Param("binaryId") UUID binaryId,
|
||||||
|
@Param("queryEmbedding") String queryEmbedding,
|
||||||
|
@Param("threshold") double threshold,
|
||||||
|
@Param("limit") int limit);
|
||||||
|
|
||||||
|
@Modifying
|
||||||
|
@Transactional
|
||||||
|
@Query(
|
||||||
|
value =
|
||||||
|
"""
|
||||||
|
INSERT INTO embedding_chunk
|
||||||
|
(id, binary_id, chunk_type, source_type, source_id, content, embedding, metadata, token_count, created_at)
|
||||||
|
VALUES
|
||||||
|
(:id, :binaryId, :chunkType, :sourceType, :sourceId, :content, CAST(:embedding AS vector), CAST(:metadata AS jsonb), :tokenCount, :createdAt)
|
||||||
|
""",
|
||||||
|
nativeQuery = true)
|
||||||
|
void insertChunk(
|
||||||
|
@Param("id") UUID id,
|
||||||
|
@Param("binaryId") UUID binaryId,
|
||||||
|
@Param("chunkType") String chunkType,
|
||||||
|
@Param("sourceType") String sourceType,
|
||||||
|
@Param("sourceId") UUID sourceId,
|
||||||
|
@Param("content") String content,
|
||||||
|
@Param("embedding") String embedding,
|
||||||
|
@Param("metadata") String metadata,
|
||||||
|
@Param("tokenCount") Integer tokenCount,
|
||||||
|
@Param("createdAt") java.time.Instant createdAt);
|
||||||
|
|
||||||
|
void deleteByBinaryId(UUID binaryId);
|
||||||
|
|
||||||
|
@Query(
|
||||||
|
value =
|
||||||
|
"""
|
||||||
|
SELECT * FROM embedding_chunk
|
||||||
|
WHERE binary_id = :binaryId
|
||||||
|
AND LOWER(content) LIKE LOWER(CONCAT('%', :term, '%'))
|
||||||
|
LIMIT :limit
|
||||||
|
""",
|
||||||
|
nativeQuery = true)
|
||||||
|
List<EmbeddingChunk> searchByContent(
|
||||||
|
@Param("binaryId") UUID binaryId, @Param("term") String term, @Param("limit") int limit);
|
||||||
|
}
|
||||||
@ -0,0 +1 @@
|
|||||||
|
package ai.decompile.ai.model.repository;
|
||||||
10
src/main/java/ai/decompile/ai/package-info.java
Normal file
10
src/main/java/ai/decompile/ai/package-info.java
Normal file
@ -0,0 +1,10 @@
|
|||||||
|
@org.springframework.modulith.ApplicationModule(
|
||||||
|
displayName = "AI",
|
||||||
|
allowedDependencies = {
|
||||||
|
"workspace::entities",
|
||||||
|
"workspace::services",
|
||||||
|
"analysis::entities",
|
||||||
|
"analysis::services",
|
||||||
|
"common"
|
||||||
|
})
|
||||||
|
package ai.decompile.ai;
|
||||||
50
src/main/java/ai/decompile/ai/service/AiUtils.java
Normal file
50
src/main/java/ai/decompile/ai/service/AiUtils.java
Normal file
@ -0,0 +1,50 @@
|
|||||||
|
package ai.decompile.ai.service;
|
||||||
|
|
||||||
|
import com.fasterxml.jackson.core.JsonProcessingException;
|
||||||
|
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||||
|
import java.util.Objects;
|
||||||
|
import lombok.AccessLevel;
|
||||||
|
import lombok.NoArgsConstructor;
|
||||||
|
import lombok.extern.log4j.Log4j2;
|
||||||
|
|
||||||
|
@Log4j2
|
||||||
|
@NoArgsConstructor(access = AccessLevel.PRIVATE)
|
||||||
|
public final class AiUtils {
|
||||||
|
public static String truncate(String text, int maxChars) {
|
||||||
|
if (Objects.isNull(text) || text.length() <= maxChars) {
|
||||||
|
return Objects.nonNull(text) ? text : "";
|
||||||
|
}
|
||||||
|
|
||||||
|
return text.substring(0, maxChars) + "\n... [truncated]";
|
||||||
|
}
|
||||||
|
|
||||||
|
public static String orUnknown(String value) {
|
||||||
|
return Objects.nonNull(value) ? value : "unknown";
|
||||||
|
}
|
||||||
|
|
||||||
|
public static int estimateTokens(String text) {
|
||||||
|
return Objects.nonNull(text) ? (int) Math.ceil(text.length() / 3.5) : 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static String toVectorString(float[] embedding) {
|
||||||
|
var sb = new StringBuilder("[");
|
||||||
|
for (var i = 0; i < embedding.length; i++) {
|
||||||
|
if (i > 0) {
|
||||||
|
sb.append(",");
|
||||||
|
}
|
||||||
|
|
||||||
|
sb.append(embedding[i]);
|
||||||
|
}
|
||||||
|
|
||||||
|
return sb.append("]").toString();
|
||||||
|
}
|
||||||
|
|
||||||
|
public static String toJson(ObjectMapper mapper, Object obj) {
|
||||||
|
try {
|
||||||
|
return mapper.writeValueAsString(obj);
|
||||||
|
} catch (JsonProcessingException e) {
|
||||||
|
log.warn("Failed to serialize object to JSON: {}", e.toString());
|
||||||
|
return "{}";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
309
src/main/java/ai/decompile/ai/service/ChatContextBuilder.java
Normal file
309
src/main/java/ai/decompile/ai/service/ChatContextBuilder.java
Normal file
@ -0,0 +1,309 @@
|
|||||||
|
package ai.decompile.ai.service;
|
||||||
|
|
||||||
|
import ai.decompile.ai.model.entity.EmbeddingChunk;
|
||||||
|
import ai.decompile.ai.model.repository.EmbeddingChunkRepository;
|
||||||
|
import ai.decompile.analysis.model.entity.StaticAnalysis;
|
||||||
|
import ai.decompile.analysis.model.entity.StaticFunction;
|
||||||
|
import ai.decompile.analysis.model.entity.StaticXref;
|
||||||
|
import ai.decompile.analysis.model.repository.StaticAnalysisRepository;
|
||||||
|
import ai.decompile.analysis.model.repository.StaticFunctionRepository;
|
||||||
|
import ai.decompile.analysis.model.repository.StaticXrefRepository;
|
||||||
|
import ai.decompile.workspace.model.entity.Binary;
|
||||||
|
import java.util.ArrayList;
|
||||||
|
import java.util.LinkedHashSet;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Objects;
|
||||||
|
import java.util.Set;
|
||||||
|
import java.util.UUID;
|
||||||
|
import java.util.regex.Pattern;
|
||||||
|
import java.util.stream.Stream;
|
||||||
|
import lombok.RequiredArgsConstructor;
|
||||||
|
import lombok.extern.log4j.Log4j2;
|
||||||
|
import org.springframework.beans.factory.annotation.Value;
|
||||||
|
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
|
||||||
|
import org.springframework.stereotype.Component;
|
||||||
|
|
||||||
|
@Log4j2
|
||||||
|
@Component
|
||||||
|
@RequiredArgsConstructor
|
||||||
|
@ConditionalOnProperty(name = "app.ai.enabled", havingValue = "true", matchIfMissing = true)
|
||||||
|
public class ChatContextBuilder {
|
||||||
|
// Detects: IDA auto-names (sub_XXXX), hex addresses (0xXXXXXXXX),
|
||||||
|
// camelCase/SCREAMING_SNAKE names with 2+ uppercase segments, and offset labels (name@addr)
|
||||||
|
private static final Pattern FUNCTION_PATTERN =
|
||||||
|
Pattern.compile("\\b(sub_\\w+|0x[0-9a-fA-F]+|[A-Z]\\w*[A-Z]\\w*|\\w+@\\w+)\\b");
|
||||||
|
private static final int MIN_TOKEN_LENGTH = 3;
|
||||||
|
private static final int MAX_FUNCTION_REFERENCES = 5;
|
||||||
|
private static final int NEIGHBOR_CONTEXT_MAX_CHARS = 3000;
|
||||||
|
private static final int DEFAULT_CALL_GRAPH_HOPS = 1;
|
||||||
|
|
||||||
|
private final EmbeddingService embeddingService;
|
||||||
|
private final EmbeddingChunkRepository embeddingChunkRepository;
|
||||||
|
private final StaticAnalysisRepository analysisRepository;
|
||||||
|
private final StaticFunctionRepository functionRepository;
|
||||||
|
private final StaticXrefRepository xrefRepository;
|
||||||
|
|
||||||
|
@Value("${app.ai.rag.top-k:10}")
|
||||||
|
private int topK;
|
||||||
|
|
||||||
|
@Value("${app.ai.rag.similarity-threshold:0.6}")
|
||||||
|
private double similarityThreshold;
|
||||||
|
|
||||||
|
@Value("${app.ai.rag.fallback-min-ratio:0.4}")
|
||||||
|
private double fallbackMinRatio;
|
||||||
|
|
||||||
|
@Value("${app.ai.rag.function-context-max-chars:5000}")
|
||||||
|
private int functionContextMaxChars;
|
||||||
|
|
||||||
|
public String buildContext(UUID binaryId, String query, Binary binary) {
|
||||||
|
var merged = new LinkedHashSet<EmbeddingChunk>();
|
||||||
|
var inlineSections = new ArrayList<String>();
|
||||||
|
|
||||||
|
enrichWithSemanticAndTextualSearch(merged, binaryId, query);
|
||||||
|
enrichWithCallGraph(inlineSections, merged, query, binaryId);
|
||||||
|
|
||||||
|
return formatContext(inlineSections, merged, binary.getFilename());
|
||||||
|
}
|
||||||
|
|
||||||
|
public String buildSystemPrompt(String context, Binary binary) {
|
||||||
|
return String.format(
|
||||||
|
"""
|
||||||
|
You are an expert reverse engineer and malware analyst.
|
||||||
|
You are analyzing a binary file with the following characteristics.
|
||||||
|
Use the provided function disassembly and context to answer the user's questions.
|
||||||
|
When referencing code, mention function names, addresses, and the binary context.
|
||||||
|
If the provided context does not contain enough information, acknowledge the limitation
|
||||||
|
and suggest what additional analysis would help.
|
||||||
|
|
||||||
|
[BINARY OVERVIEW]
|
||||||
|
Filename: %s
|
||||||
|
Format: %s
|
||||||
|
Architecture: %s
|
||||||
|
Compiler: %s
|
||||||
|
|
||||||
|
%s
|
||||||
|
|
||||||
|
[CONVERSATION GUIDELINES]
|
||||||
|
- Cite function addresses when relevant (e.g., "the function at 0x401000")
|
||||||
|
- Explain assembly in natural language when useful
|
||||||
|
- If you identify patterns (loops, conditionals, API calls), highlight them
|
||||||
|
- If you detect malicious intent, point it out objectively
|
||||||
|
- Be concise but thorough in your technical analysis
|
||||||
|
- If the context includes a [REQUESTED FUNCTION ANALYSIS] section with full assembly,
|
||||||
|
analyze that function in depth rather than saying you don't have access to it
|
||||||
|
""",
|
||||||
|
AiUtils.orUnknown(binary.getFilename()),
|
||||||
|
AiUtils.orUnknown(binary.getFormat()),
|
||||||
|
AiUtils.orUnknown(binary.getArchitecture()),
|
||||||
|
AiUtils.orUnknown(binary.getCompiler()),
|
||||||
|
context);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void enrichWithSemanticAndTextualSearch(
|
||||||
|
LinkedHashSet<EmbeddingChunk> merged, UUID binaryId, String query) {
|
||||||
|
try {
|
||||||
|
var semanticChunks = semanticSearch(binaryId, query);
|
||||||
|
log.debug(
|
||||||
|
"Semantic search returned {} chunks for binary {}", semanticChunks.size(), binaryId);
|
||||||
|
merged.addAll(semanticChunks);
|
||||||
|
|
||||||
|
if (semanticChunks.size() < topK * fallbackMinRatio) {
|
||||||
|
var tokens = extractMeaningfulTokens(query);
|
||||||
|
enrichWithTextualFallback(merged, binaryId, tokens);
|
||||||
|
}
|
||||||
|
} catch (Exception e) {
|
||||||
|
log.warn("Semantic/textual search failed, continuing with call graph: {}", e.getMessage());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void enrichWithTextualFallback(
|
||||||
|
LinkedHashSet<EmbeddingChunk> merged, UUID binaryId, List<String> tokens) {
|
||||||
|
int remaining = topK - merged.size();
|
||||||
|
|
||||||
|
for (var token : tokens) {
|
||||||
|
if (remaining <= 0 || merged.size() >= topK) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
remaining = addTextChunksToMerged(merged, binaryId, token, remaining);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private int addTextChunksToMerged(
|
||||||
|
LinkedHashSet<EmbeddingChunk> merged, UUID binaryId, String token, int limit) {
|
||||||
|
var textChunks = embeddingChunkRepository.searchByContent(binaryId, token, limit);
|
||||||
|
|
||||||
|
for (var chunk : textChunks) {
|
||||||
|
if (limit <= 0) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (merged.add(chunk)) {
|
||||||
|
limit--;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return limit;
|
||||||
|
}
|
||||||
|
|
||||||
|
private void enrichWithCallGraph(
|
||||||
|
List<String> inlineSections,
|
||||||
|
LinkedHashSet<EmbeddingChunk> merged,
|
||||||
|
String query,
|
||||||
|
UUID binaryId) {
|
||||||
|
try {
|
||||||
|
var detectedFunctions = detectFunctionReferences(query, binaryId);
|
||||||
|
|
||||||
|
for (var func : detectedFunctions.limit(5).toList()) {
|
||||||
|
if (isFunctionAlreadyInContext(merged, func)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
var section = buildFunctionContext(func);
|
||||||
|
inlineSections.add(section);
|
||||||
|
}
|
||||||
|
} catch (Exception e) {
|
||||||
|
log.warn("Call graph expansion failed: {}", e.getMessage());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private boolean isFunctionAlreadyInContext(
|
||||||
|
LinkedHashSet<EmbeddingChunk> merged, StaticFunction func) {
|
||||||
|
return merged.stream()
|
||||||
|
.anyMatch(c -> Objects.nonNull(c.getSourceId()) && c.getSourceId().equals(func.getId()));
|
||||||
|
}
|
||||||
|
|
||||||
|
private String formatContext(
|
||||||
|
List<String> inlineSections, LinkedHashSet<EmbeddingChunk> merged, String filename) {
|
||||||
|
if (merged.isEmpty() && inlineSections.isEmpty()) {
|
||||||
|
return "[No relevant code sections found for this query in binary " + filename + ".]";
|
||||||
|
}
|
||||||
|
|
||||||
|
var sb = new StringBuilder();
|
||||||
|
|
||||||
|
if (!inlineSections.isEmpty()) {
|
||||||
|
sb.append("[REQUESTED FUNCTION ANALYSIS]\n\n");
|
||||||
|
for (var section : inlineSections) {
|
||||||
|
sb.append(section).append("\n");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!merged.isEmpty()) {
|
||||||
|
sb.append("[RELEVANT CODE SECTIONS]\n\n");
|
||||||
|
int idx = 1;
|
||||||
|
for (var chunk : merged) {
|
||||||
|
sb.append("Section ").append(idx++).append(":\n");
|
||||||
|
sb.append(chunk.getContent()).append("\n");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return sb.toString();
|
||||||
|
}
|
||||||
|
|
||||||
|
private List<EmbeddingChunk> semanticSearch(UUID binaryId, String query) {
|
||||||
|
var queryEmbedding = embeddingService.embed(query);
|
||||||
|
var vectorStr = AiUtils.toVectorString(queryEmbedding);
|
||||||
|
return embeddingChunkRepository.findSimilar(binaryId, vectorStr, similarityThreshold, topK);
|
||||||
|
}
|
||||||
|
|
||||||
|
private List<String> extractMeaningfulTokens(String query) {
|
||||||
|
return Stream.of(query.split("[\\s,.;:!?()\\[\\]{}\"']+"))
|
||||||
|
.filter(t -> t.length() >= MIN_TOKEN_LENGTH)
|
||||||
|
.distinct()
|
||||||
|
.limit(10)
|
||||||
|
.toList();
|
||||||
|
}
|
||||||
|
|
||||||
|
private Stream<StaticFunction> detectFunctionReferences(String query, UUID binaryId) {
|
||||||
|
var analysis = getLatestAnalysis(binaryId);
|
||||||
|
if (Objects.isNull(analysis)) {
|
||||||
|
return Stream.empty();
|
||||||
|
}
|
||||||
|
|
||||||
|
var tokens = extractMeaningfulTokens(query);
|
||||||
|
var matcher = FUNCTION_PATTERN.matcher(query);
|
||||||
|
var extraTokens = new ArrayList<String>();
|
||||||
|
while (matcher.find() && extraTokens.size() < 10) {
|
||||||
|
extraTokens.add(matcher.group(1));
|
||||||
|
}
|
||||||
|
|
||||||
|
var allTokens = Stream.concat(tokens.stream(), extraTokens.stream()).distinct().toList();
|
||||||
|
|
||||||
|
Set<StaticFunction> results = new LinkedHashSet<>();
|
||||||
|
for (var token : allTokens) {
|
||||||
|
if (results.size() >= MAX_FUNCTION_REFERENCES) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
var functions =
|
||||||
|
functionRepository.findByNameIgnoreCaseContainingAndAnalysisId(token, analysis.getId());
|
||||||
|
results.addAll(functions);
|
||||||
|
}
|
||||||
|
|
||||||
|
return results.stream();
|
||||||
|
}
|
||||||
|
|
||||||
|
private String buildFunctionContext(StaticFunction func) {
|
||||||
|
var sb = new StringBuilder();
|
||||||
|
sb.append(String.format("[FUNCTION] %s at %s\n", func.getName(), func.getAddress()));
|
||||||
|
|
||||||
|
var callers = xrefRepository.findByCalleeId(func.getId());
|
||||||
|
var callees = xrefRepository.findByCallerId(func.getId());
|
||||||
|
|
||||||
|
appendCallers(sb, callers);
|
||||||
|
appendCallees(sb, callees);
|
||||||
|
|
||||||
|
sb.append("\n");
|
||||||
|
sb.append("[ASSEMBLY]\n");
|
||||||
|
sb.append(AiUtils.truncate(func.getAssembly(), functionContextMaxChars));
|
||||||
|
|
||||||
|
if (DEFAULT_CALL_GRAPH_HOPS > 0) {
|
||||||
|
appendNeighborContext(sb, callers, callees, func);
|
||||||
|
}
|
||||||
|
|
||||||
|
return sb.toString();
|
||||||
|
}
|
||||||
|
|
||||||
|
private void appendCallers(StringBuilder sb, List<StaticXref> callers) {
|
||||||
|
if (callers.isEmpty()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
sb.append("Callers: ");
|
||||||
|
sb.append(
|
||||||
|
String.join(", ", callers.stream().limit(5).map(x -> x.getCaller().getName()).toList()));
|
||||||
|
sb.append("\n");
|
||||||
|
}
|
||||||
|
|
||||||
|
private void appendCallees(StringBuilder sb, List<StaticXref> callees) {
|
||||||
|
if (callees.isEmpty()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
sb.append("Callees: ");
|
||||||
|
sb.append(
|
||||||
|
String.join(", ", callees.stream().limit(10).map(x -> x.getCallee().getName()).toList()));
|
||||||
|
sb.append("\n");
|
||||||
|
}
|
||||||
|
|
||||||
|
private void appendNeighborContext(
|
||||||
|
StringBuilder sb, List<StaticXref> callers, List<StaticXref> callees, StaticFunction func) {
|
||||||
|
var neighbors = new ArrayList<StaticFunction>();
|
||||||
|
neighbors.addAll(callers.stream().limit(2).map(StaticXref::getCaller).toList());
|
||||||
|
neighbors.addAll(callees.stream().limit(2).map(StaticXref::getCallee).toList());
|
||||||
|
|
||||||
|
for (var neighbor : neighbors) {
|
||||||
|
if (neighbor.getId().equals(func.getId())) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
sb.append("\n").append("[RELATED] ").append(neighbor.getName());
|
||||||
|
sb.append(" at ").append(neighbor.getAddress()).append("\n");
|
||||||
|
sb.append(AiUtils.truncate(neighbor.getAssembly(), NEIGHBOR_CONTEXT_MAX_CHARS)).append("\n");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private StaticAnalysis getLatestAnalysis(UUID binaryId) {
|
||||||
|
return analysisRepository.findFirstByBinaryIdOrderByCreatedAtDesc(binaryId).orElse(null);
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,52 @@
|
|||||||
|
package ai.decompile.ai.service;
|
||||||
|
|
||||||
|
import ai.decompile.ai.model.entity.ChatMessage;
|
||||||
|
import java.util.ArrayList;
|
||||||
|
import java.util.Collections;
|
||||||
|
import java.util.List;
|
||||||
|
import org.springframework.ai.chat.messages.AssistantMessage;
|
||||||
|
import org.springframework.ai.chat.messages.Message;
|
||||||
|
import org.springframework.ai.chat.messages.SystemMessage;
|
||||||
|
import org.springframework.ai.chat.messages.UserMessage;
|
||||||
|
import org.springframework.beans.factory.annotation.Value;
|
||||||
|
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
|
||||||
|
import org.springframework.stereotype.Component;
|
||||||
|
|
||||||
|
@Component
|
||||||
|
@ConditionalOnProperty(name = "app.ai.enabled", havingValue = "true", matchIfMissing = true)
|
||||||
|
public class ChatMessageConverter {
|
||||||
|
|
||||||
|
@Value("${app.ai.chat.max-history-messages:20}")
|
||||||
|
private int maxHistoryMessages;
|
||||||
|
|
||||||
|
public List<Message> buildMessageList(
|
||||||
|
String systemPrompt, List<ChatMessage> historyMessages, String userMessageContent) {
|
||||||
|
var messages = new ArrayList<Message>();
|
||||||
|
messages.add(new SystemMessage(systemPrompt));
|
||||||
|
|
||||||
|
for (var histMsg : historyMessages) {
|
||||||
|
if ("USER".equals(histMsg.getRole())) {
|
||||||
|
messages.add(new UserMessage(histMsg.getContent()));
|
||||||
|
} else {
|
||||||
|
messages.add(new AssistantMessage(histMsg.getContent()));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
messages.add(new UserMessage(userMessageContent));
|
||||||
|
return messages;
|
||||||
|
}
|
||||||
|
|
||||||
|
public List<ChatMessage> getRecentHistoryExcludingLastMessage(List<ChatMessage> messages) {
|
||||||
|
if (messages.isEmpty()) {
|
||||||
|
return Collections.emptyList();
|
||||||
|
}
|
||||||
|
|
||||||
|
var msgList = new ArrayList<>(messages);
|
||||||
|
msgList.removeLast();
|
||||||
|
if (msgList.size() > maxHistoryMessages) {
|
||||||
|
return msgList.subList(msgList.size() - maxHistoryMessages, msgList.size());
|
||||||
|
}
|
||||||
|
|
||||||
|
return msgList;
|
||||||
|
}
|
||||||
|
}
|
||||||
65
src/main/java/ai/decompile/ai/service/ChatService.java
Normal file
65
src/main/java/ai/decompile/ai/service/ChatService.java
Normal file
@ -0,0 +1,65 @@
|
|||||||
|
package ai.decompile.ai.service;
|
||||||
|
|
||||||
|
import ai.decompile.ai.model.entity.ChatSession;
|
||||||
|
import ai.decompile.workspace.model.entity.Binary;
|
||||||
|
import ai.decompile.workspace.service.BinaryService;
|
||||||
|
import java.util.Objects;
|
||||||
|
import java.util.UUID;
|
||||||
|
import java.util.concurrent.atomic.AtomicReference;
|
||||||
|
import lombok.RequiredArgsConstructor;
|
||||||
|
import lombok.extern.log4j.Log4j2;
|
||||||
|
import org.springframework.ai.chat.messages.Message;
|
||||||
|
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
|
||||||
|
import org.springframework.stereotype.Service;
|
||||||
|
import reactor.core.publisher.Flux;
|
||||||
|
|
||||||
|
@Log4j2
|
||||||
|
@Service
|
||||||
|
@RequiredArgsConstructor
|
||||||
|
@ConditionalOnProperty(name = "app.ai.enabled", havingValue = "true", matchIfMissing = true)
|
||||||
|
public class ChatService {
|
||||||
|
private final StreamingModelProvider streamingModelProvider;
|
||||||
|
private final ChatSessionService sessionService;
|
||||||
|
private final ChatMessageConverter messageConverter;
|
||||||
|
private final ChatContextBuilder contextBuilder;
|
||||||
|
private final BinaryService binaryService;
|
||||||
|
|
||||||
|
public Flux<String> chat(UUID sessionId, String userMessageContent) {
|
||||||
|
var session = sessionService.getSession(sessionId);
|
||||||
|
var binary = binaryService.getBinary(session.getBinaryId());
|
||||||
|
|
||||||
|
sessionService.saveMessage(session, "USER", userMessageContent);
|
||||||
|
|
||||||
|
var messages = buildChatMessages(session, binary, userMessageContent);
|
||||||
|
var streamingModel = streamingModelProvider.resolve();
|
||||||
|
var assistantContent = new AtomicReference<>("");
|
||||||
|
|
||||||
|
return streamingModel.stream(messages)
|
||||||
|
.doOnNext(token -> assistantContent.updateAndGet(current -> current + token))
|
||||||
|
.doOnComplete(() -> saveAssistantMessage(session, assistantContent))
|
||||||
|
.doOnError(
|
||||||
|
error -> log.error("Error during chat streaming for session {}", sessionId, error));
|
||||||
|
}
|
||||||
|
|
||||||
|
private Message[] buildChatMessages(
|
||||||
|
ChatSession session, Binary binary, String userMessageContent) {
|
||||||
|
var historyMessages = sessionService.getMessages(session.getId());
|
||||||
|
|
||||||
|
var context = contextBuilder.buildContext(session.getBinaryId(), userMessageContent, binary);
|
||||||
|
var systemPrompt = contextBuilder.buildSystemPrompt(context, binary);
|
||||||
|
|
||||||
|
return messageConverter
|
||||||
|
.buildMessageList(
|
||||||
|
systemPrompt,
|
||||||
|
messageConverter.getRecentHistoryExcludingLastMessage(historyMessages),
|
||||||
|
userMessageContent)
|
||||||
|
.toArray(new Message[0]);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void saveAssistantMessage(ChatSession session, AtomicReference<String> assistantContent) {
|
||||||
|
var fullContent = assistantContent.get();
|
||||||
|
if (Objects.nonNull(fullContent) && !fullContent.isBlank()) {
|
||||||
|
sessionService.saveMessage(session, "ASSISTANT", fullContent);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,86 @@
|
|||||||
|
package ai.decompile.ai.service;
|
||||||
|
|
||||||
|
import ai.decompile.ai.model.entity.ChatMessage;
|
||||||
|
import ai.decompile.ai.model.entity.ChatSession;
|
||||||
|
import ai.decompile.ai.model.repository.ChatMessageRepository;
|
||||||
|
import ai.decompile.ai.model.repository.ChatSessionRepository;
|
||||||
|
import ai.decompile.common.exception.NotFoundException;
|
||||||
|
import ai.decompile.workspace.service.BinaryService;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Objects;
|
||||||
|
import java.util.UUID;
|
||||||
|
import lombok.RequiredArgsConstructor;
|
||||||
|
import lombok.extern.log4j.Log4j2;
|
||||||
|
import org.springframework.beans.factory.annotation.Value;
|
||||||
|
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
|
||||||
|
import org.springframework.stereotype.Service;
|
||||||
|
import org.springframework.transaction.annotation.Propagation;
|
||||||
|
import org.springframework.transaction.annotation.Transactional;
|
||||||
|
|
||||||
|
@Log4j2
|
||||||
|
@Service
|
||||||
|
@RequiredArgsConstructor
|
||||||
|
@ConditionalOnProperty(name = "app.ai.enabled", havingValue = "true", matchIfMissing = true)
|
||||||
|
public class ChatSessionService {
|
||||||
|
private final ChatSessionRepository sessionRepository;
|
||||||
|
private final ChatMessageRepository messageRepository;
|
||||||
|
private final BinaryService binaryService;
|
||||||
|
private final StreamingModelProvider streamingModelProvider;
|
||||||
|
|
||||||
|
@Value("${app.ai.chat.default-model:gemma4:12b}")
|
||||||
|
private String defaultModel;
|
||||||
|
|
||||||
|
@Transactional
|
||||||
|
public ChatSession createSession(UUID binaryId, UUID projectId, String model) {
|
||||||
|
var binary = binaryService.getBinary(binaryId);
|
||||||
|
var effectiveModel = Objects.nonNull(model) ? model : defaultModel;
|
||||||
|
streamingModelProvider.resolve();
|
||||||
|
|
||||||
|
var session =
|
||||||
|
ChatSession.builder()
|
||||||
|
.binaryId(binaryId)
|
||||||
|
.projectId(projectId)
|
||||||
|
.title(binary.getFilename() + " — Chat")
|
||||||
|
.chatModel(effectiveModel)
|
||||||
|
.build();
|
||||||
|
|
||||||
|
return sessionRepository.save(session);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Transactional(readOnly = true)
|
||||||
|
public List<ChatSession> getSessions(UUID binaryId) {
|
||||||
|
return sessionRepository.findByBinaryIdOrderByUpdatedAtDesc(binaryId);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Transactional(readOnly = true)
|
||||||
|
public ChatSession getSession(UUID sessionId) {
|
||||||
|
return sessionRepository
|
||||||
|
.findById(sessionId)
|
||||||
|
.orElseThrow(
|
||||||
|
() -> new NotFoundException("Chat session with id '" + sessionId + "' not found"));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Transactional
|
||||||
|
public void deleteSession(UUID sessionId) {
|
||||||
|
var session = getSession(sessionId);
|
||||||
|
sessionRepository.delete(session);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Transactional(readOnly = true)
|
||||||
|
public List<ChatMessage> getMessages(UUID sessionId) {
|
||||||
|
return messageRepository.findBySessionIdOrderByCreatedAtAsc(sessionId);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Transactional(propagation = Propagation.REQUIRES_NEW)
|
||||||
|
public void saveMessage(ChatSession session, String role, String content) {
|
||||||
|
var msg =
|
||||||
|
ChatMessage.builder()
|
||||||
|
.session(session)
|
||||||
|
.role(role)
|
||||||
|
.content(content)
|
||||||
|
.tokenCount(AiUtils.estimateTokens(content))
|
||||||
|
.build();
|
||||||
|
|
||||||
|
messageRepository.save(msg);
|
||||||
|
}
|
||||||
|
}
|
||||||
179
src/main/java/ai/decompile/ai/service/EmbeddingChunkBuilder.java
Normal file
179
src/main/java/ai/decompile/ai/service/EmbeddingChunkBuilder.java
Normal file
@ -0,0 +1,179 @@
|
|||||||
|
package ai.decompile.ai.service;
|
||||||
|
|
||||||
|
import ai.decompile.ai.model.entity.EmbeddingChunk;
|
||||||
|
import ai.decompile.analysis.model.entity.StaticFunction;
|
||||||
|
import ai.decompile.analysis.model.entity.StaticXref;
|
||||||
|
import ai.decompile.analysis.model.repository.StaticLabelRepository;
|
||||||
|
import ai.decompile.analysis.model.repository.StaticXrefRepository;
|
||||||
|
import ai.decompile.workspace.model.entity.Binary;
|
||||||
|
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||||
|
import java.util.ArrayList;
|
||||||
|
import java.util.HashMap;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Map;
|
||||||
|
import java.util.Objects;
|
||||||
|
import java.util.UUID;
|
||||||
|
import java.util.function.Function;
|
||||||
|
import lombok.RequiredArgsConstructor;
|
||||||
|
import org.springframework.beans.factory.annotation.Value;
|
||||||
|
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
|
||||||
|
import org.springframework.stereotype.Component;
|
||||||
|
|
||||||
|
@Component
|
||||||
|
@RequiredArgsConstructor
|
||||||
|
@ConditionalOnProperty(name = "app.ai.enabled", havingValue = "true", matchIfMissing = true)
|
||||||
|
public class EmbeddingChunkBuilder {
|
||||||
|
private final StaticXrefRepository xrefRepository;
|
||||||
|
private final StaticLabelRepository labelRepository;
|
||||||
|
private final ObjectMapper objectMapper;
|
||||||
|
|
||||||
|
@Value("${app.ai.embedding.chunk-max-chars:3000}")
|
||||||
|
private int chunkMaxChars;
|
||||||
|
|
||||||
|
public List<EmbeddingChunk> buildFunctionChunks(List<StaticFunction> functions, Binary binary) {
|
||||||
|
var graphContext = loadGraphContext(functions);
|
||||||
|
|
||||||
|
var chunks = new ArrayList<EmbeddingChunk>();
|
||||||
|
for (var func : functions) {
|
||||||
|
chunks.add(
|
||||||
|
EmbeddingChunk.builder()
|
||||||
|
.id(UUID.randomUUID())
|
||||||
|
.binaryId(binary.getId())
|
||||||
|
.chunkType("FUNCTION")
|
||||||
|
.sourceType("STATIC_FUNCTION")
|
||||||
|
.sourceId(func.getId())
|
||||||
|
.content(buildFunctionChunkContent(func, binary, graphContext))
|
||||||
|
.metadata(AiUtils.toJson(objectMapper, buildFunctionMetadata(func, graphContext)))
|
||||||
|
.tokenCount(AiUtils.estimateTokens(func.getAssembly()))
|
||||||
|
.createdAt(java.time.Instant.now())
|
||||||
|
.build());
|
||||||
|
}
|
||||||
|
|
||||||
|
return chunks;
|
||||||
|
}
|
||||||
|
|
||||||
|
public EmbeddingChunk buildMetadataChunk(Binary binary, int functionCount) {
|
||||||
|
var content =
|
||||||
|
String.format(
|
||||||
|
"[METADATA] Binary Overview\nFilename: %s\nFormat: %s\nArchitecture: %s\nCompiler: %s\nFunction count: %d\n",
|
||||||
|
AiUtils.orUnknown(binary.getFilename()),
|
||||||
|
AiUtils.orUnknown(binary.getFormat()),
|
||||||
|
AiUtils.orUnknown(binary.getArchitecture()),
|
||||||
|
AiUtils.orUnknown(binary.getCompiler()),
|
||||||
|
functionCount);
|
||||||
|
|
||||||
|
return EmbeddingChunk.builder()
|
||||||
|
.id(UUID.randomUUID())
|
||||||
|
.binaryId(binary.getId())
|
||||||
|
.chunkType("METADATA")
|
||||||
|
.content(content)
|
||||||
|
.metadata(
|
||||||
|
AiUtils.toJson(
|
||||||
|
objectMapper, Map.of("chunk_type", "METADATA", "function_count", functionCount)))
|
||||||
|
.tokenCount(AiUtils.estimateTokens(content))
|
||||||
|
.createdAt(java.time.Instant.now())
|
||||||
|
.build();
|
||||||
|
}
|
||||||
|
|
||||||
|
private FunctionGraphContext loadGraphContext(List<StaticFunction> functions) {
|
||||||
|
var nameToCallers =
|
||||||
|
loadXrefNames(functions, xrefRepository::findByCalleeId, xr -> xr.getCaller().getName());
|
||||||
|
|
||||||
|
var nameToCallees =
|
||||||
|
loadXrefNames(functions, xrefRepository::findByCallerId, xr -> xr.getCallee().getName());
|
||||||
|
|
||||||
|
var funcToLabels = loadLabels(functions);
|
||||||
|
|
||||||
|
return new FunctionGraphContext(nameToCallers, nameToCallees, funcToLabels);
|
||||||
|
}
|
||||||
|
|
||||||
|
private Map<String, List<String>> loadXrefNames(
|
||||||
|
List<StaticFunction> functions,
|
||||||
|
Function<UUID, List<StaticXref>> queryFn,
|
||||||
|
Function<StaticXref, String> nameFn) {
|
||||||
|
var m = new HashMap<String, List<String>>();
|
||||||
|
for (var f : functions) {
|
||||||
|
var xrefs = queryFn.apply(f.getId());
|
||||||
|
if (!xrefs.isEmpty()) {
|
||||||
|
m.put(f.getName(), xrefs.stream().map(nameFn).distinct().toList());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return m;
|
||||||
|
}
|
||||||
|
|
||||||
|
private Map<UUID, List<String>> loadLabels(List<StaticFunction> functions) {
|
||||||
|
var m = new HashMap<UUID, List<String>>();
|
||||||
|
for (var f : functions) {
|
||||||
|
var labels = labelRepository.findByFunctionId(f.getId());
|
||||||
|
if (!labels.isEmpty()) {
|
||||||
|
m.put(
|
||||||
|
f.getId(),
|
||||||
|
labels.stream().map(l -> l.getAddress() + " (" + l.getName() + ")").toList());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return m;
|
||||||
|
}
|
||||||
|
|
||||||
|
private String buildFunctionChunkContent(
|
||||||
|
StaticFunction func, Binary binary, FunctionGraphContext ctx) {
|
||||||
|
var sb = new StringBuilder();
|
||||||
|
sb.append(String.format("[FUNCTION] %s at %s\n", func.getName(), func.getAddress()));
|
||||||
|
sb.append(
|
||||||
|
String.format(
|
||||||
|
"Binary: %s | %s | %s | %s\n",
|
||||||
|
AiUtils.orUnknown(binary.getFilename()),
|
||||||
|
AiUtils.orUnknown(binary.getArchitecture()),
|
||||||
|
AiUtils.orUnknown(binary.getCompiler()),
|
||||||
|
AiUtils.orUnknown(binary.getFormat())));
|
||||||
|
|
||||||
|
var callers = ctx.nameToCallers.getOrDefault(func.getName(), List.of());
|
||||||
|
if (!callers.isEmpty()) {
|
||||||
|
sb.append("Callers: ").append(String.join(", ", callers)).append("\n");
|
||||||
|
}
|
||||||
|
|
||||||
|
var callees = ctx.nameToCallees.getOrDefault(func.getName(), List.of());
|
||||||
|
if (!callees.isEmpty()) {
|
||||||
|
sb.append("Callees: ").append(String.join(", ", callees)).append("\n");
|
||||||
|
}
|
||||||
|
|
||||||
|
var labels = ctx.funcToLabels.getOrDefault(func.getId(), List.of());
|
||||||
|
if (!labels.isEmpty()) {
|
||||||
|
sb.append("Labels:\n");
|
||||||
|
for (var l : labels) {
|
||||||
|
sb.append(" ").append(l).append("\n");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
sb.append("\n");
|
||||||
|
if (Objects.nonNull(func.getDecompiledCode()) && !func.getDecompiledCode().isBlank()) {
|
||||||
|
sb.append("[DECOMPILED]\n")
|
||||||
|
.append(AiUtils.truncate(func.getDecompiledCode(), chunkMaxChars / 2))
|
||||||
|
.append("\n");
|
||||||
|
}
|
||||||
|
|
||||||
|
sb.append("[ASSEMBLY]\n").append(AiUtils.truncate(func.getAssembly(), chunkMaxChars));
|
||||||
|
|
||||||
|
return sb.toString();
|
||||||
|
}
|
||||||
|
|
||||||
|
private Map<String, Object> buildFunctionMetadata(StaticFunction func, FunctionGraphContext ctx) {
|
||||||
|
Map<String, Object> m = new HashMap<>();
|
||||||
|
m.put("function_name", func.getName());
|
||||||
|
m.put("address", func.getAddress());
|
||||||
|
m.put("callers", ctx.nameToCallers.getOrDefault(func.getName(), List.of()));
|
||||||
|
m.put("callees", ctx.nameToCallees.getOrDefault(func.getName(), List.of()));
|
||||||
|
m.put("label_count", ctx.funcToLabels.getOrDefault(func.getId(), List.of()).size());
|
||||||
|
m.put("assembly_length", Objects.nonNull(func.getAssembly()) ? func.getAssembly().length() : 0);
|
||||||
|
m.put(
|
||||||
|
"has_decompiled_code",
|
||||||
|
Objects.nonNull(func.getDecompiledCode()) && !func.getDecompiledCode().isBlank());
|
||||||
|
return m;
|
||||||
|
}
|
||||||
|
|
||||||
|
record FunctionGraphContext(
|
||||||
|
Map<String, List<String>> nameToCallers,
|
||||||
|
Map<String, List<String>> nameToCallees,
|
||||||
|
Map<UUID, List<String>> funcToLabels) {}
|
||||||
|
}
|
||||||
@ -0,0 +1,9 @@
|
|||||||
|
package ai.decompile.ai.service;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
public interface EmbeddingClient {
|
||||||
|
float[] embed(String text);
|
||||||
|
|
||||||
|
List<float[]> embed(List<String> texts);
|
||||||
|
}
|
||||||
97
src/main/java/ai/decompile/ai/service/EmbeddingService.java
Normal file
97
src/main/java/ai/decompile/ai/service/EmbeddingService.java
Normal file
@ -0,0 +1,97 @@
|
|||||||
|
package ai.decompile.ai.service;
|
||||||
|
|
||||||
|
import ai.decompile.ai.model.entity.EmbeddingChunk;
|
||||||
|
import ai.decompile.ai.model.repository.EmbeddingChunkRepository;
|
||||||
|
import ai.decompile.analysis.model.entity.StaticAnalysis;
|
||||||
|
import ai.decompile.analysis.model.repository.StaticAnalysisRepository;
|
||||||
|
import ai.decompile.workspace.service.BinaryService;
|
||||||
|
import java.util.ArrayList;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Objects;
|
||||||
|
import java.util.UUID;
|
||||||
|
import lombok.RequiredArgsConstructor;
|
||||||
|
import lombok.extern.log4j.Log4j2;
|
||||||
|
import org.springframework.beans.factory.annotation.Value;
|
||||||
|
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
|
||||||
|
import org.springframework.stereotype.Service;
|
||||||
|
import org.springframework.transaction.annotation.Transactional;
|
||||||
|
|
||||||
|
@Log4j2
|
||||||
|
@Service
|
||||||
|
@RequiredArgsConstructor
|
||||||
|
@ConditionalOnProperty(name = "app.ai.enabled", havingValue = "true", matchIfMissing = true)
|
||||||
|
public class EmbeddingService {
|
||||||
|
private final EmbeddingClient embeddingClient;
|
||||||
|
private final EmbeddingChunkBuilder chunkBuilder;
|
||||||
|
private final EmbeddingChunkRepository embeddingChunkRepository;
|
||||||
|
private final StaticAnalysisRepository analysisRepository;
|
||||||
|
private final BinaryService binaryService;
|
||||||
|
|
||||||
|
@Value("${app.ai.embedding.batch-size:20}")
|
||||||
|
private int batchSize;
|
||||||
|
|
||||||
|
@Value("${app.ai.embedding.similarity-threshold:0.6}")
|
||||||
|
private double similarityThreshold;
|
||||||
|
|
||||||
|
public float[] embed(String text) {
|
||||||
|
return embeddingClient.embed(text);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Transactional
|
||||||
|
public int indexBinary(UUID binaryId) {
|
||||||
|
log.info("Starting embedding generation for binary {}", binaryId);
|
||||||
|
embeddingChunkRepository.deleteByBinaryId(binaryId);
|
||||||
|
embeddingChunkRepository.flush();
|
||||||
|
|
||||||
|
var binary = binaryService.getBinary(binaryId);
|
||||||
|
var analysis = getLatestAnalysis(binaryId);
|
||||||
|
if (Objects.isNull(analysis)) {
|
||||||
|
throw new IllegalStateException(
|
||||||
|
"No static analysis found for binary " + binaryId + ". Run analysis first.");
|
||||||
|
}
|
||||||
|
|
||||||
|
var functions = analysis.getFunctions();
|
||||||
|
log.info("Indexing {} functions for binary {}", functions.size(), binaryId);
|
||||||
|
|
||||||
|
var functionChunks = chunkBuilder.buildFunctionChunks(functions, binary);
|
||||||
|
var allChunks = new ArrayList<>(functionChunks);
|
||||||
|
allChunks.add(chunkBuilder.buildMetadataChunk(binary, functions.size()));
|
||||||
|
embedAndPersistChunks(allChunks);
|
||||||
|
|
||||||
|
log.info("Embedding complete for binary {}: {} chunks", binaryId, allChunks.size());
|
||||||
|
return allChunks.size();
|
||||||
|
}
|
||||||
|
|
||||||
|
private void embedAndPersistChunks(List<EmbeddingChunk> chunks) {
|
||||||
|
for (var i = 0; i < chunks.size(); i += batchSize) {
|
||||||
|
var batch = chunks.subList(i, Math.min(i + batchSize, chunks.size()));
|
||||||
|
var texts = batch.stream().map(EmbeddingChunk::getContent).toList();
|
||||||
|
var embeddings = embeddingClient.embed(texts);
|
||||||
|
|
||||||
|
for (int j = 0; j < batch.size(); j++) {
|
||||||
|
var c = batch.get(j);
|
||||||
|
embeddingChunkRepository.insertChunk(
|
||||||
|
c.getId(),
|
||||||
|
c.getBinaryId(),
|
||||||
|
c.getChunkType(),
|
||||||
|
c.getSourceType(),
|
||||||
|
c.getSourceId(),
|
||||||
|
c.getContent(),
|
||||||
|
AiUtils.toVectorString(embeddings.get(j)),
|
||||||
|
c.getMetadata(),
|
||||||
|
c.getTokenCount(),
|
||||||
|
c.getCreatedAt());
|
||||||
|
}
|
||||||
|
|
||||||
|
log.debug(
|
||||||
|
"Saved batch {}-{}/{} chunks",
|
||||||
|
i + 1,
|
||||||
|
Math.min(i + batchSize, chunks.size()),
|
||||||
|
chunks.size());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private StaticAnalysis getLatestAnalysis(UUID binaryId) {
|
||||||
|
return analysisRepository.findFirstByBinaryIdOrderByCreatedAtDesc(binaryId).orElse(null);
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,28 @@
|
|||||||
|
package ai.decompile.ai.service;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
|
import lombok.RequiredArgsConstructor;
|
||||||
|
import lombok.extern.log4j.Log4j2;
|
||||||
|
import org.springframework.ai.ollama.OllamaEmbeddingModel;
|
||||||
|
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
|
||||||
|
import org.springframework.resilience.annotation.Retryable;
|
||||||
|
import org.springframework.stereotype.Component;
|
||||||
|
|
||||||
|
@Log4j2
|
||||||
|
@Component
|
||||||
|
@RequiredArgsConstructor
|
||||||
|
@ConditionalOnProperty(name = "app.ai.enabled", havingValue = "true", matchIfMissing = true)
|
||||||
|
public class OllamaEmbeddingClient implements EmbeddingClient {
|
||||||
|
private final OllamaEmbeddingModel embeddingModel;
|
||||||
|
|
||||||
|
@Override
|
||||||
|
@Retryable(maxRetries = 2, delay = 500, multiplier = 2)
|
||||||
|
public float[] embed(String text) {
|
||||||
|
return embeddingModel.embed(text);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public List<float[]> embed(List<String> texts) {
|
||||||
|
return embeddingModel.embed(texts);
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,29 @@
|
|||||||
|
package ai.decompile.ai.service;
|
||||||
|
|
||||||
|
import java.util.Objects;
|
||||||
|
import lombok.RequiredArgsConstructor;
|
||||||
|
import lombok.extern.log4j.Log4j2;
|
||||||
|
import org.springframework.ai.chat.model.StreamingChatModel;
|
||||||
|
import org.springframework.ai.ollama.OllamaChatModel;
|
||||||
|
import org.springframework.beans.factory.ObjectProvider;
|
||||||
|
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
|
||||||
|
import org.springframework.stereotype.Component;
|
||||||
|
|
||||||
|
@Log4j2
|
||||||
|
@Component
|
||||||
|
@RequiredArgsConstructor
|
||||||
|
@ConditionalOnProperty(name = "app.ai.enabled", havingValue = "true", matchIfMissing = true)
|
||||||
|
public class OllamaStreamingModelProvider implements StreamingModelProvider {
|
||||||
|
private final ObjectProvider<OllamaChatModel> ollamaChatModelProvider;
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public StreamingChatModel resolve() {
|
||||||
|
var ollamaModel = ollamaChatModelProvider.getIfAvailable();
|
||||||
|
if (Objects.isNull(ollamaModel)) {
|
||||||
|
throw new IllegalStateException(
|
||||||
|
"Ollama is not available. Install ollama and run 'ollama pull gemma4:12b'.");
|
||||||
|
}
|
||||||
|
|
||||||
|
return ollamaModel;
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,8 @@
|
|||||||
|
package ai.decompile.ai.service;
|
||||||
|
|
||||||
|
import org.springframework.ai.chat.model.StreamingChatModel;
|
||||||
|
|
||||||
|
@FunctionalInterface
|
||||||
|
public interface StreamingModelProvider {
|
||||||
|
StreamingChatModel resolve();
|
||||||
|
}
|
||||||
1
src/main/java/ai/decompile/ai/service/package-info.java
Normal file
1
src/main/java/ai/decompile/ai/service/package-info.java
Normal file
@ -0,0 +1 @@
|
|||||||
|
package ai.decompile.ai.service;
|
||||||
@ -4,6 +4,8 @@ spring:
|
|||||||
enabled: true
|
enabled: true
|
||||||
application:
|
application:
|
||||||
name: decompile-ai
|
name: decompile-ai
|
||||||
|
config:
|
||||||
|
import: optional:file:.env[.properties]
|
||||||
datasource:
|
datasource:
|
||||||
url: jdbc:postgresql://localhost:5432/decompile_ai
|
url: jdbc:postgresql://localhost:5432/decompile_ai
|
||||||
username: decompile_ai
|
username: decompile_ai
|
||||||
@ -22,6 +24,18 @@ spring:
|
|||||||
multipart:
|
multipart:
|
||||||
max-file-size: 500MB
|
max-file-size: 500MB
|
||||||
max-request-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
|
||||||
|
|
||||||
app:
|
app:
|
||||||
storage:
|
storage:
|
||||||
@ -43,7 +57,24 @@ app:
|
|||||||
ghidra:
|
ghidra:
|
||||||
image: decompile-ai/ghidra:latest
|
image: decompile-ai/ghidra:latest
|
||||||
timeout-seconds: 600
|
timeout-seconds: 600
|
||||||
|
ai:
|
||||||
|
default-chat-provider: ${AI_DEFAULT_CHAT_PROVIDER:ollama}
|
||||||
|
embedding:
|
||||||
|
dimension: ${AI_EMBEDDING_DIMENSION:4096}
|
||||||
|
chunk-max-chars: 3000
|
||||||
|
batch-size: 20
|
||||||
|
rag:
|
||||||
|
top-k: 10
|
||||||
|
similarity-threshold: 0.6
|
||||||
|
max-context-tokens: 8000
|
||||||
|
fallback-min-ratio: 0.4
|
||||||
|
function-context-max-chars: 5000
|
||||||
|
chat:
|
||||||
|
max-history-messages: 20
|
||||||
|
temperature: 0.3
|
||||||
|
|
||||||
logging:
|
logging:
|
||||||
level:
|
level:
|
||||||
org.springframework.modulith: DEBUG
|
org.springframework.modulith: DEBUG
|
||||||
|
ai.decompile.ai: DEBUG
|
||||||
|
org.springframework.ai: INFO
|
||||||
|
|||||||
@ -0,0 +1,43 @@
|
|||||||
|
CREATE EXTENSION IF NOT EXISTS vector;
|
||||||
|
|
||||||
|
CREATE TABLE embedding_chunk
|
||||||
|
(
|
||||||
|
id UUID PRIMARY KEY,
|
||||||
|
binary_id UUID NOT NULL REFERENCES binaries (id) ON DELETE CASCADE,
|
||||||
|
chunk_type VARCHAR(30) NOT NULL,
|
||||||
|
source_type VARCHAR(30),
|
||||||
|
source_id UUID,
|
||||||
|
content TEXT NOT NULL,
|
||||||
|
embedding vector(4096),
|
||||||
|
metadata JSONB,
|
||||||
|
token_count INTEGER,
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX idx_embedding_chunk_binary ON embedding_chunk (binary_id);
|
||||||
|
CREATE INDEX idx_embedding_chunk_source ON embedding_chunk (source_type, source_id);
|
||||||
|
|
||||||
|
CREATE TABLE chat_session
|
||||||
|
(
|
||||||
|
id UUID PRIMARY KEY,
|
||||||
|
binary_id UUID NOT NULL REFERENCES binaries (id) ON DELETE CASCADE,
|
||||||
|
project_id UUID NOT NULL REFERENCES projects (id) ON DELETE CASCADE,
|
||||||
|
title VARCHAR(255),
|
||||||
|
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);
|
||||||
|
|
||||||
|
CREATE TABLE chat_message
|
||||||
|
(
|
||||||
|
id UUID PRIMARY KEY,
|
||||||
|
session_id UUID NOT NULL REFERENCES chat_session (id) ON DELETE CASCADE,
|
||||||
|
role VARCHAR(20) NOT NULL,
|
||||||
|
content TEXT NOT NULL,
|
||||||
|
token_count INTEGER,
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX idx_chat_message_session ON chat_message (session_id, created_at);
|
||||||
Loading…
x
Reference in New Issue
Block a user