Compare commits

...

4 Commits

23 changed files with 441 additions and 32 deletions

View File

@ -12,5 +12,4 @@ uploads/
.env
.env.example
docker-compose.yml
Dockerfile*
engines/

View File

@ -4,18 +4,27 @@
# 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) ---
# --- LLM (Ollama local) ---
# 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
# Ollama embedding model (default: nomic-embed-text)
# AI_EMBEDDING_MODEL=nomic-embed-text
# Embedding vector dimension (default: 4096)
# AI_EMBEDDING_DIMENSION=4096
# Embedding vector dimension (default: 768)
# AI_EMBEDDING_DIMENSION=768
# --- Cloud Chat (Ollama Cloud, DeepSeek, etc.) ---
# Chat provider: "ollama" (local, default), "ollama-cloud", or "deepseek"
# AI_DEFAULT_CHAT_PROVIDER=ollama
# Cloud API key (required for ollama-cloud or deepseek)
# AI_CLOUD_API_KEY=sk-...
# Cloud base URL (Ollama Cloud: https://api.ollama.ai/v1 | DeepSeek: https://api.deepseek.com)
# AI_CLOUD_BASE_URL=https://api.ollama.ai/v1
# Cloud chat model (Ollama Cloud: gemma4:31b-cloud | DeepSeek: deepseek-chat)
# AI_CLOUD_CHAT_MODEL=gemma4:31b-cloud

View File

@ -1,5 +1,7 @@
services:
backend:
profiles:
- all
container_name: decompile-ai-backend
build: .
ports:
@ -20,6 +22,9 @@ services:
- decompile-ai-network
postgres:
profiles:
- dev
- all
container_name: decompile-ai-postgres
image: pgvector/pgvector:pg18
environment:
@ -39,6 +44,9 @@ services:
- decompile-ai-network
rabbitmq:
profiles:
- dev
- all
container_name: decompile-ai-rabbitmq
image: rabbitmq:4-management
ports:

10
pom.xml
View File

@ -151,10 +151,19 @@
</exclusion>
</exclusions>
</dependency>
<dependency>
<groupId>org.springframework.ai</groupId>
<artifactId>spring-ai-openai</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.ai</groupId>
<artifactId>spring-ai-client-chat</artifactId>
</dependency>
<dependency>
<groupId>me.paulschwarz</groupId>
<artifactId>spring-dotenv</artifactId>
<version>5.1.0</version>
</dependency>
</dependencies>
<dependencyManagement>
@ -193,6 +202,7 @@
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-compiler-plugin</artifactId>
<version>3.15.0</version>
<executions>
<execution>
<id>default-compile</id>

View File

