@ -8,8 +8,12 @@ import {
PROPERTY_OPENROUTER_MODEL
PROPERTY_OPENROUTER_MODEL
} from '@ghostfolio/common/config' ;
} from '@ghostfolio/common/config' ;
import { DATE_FORMAT , isAccountExcluded } from '@ghostfolio/common/helper' ;
import { DATE_FORMAT , isAccountExcluded } from '@ghostfolio/common/helper' ;
import { Filter } from '@ghostfolio/common/interfaces' ;
import {
import type { AiPromptMode } from '@ghostfolio/common/types' ;
Activity ,
Filter ,
PortfolioPosition
} from '@ghostfolio/common/interfaces' ;
import type { AccountWithValue , AiPromptMode } from '@ghostfolio/common/types' ;
import { Injectable } from '@nestjs/common' ;
import { Injectable } from '@nestjs/common' ;
import { createOpenRouter } from '@openrouter/ai-sdk-provider' ;
import { createOpenRouter } from '@openrouter/ai-sdk-provider' ;
@ -22,75 +26,18 @@ import { generateText } from 'ai';
import { format } from 'date-fns' ;
import { format } from 'date-fns' ;
import type { ColumnDescriptor } from 'tablemark' ;
import type { ColumnDescriptor } from 'tablemark' ;
/ * *
* Describes one column of a table which a tool or a prompt presents . The value
* of a cell is derived from the item of the row , hence a table needs no
* mapping of its own .
* /
type TableColumnDefinition < T > = ColumnDescriptor & {
getValue : ( item : T ) = > string ;
name : string ;
} ;
@Injectable ( )
@Injectable ( )
export class AiService {
export class AiService {
private static readonly ACCOUNTS_TABLE_COLUMN_DEFINITIONS : ( {
key :
| 'ACTIVITIES_COUNT'
| 'ALLOCATION_PERCENTAGE'
| 'CURRENCY'
| 'EXCLUDED_FROM_ANALYSIS'
| 'ID'
| 'NAME'
| 'PLATFORM' ;
} & ColumnDescriptor ) [ ] = [
{ key : 'ID' , name : 'Id' } ,
{ key : 'NAME' , name : 'Name' } ,
{ key : 'CURRENCY' , name : 'Currency' } ,
{ key : 'PLATFORM' , name : 'Platform' } ,
{ align : 'right' , key : 'ACTIVITIES_COUNT' , name : 'Activities Count' } ,
{
align : 'right' ,
key : 'ALLOCATION_PERCENTAGE' ,
name : 'Allocation in Percentage'
} ,
{ key : 'EXCLUDED_FROM_ANALYSIS' , name : 'Excluded from Analysis' }
] ;
private static readonly ACTIVITIES_TABLE_COLUMN_DEFINITIONS : ( {
key :
| 'ACCOUNT'
| 'CURRENCY'
| 'DATE'
| 'NAME'
| 'SYMBOL'
| 'TYPE'
| 'UNIT_PRICE' ;
} & ColumnDescriptor ) [ ] = [
{ key : 'DATE' , name : 'Date' } ,
{ key : 'TYPE' , name : 'Type' } ,
{ key : 'NAME' , name : 'Name' } ,
{ key : 'SYMBOL' , name : 'Symbol' } ,
{ key : 'CURRENCY' , name : 'Currency' } ,
{ align : 'right' , key : 'UNIT_PRICE' , name : 'Unit Price' } ,
{ key : 'ACCOUNT' , name : 'Account' }
] ;
private static readonly HOLDINGS_TABLE_COLUMN_DEFINITIONS : ( {
key :
| 'ACTIVITIES_COUNT'
| 'ALLOCATION_PERCENTAGE'
| 'ASSET_CLASS'
| 'ASSET_SUB_CLASS'
| 'CURRENCY'
| 'DATE_OF_FIRST_ACTIVITY'
| 'NAME'
| 'SYMBOL' ;
} & ColumnDescriptor ) [ ] = [
{ key : 'NAME' , name : 'Name' } ,
{ key : 'SYMBOL' , name : 'Symbol' } ,
{ key : 'CURRENCY' , name : 'Currency' } ,
{ key : 'ASSET_CLASS' , name : 'Asset Class' } ,
{ key : 'ASSET_SUB_CLASS' , name : 'Asset Sub Class' } ,
{ key : 'DATE_OF_FIRST_ACTIVITY' , name : 'Date of First Activity' } ,
{ align : 'right' , key : 'ACTIVITIES_COUNT' , name : 'Activities Count' } ,
{
align : 'right' ,
key : 'ALLOCATION_PERCENTAGE' ,
name : 'Allocation in Percentage'
}
] ;
public constructor (
public constructor (
private readonly activitiesService : ActivitiesService ,
private readonly activitiesService : ActivitiesService ,
private readonly configurationService : ConfigurationService ,
private readonly configurationService : ConfigurationService ,
@ -100,23 +47,189 @@ export class AiService {
) { }
) { }
public static getAccountsTableColumnNames() {
public static getAccountsTableColumnNames() {
return AiService . ACCOUNTS_TABLE_COLUMN_DEFINITIONS . map ( ( { name } ) = > {
return AiService . getAccountsTableColumnDefinitions ( ) . map ( ( { name } ) = > {
return name ;
return name ;
} ) ;
} ) ;
}
}
public static getActivitiesTableColumnNames() {
public static getActivitiesTableColumnNames() {
return AiService . ACTIVITIES_TABLE_COLUMN_DEFINITIONS . map ( ( { name } ) = > {
return AiService . getActivitiesTableColumnDefinitions ( ) . map ( ( { name } ) = > {
return name ;
return name ;
} ) ;
} ) ;
}
}
public static getHoldingsTableColumnNames() {
public static getHoldingsTableColumnNames() {
return AiService . HOLDINGS_TABLE_COLUMN_DEFINITIONS . map ( ( { name } ) = > {
return AiService . getHoldingsTableColumnDefinitions ( ) . map ( ( { name } ) = > {
return name ;
return name ;
} ) ;
} ) ;
}
}
private static getAccountsTableColumnDefinitions ( ) : TableColumnDefinition < AccountWithValue > [ ] {
return [
{
getValue : ( { id } ) = > {
return id ;
} ,
name : 'Id'
} ,
{
getValue : ( { name } ) = > {
return name ? ? '' ;
} ,
name : 'Name'
} ,
{
getValue : ( { currency } ) = > {
return currency ? ? '' ;
} ,
name : 'Currency'
} ,
{
getValue : ( { platform } ) = > {
return platform ? . name ? ? '' ;
} ,
name : 'Platform'
} ,
{
align : 'right' ,
getValue : ( { activitiesCount } ) = > {
return activitiesCount . toString ( ) ;
} ,
name : 'Activities Count'
} ,
{
align : 'right' ,
getValue : ( { allocationInPercentage } ) = > {
return ` ${ ( allocationInPercentage * 100 ) . toFixed ( 3 ) } % ` ;
} ,
name : 'Allocation in Percentage'
} ,
{
getValue : ( { tags } ) = > {
return isAccountExcluded ( { tags } ) . toString ( ) ;
} ,
name : 'Excluded from Analysis'
}
] ;
}
private static getActivitiesTableColumnDefinitions ( ) : TableColumnDefinition < Activity > [ ] {
return [
{
getValue : ( { date } ) = > {
return format ( date , DATE_FORMAT ) ;
} ,
name : 'Date'
} ,
{
getValue : ( { type } ) = > {
return type ;
} ,
name : 'Type'
} ,
{
getValue : ( { assetProfile } ) = > {
return assetProfile . name ? ? '' ;
} ,
name : 'Name'
} ,
{
getValue : ( { assetProfile } ) = > {
return assetProfile . symbol ;
} ,
name : 'Symbol'
} ,
{
getValue : ( { assetProfile , currency } ) = > {
return currency ? ? assetProfile . currency ;
} ,
name : 'Currency'
} ,
{
align : 'right' ,
getValue : ( { unitPrice } ) = > {
return unitPrice . toString ( ) ;
} ,
name : 'Unit Price'
} ,
{
getValue : ( { account } ) = > {
return account ? . name ? ? '' ;
} ,
name : 'Account'
}
] ;
}
/ * *
* The translations of the enumerations depend on the language of the caller ,
* hence the definitions are built per request . The names of the columns are
* independent of them , hence a caller which needs the names only passes no
* translation .
* /
private static getHoldingsTableColumnDefinitions ( {
assetClassTranslations = { } ,
assetSubClassTranslations = { }
} : {
assetClassTranslations? : Record < string , string > ;
assetSubClassTranslations? : Record < string , string > ;
} = { } ) : TableColumnDefinition < PortfolioPosition > [ ] {
return [
{
getValue : ( { assetProfile } ) = > {
return assetProfile . name ? ? '' ;
} ,
name : 'Name'
} ,
{
getValue : ( { assetProfile } ) = > {
return assetProfile . symbol ;
} ,
name : 'Symbol'
} ,
{
getValue : ( { assetProfile } ) = > {
return assetProfile . currency ;
} ,
name : 'Currency'
} ,
{
getValue : ( { assetProfile } ) = > {
return assetClassTranslations [ assetProfile . assetClass ] ? ? '' ;
} ,
name : 'Asset Class'
} ,
{
getValue : ( { assetProfile } ) = > {
return assetSubClassTranslations [ assetProfile . assetSubClass ] ? ? '' ;
} ,
name : 'Asset Sub Class'
} ,
{
getValue : ( { dateOfFirstActivity } ) = > {
return dateOfFirstActivity
? format ( dateOfFirstActivity , DATE_FORMAT )
: '' ;
} ,
name : 'Date of First Activity'
} ,
{
align : 'right' ,
getValue : ( { activitiesCount } ) = > {
return activitiesCount . toString ( ) ;
} ,
name : 'Activities Count'
} ,
{
align : 'right' ,
getValue : ( { allocationInPercentage } ) = > {
return ` ${ ( allocationInPercentage * 100 ) . toFixed ( 3 ) } % ` ;
} ,
name : 'Allocation in Percentage'
}
] ;
}
public async generateText ( {
public async generateText ( {
prompt ,
prompt ,
requestTimeout = this . configurationService . get ( 'REQUEST_TIMEOUT' )
requestTimeout = this . configurationService . get ( 'REQUEST_TIMEOUT' )
@ -157,73 +270,12 @@ export class AiService {
withExcludedAccounts : true
withExcludedAccounts : true
} ) ;
} ) ;
const accountsTableRows = accounts . map (
return this . getSection ( {
( {
columnDefinitions : AiService.getAccountsTableColumnDefinitions ( ) ,
activitiesCount ,
items : accounts ,
allocationInPercentage ,
messageIfEmpty : 'No accounts found.' ,
currency ,
title : '## Accounts'
id ,
} ) ;
name : label ,
platform ,
tags
} ) = > {
return AiService . ACCOUNTS_TABLE_COLUMN_DEFINITIONS . reduce (
( row , { key , name } ) = > {
switch ( key ) {
case 'ACTIVITIES_COUNT' :
row [ name ] = activitiesCount . toString ( ) ;
break ;
case 'ALLOCATION_PERCENTAGE' :
row [ name ] = ` ${ ( allocationInPercentage * 100 ) . toFixed ( 3 ) } % ` ;
break ;
case 'CURRENCY' :
row [ name ] = currency ? ? '' ;
break ;
case 'EXCLUDED_FROM_ANALYSIS' :
row [ name ] = isAccountExcluded ( { tags } ) . toString ( ) ;
break ;
case 'ID' :
row [ name ] = id ;
break ;
case 'NAME' :
row [ name ] = label ? ? '' ;
break ;
case 'PLATFORM' :
row [ name ] = platform ? . name ? ? '' ;
break ;
default :
row [ name ] = '' ;
break ;
}
return row ;
} ,
{ } as Record < string , string >
) ;
}
) ;
const accountsSection = [ '## Accounts' , '' ] ;
if ( accountsTableRows . length > 0 ) {
accountsSection . push (
await this . getMarkdownTable ( {
columnDefinitions : AiService.ACCOUNTS_TABLE_COLUMN_DEFINITIONS ,
rows : accountsTableRows
} )
) ;
} else {
accountsSection . push ( 'No accounts found.' ) ;
}
return accountsSection . join ( '\n' ) ;
}
}
public async getActivitiesTable ( {
public async getActivitiesTable ( {
@ -260,51 +312,6 @@ export class AiService {
withExcludedAccountsAndActivities : true
withExcludedAccountsAndActivities : true
} ) ;
} ) ;
const activitiesTableRows = activities . map (
( { account , assetProfile , currency , date , type , unitPrice } ) = > {
return AiService . ACTIVITIES_TABLE_COLUMN_DEFINITIONS . reduce (
( row , { key , name } ) = > {
switch ( key ) {
case 'ACCOUNT' :
row [ name ] = account ? . name ? ? '' ;
break ;
case 'CURRENCY' :
row [ name ] = currency ? ? assetProfile . currency ;
break ;
case 'DATE' :
row [ name ] = format ( date , DATE_FORMAT ) ;
break ;
case 'NAME' :
row [ name ] = assetProfile . name ? ? '' ;
break ;
case 'SYMBOL' :
row [ name ] = assetProfile . symbol ;
break ;
case 'TYPE' :
row [ name ] = type ;
break ;
case 'UNIT_PRICE' :
row [ name ] = unitPrice . toString ( ) ;
break ;
default :
row [ name ] = '' ;
break ;
}
return row ;
} ,
{ } as Record < string , string >
) ;
}
) ;
const activitiesSection = [
const activitiesSection = [
'## Activities' ,
'## Activities' ,
'' ,
'' ,
@ -315,12 +322,12 @@ export class AiService {
} )
} )
] ;
] ;
if ( activitiesTableRows . length > 0 ) {
if ( activities . length > 0 ) {
activitiesSection . push (
activitiesSection . push (
'' ,
'' ,
await this . getMarkdownTable ( {
await this . getMarkdownTable ( {
columnDefinitions : AiService.ACTIVITIES_TABLE_COLUMN_DEFINITIONS ,
columnDefinitions : AiService.getActivitiesTableColumnDefinitions ( ) ,
rows : activitiesTableRow s
items : activitie s
} )
} )
) ;
) ;
}
}
@ -328,17 +335,13 @@ export class AiService {
return activitiesSection . join ( '\n' ) ;
return activitiesSection . join ( '\n' ) ;
}
}
public async getPrompt ( {
public async getHoldingsTable ( {
filters ,
filters ,
languageCode ,
languageCode ,
mode ,
userCurrency ,
userId
userId
} : {
} : {
filters? : Filter [ ] ;
filters? : Filter [ ] ;
languageCode : string ;
languageCode : string ;
mode : AiPromptMode ;
userCurrency : string ;
userId : string ;
userId : string ;
} ) {
} ) {
const { holdings } = await this . portfolioService . getDetails ( {
const { holdings } = await this . portfolioService . getDetails ( {
@ -346,92 +349,47 @@ export class AiService {
userId
userId
} ) ;
} ) ;
const assetClassTranslations = this . getEnumTranslations ( {
const columnDefinitions = AiService . getHoldingsTableColumnDefinitions ( {
languageCode ,
assetClassTranslations : this.getEnumTranslations ( {
id : 'assetClass' ,
languageCode ,
values : Object.values ( AssetClass )
id : 'assetClass' ,
} ) ;
values : Object.values ( AssetClass )
} ) ,
const assetSubClassTranslations = this . getEnumTranslations ( {
assetSubClassTranslations : this.getEnumTranslations ( {
languageCode ,
languageCode ,
id : 'assetSubClass' ,
id : 'assetSubClass' ,
values : Object.values ( AssetSubClass )
values : Object.values ( AssetSubClass )
} )
} ) ;
} ) ;
const holdingsTableRows = [ . . . holdings ]
return this . getSection ( {
. sort ( ( a , b ) = > {
columnDefinitions ,
items : [ . . . holdings ] . sort ( ( a , b ) = > {
return b . allocationInPercentage - a . allocationInPercentage ;
return b . allocationInPercentage - a . allocationInPercentage ;
} )
} ) ,
. map (
messageIfEmpty : 'No holdings found.' ,
( {
title : '## Holdings'
activitiesCount ,
} ) ;
allocationInPercentage ,
}
assetProfile : {
assetClass ,
assetSubClass ,
currency ,
name : label ,
symbol
} ,
dateOfFirstActivity
} ) = > {
return AiService . HOLDINGS_TABLE_COLUMN_DEFINITIONS . reduce (
( row , { key , name } ) = > {
switch ( key ) {
case 'ACTIVITIES_COUNT' :
row [ name ] = activitiesCount . toString ( ) ;
break ;
case 'ALLOCATION_PERCENTAGE' :
row [ name ] = ` ${ ( allocationInPercentage * 100 ) . toFixed ( 3 ) } % ` ;
break ;
case 'ASSET_CLASS' :
row [ name ] = assetClassTranslations [ assetClass ] ? ? '' ;
break ;
case 'ASSET_SUB_CLASS' :
row [ name ] = assetSubClassTranslations [ assetSubClass ] ? ? '' ;
break ;
case 'CURRENCY' :
row [ name ] = currency ;
break ;
case 'DATE_OF_FIRST_ACTIVITY' :
row [ name ] = dateOfFirstActivity
? format ( dateOfFirstActivity , DATE_FORMAT )
: '' ;
break ;
case 'NAME' :
row [ name ] = label ;
break ;
case 'SYMBOL' :
row [ name ] = symbol ;
break ;
default :
row [ name ] = '' ;
break ;
}
return row ;
} ,
{ } as Record < string , string >
) ;
}
) ;
const holdingsSection = [
public async getPrompt ( {
'## Holdings' ,
filters ,
'' ,
languageCode ,
await this . getMarkdownTable ( {
mode ,
columnDefinitions : AiService.HOLDINGS_TABLE_COLUMN_DEFINITIONS ,
userCurrency ,
rows : holdingsTableRows
userId
} )
} : {
] . join ( '\n' ) ;
filters? : Filter [ ] ;
languageCode : string ;
mode : AiPromptMode ;
userCurrency : string ;
userId : string ;
} ) {
const holdingsSection = await this . getHoldingsTable ( {
filters ,
languageCode ,
userId
} ) ;
if ( mode === 'portfolio' ) {
if ( mode === 'portfolio' ) {
return holdingsSection ;
return holdingsSection ;
@ -509,12 +467,12 @@ export class AiService {
) ;
) ;
}
}
private async getMarkdownTable ( {
private async getMarkdownTable < T > ( {
columnDefinitions ,
columnDefinitions ,
row s
item s
} : {
} : {
columnDefinitions : readonly ColumnDescriptor [ ] ;
columnDefinitions : readonly TableColumnDefinition < T > [ ] ;
rows : Record < string , string > [ ] ;
items : T [ ] ;
} ) {
} ) {
// Dynamic import to load ESM module from CommonJS context
// Dynamic import to load ESM module from CommonJS context
// eslint-disable-next-line @typescript-eslint/no-implied-eval
// eslint-disable-next-line @typescript-eslint/no-implied-eval
@ -523,10 +481,43 @@ export class AiService {
) = > Promise < typeof import ( ' tablemark ' ) > ;
) = > Promise < typeof import ( ' tablemark ' ) > ;
const { tablemark } = await dynamicImport ( 'tablemark' ) ;
const { tablemark } = await dynamicImport ( 'tablemark' ) ;
const rows = items . map ( ( item ) = > {
return columnDefinitions . reduce (
( row , { getValue , name } ) = > {
row [ name ] = getValue ( item ) ;
return row ;
} ,
{ } as Record < string , string >
) ;
} ) ;
return tablemark ( rows , {
return tablemark ( rows , {
columns : columnDefinitions.map ( ( { align , name } ) = > {
columns : columnDefinitions.map ( ( { align , name } ) = > {
return { name , align : align ? ? 'left' } ;
return { name , align : align ? ? 'left' } ;
} )
} )
} ) ;
} ) ;
}
}
private async getSection < T > ( {
columnDefinitions ,
items ,
messageIfEmpty ,
title
} : {
columnDefinitions : readonly TableColumnDefinition < T > [ ] ;
items : T [ ] ;
messageIfEmpty : string ;
title : string ;
} ) {
const section = [ title , '' ] ;
if ( items . length > 0 ) {
section . push ( await this . getMarkdownTable ( { columnDefinitions , items } ) ) ;
} else {
section . push ( messageIfEmpty ) ;
}
return section . join ( '\n' ) ;
}
}
}