-
Notifications
You must be signed in to change notification settings - Fork 0
feat(registry-dash): admin system prompt editor #118
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -58,6 +58,8 @@ export class AiAnalysisModalComponent implements OnInit { | |
| followUpText = ''; | ||
| showAdvanced = signal(false); | ||
| editableSystemPrompt = ''; | ||
| defaultSystemPrompt = ''; | ||
| loadingPrompt = signal(false); | ||
|
|
||
| streaming = computed(() => this.aiService.streaming()); | ||
| streamedText = computed(() => this.aiService.streamedText()); | ||
|
|
@@ -144,10 +146,48 @@ export class AiAnalysisModalComponent implements OnInit { | |
| } | ||
| } | ||
|
|
||
| toggleAdvanced() { | ||
| async toggleAdvanced() { | ||
| this.showAdvanced.update(v => !v); | ||
| if (this.showAdvanced() && !this.editableSystemPrompt) { | ||
| this.editableSystemPrompt = this.data.systemPrompt ?? ''; | ||
| const storageKey = `ai-prompt-${this.data.page}-${this.data.promptType}`; | ||
| const saved = localStorage.getItem(storageKey); | ||
| if (saved) { | ||
| this.editableSystemPrompt = saved; | ||
| } | ||
|
Comment on lines
+152
to
+156
|
||
| await this.fetchDefaultPrompt(); | ||
| if (!saved && this.defaultSystemPrompt) { | ||
| this.editableSystemPrompt = this.defaultSystemPrompt; | ||
| } | ||
| } | ||
| } | ||
|
|
||
| async resetToDefault() { | ||
| await this.fetchDefaultPrompt(); | ||
| this.editableSystemPrompt = this.defaultSystemPrompt; | ||
| const storageKey = `ai-prompt-${this.data.page}-${this.data.promptType}`; | ||
| localStorage.removeItem(storageKey); | ||
| } | ||
|
|
||
| private async fetchDefaultPrompt() { | ||
| if (this.defaultSystemPrompt) return; | ||
| this.loadingPrompt.set(true); | ||
| try { | ||
| this.defaultSystemPrompt = await this.aiService.getDefaultPrompt( | ||
| this.data.page, this.data.promptType); | ||
| } catch { | ||
| // Fall back silently — editor still works with manual input | ||
| } finally { | ||
| this.loadingPrompt.set(false); | ||
| } | ||
| } | ||
|
|
||
| onSystemPromptChange(value: string) { | ||
| this.editableSystemPrompt = value; | ||
| const storageKey = `ai-prompt-${this.data.page}-${this.data.promptType}`; | ||
| if (value && value !== this.defaultSystemPrompt) { | ||
| localStorage.setItem(storageKey, value); | ||
| } else { | ||
| localStorage.removeItem(storageKey); | ||
| } | ||
| } | ||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -21,6 +21,18 @@ export class AiAnalysisService { | |
| streamedText = signal(''); | ||
| error = signal<string | null>(null); | ||
|
|
||
| async getDefaultPrompt(page: string, promptType: string): Promise<string> { | ||
| const response = await fetch( | ||
| `/console-api/registry-dash/ai/analyze?page=${encodeURIComponent(page)}&promptType=${encodeURIComponent(promptType)}`, | ||
| { credentials: 'same-origin' }, | ||
| ); | ||
| if (!response.ok) { | ||
| throw new Error(`Failed to fetch default prompt: ${response.status}`); | ||
| } | ||
| const data = await response.json(); | ||
| return data.systemPrompt; | ||
| } | ||
|
|
||
| async analyze(request: AiAnalyzeRequest): Promise<void> { | ||
| this.streaming.set(true); | ||
| this.streamedText.set(''); | ||
|
|
@@ -46,6 +58,17 @@ export class AiAnalysisService { | |
| return; | ||
| } | ||
| if (response.status === 502) { | ||
| try { | ||
| const body = await response.text(); | ||
| const match = body.match(/data: (.+)/); | ||
| if (match) { | ||
| const parsed = JSON.parse(match[1]); | ||
| if (parsed.error) { | ||
| this.error.set(`API error: ${parsed.error}`); | ||
| return; | ||
| } | ||
| } | ||
| } catch { /* fall through */ } | ||
|
Comment on lines
60
to
+71
|
||
| this.error.set('Analysis temporarily unavailable. Please try again.'); | ||
| return; | ||
| } | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -102,7 +102,10 @@ public void streamMessage( | |
| throw new AnthropicRateLimitException("Anthropic API rate limited: " + response.code()); | ||
| } | ||
| if (!response.isSuccessful()) { | ||
| throw new IOException("Anthropic API error: " + response.code()); | ||
| String errorBody = response.body() != null | ||
| ? response.body().string() : "no response body"; | ||
| throw new IOException( | ||
| "Anthropic API error: " + response.code() + " - " + errorBody); | ||
|
Comment on lines
104
to
+108
|
||
| } | ||
|
|
||
| try (BufferedReader reader = new BufferedReader( | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -42,7 +42,7 @@ | |
| @Action( | ||
| service = Service.CONSOLE, | ||
| path = RegistryDashAiAction.PATH, | ||
| method = Action.Method.POST, | ||
| method = {Action.Method.GET, Action.Method.POST}, | ||
| auth = Auth.AUTH_PUBLIC_LOGGED_IN) | ||
| public class RegistryDashAiAction extends ConsoleApiAction { | ||
|
|
||
|
|
@@ -53,6 +53,8 @@ public class RegistryDashAiAction extends ConsoleApiAction { | |
| private static final Gson PLAIN_GSON = new Gson(); | ||
|
|
||
| private final Optional<JsonElement> payload; | ||
| private final Optional<String> aiPage; | ||
| private final Optional<String> aiPromptType; | ||
| private final AnthropicClient anthropicClient; | ||
| private final AiRateLimiter rateLimiter; | ||
| private final Gson gson; | ||
|
|
@@ -61,15 +63,40 @@ public class RegistryDashAiAction extends ConsoleApiAction { | |
| public RegistryDashAiAction( | ||
| ConsoleApiParams consoleApiParams, | ||
| @Parameter("aiAnalyzePayload") Optional<JsonElement> payload, | ||
| @Parameter("aiPage") Optional<String> aiPage, | ||
| @Parameter("aiPromptType") Optional<String> aiPromptType, | ||
| AnthropicClient anthropicClient, | ||
| AiRateLimiter rateLimiter) { | ||
| super(consoleApiParams); | ||
| this.payload = payload; | ||
| this.aiPage = aiPage; | ||
| this.aiPromptType = aiPromptType; | ||
| this.anthropicClient = anthropicClient; | ||
| this.rateLimiter = rateLimiter; | ||
| this.gson = consoleApiParams.gson(); | ||
| } | ||
|
|
||
| @Override | ||
| protected void getHandler(User user) { | ||
| boolean isProduction = RegistryEnvironment.get() == RegistryEnvironment.PRODUCTION; | ||
| boolean isAdmin = user.getUserRoles().getGlobalRole() == GlobalRole.FTE; | ||
|
|
||
| if (isProduction || !isAdmin) { | ||
| consoleApiParams.response().setStatus(SC_FORBIDDEN); | ||
| return; | ||
|
UDtorrey marked this conversation as resolved.
|
||
| } | ||
|
|
||
| if (aiPage.isEmpty() || aiPromptType.isEmpty()) { | ||
| setFailedResponse("page and promptType query parameters are required", SC_BAD_REQUEST); | ||
| return; | ||
| } | ||
|
|
||
| String prompt = getDefaultSystemPrompt(aiPage.get(), aiPromptType.get(), null, null); | ||
| JsonObject result = new JsonObject(); | ||
| result.addProperty("systemPrompt", prompt); | ||
| consoleApiParams.response().setPayload(PLAIN_GSON.toJson(result)); | ||
| } | ||
|
|
||
| @Override | ||
| protected void postHandler(User user) { | ||
| if (!user.getUserRoles().hasGlobalPermission(ConsolePermission.VIEW_DASHBOARD_OVERVIEW)) { | ||
|
|
@@ -131,15 +158,25 @@ protected void postHandler(User user) { | |
| consoleApiParams.response().setHeader("Retry-After", "30"); | ||
| } catch (IOException e) { | ||
| logger.atWarning().withCause(e).log("Anthropic API error"); | ||
| consoleApiParams.response().setStatus(502); | ||
| try { | ||
| PrintWriter writer = consoleApiParams.response().getWriter(); | ||
| consoleApiParams.response().setHeader( | ||
| "Content-Type", "text/event-stream"); | ||
| consoleApiParams.response().setStatus(502); | ||
| String detail = e.getMessage() != null ? e.getMessage() : ""; | ||
| writer.write("data: " + PLAIN_GSON.toJson( | ||
| new ErrorChunk(detail)) + "\n\n"); | ||
| writer.flush(); | ||
|
Comment on lines
+166
to
+169
|
||
| } catch (IOException ignored) { | ||
| consoleApiParams.response().setStatus(502); | ||
| } | ||
| } | ||
| } | ||
|
|
||
| private String buildSystemPrompt(AiAnalyzeRequest request, User user) { | ||
| boolean isProduction = RegistryEnvironment.get() == RegistryEnvironment.PRODUCTION; | ||
| boolean isAdmin = user.getUserRoles().getGlobalRole() == GlobalRole.FTE; | ||
|
|
||
| if (!isProduction && isAdmin | ||
| if (isAdmin | ||
| && request.systemPrompt != null && !request.systemPrompt.isEmpty()) { | ||
| return request.systemPrompt; | ||
| } | ||
|
|
@@ -188,12 +225,18 @@ private String getDefaultSystemPrompt( | |
| } | ||
| } | ||
|
|
||
| sb.append("\n## Data\n```json\n").append(gson.toJson(chartData)).append("\n```\n"); | ||
| sb.append("\n## Data\n```json\n"); | ||
| sb.append(chartData != null | ||
| ? gson.toJson(chartData) | ||
| : "[chart data will be injected at request time]"); | ||
| sb.append("\n```\n"); | ||
| sb.append("\nProvide your analysis in clear markdown. Use specific numbers from the data. "); | ||
| sb.append("Keep your response concise and actionable."); | ||
|
|
||
| return sb.toString(); | ||
| } | ||
|
|
||
| private record TextChunk(String text) {} | ||
|
|
||
| private record ErrorChunk(String error) {} | ||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -84,7 +84,8 @@ void testSuccess_streamsResponse() throws Exception { | |
| }).when(anthropicClient).streamMessage(any(), any(), any(), any()); | ||
|
|
||
| RegistryDashAiAction action = new RegistryDashAiAction( | ||
| params, Optional.of(json), anthropicClient, rateLimiter); | ||
| params, Optional.of(json), Optional.empty(), Optional.empty(), | ||
| anthropicClient, rateLimiter); | ||
| action.run(); | ||
|
|
||
| assertThat(response.getStatus()).isEqualTo(200); | ||
|
|
@@ -97,7 +98,8 @@ void testSuccess_streamsResponse() throws Exception { | |
| @Test | ||
| void testBadRequest_missingPayload() { | ||
| RegistryDashAiAction action = new RegistryDashAiAction( | ||
| params, Optional.empty(), anthropicClient, rateLimiter); | ||
| params, Optional.empty(), Optional.empty(), Optional.empty(), | ||
| anthropicClient, rateLimiter); | ||
| action.run(); | ||
|
|
||
| assertThat(response.getStatus()).isEqualTo(400); | ||
|
|
@@ -110,7 +112,37 @@ void testBadRequest_invalidPage() { | |
| JsonElement json = JsonParser.parseString(payload); | ||
|
|
||
| RegistryDashAiAction action = new RegistryDashAiAction( | ||
| params, Optional.of(json), anthropicClient, rateLimiter); | ||
| params, Optional.of(json), Optional.empty(), Optional.empty(), | ||
| anthropicClient, rateLimiter); | ||
| action.run(); | ||
|
|
||
| assertThat(response.getStatus()).isEqualTo(400); | ||
| } | ||
|
|
||
| @Test | ||
| void testGetDefaultPrompt_returnsSystemPrompt() { | ||
| when(params.request().getMethod()).thenReturn("GET"); | ||
|
|
||
| RegistryDashAiAction action = new RegistryDashAiAction( | ||
| params, Optional.empty(), | ||
| Optional.of("domain-activity"), Optional.of("summarize_trends"), | ||
| anthropicClient, rateLimiter); | ||
| action.run(); | ||
|
|
||
| assertThat(response.getStatus()).isEqualTo(200); | ||
| String payload = response.getPayload(); | ||
| assertThat(payload).contains("systemPrompt"); | ||
| assertThat(payload).contains("domain-activity"); | ||
| } | ||
|
Comment on lines
+122
to
+136
|
||
|
|
||
| @Test | ||
| void testGetDefaultPrompt_missingParams() { | ||
| when(params.request().getMethod()).thenReturn("GET"); | ||
|
|
||
| RegistryDashAiAction action = new RegistryDashAiAction( | ||
| params, Optional.empty(), | ||
| Optional.empty(), Optional.empty(), | ||
| anthropicClient, rateLimiter); | ||
| action.run(); | ||
|
|
||
| assertThat(response.getStatus()).isEqualTo(400); | ||
|
|
@@ -126,7 +158,8 @@ void testRateLimitExceeded() { | |
| JsonElement json = JsonParser.parseString(payload); | ||
|
|
||
| RegistryDashAiAction action = new RegistryDashAiAction( | ||
| params, Optional.of(json), anthropicClient, strictLimiter); | ||
| params, Optional.of(json), Optional.empty(), Optional.empty(), | ||
| anthropicClient, strictLimiter); | ||
| action.run(); | ||
|
|
||
| assertThat(response.getStatus()).isEqualTo(429); | ||
|
|
||
Uh oh!
There was an error while loading. Please reload this page.