Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -2481,3 +2481,5 @@
/sonar-export/issues/
/sonar-export-fresh/issues/
/test-results/
/doc-noindex/presentation/intro/goodone_technical_notes_package/
/doc-noindex/presentation/intro/goodone_speaker_notes/
1 change: 1 addition & 0 deletions .junie/guidelines.md
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,7 @@ This document outlines the best practices and standards for the GoodOne project.
- Application can be demoed repeatedly without manual fixes.
- Playwright UX guardrails are successful (`npx playwright test e2e/ux-guardrails.spec.ts`).
- **Task Log Update**: The relevant task `.md` file MUST be updated with a log entry and testing instructions.
- **Traceability**: PR and Commit links MUST be added to the YAML frontmatter and `## Links` section.
- **Basic Regression**: All relevant unit and E2E tests MUST pass before reporting "DONE".
- **Docker First**: Ensure all changes are compatible with the Docker-based deployment.
- **Language**: Always communicate in English for all interactions, thoughts, and documentation, unless explicitly requested otherwise by the user.
Expand Down
17 changes: 9 additions & 8 deletions backend/src/main/java/ch/goodone/backend/ai/AiProperties.java
Original file line number Diff line number Diff line change
Expand Up @@ -15,11 +15,11 @@ public class AiProperties {
private String disabledMessage = "AI features are currently disabled by the administrator.";
private boolean loggingDetailed = false;

private CapabilityConfig quickAdd;
private CapabilityConfig architecture;
private CapabilityConfig retrospective;
private CapabilityConfig embedding;
private EvaluationConfig evaluation;
private CapabilityConfig quickAdd = new CapabilityConfig();
private CapabilityConfig architecture = new CapabilityConfig();
private CapabilityConfig retrospective = new CapabilityConfig();
private CapabilityConfig embedding = new CapabilityConfig();
private EvaluationConfig evaluation = new EvaluationConfig();
private PromptConfig prompt = new PromptConfig();

/**
Expand All @@ -35,6 +35,7 @@ public String resolvePromptVersion(String feature, String defaultVersion) {
return defaultVersion;
}
}

private RoutingConfig routing = new RoutingConfig();
private Map<String, ModelPrice> pricing;
private OpenAiConfig openai;
Expand Down Expand Up @@ -83,7 +84,7 @@ public static class RoutingConfig {
@Data
public static class OpenAiConfig {
private String apiKey;
private String chatModel = "gpt-4o";
private String chatModel = "gpt-4o-mini";
private String embeddingModel = "text-embedding-3-small";
private String baseUrl = "https://api.openai.com/v1";
private Double temperature;
Expand All @@ -104,8 +105,8 @@ public static class OllamaConfig {

@Data
public static class CapabilityConfig {
private String provider;
private String model;
private String provider = "openai";
private String model = "gpt-4o-mini";
private boolean enabled = true;
private int topK = 6;
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -71,8 +71,12 @@ public String explainBacklogLeakage(AiIntelligenceDashboardDto.BacklogLeakageSum

private String callJudge(String promptText, String operation, String sprintId) {
ChatModel model = aiProviderService.getEvaluationChatModel();
String modelName = aiProperties.getEvaluation().getModel();
String provider = aiProperties.getEvaluation().getProvider();
String modelName = (aiProperties.getEvaluation() != null && aiProperties.getEvaluation().getModel() != null)
? aiProperties.getEvaluation().getModel()
: "gpt-4o-mini";
String provider = (aiProperties.getEvaluation() != null && aiProperties.getEvaluation().getProvider() != null)
? aiProperties.getEvaluation().getProvider()
: "openai";

try {
AiCallParams<String> params = AiCallParams.<String>builder()
Expand Down Expand Up @@ -120,7 +124,12 @@ private String callJudge(String promptText, String operation, String sprintId) {

return observabilityService.recordCall(params);
} catch (Exception e) {
log.error("Failed to generate dashboard explanation: {}", e.getMessage());
String errorMessage = e.getMessage() != null ? e.getMessage() : "Unknown error";
if (errorMessage.contains("429") || errorMessage.toLowerCase().contains("rate limit")) {
log.error("Rate limit hit for operation {}: {}", operation, errorMessage);
return "Summary unavailable (rate limit).";
}
log.error("Failed to generate dashboard explanation for {}: {}", operation, errorMessage);
return "Summary unavailable.";
}
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -13,12 +13,15 @@
import ch.goodone.backend.ai.prompt.PromptBuildResult;
import ch.goodone.backend.ai.prompt.PromptManifestService;
import ch.goodone.backend.ai.governance.AiFailureClassifier;
import ch.goodone.backend.ai.exception.AiException;
import ch.goodone.backend.model.taxonomy.CopilotCapability;
import ch.goodone.backend.model.taxonomy.CopilotContextMode;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service;
import org.springframework.util.StreamUtils;

import java.nio.charset.StandardCharsets;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
Expand Down Expand Up @@ -73,11 +76,19 @@ public CopilotResponse execute(ArchitectureExplainRequest request) {

String provider = aiProperties.getArchitecture().getProvider();
String model = aiProperties.getArchitecture().getModel();
String promptVersion = aiProperties.resolvePromptVersion("architecture-explain", "v1");
String promptVersion = aiProperties.resolvePromptVersion(FEATURE_ARCH_EXPLAIN, "v1");

String systemPrompt;
try {
systemPrompt = StreamUtils.copyToString(promptManifestService.getPrompt(FEATURE_ARCH_EXPLAIN).getInputStream(), StandardCharsets.UTF_8);
} catch (Exception e) {
log.error("Failed to load prompt template for feature {}: {}", FEATURE_ARCH_EXPLAIN, e.getMessage());
throw new AiException("Failed to load architecture explain prompt template", e);
}

// Calculate prompt hash for transparency
PromptBuildResult buildResult = promptBuilder.build(
FEATURE_ARCH_EXPLAIN, // Using prompt ID as surrogate for system prompt for hashing
systemPrompt,
request.getQuestion(),
contextResult.getRetrievedChunks(),
mode.name()
Expand Down Expand Up @@ -142,22 +153,24 @@ private CopilotResponse performAiExplanation(ArchitectureExplainRequest request,
.feature(FEATURE_ARCH_EXPLAIN)
.systemPrompt(buildResult.systemPrompt())
.schemaName("copilotAnswer")
.context(contextResult)
.build(),
CopilotResponse.class);

if (result != null) {
updateResultMetadata(result, buildResult, contextResult, provider, model);
updateResultMetadata(result, buildResult, contextResult, provider, model, mode);
}
return result;
}

private void updateResultMetadata(CopilotResponse result, PromptBuildResult buildResult, AssembledContext contextResult, String provider, String model) {
private void updateResultMetadata(CopilotResponse result, PromptBuildResult buildResult, AssembledContext contextResult, String provider, String model, CopilotContextMode mode) {
String answer = result.getAnswer();
AiFailureClassifier.ClassificationResult classification = failureClassifier.classify(answer);
double qualityScore = failureClassifier.calculateQualityScore(answer);

observabilityService.updateTraceMetadata(m -> {
m.setRetrievedDocumentPaths(contextResult.getRetrievedChunks());
m.setRetrievedDocumentPaths(contextResult.getRetrievedPaths());
m.setRetrievedDocumentContents(contextResult.getRetrievedChunks());
m.setFailureClassification(classification.getFailureMode());
m.setQualityScore(qualityScore);
});
Expand All @@ -167,14 +180,16 @@ private void updateResultMetadata(CopilotResponse result, PromptBuildResult buil
metadata.put("model", model);
metadata.put("provider", provider);
metadata.put("promptHash", buildResult.promptHash());
metadata.put("section", mode.name());
result.setMetadata(metadata);

// Explicit transparency fields (Phase 5)
result.setProvider(provider);
result.setModel(model);
result.setPromptHash(buildResult.promptHash());
result.setRetrievedDocumentCount(contextResult.getRetrievedChunks() != null ? contextResult.getRetrievedChunks().size() : 0);
result.setRetrievedDocumentPaths(contextResult.getRetrievedChunks());
result.setRetrievedDocumentCount(contextResult.getChunks() != null ? contextResult.getChunks().size() : 0);
result.setRetrievedDocumentPaths(contextResult.getRetrievedPaths());
result.setRetrievedDocumentContents(contextResult.getRetrievedChunks());
result.setFallbackUsed(false); // Initial call is not a fallback
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -153,15 +153,15 @@ private CopilotResponse performEngineeringChatCall(ChatCallParams params) {
double qualityScore = failureClassifier.calculateQualityScore(finalAnswer);
updateTraceMetadata(params.buildResult(), params.request(), params.contextResult(), params.mode(), finalAnswer, classification, qualityScore);

CopilotResponse copilotResponse = buildCopilotResponse(finalAnswer, params.contextResult(), params.buildResult(), params.provider(), params.model());
CopilotResponse copilotResponse = buildCopilotResponse(finalAnswer, params.contextResult(), params.buildResult(), params.provider(), params.model(), params.mode());
if (params.contextResult().hasFailures()) {
copilotResponse.setPartialFailures(params.contextResult().getPartialFailures());
}
return copilotResponse;
}

private ChatExecutionResult executeWithRetries(ChatModel chatModel, Prompt prompt) {
int maxRetries = 3;
int maxRetries = 5;
int attempt = 0;
long backoffMs = 2000;
String rawAnswer = null;
Expand All @@ -176,10 +176,25 @@ private ChatExecutionResult executeWithRetries(ChatModel chatModel, Prompt promp
attempt++;
} catch (Exception e) {
attempt++;
if (isRateLimit(e)) {
// Increase backoff more aggressively for rate limits
if (attempt == 1) {
backoffMs = 3000;
} else if (attempt == 2) {
backoffMs = 6000;
} else if (attempt == 3) {
backoffMs = 12000;
} else {
backoffMs = 20000;
}
}

if (!shouldRetryException(e, attempt, maxRetries, backoffMs)) {
throw e;
}
backoffMs *= 2;
if (!isRateLimit(e)) {
backoffMs *= 2;
}
}
}
return new ChatExecutionResult(rawAnswer);
Expand Down Expand Up @@ -235,7 +250,8 @@ private void updateTraceMetadata(PromptBuildResult buildResult, EngineeringChatR
.userPrompt(buildResult.userPrompt())
.fullPrompt(buildResult.fullPrompt())
.sprint(request.getSprintId())
.retrievedDocumentPaths(contextResult.getRetrievedChunks())
.retrievedDocumentPaths(contextResult.getRetrievedPaths())
.retrievedDocumentContents(contextResult.getRetrievedChunks())
.rawResponse(finalAnswer)
.finalResponse(finalAnswer)
.feature(getCapability().name())
Expand All @@ -246,23 +262,25 @@ private void updateTraceMetadata(PromptBuildResult buildResult, EngineeringChatR
observabilityService.reportTraceMetadata(metadataCapture);
}

private CopilotResponse buildCopilotResponse(String answer, AssembledContext contextResult, PromptBuildResult buildResult, String provider, String model) {
private CopilotResponse buildCopilotResponse(String answer, AssembledContext contextResult, PromptBuildResult buildResult, String provider, String model, CopilotContextMode mode) {
java.util.Map<String, Object> metadata = new java.util.HashMap<>();
metadata.put("model", model);
metadata.put("provider", provider);
metadata.put("promptHash", buildResult.promptHash());
metadata.put("sources", contextResult.getRetrievedChunks());
metadata.put("section", mode.name());
metadata.put("sources", contextResult.getRetrievedPaths());

return CopilotResponse.builder()
.answer(answer)
.evidence(contextResult.getRetrievedChunks())
.evidence(contextResult.getRetrievedPaths())
.confidence(0.9)
.metadata(metadata)
.provider(provider)
.model(model)
.promptHash(buildResult.promptHash())
.retrievedDocumentCount(contextResult.getRetrievedChunks() != null ? contextResult.getRetrievedChunks().size() : 0)
.retrievedDocumentPaths(contextResult.getRetrievedChunks())
.retrievedDocumentCount(contextResult.getChunks() != null ? contextResult.getChunks().size() : 0)
.retrievedDocumentPaths(contextResult.getRetrievedPaths())
.retrievedDocumentContents(contextResult.getRetrievedChunks())
.fallbackUsed(false)
.build();
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -91,7 +91,7 @@ private CopilotResponse recordOnboardingCall(OnboardingRequest request, Assemble
.input(request.getQuery())
.capability(getCapability().name())
.contextMode(mode.name())
.call(() -> performOnboardingAiCall(request, contextResult, buildResult, provider, model))
.call(() -> performOnboardingAiCall(request, contextResult, buildResult, provider, model, mode))
.build();

return observabilityService.recordCall(params);
Expand All @@ -105,7 +105,7 @@ private CopilotResponse buildErrorResponse(Exception e) {
.build();
}

private CopilotResponse performOnboardingAiCall(OnboardingRequest request, AssembledContext contextResult, PromptBuildResult buildResult, String provider, String model) {
private CopilotResponse performOnboardingAiCall(OnboardingRequest request, AssembledContext contextResult, PromptBuildResult buildResult, String provider, String model, CopilotContextMode mode) {
observabilityService.updateTraceMetadata(m -> {
m.setSystemPrompt(buildResult.systemPrompt());
m.setUserPrompt(buildResult.userPrompt());
Expand Down Expand Up @@ -137,19 +137,20 @@ private CopilotResponse performOnboardingAiCall(OnboardingRequest request, Assem
);

if (response != null) {
updateOnboardingMetadata(response, contextResult, buildResult, provider, model);
updateOnboardingMetadata(response, contextResult, buildResult, provider, model, mode);
}

return response;
}

private void updateOnboardingMetadata(CopilotResponse response, AssembledContext contextResult, PromptBuildResult buildResult, String provider, String model) {
private void updateOnboardingMetadata(CopilotResponse response, AssembledContext contextResult, PromptBuildResult buildResult, String provider, String model, CopilotContextMode mode) {
String answer = response.getAnswer();
AiFailureClassifier.ClassificationResult classification = failureClassifier.classify(answer);
double qualityScore = failureClassifier.calculateQualityScore(answer);

observabilityService.updateTraceMetadata(m -> {
m.setRetrievedDocumentPaths(contextResult.getRetrievedChunks());
m.setRetrievedDocumentPaths(contextResult.getRetrievedPaths());
m.setRetrievedDocumentContents(contextResult.getRetrievedChunks());
m.setFailureClassification(classification.getFailureMode());
m.setQualityScore(qualityScore);
});
Expand All @@ -159,13 +160,15 @@ private void updateOnboardingMetadata(CopilotResponse response, AssembledContext
metadata.put("model", model);
metadata.put("provider", provider);
metadata.put("promptHash", buildResult.promptHash());
metadata.put("section", mode.name());
response.setMetadata(metadata);

response.setProvider(provider);
response.setModel(model);
response.setPromptHash(buildResult.promptHash());
response.setRetrievedDocumentCount(contextResult.getRetrievedChunks() != null ? contextResult.getRetrievedChunks().size() : 0);
response.setRetrievedDocumentPaths(contextResult.getRetrievedChunks());
response.setRetrievedDocumentCount(contextResult.getChunks() != null ? contextResult.getChunks().size() : 0);
response.setRetrievedDocumentPaths(contextResult.getRetrievedPaths());
response.setRetrievedDocumentContents(contextResult.getRetrievedChunks());
response.setFallbackUsed(false);

if (contextResult.hasFailures()) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,6 @@

import java.nio.charset.StandardCharsets;
import java.util.List;
import java.util.Map;

/**
* Use case for parsing a quick-add task string into structured data using AI.
Expand Down Expand Up @@ -56,7 +55,7 @@ public QuickAddParseResult execute(QuickAddParseRequest request, String login) {
QuickAddParseResult result;
if (aiNeeded) {
result = callAi(input);
result = merge(result, deterministic, result.aiUsed());
result = merge(result, deterministic, result.aiUsed() != null ? result.aiUsed() : true);
} else {
result = fromDeterministic(deterministic);
}
Expand All @@ -78,7 +77,7 @@ private boolean isAiNeeded(String input, TaskParserService.ParsedTask determinis
if (input.split("\\s+").length > 4) {
return true;
}
return deterministic.dueDate() == null && deterministic.priority() == ch.goodone.backend.model.Priority.MEDIUM;
return deterministic.dueDate() == null && (deterministic.priority() == null || deterministic.priority() == ch.goodone.backend.model.Priority.MEDIUM);
}

private QuickAddParseResult fromDeterministic(TaskParserService.ParsedTask d) {
Expand All @@ -91,8 +90,8 @@ private QuickAddParseResult fromDeterministic(TaskParserService.ParsedTask d) {
List.of("Parsed deterministically"),
d.dueDate() != null ? d.dueDate().toString() : null,
d.dueTime() != null ? d.dueTime().toString() : null,
d.priority() != null ? d.priority().name() : null,
d.status() != null ? d.status().name() : null,
d.priority() != null ? d.priority().name() : "MEDIUM",
d.status() != null ? d.status().name() : "OPEN",
false
);
}
Expand Down Expand Up @@ -125,7 +124,7 @@ private QuickAddParseResult callAi(String input) {
}
}

private QuickAddParseResult merge(QuickAddParseResult ai, TaskParserService.ParsedTask deterministic, boolean aiUsed) {
private QuickAddParseResult merge(QuickAddParseResult ai, TaskParserService.ParsedTask deterministic, Boolean aiUsed) {
// Deterministic attributes take precedence as they are very reliable
String finalDueDate = resolveDueDate(ai, deterministic);
String finalDueTime = resolveDueTime(ai, deterministic);
Expand Down
Loading
Loading