@ -1,13 +1,17 @@
package ai.decompile.ai.config;
import io.micrometer.observation.ObservationRegistry;
import java.time.Duration;
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.OllamaChatOptions;
import org.springframework.ai.ollama.api.OllamaEmbeddingOptions;
import org.springframework.ai.ollama.management.ModelManagementOptions;
import org.springframework.ai.openai.OpenAiChatModel;
import org.springframework.ai.openai.OpenAiChatOptions;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.boot.autoconfigure.condition.ConditionalOnExpression;
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
@ -29,12 +33,25 @@ public class AiConfig {
@Value("${spring.ai.ollama.embedding.options.model:qwen3-embedding:latest}")
private String ollamaEmbeddingModel;
@Value("${AI_CLOUD_API_KEY:}")
private String cloudApiKey;
@Value("${AI_CLOUD_BASE_URL:http://localhost:11434/v1}")
private String cloudBaseUrl;
@Value("${AI_CLOUD_CHAT_MODEL:gemma4:12b}")
private String cloudChatModel;
@Bean
OllamaApi ollamaApi() {
return OllamaApi.builder().baseUrl(ollamaBaseUrl).build();
}
@Bean
@ConditionalOnProperty(
name = "app.ai.default-chat-provider",
havingValue = "ollama",
matchIfMissing = true)
OllamaChatModel ollamaChatModel(OllamaApi ollamaApi, ObservationRegistry observationRegistry) {
return OllamaChatModel.builder()
.ollamaApi(ollamaApi)
@ -57,4 +74,20 @@ public class AiConfig {
.modelManagementOptions(ModelManagementOptions.defaults())
.build();
}
@Bean
@ConditionalOnExpression(
"'${app.ai.default-chat-provider:ollama}' != 'ollama' && '${AI_CLOUD_API_KEY:}' != ''")
OpenAiChatModel openAiChatModel(ObservationRegistry observationRegistry) {
return OpenAiChatModel.builder()
.options(
OpenAiChatOptions.builder()
.baseUrl(cloudBaseUrl)
.apiKey(cloudApiKey)
.model(cloudChatModel)
.timeout(Duration.ofMinutes(5))
.build())
.observationRegistry(observationRegistry)
.build();
}
}

View File

@ -45,7 +45,7 @@ public class ChatController {
public ResponseEntity<SessionResponse> createSession(
@PathVariable UUID binaryId,
@RequestParam(required = false) UUID projectId,
@RequestParam(required = false, defaultValue = "gemma4:12b") String model) {
@RequestParam(required = false) String model) {
if (projectId == null) {
return ResponseEntity.badRequest().build();
}

View File

@ -39,7 +39,7 @@ public class EmbeddingChunk {
private String content;
@JdbcTypeCode(SqlTypes.VECTOR)
@Column(name = "embedding", columnDefinition = "vector(4096)")
@Column(name = "embedding", columnDefinition = "vector(768)")
private float[] embedding;
@JdbcTypeCode(SqlTypes.JSON)

View File

@ -51,6 +51,8 @@ public interface EmbeddingChunkRepository extends JpaRepository<EmbeddingChunk,
void deleteByBinaryId(UUID binaryId);
void deleteByBinaryIdAndChunkType(UUID binaryId, String chunkType);
@Modifying
@Transactional
@Query("DELETE FROM EmbeddingChunk e WHERE e.sourceId = :sourceId AND e.sourceType = :sourceType")

View File

@ -30,8 +30,14 @@ public class ChatSessionService {
private final StaticAnalysisRepository analysisRepository;
private final StreamingModelProvider streamingModelProvider;
@Value("${app.ai.chat.default-model:gemma4:12b}")
private String defaultModel;
@Value("${AI_CLOUD_CHAT_MODEL:gemma4:12b}")
private String cloudChatModel;
@Value("${spring.ai.ollama.chat.options.model:gemma4:12b}")
private String ollamaChatModel;
@Value("${app.ai.default-chat-provider:ollama}")
private String chatProvider;
@Transactional
public ChatSession createSession(UUID binaryId, UUID projectId, String model) {
@ -46,7 +52,7 @@ public class ChatSessionService {
+ "' has no static analysis yet. Run static analysis before starting a chat session.");
}
var effectiveModel = Objects.nonNull(model) ? model : defaultModel;
var effectiveModel = Objects.nonNull(model) ? model : resolveDefaultModel();
streamingModelProvider.resolve();
var session =
@ -60,6 +66,14 @@ public class ChatSessionService {
return sessionRepository.save(session);
}
private String resolveDefaultModel() {
if ("ollama".equals(chatProvider)) {
return ollamaChatModel;
}
return cloudChatModel;
}
@Transactional(readOnly = true)
public List<ChatSession> getSessions(UUID binaryId) {
return sessionRepository.findByBinaryIdOrderByUpdatedAtDesc(binaryId);

View File

@ -1,7 +1,6 @@
package ai.decompile.ai.service;
import ai.decompile.ai.model.entity.EmbeddingChunk;
import ai.decompile.analysis.model.entity.MutationSuggestion;
import ai.decompile.analysis.model.entity.StaticAnalysis;
import ai.decompile.analysis.model.entity.StaticDataLabel;
import ai.decompile.analysis.model.entity.StaticEnum;
@ -87,10 +86,30 @@ public class EmbeddingChunkBuilder {
}
var analysisId = functions.getFirst().getAnalysis().getId();
return mutationRepository.findApprovedFunctionRenamesByAnalysisId(analysisId).stream()
.collect(
java.util.stream.Collectors.toMap(
MutationSuggestion::getOldValue, MutationSuggestion::getNewValue, (_, b) -> b));
var functionRenames = mutationRepository.findApprovedFunctionRenamesByAnalysisId(analysisId);
var labelRenames = mutationRepository.findApprovedLabelRenamesByAnalysisId(analysisId);
var globalVarRenames = mutationRepository.findApprovedGlobalVarRenamesByAnalysisId(analysisId);
var structRenames = mutationRepository.findApprovedStructRenamesByAnalysisId(analysisId);
var map = new HashMap<String, String>();
for (var m : labelRenames) {
map.put(m.getOldValue(), m.getNewValue());
}
for (var m : globalVarRenames) {
map.put(m.getOldValue(), m.getNewValue());
}
for (var m : structRenames) {
map.put(m.getOldValue(), m.getNewValue());
}
for (var m : functionRenames) {
map.put(m.getOldValue(), m.getNewValue());
}
return map;
}
public EmbeddingChunk buildMetadataChunk(

View File

@ -133,6 +133,31 @@ public class EmbeddingService {
binaryId);
}
@Transactional
public void reindexAllFunctions(UUID binaryId) {
var analysis = getLatestAnalysis(binaryId);
if (Objects.isNull(analysis)) {
log.warn("reindexAllFunctions: no analysis found for binary {}, skipping", binaryId);
return;
}
var functions = analysis.getFunctions();
if (functions.isEmpty()) {
log.debug("reindexAllFunctions: no functions for binary {}, nothing to do", binaryId);
return;
}
var binary = binaryService.getBinary(binaryId);
embeddingChunkRepository.deleteByBinaryIdAndChunkType(binaryId, "FUNCTION");
embeddingChunkRepository.flush();
var chunks = chunkBuilder.buildFunctionChunks(functions, binary);
embedAndPersistChunks(chunks);
log.info("Re-indexed all {} function chunks for binary {}", functions.size(), binaryId);
}
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()));

View File

@ -6,6 +6,7 @@ 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.ConditionalOnExpression;
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
import org.springframework.stereotype.Component;
@ -13,6 +14,7 @@ import org.springframework.stereotype.Component;
@Component
@RequiredArgsConstructor
@ConditionalOnProperty(name = "app.ai.enabled", havingValue = "true", matchIfMissing = true)
@ConditionalOnExpression("'${app.ai.default-chat-provider:ollama}' == 'ollama'")
public class OllamaStreamingModelProvider implements StreamingModelProvider {
private final ObjectProvider<OllamaChatModel> ollamaChatModelProvider;

View File

@ -0,0 +1,32 @@
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.openai.OpenAiChatModel;
import org.springframework.beans.factory.ObjectProvider;
import org.springframework.boot.autoconfigure.condition.ConditionalOnExpression;
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
import org.springframework.stereotype.Component;
@Log4j2
@Component
@RequiredArgsConstructor
@ConditionalOnProperty(name = "app.ai.enabled", havingValue = "true", matchIfMissing = true)
@ConditionalOnExpression("'${app.ai.default-chat-provider:ollama}' != 'ollama'")
public class OpenAiStreamingModelProvider implements StreamingModelProvider {
private final ObjectProvider<OpenAiChatModel> openAiChatModelProvider;
@Override
public StreamingChatModel resolve() {
var model = openAiChatModelProvider.getIfAvailable();
if (Objects.isNull(model)) {
throw new IllegalStateException(
"Cloud chat is not configured. Set AI_CLOUD_API_KEY in your .env file.");
}
log.info("Using cloud chat provider with model: {}", model);
return model;
}
}

View File

@ -1,17 +1,21 @@
package ai.decompile.analysis.controller;
import ai.decompile.analysis.model.dto.LibraryExport;
import ai.decompile.analysis.model.dto.LibraryResponse;
import ai.decompile.analysis.model.dto.LibrarySymbolResponse;
import ai.decompile.analysis.service.LibraryService;
import ai.decompile.analysis.service.SymbolResolutionService;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.Parameter;
import java.nio.charset.StandardCharsets;
import java.util.List;
import java.util.Map;
import java.util.UUID;
import lombok.RequiredArgsConstructor;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.Pageable;
import org.springframework.http.ContentDisposition;
import org.springframework.http.HttpHeaders;
import org.springframework.http.HttpStatus;
import org.springframework.http.MediaType;
import org.springframework.http.ResponseEntity;
@ -47,6 +51,19 @@ public class LibraryController {
return ResponseEntity.status(HttpStatus.CREATED).body(lib);
}
@Operation(
summary = "Import a library from JSON export",
description =
"Create a new library from a previously exported JSON backup. "
+ "The new library gets its own IDs — existing libraries are not affected.",
tags = {"Library Symbols"},
operationId = "importLibrary")
@PostMapping("/libraries/import")
public ResponseEntity<LibraryResponse> importLibrary(@RequestBody LibraryExport data) {
var lib = libraryService.importFromExport(data);
return ResponseEntity.status(HttpStatus.CREATED).body(lib);
}
@Operation(
summary = "List available parsers",
description = "Retrieve the list of supported library symbol parser formats.",
@ -77,6 +94,26 @@ public class LibraryController {
return ResponseEntity.ok(libraryService.getLibrary(libraryId));
}
@Operation(
summary = "Export a library as JSON",
description =
"Download the complete library (name, description, aliases, and all symbols) "
+ "as a JSON file for backup or transfer.",
tags = {"Library Symbols"},
operationId = "exportLibrary")
@GetMapping("/libraries/{libraryId}/export")
public ResponseEntity<LibraryExport> exportLibrary(@PathVariable UUID libraryId) {
var data = libraryService.exportLibrary(libraryId);
var filename = sanitizeFilename(data.name()) + ".json";
var headers = new HttpHeaders();
headers.setContentType(MediaType.APPLICATION_JSON);
headers.setContentDisposition(
ContentDisposition.attachment().filename(filename, StandardCharsets.UTF_8).build());
return ResponseEntity.ok().headers(headers).body(data);
}
@Operation(
summary = "Delete a library",
description = "Delete a library and all its associated symbols and aliases.",
@ -122,4 +159,8 @@ public class LibraryController {
var resolvedCount = symbolResolutionService.resolveAllUnresolved();
return ResponseEntity.ok(Map.of("resolvedCount", resolvedCount));
}
private String sanitizeFilename(String name) {
return name.replaceAll("[^a-zA-Z0-9._-]", "_");
}
}

View File

@ -0,0 +1,47 @@
package ai.decompile.analysis.model.dto;
import ai.decompile.analysis.model.entity.Library;
import ai.decompile.analysis.model.entity.LibrarySymbol;
import com.fasterxml.jackson.annotation.JsonRootName;
import java.util.List;
import java.util.Objects;
@JsonRootName("library")
public record LibraryExport(
String name, String description, List<String> aliases, List<SymbolEntry> symbols) {
public record SymbolEntry(
String moduleName,
String className,
String symbolName,
int ordinal,
String symbolType,
String parameters,
String category) {
static SymbolEntry from(LibrarySymbol sym) {
return new SymbolEntry(
sym.getModuleName(),
sym.getClassName(),
sym.getSymbolName(),
sym.getOrdinal(),
sym.getSymbolType(),
sym.getParameters(),
sym.getCategory());
}
}
public static LibraryExport from(Library library) {
var aliasList =
Objects.nonNull(library.getAliases())
? library.getAliases().stream().map(a -> a.getAlias()).toList()
: List.<String>of();
var symbolList =
Objects.nonNull(library.getSymbols())
? library.getSymbols().stream().map(SymbolEntry::from).toList()
: List.<SymbolEntry>of();
return new LibraryExport(library.getName(), library.getDescription(), aliasList, symbolList);
}
}

View File

@ -39,4 +39,38 @@ public interface MutationSuggestionRepository extends JpaRepository<MutationSugg
""")
List<MutationSuggestion> findApprovedFunctionRenamesByAnalysisId(
@Param("analysisId") UUID analysisId);
@Query(
"""
SELECT m FROM MutationSuggestion m
JOIN StaticLabel l ON l.id = m.targetId
JOIN StaticFunction f ON f.id = l.function.id
JOIN StaticAnalysis a ON a.id = f.analysis.id
WHERE m.targetType = 'LABEL' AND m.fieldName = 'name'
AND a.id = :analysisId AND m.status = 'APPROVED'
""")
List<MutationSuggestion> findApprovedLabelRenamesByAnalysisId(
@Param("analysisId") UUID analysisId);
@Query(
"""
SELECT m FROM MutationSuggestion m
JOIN StaticDataLabel l ON l.id = m.targetId
JOIN StaticAnalysis a ON a.id = l.analysis.id
WHERE m.targetType = 'GLOBAL_VAR' AND m.fieldName = 'name'
AND a.id = :analysisId AND m.status = 'APPROVED'
""")
List<MutationSuggestion> findApprovedGlobalVarRenamesByAnalysisId(
@Param("analysisId") UUID analysisId);
@Query(
"""
SELECT m FROM MutationSuggestion m
JOIN StaticStruct s ON s.id = m.targetId
JOIN StaticAnalysis a ON a.id = s.analysis.id
WHERE m.targetType = 'STRUCT' AND m.fieldName = 'name'
AND a.id = :analysisId AND m.status = 'APPROVED'
""")
List<MutationSuggestion> findApprovedStructRenamesByAnalysisId(
@Param("analysisId") UUID analysisId);
}

View File

@ -1,5 +1,6 @@
package ai.decompile.analysis.service;
import ai.decompile.analysis.model.dto.LibraryExport;
import ai.decompile.analysis.model.dto.LibraryResponse;
import ai.decompile.analysis.model.dto.LibrarySymbolResponse;
import ai.decompile.analysis.model.entity.Library;
@ -35,6 +36,7 @@ public class LibraryService {
private final LibraryAliasRepository aliasRepository;
private final LibrarySymbolRepository symbolRepository;
private final LibrarySymbolParserRegistry parserRegistry;
private final SymbolResolutionService symbolResolutionService;
@Transactional
public LibraryResponse parseAndStore(
@ -85,6 +87,64 @@ public class LibraryService {
return LibraryResponse.from(library);
}
@Transactional
public LibraryResponse importFromExport(LibraryExport data) {
var name = data.name();
if (Objects.isNull(name) || name.isBlank()) {
throw new IllegalArgumentException("Library name is required");
}
var library =
libraryRepository.save(
Library.builder().name(name).description(data.description()).build());
var aliasList = data.aliases();
if (Objects.nonNull(aliasList)) {
for (var alias : aliasList) {
aliasRepository.save(LibraryAlias.builder().library(library).alias(alias).build());
}
}
var symbolList = data.symbols();
if (Objects.nonNull(symbolList) && !symbolList.isEmpty()) {
var batch = new ArrayList<LibrarySymbol>(SYMBOL_BATCH_SIZE);
for (var entry : symbolList) {
batch.add(
LibrarySymbol.builder()
.library(library)
.moduleName(entry.moduleName())
.className(entry.className())
.symbolName(entry.symbolName())
.ordinal(entry.ordinal())
.symbolType(entry.symbolType())
.parameters(entry.parameters())
.category(entry.category())
.build());
if (batch.size() >= SYMBOL_BATCH_SIZE) {
symbolRepository.saveAll(batch);
batch.clear();
}
}
if (!batch.isEmpty()) {
symbolRepository.saveAll(batch);
}
}
var aliasCount = Objects.nonNull(aliasList) ? aliasList.size() : 0;
var symbolCount = Objects.nonNull(symbolList) ? symbolList.size() : 0;
log.info("Library '{}' imported with {} symbols and {} aliases", name, symbolCount, aliasCount);
var resolved = symbolResolutionService.resolveAllUnresolved();
if (resolved > 0) {
log.info("Import of '{}' resolved {} previously unresolved imports", name, resolved);
}
return LibraryResponse.from(library);
}
@Transactional(readOnly = true)
public List<LibraryResponse> listLibraries() {
return libraryRepository.findAll().stream().map(LibraryResponse::from).toList();
@ -109,6 +169,13 @@ public class LibraryService {
.map(LibrarySymbolResponse::from);
}
@Transactional(readOnly = true)
public LibraryExport exportLibrary(UUID id) {
var library = findLibrary(id);
return LibraryExport.from(library);
}
@Transactional(readOnly = true)
public LibrarySymbolResponse getSymbol(UUID id) {
return LibrarySymbolResponse.from(findSymbol(id));

View File

@ -175,8 +175,7 @@ public class MutationService {
var fieldName = "comments";
var oldValue = handler.getCurrentValue(functionId, fieldName);
handler.applyComment(functionId, comment, address);
var newValue = handler.getCurrentValue(functionId, fieldName);
var newValue = handler.computeUpdatedComments(oldValue, comment, address);
var mutation =
buildMutation(targetType, functionId, fieldName, oldValue, newValue, source, "PENDING");
@ -285,10 +284,30 @@ public class MutationService {
@Transactional(readOnly = true)
public Map<String, String> buildRenameMap(UUID analysisId) {
return mutationRepository.findApprovedFunctionRenamesByAnalysisId(analysisId).stream()
.collect(
Collectors.toMap(
MutationSuggestion::getOldValue, MutationSuggestion::getNewValue, (_, b) -> b));
var functionRenames = mutationRepository.findApprovedFunctionRenamesByAnalysisId(analysisId);
var labelRenames = mutationRepository.findApprovedLabelRenamesByAnalysisId(analysisId);
var globalVarRenames = mutationRepository.findApprovedGlobalVarRenamesByAnalysisId(analysisId);
var structRenames = mutationRepository.findApprovedStructRenamesByAnalysisId(analysisId);
var map = new java.util.HashMap<String, String>();
for (var m : labelRenames) {
map.put(m.getOldValue(), m.getNewValue());
}
for (var m : globalVarRenames) {
map.put(m.getOldValue(), m.getNewValue());
}
for (var m : structRenames) {
map.put(m.getOldValue(), m.getNewValue());
}
for (var m : functionRenames) {
map.put(m.getOldValue(), m.getNewValue());
}
return map;
}
@Transactional(readOnly = true)

View File

@ -52,7 +52,13 @@ public class FunctionTargetHandler implements MutationTargetHandler {
switch (fieldName) {
case "name" -> func.setName(newValue);
case "signature" -> func.setSignature(newValue);
case "comments" -> func.setComments(appendComment(func.getComments(), newValue, ""));
case "comments" -> {
if ("SET".equals(operation)) {
func.setComments(newValue);
} else {
func.setComments(appendComment(func.getComments(), newValue, ""));
}
}
case "flags" -> func.setFlags(applyFlagOperation(func.getFlags(), newValue, operation));
default -> throw new IllegalArgumentException("Unknown field: " + fieldName);
}
@ -72,7 +78,13 @@ public class FunctionTargetHandler implements MutationTargetHandler {
functionRepository.save(func);
}
public String computeUpdatedComments(String existingComments, String comment, String address) {
return appendComment(existingComments, comment, address);
}
private String appendComment(String existingComments, String newCommentText, String address) {
validateNotJsonArray(newCommentText);
var list = new ArrayList<Map<String, Object>>();
if (Objects.nonNull(existingComments)
@ -104,6 +116,14 @@ public class FunctionTargetHandler implements MutationTargetHandler {
}
}
private void validateNotJsonArray(String commentText) {
var trimmed = commentText.trim();
if (trimmed.startsWith("[") && trimmed.endsWith("]")) {
throw new IllegalArgumentException(
"Comment text looks like a JSON array. Pass only the comment text, not the full comments array.");
}
}
private String applyFlagOperation(String currentFlags, String flag, String operation) {
var parts = new ArrayList<String>();
if (Objects.nonNull(currentFlags) && !currentFlags.isBlank()) {

View File

@ -3,11 +3,13 @@ package ai.decompile.job.service;
import ai.decompile.ai.service.EmbeddingService;
import ai.decompile.analysis.event.MutationAppliedEvent;
import ai.decompile.analysis.event.StaticAnalysisCompletedEvent;
import ai.decompile.analysis.model.repository.StaticLabelRepository;
import ai.decompile.job.messaging.JobMessagePublisher;
import ai.decompile.job.model.entity.Job;
import ai.decompile.job.model.enums.JobStatus;
import ai.decompile.job.model.enums.JobType;
import ai.decompile.job.model.repository.JobRepository;
import java.util.Objects;
import lombok.RequiredArgsConstructor;
import lombok.extern.log4j.Log4j2;
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
@ -24,6 +26,7 @@ public class EmbeddingEventSubscriber {
private final JobRepository jobRepository;
private final JobMessagePublisher jobMessagePublisher;
private final EmbeddingService embeddingService;
private final StaticLabelRepository labelRepository;
@ApplicationModuleListener
public void on(StaticAnalysisCompletedEvent event) {
@ -48,8 +51,25 @@ public class EmbeddingEventSubscriber {
event.fieldName(),
event.targetId());
if ("FUNCTION".equals(event.targetType()) && "name".equals(event.fieldName())) {
if (!"name".equals(event.fieldName())) {
return;
}
switch (event.targetType()) {
case "FUNCTION":
embeddingService.reindexFunction(event.targetId(), event.binaryId());
break;
case "LABEL":
var label = labelRepository.findById(event.targetId()).orElse(null);
if (Objects.nonNull(label)) {
embeddingService.reindexFunction(label.getFunction().getId(), event.binaryId());
}
break;
case "GLOBAL_VAR", "STRUCT":
embeddingService.reindexAllFunctions(event.binaryId());
break;
default:
break;
}
}

View File

@ -29,13 +29,18 @@ spring:
base-url: http://localhost:11434
embedding:
options:
model: ${AI_EMBEDDING_MODEL:qwen3-embedding:latest}
model: ${AI_EMBEDDING_MODEL:nomic-embed-text}
chat:
options:
model: ${AI_CHAT_MODEL_OLLAMA:gemma4:12b}
temperature: 0.3
top-p: 0.95
top-k: 64
openai:
base-url: ${AI_CLOUD_BASE_URL:http://localhost:11434/v1}
chat:
options:
model: ${AI_CLOUD_CHAT_MODEL:gemma4:12b}
app:
storage:
@ -63,7 +68,7 @@ app:
ai:
default-chat-provider: ${AI_DEFAULT_CHAT_PROVIDER:ollama}
embedding:
dimension: ${AI_EMBEDDING_DIMENSION:4096}
dimension: ${AI_EMBEDDING_DIMENSION:768}
chunk-max-chars: 3000
batch-size: 20
rag:

View File

@ -0,0 +1,2 @@
ALTER TABLE embedding_chunk
ALTER COLUMN embedding TYPE vector(768);

View File

@ -23,6 +23,7 @@ spring:
autoconfigure:
exclude:
- org.springframework.ai.autoconfigure.ollama.OllamaAutoConfiguration
- org.springframework.ai.autoconfigure.openai.OpenAiAutoConfiguration
app:
storage: