Files
indie/backend/src/main/java/com/krrishg/service/LLMService.java
2026-07-10 11:39:19 +05:45

286 lines
13 KiB
Java

package com.krrishg.service;
import com.krrishg.config.EncryptionService;
import com.krrishg.dto.LLMOptions;
import com.krrishg.dto.LLMResult;
import com.krrishg.dto.Reference;
import com.krrishg.dto.SiteStructure;
import org.springframework.web.client.HttpClientErrorException;
import org.springframework.web.client.ResourceAccessException;
import com.krrishg.model.GenerationVersion;
import com.krrishg.model.User;
import com.krrishg.repository.UserRepository;
import com.krrishg.service.llm.LLMProvider;
import com.krrishg.service.llm.ProviderFactory;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.core.io.Resource;
import org.springframework.core.io.ResourceLoader;
import org.springframework.data.domain.Sort;
import org.springframework.data.mongodb.core.MongoTemplate;
import org.springframework.data.mongodb.core.query.Criteria;
import org.springframework.data.mongodb.core.query.Query;
import org.springframework.stereotype.Service;
import java.io.BufferedReader;
import java.io.IOException;
import java.io.InputStreamReader;
import java.nio.charset.StandardCharsets;
import java.time.LocalDateTime;
import java.util.List;
import java.util.Map;
import java.util.UUID;
import java.util.stream.Collectors;
@Service
public class LLMService {
private static final Logger log = LoggerFactory.getLogger(LLMService.class);
private static final int MAX_VERSIONS_PER_SITE = 100;
private final ProviderFactory providerFactory;
private final LLMResponseParser responseParser;
private final ReferenceService referenceService;
private final MongoTemplate mongoTemplate;
private final UserRepository userRepository;
private final EncryptionService encryptionService;
private final String systemPrompt;
private final String refinerPrompt;
public LLMService(ProviderFactory providerFactory,
LLMResponseParser responseParser,
ReferenceService referenceService,
MongoTemplate mongoTemplate,
UserRepository userRepository,
EncryptionService encryptionService,
ResourceLoader resourceLoader,
@Value("${indie.llm.system-prompt-path:classpath:prompts/site-generator.txt}") String promptPath,
@Value("${indie.llm.refiner-prompt-path:classpath:prompts/site-refiner.txt}") String refinerPath) {
this.providerFactory = providerFactory;
this.responseParser = responseParser;
this.referenceService = referenceService;
this.mongoTemplate = mongoTemplate;
this.userRepository = userRepository;
this.encryptionService = encryptionService;
this.systemPrompt = loadSystemPrompt(resourceLoader, promptPath);
this.refinerPrompt = loadSystemPrompt(resourceLoader, refinerPath);
}
public SiteStructure generateSite(UUID userId, UUID siteId, String prompt,
List<Reference> references, String provider, String apiKey) {
return generateSite(userId, siteId, prompt, references, provider, apiKey, null, null);
}
public SiteStructure generateSite(UUID userId, UUID siteId, String prompt,
List<Reference> references, String provider, String apiKey, String baseUrl) {
return generateSite(userId, siteId, prompt, references, provider, apiKey, baseUrl, null);
}
public SiteStructure generateSite(UUID userId, UUID siteId, String prompt,
List<Reference> references, String provider, String apiKey, String baseUrl, String model) {
String resolvedKey = resolveApiKey(userId, provider, apiKey);
String resolvedBaseUrl = resolveBaseUrl(userId, provider, baseUrl);
String resolvedModel = resolveModel(userId, provider, model);
String referenceContext = referenceService.buildReferenceContext(references);
String fullPrompt = prompt + referenceContext;
LLMOptions options = LLMOptions.builder().build();
LLMResult result = callLLM(provider, resolvedKey, resolvedBaseUrl, resolvedModel, systemPrompt, fullPrompt, references, options);
log.info("LLM generation completed: model={}, latency={}ms, tokens={}+{}",
result.getModel(), result.getLatencyMs(),
result.getPromptTokens(), result.getCompletionTokens());
SiteStructure structure = responseParser.parse(result.getContent());
saveVersion(userId, siteId, prompt, references, result.getContent());
return structure;
}
public SiteStructure refineSite(UUID userId, UUID siteId, SiteStructure currentStructure,
String refinementPrompt, List<Reference> references,
String provider, String apiKey) {
return refineSite(userId, siteId, currentStructure, refinementPrompt, references, provider, apiKey, null, null);
}
public SiteStructure refineSite(UUID userId, UUID siteId, SiteStructure currentStructure,
String refinementPrompt, List<Reference> references,
String provider, String apiKey, String baseUrl) {
return refineSite(userId, siteId, currentStructure, refinementPrompt, references, provider, apiKey, baseUrl, null);
}
public SiteStructure refineSite(UUID userId, UUID siteId, SiteStructure currentStructure,
String refinementPrompt, List<Reference> references,
String provider, String apiKey, String baseUrl, String model) {
String resolvedKey = resolveApiKey(userId, provider, apiKey);
String resolvedBaseUrl = resolveBaseUrl(userId, provider, baseUrl);
String resolvedModel = resolveModel(userId, provider, model);
String currentJson = serializeStructure(currentStructure);
String referenceContext = referenceService.buildReferenceContext(references);
String combinedPrompt = "Current site structure:\n" + currentJson
+ "\n\nRefinement request: " + refinementPrompt
+ referenceContext
+ "\n\nOutput the complete updated JSON structure with the requested changes applied.";
LLMOptions options = LLMOptions.builder()
.temperature(0.5)
.build();
LLMResult result = callLLM(provider, resolvedKey, resolvedBaseUrl, resolvedModel, refinerPrompt, combinedPrompt, references, options);
log.info("LLM refinement completed: model={}, latency={}ms, tokens={}+{}",
result.getModel(), result.getLatencyMs(),
result.getPromptTokens(), result.getCompletionTokens());
SiteStructure structure = responseParser.parse(result.getContent());
saveVersion(userId, siteId, refinementPrompt, references, result.getContent());
return structure;
}
public List<GenerationVersion> getVersions(UUID siteId) {
Query query = new Query(Criteria.where("siteId").is(siteId))
.with(Sort.by(Sort.Direction.DESC, "versionNumber"))
.limit(MAX_VERSIONS_PER_SITE);
return mongoTemplate.find(query, GenerationVersion.class);
}
public SiteStructure restoreVersion(String versionId) {
GenerationVersion version = mongoTemplate.findById(versionId, GenerationVersion.class);
if (version == null) {
throw new IllegalArgumentException("Version not found: " + versionId);
}
return responseParser.parse(version.getFullStructure());
}
public void deleteVersion(String versionId) {
GenerationVersion version = mongoTemplate.findById(versionId, GenerationVersion.class);
if (version == null) {
throw new IllegalArgumentException("Version not found: " + versionId);
}
mongoTemplate.remove(version);
}
private String resolveApiKey(UUID userId, String provider, String apiKey) {
if (apiKey != null && !apiKey.isBlank()) {
return apiKey;
}
User user = userRepository.findById(userId)
.orElseThrow(() -> new IllegalArgumentException("User not found"));
Map<String, String> keys = user.getLlmApiKeys();
if (keys != null && keys.containsKey(provider)) {
return encryptionService.decrypt(keys.get(provider));
}
throw new IllegalStateException("No API key configured for " + provider
+ ". Add one in Settings.");
}
private String resolveModel(UUID userId, String provider, String model) {
if (model != null && !model.isBlank()) {
return model;
}
User user = userRepository.findById(userId)
.orElseThrow(() -> new IllegalArgumentException("User not found"));
Map<String, String> models = user.getLlmModels();
if (models != null && models.containsKey(provider)) {
return models.get(provider);
}
return null;
}
private String resolveBaseUrl(UUID userId, String provider, String baseUrl) {
if (baseUrl != null && !baseUrl.isBlank()) {
return baseUrl;
}
User user = userRepository.findById(userId)
.orElseThrow(() -> new IllegalArgumentException("User not found"));
Map<String, String> urls = user.getLlmBaseUrls();
if (urls != null && urls.containsKey(provider)) {
return urls.get(provider);
}
return null;
}
private LLMResult callLLM(String provider, String apiKey, String baseUrl, String model, String systemPrompt,
String userPrompt, List<Reference> references, LLMOptions options) {
LLMProvider p = providerFactory.createProvider(provider, apiKey, baseUrl, model);
try {
return p.generate(systemPrompt, userPrompt, references, options);
} catch (ResourceAccessException e) {
throw new IllegalStateException(
provider + " timed out. Please try again or use a simpler prompt.");
} catch (HttpClientErrorException e) {
String detail = e.getResponseBodyAsString();
String msg = switch (e.getStatusCode().value()) {
case 401 -> provider + " rejected the API key (401 Unauthorized). Verify the key is valid in Settings.";
case 429 -> provider + " rate limit exceeded (429). Try again later.";
default -> {
String body = (detail != null && !detail.isBlank()) ? ": " + detail : "";
yield provider + " API error: " + e.getStatusCode() + " " + e.getStatusText() + body;
}
};
throw new IllegalStateException(msg);
}
}
private void saveVersion(UUID userId, UUID siteId, String prompt,
List<Reference> references, String generatedJson) {
int nextVersion = getNextVersionNumber(siteId);
List<GenerationVersion.ReferenceEntry> refEntries = null;
if (references != null) {
refEntries = references.stream()
.map(r -> GenerationVersion.ReferenceEntry.builder()
.type(r.getType().name())
.url(r.getUrl())
.altText(r.getAltText())
.build())
.toList();
}
GenerationVersion version = GenerationVersion.builder()
.siteId(siteId)
.userId(userId)
.versionNumber(nextVersion)
.prompt(prompt)
.references(refEntries)
.fullStructure(generatedJson)
.createdAt(LocalDateTime.now())
.build();
mongoTemplate.save(version);
}
private int getNextVersionNumber(UUID siteId) {
Query query = new Query(Criteria.where("siteId").is(siteId))
.with(Sort.by(Sort.Direction.DESC, "versionNumber"))
.limit(1);
GenerationVersion latest = mongoTemplate.findOne(query, GenerationVersion.class);
return latest != null ? latest.getVersionNumber() + 1 : 1;
}
private String loadSystemPrompt(ResourceLoader resourceLoader, String path) {
try {
Resource resource = resourceLoader.getResource(path);
if (!resource.exists()) {
log.warn("System prompt file not found at {}, using default", path);
return "You are a website generator. Given a user's description, output a valid JSON site structure.";
}
try (BufferedReader reader = new BufferedReader(
new InputStreamReader(resource.getInputStream(), StandardCharsets.UTF_8))) {
return reader.lines().collect(Collectors.joining("\n"));
}
} catch (IOException e) {
log.warn("Failed to load system prompt from {}: {}", path, e.getMessage());
return "You are a website generator. Given a user's description, output a valid JSON site structure.";
}
}
private String serializeStructure(SiteStructure structure) {
try {
ObjectMapper mapper = new ObjectMapper();
return mapper.writerWithDefaultPrettyPrinter().writeValueAsString(structure);
} catch (Exception e) {
throw new RuntimeException("Failed to serialize site structure", e);
}
}
}