mirror of https://github.com/ghostfolio/ghostfolio
You can not select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
318 lines
11 KiB
318 lines
11 KiB
import { AccountBalanceService } from '@ghostfolio/api/app/account-balance/account-balance.service';
|
|
import { AccountService } from '@ghostfolio/api/app/account/account.service';
|
|
import { WatchlistService } from '@ghostfolio/api/app/endpoints/watchlist/watchlist.service';
|
|
import { OrderService } from '@ghostfolio/api/app/order/order.service';
|
|
import { PortfolioService } from '@ghostfolio/api/app/portfolio/portfolio.service';
|
|
import { RedisCacheService } from '@ghostfolio/api/app/redis-cache/redis-cache.service';
|
|
import { UserService } from '@ghostfolio/api/app/user/user.service';
|
|
import { DataProviderService } from '@ghostfolio/api/services/data-provider/data-provider.service';
|
|
import { PortfolioSnapshotService } from '@ghostfolio/api/services/queues/portfolio-snapshot/portfolio-snapshot.service';
|
|
import { TagService } from '@ghostfolio/api/services/tag/tag.service';
|
|
|
|
import { createAnthropic } from '@ai-sdk/anthropic';
|
|
import { Injectable, Logger } from '@nestjs/common';
|
|
import {
|
|
ToolLoopAgent,
|
|
stepCountIs,
|
|
type ModelMessage,
|
|
type PrepareStepFunction,
|
|
type UIMessage
|
|
} from 'ai';
|
|
import { randomUUID } from 'node:crypto';
|
|
import { join } from 'node:path';
|
|
|
|
import { AgentMetricsService } from './agent-metrics.service';
|
|
import { validateModelId } from './models';
|
|
import { createPrepareStep, loadSkills } from './prepare-step';
|
|
import { createAccountManageTool } from './tools/account-manage.tool';
|
|
import { createActivityManageTool } from './tools/activity-manage.tool';
|
|
import { createHoldingsLookupTool } from './tools/holdings.tool';
|
|
import { createMarketDataTool } from './tools/market-data.tool';
|
|
import { createPortfolioPerformanceTool } from './tools/performance.tool';
|
|
import { createPortfolioAnalysisTool } from './tools/portfolio.tool';
|
|
import { createSymbolSearchTool } from './tools/symbol-search.tool';
|
|
import { createTagManageTool } from './tools/tag-manage.tool';
|
|
import { createTransactionHistoryTool } from './tools/transactions.tool';
|
|
import { createWatchlistManageTool } from './tools/watchlist-manage.tool';
|
|
import {
|
|
checkHallucination,
|
|
computeConfidence,
|
|
validateOutput
|
|
} from './verification';
|
|
|
|
const BASE_INSTRUCTIONS = `Be extremely concise. Sacrifice grammar for the sake of concision.
|
|
|
|
You are a financial analysis assistant powered by Ghostfolio. You help users understand their investment portfolio through data-driven insights.
|
|
|
|
RULES:
|
|
- You can read AND write portfolio data. You can create/update/delete accounts, transactions, watchlist items, and tags.
|
|
- Never provide investment advice. Always include "This is not financial advice" when making forward-looking statements.
|
|
- Only reference data returned by your tools. Never fabricate numbers or holdings.
|
|
- Do not narrate or announce tool calls. Execute tools directly, then respond with results.
|
|
- If a tool call fails, tell the user honestly rather than guessing.
|
|
- Be concise and data-focused. Use tables and bullet points for clarity.
|
|
- When presenting monetary values, use the user's base currency.
|
|
- When presenting percentages, round to 2 decimal places.
|
|
|
|
FORMATTING:
|
|
- Never use emojis.
|
|
- Structure data responses with a clear markdown heading (e.g., "## Portfolio Performance (YTD)").
|
|
- Present multi-item or comparative data in markdown tables. Use concise column headers.
|
|
- After data tables, include one sentence of insight or context.
|
|
- Use **bold** for key metrics mentioned inline. Never use ALL CAPS for emphasis.
|
|
- Format currency with commas and two decimals (e.g., $48,210.45).
|
|
- Round percentages to two decimal places. Prefix positive returns with +.
|
|
- Keep responses focused -- no filler, no disclaimers unless discussing forward-looking statements.
|
|
|
|
RICH FORMATTING (use these custom fenced blocks when the data fits):
|
|
|
|
1. Allocation breakdowns: use a 2-column markdown table with percentage values.
|
|
| Asset Class | Allocation |
|
|
|---|---|
|
|
| Equities | 65% |
|
|
|
|
2. Key metric summaries (2-4 values): use \`\`\`metrics block. One "Label: Value: Delta" per line. Use "--" if no delta.
|
|
\`\`\`metrics
|
|
Net Worth: $85k: +4.2%
|
|
Div. Yield: 3.1%: --
|
|
\`\`\`
|
|
|
|
3. Follow-up suggestions: ALWAYS end responses with a \`\`\`suggestions block (exactly 2 suggestions, one per line).
|
|
\`\`\`suggestions
|
|
Show my dividend history
|
|
Compare YTD vs last year
|
|
\`\`\`
|
|
|
|
4. Sparklines for trends: \`\`\`sparkline with title and comma-separated values.
|
|
5. Charts when user asks to visualize: \`\`\`chart-area or \`\`\`chart-bar with "Label: Value" per line.`;
|
|
|
|
@Injectable()
|
|
export class AgentService {
|
|
private readonly logger = new Logger(AgentService.name);
|
|
private readonly skills: ReturnType<typeof loadSkills>;
|
|
|
|
public constructor(
|
|
private readonly accountBalanceService: AccountBalanceService,
|
|
private readonly accountService: AccountService,
|
|
private readonly agentMetricsService: AgentMetricsService,
|
|
private readonly dataProviderService: DataProviderService,
|
|
private readonly orderService: OrderService,
|
|
private readonly portfolioService: PortfolioService,
|
|
private readonly portfolioSnapshotService: PortfolioSnapshotService,
|
|
private readonly redisCacheService: RedisCacheService,
|
|
private readonly tagService: TagService,
|
|
private readonly userService: UserService,
|
|
private readonly watchlistService: WatchlistService
|
|
) {
|
|
this.skills = loadSkills(join(__dirname, 'skills'));
|
|
}
|
|
|
|
public async chat({
|
|
approvedActions,
|
|
messages,
|
|
model,
|
|
toolHistory,
|
|
userId
|
|
}: {
|
|
approvedActions?: string[];
|
|
messages: ModelMessage[] | UIMessage[];
|
|
model?: string;
|
|
toolHistory?: string[];
|
|
userId: string;
|
|
}) {
|
|
const requestId = randomUUID();
|
|
const startTime = Date.now();
|
|
const modelId = validateModelId(model);
|
|
|
|
this.logger.log(
|
|
JSON.stringify({
|
|
event: 'chat_start',
|
|
requestId,
|
|
userId,
|
|
modelId,
|
|
messageCount: messages.length
|
|
})
|
|
);
|
|
|
|
const anthropic = createAnthropic();
|
|
|
|
const tools = this.buildTools(userId, approvedActions);
|
|
const prepareStep = createPrepareStep(
|
|
this.skills,
|
|
BASE_INSTRUCTIONS,
|
|
toolHistory
|
|
);
|
|
|
|
const agent = new ToolLoopAgent({
|
|
model: anthropic(modelId),
|
|
instructions: BASE_INSTRUCTIONS,
|
|
tools,
|
|
prepareStep: prepareStep as unknown as PrepareStepFunction<typeof tools>,
|
|
stopWhen: stepCountIs(10),
|
|
onStepFinish: ({ toolCalls, usage, finishReason, stepNumber }) => {
|
|
const toolNames = toolCalls.map((tc) => tc.toolName);
|
|
this.logger.log(
|
|
JSON.stringify({
|
|
event: 'step_finish',
|
|
requestId,
|
|
userId,
|
|
step: stepNumber,
|
|
finishReason,
|
|
toolsCalled: toolNames.length > 0 ? toolNames : undefined,
|
|
tokens: usage
|
|
})
|
|
);
|
|
},
|
|
onFinish: ({ steps, usage, text }) => {
|
|
const latencyMs = Date.now() - startTime;
|
|
const allTools = steps.flatMap((s) =>
|
|
s.toolCalls.map((tc) => tc.toolName)
|
|
);
|
|
const uniqueTools = [...new Set(allTools)];
|
|
|
|
// Run verification
|
|
const toolResults = steps.flatMap((s) =>
|
|
s.toolResults.map((tr: any) => ({
|
|
toolName: tr.toolName as string,
|
|
result: tr.output
|
|
}))
|
|
);
|
|
const toolErrors = steps.flatMap((s) =>
|
|
s.toolResults.filter((tr: any) => {
|
|
const out = tr.output;
|
|
return out && typeof out === 'object' && 'error' in out;
|
|
})
|
|
);
|
|
|
|
const validation = validateOutput({
|
|
text,
|
|
toolCalls: allTools
|
|
});
|
|
const hallucination = checkHallucination({
|
|
text,
|
|
toolResults
|
|
});
|
|
const confidence = computeConfidence({
|
|
toolCallCount: allTools.length,
|
|
toolErrorCount: toolErrors.length,
|
|
stepCount: steps.length,
|
|
maxSteps: 10,
|
|
validation,
|
|
hallucination
|
|
});
|
|
|
|
this.logger.log(
|
|
JSON.stringify({
|
|
event: 'verification',
|
|
requestId,
|
|
userId,
|
|
confidence: confidence.score,
|
|
validationIssues: validation.issues,
|
|
hallucinationIssues: hallucination.issues
|
|
})
|
|
);
|
|
|
|
this.logger.log(
|
|
JSON.stringify({
|
|
event: 'chat_complete',
|
|
requestId,
|
|
userId,
|
|
latencyMs,
|
|
totalSteps: steps.length,
|
|
totalTokens: usage,
|
|
toolsUsed: uniqueTools
|
|
})
|
|
);
|
|
|
|
this.agentMetricsService.record({
|
|
requestId,
|
|
userId,
|
|
modelId,
|
|
latencyMs,
|
|
totalSteps: steps.length,
|
|
toolsUsed: uniqueTools,
|
|
promptTokens:
|
|
(usage as any).promptTokens ?? (usage as any).inputTokens ?? 0,
|
|
completionTokens:
|
|
(usage as any).completionTokens ?? (usage as any).outputTokens ?? 0,
|
|
totalTokens: usage.totalTokens ?? 0,
|
|
timestamp: Date.now(),
|
|
verificationScore: confidence.score,
|
|
verificationResult: {
|
|
confidence: confidence.breakdown,
|
|
validation: {
|
|
valid: validation.valid,
|
|
score: validation.score,
|
|
issues: validation.issues
|
|
},
|
|
hallucination: {
|
|
clean: hallucination.clean,
|
|
score: hallucination.score,
|
|
issues: hallucination.issues
|
|
}
|
|
}
|
|
});
|
|
}
|
|
});
|
|
|
|
return { agent, requestId };
|
|
}
|
|
|
|
private buildTools(userId: string, approvedActions?: string[]) {
|
|
return {
|
|
account_manage: createAccountManageTool({
|
|
accountService: this.accountService,
|
|
approvedActions,
|
|
portfolioSnapshotService: this.portfolioSnapshotService,
|
|
redisCacheService: this.redisCacheService,
|
|
userService: this.userService,
|
|
userId
|
|
}),
|
|
activity_manage: createActivityManageTool({
|
|
accountService: this.accountService,
|
|
approvedActions,
|
|
dataProviderService: this.dataProviderService,
|
|
orderService: this.orderService,
|
|
portfolioSnapshotService: this.portfolioSnapshotService,
|
|
redisCacheService: this.redisCacheService,
|
|
userService: this.userService,
|
|
userId
|
|
}),
|
|
portfolio_analysis: createPortfolioAnalysisTool({
|
|
portfolioService: this.portfolioService,
|
|
userId
|
|
}),
|
|
portfolio_performance: createPortfolioPerformanceTool({
|
|
portfolioService: this.portfolioService,
|
|
userId
|
|
}),
|
|
holdings_lookup: createHoldingsLookupTool({
|
|
portfolioService: this.portfolioService,
|
|
userId
|
|
}),
|
|
market_data: createMarketDataTool({
|
|
dataProviderService: this.dataProviderService
|
|
}),
|
|
symbol_search: createSymbolSearchTool({
|
|
dataProviderService: this.dataProviderService,
|
|
userService: this.userService,
|
|
userId
|
|
}),
|
|
tag_manage: createTagManageTool({
|
|
tagService: this.tagService,
|
|
userId
|
|
}),
|
|
transaction_history: createTransactionHistoryTool({
|
|
accountBalanceService: this.accountBalanceService,
|
|
accountService: this.accountService,
|
|
orderService: this.orderService,
|
|
userService: this.userService,
|
|
userId
|
|
}),
|
|
watchlist_manage: createWatchlistManageTool({
|
|
watchlistService: this.watchlistService,
|
|
userId
|
|
})
|
|
};
|
|
}
|
|
}
|
|
|