Browse Source

feat(ai): persist chat panel state and improve no-tool direct prompts

pull/6395/head
Max P 1 month ago
parent
commit
325d9e497c
  1. 2
      Tasks.md
  2. 20
      apps/api/src/app/endpoints/ai/ai-agent.policy.utils.ts
  3. 24
      apps/api/src/app/endpoints/ai/ai-agent.utils.spec.ts
  4. 2
      apps/api/src/app/endpoints/ai/ai.service.spec.ts
  5. 106
      apps/client/src/app/pages/portfolio/analysis/ai-chat-panel/ai-chat-panel.component.spec.ts
  6. 180
      apps/client/src/app/pages/portfolio/analysis/ai-chat-panel/ai-chat-panel.component.ts
  7. 13
      tasks/tasks.md

2
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?".

20
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<AiAgentToolName>([
'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.`;
}

24
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: [],

2
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();

106
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<GfAiChatPanelComponent>;
@ -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(() => {

180
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<AiChatMessage, 'createdAt'> & {
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<StoredAiChatMessage>;
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();
}
}

13
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)

Loading…
Cancel
Save