From 325d9e497c876cca7417e4c316e4c207da8a7918 Mon Sep 17 00:00:00 2001 From: Max P Date: Tue, 24 Feb 2026 11:12:39 -0500 Subject: [PATCH] feat(ai): persist chat panel state and improve no-tool direct prompts --- Tasks.md | 2 + .../app/endpoints/ai/ai-agent.policy.utils.ts | 20 ++ .../app/endpoints/ai/ai-agent.utils.spec.ts | 24 +++ .../src/app/endpoints/ai/ai.service.spec.ts | 2 +- .../ai-chat-panel.component.spec.ts | 106 +++++++++++ .../ai-chat-panel/ai-chat-panel.component.ts | 180 ++++++++++++++---- tasks/tasks.md | 13 ++ 7 files changed, 314 insertions(+), 33 deletions(-) diff --git a/Tasks.md b/Tasks.md index 0253ba8d5..2352ccace 100644 --- a/Tasks.md +++ b/Tasks.md @@ -15,6 +15,7 @@ Last updated: 2026-02-24 | T-007 | Observability wiring (LangSmith traces and metrics) | Complete | `apps/api/src/app/endpoints/ai/ai.service.spec.ts`, `apps/api/src/app/endpoints/ai/ai-feedback.service.spec.ts`, `apps/api/src/app/endpoints/ai/evals/mvp-eval.runner.spec.ts` | Local implementation | | T-008 | Deployment and submission bundle | Complete | `npm run test:ai` + Railway healthcheck + submission docs checklist | `2b6506de8` | | T-009 | Open source eval framework contribution | In Review | `@ghostfolio/finance-agent-evals` package scaffold + dataset export + smoke/pack checks | openai/evals PR #1625 + langchain PR #35421 | +| T-010 | Chat history persistence and simple direct-query handling | Complete | `apps/client/src/app/pages/portfolio/analysis/ai-chat-panel/ai-chat-panel.component.spec.ts`, `apps/api/src/app/endpoints/ai/ai-agent.utils.spec.ts`, `apps/api/src/app/endpoints/ai/ai.service.spec.ts` | Local implementation | ## Notes @@ -33,3 +34,4 @@ Last updated: 2026-02-24 - Condensed architecture doc (2026-02-24): `docs/ARCHITECTURE-CONDENSED.md`. - Railway crash recovery (2026-02-23): `railway.toml` start command corrected to `node dist/apps/api/main.js`, deployed to Railway (`4f26063a-97e5-43dd-b2dd-360e9e12a951`), and validated with production health check. - Tool gating hardening (2026-02-24): planner unknown-intent fallback changed to no-tools, executor policy gate added (`direct|tools|clarify`), and policy metrics emitted via verification and observability logs. +- Chat persistence + simple direct-query handling (2026-02-24): client chat panel now restores/persists session + bounded message history via localStorage and policy no-tool prompts now return assistant capability guidance for queries like "Who are you?". diff --git a/apps/api/src/app/endpoints/ai/ai-agent.policy.utils.ts b/apps/api/src/app/endpoints/ai/ai-agent.policy.utils.ts index ded6cfb6a..0ee5a2b28 100644 --- a/apps/api/src/app/endpoints/ai/ai-agent.policy.utils.ts +++ b/apps/api/src/app/endpoints/ai/ai-agent.policy.utils.ts @@ -28,6 +28,11 @@ const GREETING_ONLY_PATTERN = const SIMPLE_ARITHMETIC_QUERY_PATTERN = /^\s*(?:what(?:'s| is)\s+)?[-+*/().\d\s%=]+\??\s*$/i; const SIMPLE_ARITHMETIC_OPERATOR_PATTERN = /[+\-*/]/; +const SIMPLE_ASSISTANT_QUERY_PATTERNS = [ + /^\s*(?:who are you|what are you|what can you do)\s*[!.?]*\s*$/i, + /^\s*(?:how do you work|how (?:can|do) i use (?:you|this))\s*[!.?]*\s*$/i, + /^\s*(?:help|assist(?: me)?|what can you help with)\s*[!.?]*\s*$/i +]; const READ_ONLY_TOOLS = new Set([ 'portfolio_analysis', 'risk_assessment', @@ -69,6 +74,14 @@ function isNoToolDirectQuery(query: string) { return true; } + if ( + SIMPLE_ASSISTANT_QUERY_PATTERNS.some((pattern) => { + return pattern.test(query); + }) + ) { + return true; + } + const normalized = query.trim(); if (!SIMPLE_ARITHMETIC_QUERY_PATTERN.test(normalized)) { @@ -187,6 +200,13 @@ export function createPolicyRouteResponse({ return `I can help with allocation review, concentration risk, market prices, and stress scenarios. Which one should I run next? Example: "Show concentration risk" or "Price for NVDA".`; } + if ( + policyDecision.route === 'direct' && + policyDecision.blockReason === 'no_tool_query' + ) { + return `I am your Ghostfolio AI assistant. I can help with portfolio analysis, concentration risk, market prices, rebalancing ideas, and stress scenarios. Try: "Show my top holdings" or "What is my concentration risk?".`; + } + return `I can help with portfolio analysis, concentration risk, market prices, and stress scenarios. Ask a portfolio question when you are ready.`; } diff --git a/apps/api/src/app/endpoints/ai/ai-agent.utils.spec.ts b/apps/api/src/app/endpoints/ai/ai-agent.utils.spec.ts index b5be167af..3e0408ef2 100644 --- a/apps/api/src/app/endpoints/ai/ai-agent.utils.spec.ts +++ b/apps/api/src/app/endpoints/ai/ai-agent.utils.spec.ts @@ -69,6 +69,30 @@ describe('AiAgentUtils', () => { expect(decision.forcedDirect).toBe(true); }); + it('routes assistant capability prompts to direct no-tool policy', () => { + const decision = applyToolExecutionPolicy({ + plannedTools: [], + query: 'Who are you?' + }); + + expect(decision.route).toBe('direct'); + expect(decision.toolsToExecute).toEqual([]); + expect(decision.blockReason).toBe('no_tool_query'); + expect(createPolicyRouteResponse({ policyDecision: decision })).toContain( + 'Ghostfolio AI assistant' + ); + }); + + it('keeps finance-intent prompts on clarify route even with capability phrasing', () => { + const decision = applyToolExecutionPolicy({ + plannedTools: [], + query: 'What can you do about my portfolio risk?' + }); + + expect(decision.route).toBe('clarify'); + expect(decision.blockReason).toBe('unknown'); + }); + it('routes to clarify when planner provides no tools for finance-style query', () => { const decision = applyToolExecutionPolicy({ plannedTools: [], diff --git a/apps/api/src/app/endpoints/ai/ai.service.spec.ts b/apps/api/src/app/endpoints/ai/ai.service.spec.ts index 9bd41cb7b..51843b735 100644 --- a/apps/api/src/app/endpoints/ai/ai.service.spec.ts +++ b/apps/api/src/app/endpoints/ai/ai.service.spec.ts @@ -253,7 +253,7 @@ describe('AiService', () => { userId: 'user-direct-route' }); - expect(result.answer).toContain('Ask a portfolio question when you are ready'); + expect(result.answer).toContain('Ghostfolio AI assistant'); expect(result.toolCalls).toEqual([]); expect(result.citations).toEqual([]); expect(dataProviderService.getQuotes).not.toHaveBeenCalled(); diff --git a/apps/client/src/app/pages/portfolio/analysis/ai-chat-panel/ai-chat-panel.component.spec.ts b/apps/client/src/app/pages/portfolio/analysis/ai-chat-panel/ai-chat-panel.component.spec.ts index 0e22551b4..234e14519 100644 --- a/apps/client/src/app/pages/portfolio/analysis/ai-chat-panel/ai-chat-panel.component.spec.ts +++ b/apps/client/src/app/pages/portfolio/analysis/ai-chat-panel/ai-chat-panel.component.spec.ts @@ -6,6 +6,9 @@ import { of, throwError } from 'rxjs'; import { GfAiChatPanelComponent } from './ai-chat-panel.component'; +const STORAGE_KEY_MESSAGES = 'gf_ai_chat_messages'; +const STORAGE_KEY_SESSION_ID = 'gf_ai_chat_session_id'; + function createChatResponse({ answer, sessionId, @@ -50,6 +53,23 @@ function createChatResponse({ }; } +function createStoredMessage({ + content, + id, + role +}: { + content: string; + id: number; + role: 'assistant' | 'user'; +}) { + return { + content, + createdAt: new Date().toISOString(), + id, + role + }; +} + describe('GfAiChatPanelComponent', () => { let component: GfAiChatPanelComponent; let fixture: ComponentFixture; @@ -59,6 +79,8 @@ describe('GfAiChatPanelComponent', () => { }; beforeEach(async () => { + localStorage.clear(); + dataService = { postAiChat: jest.fn(), postAiChatFeedback: jest.fn() @@ -75,6 +97,10 @@ describe('GfAiChatPanelComponent', () => { fixture.detectChanges(); }); + afterEach(() => { + localStorage.clear(); + }); + it('sends a chat query and appends assistant response', () => { dataService.postAiChat.mockReturnValue( of( @@ -106,6 +132,10 @@ describe('GfAiChatPanelComponent', () => { role: 'assistant' }) ); + expect(localStorage.getItem(STORAGE_KEY_SESSION_ID)).toBe('session-1'); + expect( + JSON.parse(localStorage.getItem(STORAGE_KEY_MESSAGES) ?? '[]') + ).toHaveLength(2); }); it('reuses session id across consecutive prompts', () => { @@ -144,6 +174,82 @@ describe('GfAiChatPanelComponent', () => { }); }); + it('restores chat session and messages from local storage', () => { + localStorage.setItem(STORAGE_KEY_SESSION_ID, 'session-restored'); + localStorage.setItem( + STORAGE_KEY_MESSAGES, + JSON.stringify([ + createStoredMessage({ + content: 'Restored user message', + id: 11, + role: 'user' + }), + createStoredMessage({ + content: 'Restored assistant message', + id: 12, + role: 'assistant' + }) + ]) + ); + + const restoredFixture = TestBed.createComponent(GfAiChatPanelComponent); + const restoredComponent = restoredFixture.componentInstance; + restoredComponent.hasPermissionToReadAiPrompt = true; + restoredFixture.detectChanges(); + + dataService.postAiChat.mockReturnValue( + of( + createChatResponse({ + answer: 'Follow-up response', + sessionId: 'session-restored', + turns: 3 + }) + ) + ); + + restoredComponent.query = 'Follow-up'; + restoredComponent.onSubmit(); + + expect(restoredComponent.chatMessages).toHaveLength(4); + expect(dataService.postAiChat).toHaveBeenCalledWith({ + query: 'Follow-up', + sessionId: 'session-restored' + }); + }); + + it('ignores invalid chat storage payload without throwing', () => { + localStorage.setItem(STORAGE_KEY_MESSAGES, '{invalid-json'); + + const restoredFixture = TestBed.createComponent(GfAiChatPanelComponent); + const restoredComponent = restoredFixture.componentInstance; + restoredComponent.hasPermissionToReadAiPrompt = true; + + expect(() => { + restoredFixture.detectChanges(); + }).not.toThrow(); + expect(restoredComponent.chatMessages).toEqual([]); + expect(localStorage.getItem(STORAGE_KEY_MESSAGES)).toBeNull(); + }); + + it('caps restored chat history to the most recent 200 messages', () => { + const storedMessages = Array.from({ length: 250 }, (_, index) => { + return createStoredMessage({ + content: `message-${index}`, + id: index, + role: index % 2 === 0 ? 'user' : 'assistant' + }); + }); + localStorage.setItem(STORAGE_KEY_MESSAGES, JSON.stringify(storedMessages)); + + const restoredFixture = TestBed.createComponent(GfAiChatPanelComponent); + const restoredComponent = restoredFixture.componentInstance; + restoredComponent.hasPermissionToReadAiPrompt = true; + restoredFixture.detectChanges(); + + expect(restoredComponent.chatMessages).toHaveLength(200); + expect(restoredComponent.chatMessages[0].id).toBe(50); + }); + it('adds a fallback assistant message when chat request fails', () => { dataService.postAiChat.mockReturnValue( throwError(() => { diff --git a/apps/client/src/app/pages/portfolio/analysis/ai-chat-panel/ai-chat-panel.component.ts b/apps/client/src/app/pages/portfolio/analysis/ai-chat-panel/ai-chat-panel.component.ts index 84d829439..f0034e59c 100644 --- a/apps/client/src/app/pages/portfolio/analysis/ai-chat-panel/ai-chat-panel.component.ts +++ b/apps/client/src/app/pages/portfolio/analysis/ai-chat-panel/ai-chat-panel.component.ts @@ -33,6 +33,10 @@ interface AiChatMessage { role: 'assistant' | 'user'; } +type StoredAiChatMessage = Omit & { + createdAt: string; +}; + @Component({ changeDetection: ChangeDetectionStrategy.OnPush, imports: [ @@ -49,6 +53,10 @@ interface AiChatMessage { templateUrl: './ai-chat-panel.component.html' }) export class GfAiChatPanelComponent implements OnDestroy { + private readonly STORAGE_KEY_MESSAGES = 'gf_ai_chat_messages'; + private readonly STORAGE_KEY_SESSION_ID = 'gf_ai_chat_session_id'; + private readonly MAX_STORED_MESSAGES = 200; + @Input() hasPermissionToReadAiPrompt = false; public readonly assistantRoleLabel = $localize`Assistant`; @@ -70,7 +78,9 @@ export class GfAiChatPanelComponent implements OnDestroy { public constructor( private readonly changeDetectorRef: ChangeDetectorRef, private readonly dataService: DataService - ) {} + ) { + this.restoreChatState(); + } public ngOnDestroy() { this.unsubscribeSubject.next(); @@ -153,15 +163,12 @@ export class GfAiChatPanelComponent implements OnDestroy { return; } - this.chatMessages = [ - ...this.chatMessages, - { - content: normalizedQuery, - createdAt: new Date(), - id: this.nextMessageId++, - role: 'user' - } - ]; + this.appendMessage({ + content: normalizedQuery, + createdAt: new Date(), + id: this.nextMessageId++, + role: 'user' + }); this.errorMessage = undefined; this.isSubmitting = true; this.query = ''; @@ -181,33 +188,27 @@ export class GfAiChatPanelComponent implements OnDestroy { .subscribe({ next: (response) => { this.chatSessionId = response.memory.sessionId; - this.chatMessages = [ - ...this.chatMessages, - { - content: response.answer, - createdAt: new Date(), - feedback: { - isSubmitting: false - }, - id: this.nextMessageId++, - response, - role: 'assistant' - } - ]; + this.appendMessage({ + content: response.answer, + createdAt: new Date(), + feedback: { + isSubmitting: false + }, + id: this.nextMessageId++, + response, + role: 'assistant' + }); this.changeDetectorRef.markForCheck(); }, error: () => { this.errorMessage = $localize`AI request failed. Check your model quota and permissions.`; - this.chatMessages = [ - ...this.chatMessages, - { - content: $localize`Request failed. Please retry.`, - createdAt: new Date(), - id: this.nextMessageId++, - role: 'assistant' - } - ]; + this.appendMessage({ + content: $localize`Request failed. Please retry.`, + createdAt: new Date(), + id: this.nextMessageId++, + role: 'assistant' + }); this.changeDetectorRef.markForCheck(); } @@ -218,10 +219,125 @@ export class GfAiChatPanelComponent implements OnDestroy { return role === 'assistant' ? this.assistantRoleLabel : this.userRoleLabel; } + private appendMessage(message: AiChatMessage) { + this.chatMessages = [...this.chatMessages, message].slice( + -this.MAX_STORED_MESSAGES + ); + this.persistChatState(); + } + + private getStorage() { + try { + return globalThis.localStorage; + } catch { + return undefined; + } + } + + private persistChatState() { + const storage = this.getStorage(); + + if (!storage) { + return; + } + + try { + if (this.chatSessionId) { + storage.setItem(this.STORAGE_KEY_SESSION_ID, this.chatSessionId); + } else { + storage.removeItem(this.STORAGE_KEY_SESSION_ID); + } + + storage.setItem( + this.STORAGE_KEY_MESSAGES, + JSON.stringify(this.chatMessages.slice(-this.MAX_STORED_MESSAGES)) + ); + } catch { + // Keep chat available if browser storage is unavailable or full. + } + } + + private restoreChatState() { + const storage = this.getStorage(); + + if (!storage) { + return; + } + + const storedSessionId = storage.getItem(this.STORAGE_KEY_SESSION_ID); + + if (storedSessionId?.trim()) { + this.chatSessionId = storedSessionId.trim(); + } + + const storedMessages = storage.getItem(this.STORAGE_KEY_MESSAGES); + + if (!storedMessages) { + return; + } + + try { + const parsedMessages = JSON.parse(storedMessages) as unknown; + + if (!Array.isArray(parsedMessages)) { + return; + } + + this.chatMessages = parsedMessages + .map((message) => { + return this.toChatMessage(message); + }) + .filter((message): message is AiChatMessage => { + return Boolean(message); + }) + .slice(-this.MAX_STORED_MESSAGES); + + this.nextMessageId = + this.chatMessages.reduce((maxId, message) => { + return Math.max(maxId, message.id); + }, -1) + 1; + } catch { + storage.removeItem(this.STORAGE_KEY_MESSAGES); + } + } + + private toChatMessage(message: unknown): AiChatMessage | undefined { + if (!message || typeof message !== 'object') { + return undefined; + } + + const storedMessage = message as Partial; + + if ( + typeof storedMessage.content !== 'string' || + typeof storedMessage.id !== 'number' || + typeof storedMessage.createdAt !== 'string' || + (storedMessage.role !== 'assistant' && storedMessage.role !== 'user') + ) { + return undefined; + } + + const createdAt = new Date(storedMessage.createdAt); + + if (Number.isNaN(createdAt.getTime())) { + return undefined; + } + + return { + content: storedMessage.content, + createdAt, + feedback: storedMessage.feedback, + id: storedMessage.id, + response: storedMessage.response, + role: storedMessage.role + }; + } + private updateMessage(index: number, updatedMessage: AiChatMessage) { this.chatMessages = this.chatMessages.map((message, messageIndex) => { return messageIndex === index ? updatedMessage : message; }); + this.persistChatState(); this.changeDetectorRef.markForCheck(); } } diff --git a/tasks/tasks.md b/tasks/tasks.md index 7d56682ef..768dea6c9 100644 --- a/tasks/tasks.md +++ b/tasks/tasks.md @@ -193,6 +193,14 @@ Last updated: 2026-02-24 - [x] Push fork branches and open PRs against upstream repositories. - [x] Update `Tasks.md` and plan artifact with PR links and current status. +## Session Plan (2026-02-24, Chat Persistence + Simple Query Handling) + +- [x] Review current chat panel storage behavior and AI policy direct-route behavior. +- [x] Implement localStorage-backed chat session/message persistence with bounded history in `ai-chat-panel.component.ts`. +- [x] Extend direct no-tool query handling for simple assistant capability/help prompts in `ai-agent.policy.utils.ts`. +- [x] Add or update unit tests for chat persistence and policy simple-query routing. +- [x] Run focused verification on touched frontend/backend AI suites and update task tracking artifacts. + ## Verification Notes - `nx run api:lint` completed successfully (existing workspace warnings only). @@ -256,3 +264,8 @@ Last updated: 2026-02-24 - `npm run test:ai` (9/9 suites, 44/44 tests) - `npm run test:mvp-eval` (pass rate threshold test still passes) - `npx nx run api:lint` (passes with existing workspace warnings) +- Chat persistence + simple query handling verification (local, 2026-02-24): + - `npx jest apps/client/src/app/pages/portfolio/analysis/ai-chat-panel/ai-chat-panel.component.spec.ts --config apps/client/jest.config.ts` (7/7 tests passed) + - `npx jest apps/api/src/app/endpoints/ai/ai-agent.utils.spec.ts apps/api/src/app/endpoints/ai/ai.service.spec.ts --config apps/api/jest.config.ts` (31/31 tests passed) + - `npx nx run api:lint` (passes with existing workspace warnings) + - `npx nx run client:lint` (passes with existing workspace warnings)