Files

414 lines
20 KiB
JavaScript
Raw Permalink Normal View History

2026-10-29 11:57:29 +01:00
// Generated, do not edit. sha256:df37b3da57b8535420153ba9f7331337454235c42435f0fd9deff4f9829eb96b
import { listProviders, getProvider } from '../data/providers.js';
import { listPriceSource } from '../data/price-source.js';
import { diagnoseSources } from '../../shared/source-diagnosis.js';
import { serviceCoveredAssets } from './service-cover.mjs';
import { apiMetricsTotals } from '../data/api-metrics.js';
import { listConnections } from '../data/connections.js';
import { listAssets } from '../data/assets.js';
import { getBaseCurrency } from '../data/portfolio.js';
import { listAccounts } from '../data/accounts.js';
import { listLabels, labelUsage } from '../data/labels.js';
import { counts } from '../data/counts.js';
import { listMetalProducts } from '../data/metals.js';
import { listProfiles, listWidgets } from '../data/dashboard.js';
import { getPref } from '../data/preferences.js';
import { hygieneReport } from '../data/hygiene.js';
import { listScanLogs } from '../data/scan-logs.js';
import { listIntegrationCalls } from '../data/integration-log.js';
import { assertScope, mayScope } from '../ai/gate.js';
import { limitedList, limitRows, omittedNote } from '../ai/limits.js';
import { vertalerVoor } from './session.mjs';
import { CONFIG, PREFERENCES, SOURCE_CAPABILITIES, TAX_CATEGORY_BY_ASSET_KIND, balanceCaptureOn } from '../../shared/config.js';
import { fromBaseByFactor } from '../../shared/units.js';
const GEEN_BEDRAGEN = 'Zonder bedragen, saldi of adressen, en wijzigt niets.';
function sindsUren(uren) {
return new Date(Date.now() - uren * 3600_000).toISOString().slice(0, 16);
}
function pct(deel, geheel) {
return geheel ? `${Math.round((deel / geheel) * 1000) / 10}%` : '0%';
}
const VENSTER_UREN = CONFIG.AI.LIMITS.METRICS_WINDOW_HOURS;
function scanBlok(t) {
const regels = [];
for (const rekening of listAccounts()) {
const logs = listScanLogs(rekening.id);
if (!logs.length && !rekening.scanEnabled) continue;
const veiligeRekening = t.toAi(rekening, { entity: 'account' });
if (!logs.length) {
regels.push(`- ${veiligeRekening.id}: staat op automatisch scannen maar is nog nooit gescand.`);
continue;
}
const laatste = t.toAi({
status: logs[0].status,
scannedAt: logs[0].scannedAt,
addressesFound: logs[0].addressesFound,
proposalsCreated: logs[0].proposalsCreated,
}, { entity: 'scanLog' });
const mislukt = logs.filter(l => l.status === 'failed').length;
regels.push(
`- ${veiligeRekening.id}: laatste scan ${laatste.status} op ${laatste.scannedAt}`
+ `, ${laatste.addressesFound ?? 0} adressen, ${laatste.proposalsCreated ?? 0} voorstellen`
+ ` (${logs.length} scans in totaal, ${mislukt} mislukt).`);
}
return limitedList(regels, r => r).lines;
}
const DIAGNOSE_TEKST = Object.freeze({
noCoverage: () => 'geen enkele bron levert hier historie voor',
allInactive: n => `de dekkende bronnen staan uit (${n})`,
noChoice: () => 'geen bron gekozen voor de historie',
choiceUnusable: n => `de gekozen bron werkt niet (${n})`,
limitReached: n => `maandlimiet bereikt (${n})`,
choiceInactive: n => `gekozen maar uitgezet (${n})`,
});
function bronkeuzeBlok(t, uitDienst = new Set()) {
const maand = `${new Date().toISOString().slice(0, 7)}-01T00:00`;
const providers = listProviders().map(p => getProvider(p.key)).filter(Boolean);
const perId = new Map(providers.map(p => [p.id, p]));
const usage = {};
for (const p of providers) if (p.callsPerMonth) usage[p.id] = apiMetricsTotals(p.id, maand).calls;
const cellen = listPriceSource().filter(c => c.rank != null).sort((a, b) => a.rank - b.rank);
const regels = [];
const basis = getBaseCurrency();
for (const asset of listAssets()) {
if (asset.symbol === basis) continue;
const veilig = t.toAi(asset, { entity: 'asset' });
const keten = cap => cellen.filter(c => c.assetId === asset.id && c.capability === cap)
.map(c => perId.get(c.providerId)).filter(Boolean);
const naam = p => `${t.toAi({ key: p.key }, { entity: 'provider' }).key}${p.isActive ? '' : ' (uit)'}`;
const delen = SOURCE_CAPABILITIES.map(cap => {
const k = keten(cap);
return `${cap === 'spot' ? 'spot' : 'historie'}: ${k.length ? k.map(naam).join(', ') : 'geen'}`;
});
const gekozen = new Set(cellen.filter(c => c.assetId === asset.id).map(c => c.providerId));
const vrij = providers.filter(p => !p.isSystem && (p.coverage || []).includes(asset.symbol) && !gekozen.has(p.id));
if (vrij.length) delen.push(`dekt ook: ${vrij.map(naam).join(', ')}`);
const dienst = uitDienst.has(asset.id);
if (dienst) delen.push('historie uit Atreides Services');
const bevindingen = diagnoseSources(asset, { providers, historyChain: keten('history').map(p => p.key), usage,
serviceCovers: dienst });
for (const b of bevindingen) {
const namen = (b.names || []).join(', ');
const tekst = (DIAGNOSE_TEKST[b.code] || (() => b.code))(namen);
delen.push(b.proposal ? `${tekst}, voorstel: ${b.proposal.providerKey}` : tekst);
}
regels.push(`- ${veilig.symbol} (${veilig.kind}): ${delen.join('; ')}.`);
}
return limitedList(regels, r => r).lines;
}
const ILOG = CONFIG.INTEGRATION_LOG;
function foutenBlok(t, magAdministratie) {
const rijen = listIntegrationCalls({ days: ILOG.WINDOW_DAYS, limit: ILOG.MAX_ROWS });
const regels = rijen.slice(0, ILOG.AI_MAX_ROWS).map(r => {
const v = t.toAi({
occurredAt: r.occurredAt, providerKey: r.providerKey, operation: r.operation, outcome: r.outcome,
httpStatus: r.httpStatus, errorKey: r.errorKey, requestId: r.requestId,
...(magAdministratie ? { subject: r.subject } : {}),
}, { entity: 'integrationCall' });
const bron = r.target === 'service' ? 'atreides_services' : v.providerKey;
return `- ${v.occurredAt} ${bron} ${v.operation}${v.subject ? ` ${v.subject}` : ''}: ${v.outcome}`
+ `${v.httpStatus ? ` ${v.httpStatus}` : ''}${v.errorKey ? ` ${v.errorKey}` : ''}`
+ `${v.requestId ? ` (request-id ${v.requestId})` : ''}`;
});
if (rijen.length > regels.length) regels.push(`- en nog ${rijen.length - regels.length} oudere.`);
return regels;
}
const integrationHealth = Object.freeze({
name: 'integration_health',
title: 'Gezondheid van de koppelingen',
description:
'Geeft de technische toestand van de externe koppelingen van AssetApp: per koersbron de geslaagde '
+ 'en mislukte aanroepen van de afgelopen 24 uur met de gemiddelde en langste reactietijd, per '
+ 'verbinding de laatst gemeten status, per automatisch gescand account de uitslag van de '
+ 'laatste wallet-scan, per asset de bronkeuze voor spot en historie met wat eraan schort, en de recente '
+ 'fouten met request-id. Zegt dus of een koersbron onbetrouwbaar is, een verbinding stuk staat of een '
+ 'scan niet meer doorkomt. Verbindingen en accounts worden aangeduid met een verwijzing zoals '
+ 'connection:7f3a9c, zodat twee koppelingen van dezelfde soort te onderscheiden zijn. '
+ GEEN_BEDRAGEN,
inputSchema: Object.freeze({ type: 'object', properties: {}, additionalProperties: false }),
scope: 'none',
async run(_args, context) {
assertScope('none');
const t = await vertalerVoor(context);
const sinds = sindsUren(VENSTER_UREN);
const regels = limitedList(listProviders(), p => {
const veilig = t.toAi(p, { entity: 'provider' });
const m = apiMetricsTotals(p.id, sinds);
const status = veilig.isActive ? 'actief' : 'uitgeschakeld';
return m.calls === 0
? `- ${veilig.key} (${status}): geen aanroepen in de afgelopen ${VENSTER_UREN} uur.`
: `- ${veilig.key} (${status}): ${m.calls} aanroepen, ${m.fail} mislukt (${pct(m.fail, m.calls)}), `
+ `gemiddeld ${m.avg_latency_ms} ms, langste ${m.maxLatencyMs} ms.`;
}).lines;
const verbindingen = limitedList(listConnections(), c => {
const veilig = t.toAi(c, { entity: 'connection' });
const uitslag = veilig.lastStatus
? `${veilig.lastStatus} (gemeten ${veilig.lastCheckedAt})`
: 'nog nooit getest';
return `- ${veilig.id} (${veilig.kind}${veilig.isActive ? ', actief' : ''}): ${uitslag}`;
}).lines;
const magAdministratie = mayScope('read');
const scans = magAdministratie ? scanBlok(t) : [];
const uitDienst = magAdministratie ? await serviceCoveredAssets() : new Set();
const bronkeuze = magAdministratie ? bronkeuzeBlok(t, uitDienst) : [];
const fouten = foutenBlok(t, magAdministratie);
return {
text: [
`Koersbronnen, afgelopen ${VENSTER_UREN} uur:`,
regels.length ? regels.join('\n') : '- geen koersbronnen ingesteld.',
'',
'Verbindingen:',
verbindingen.length ? verbindingen.join('\n') : '- geen verbindingen ingesteld.',
'',
'Wallet-scans:',
!magAdministratie
? '- niet opgevraagd: de AI-koppeling van AssetApp staat uit, en scans horen bij een account.'
: (scans.length ? scans.join('\n') : '- geen account staat op automatisch scannen.'),
'',
'Bronkeuze per asset (in volgorde; wijzigen doet de gebruiker op Integraties, Bronkeuze):',
!magAdministratie
? '- niet opgevraagd: de AI-koppeling van AssetApp staat uit, en de assets horen bij de administratie.'
: (bronkeuze.length ? bronkeuze.join('\n') : '- geen assets.'),
'',
`Fouten, afgelopen ${ILOG.WINDOW_DAYS} dagen (nieuwste eerst):`,
fouten.length ? fouten.join('\n') : '- geen.',
].join('\n'),
fieldsShared: [
'key', 'isActive', 'okCount', 'failCount', 'sumLatencyMs', 'maxLatencyMs',
'kind', 'lastStatus', 'lastCheckedAt', 'id',
...(magAdministratie ? ['scanEnabled', 'status', 'scannedAt', 'addressesFound', 'proposalsCreated',
'symbol', 'capability', 'rank', 'callsPerMonth'] : []),
...(uitDienst.size ? ['value'] : []),
...(fouten.length ? ['occurredAt', 'providerKey', 'operation', 'outcome', 'httpStatus', 'errorKey', 'requestId',
...(magAdministratie ? ['subject'] : [])] : []),
],
};
},
});
const adminOverview = Object.freeze({
name: 'admin_overview',
title: 'Overzicht van de inrichting',
description:
'Geeft de inrichting van AssetApp: welke assets er zijn (met hun symbool, soort en eenheid), '
+ 'hoeveel accounts er zijn en van welke soort, welke labels bestaan en op hoeveel on-chain '
+ 'bewegingen elk label gebruikt wordt, en hoeveel gebeurtenissen '
+ 'en balansen er zijn vastgelegd. Bedoeld voor vragen over de opzet en om '
+ 'te zien wat er ontbreekt. Plus de configuratie: metaalproducten, dashboardprofielen, '
+ 'de app-instellingen en de timers. '
+ 'Accounts en labels krijgen een verwijzing zoals account:7f3a9c, '
+ 'ook in een later gesprek dezelfde; met onbewerkt delen aan is dat het rij-id met de naam '
+ 'erachter. ' + GEEN_BEDRAGEN,
inputSchema: Object.freeze({ type: 'object', properties: {}, additionalProperties: false }),
scope: 'read',
async run(_args, context) {
assertScope(adminOverview.scope);
const t = await vertalerVoor(context);
const alleAssets = listAssets();
const alleRekeningen = listAccounts();
const alleLabels = listLabels();
const gekozen = (waarde, afgeleid, bron) => (waarde != null ? `${waarde} (gekozen)` : `${afgeleid} (${bron})`);
const assets = limitedList(alleAssets, a => {
const v = t.toAi(a, { entity: 'asset' });
return `- ${v.symbol} (${v.kind}, eenheid ${v.unit}${v.subunit ? `, ondereenheid ${v.subunit}` : ''}; `
+ `categorie ${gekozen(v.taxCategory, TAX_CATEGORY_BY_ASSET_KIND[v.kind] ?? 'onbekend', 'volgt soort')}, `
+ `belastingen ${gekozen(v.taxScope, 'all', 'standaard')}, `
+ `saldo vastleggen ${gekozen(v.balanceCapture == null ? null : (v.balanceCapture ? 'aan' : 'uit'),
balanceCaptureOn({ kind: v.kind }) ? 'aan' : 'uit', 'volgt soort')})`;
}).lines;
const onbewerkt = t.precision.rawValues;
const naamErbij = v => (onbewerkt && v.name ? ` (${v.name})` : '');
const rekeningen = limitedList(alleRekeningen, a => {
const v = t.toAi(a, { entity: 'account' });
const eigen = v.isOwnCustody ? 'zelf in bewaring' : 'bij een derde partij';
return `- ${v.id}${naamErbij(v)}: soort ${v.kind}, ${eigen}, ${v.isActive ? 'actief' : 'afgesloten'}`
+ `${v.scanEnabled ? ', wordt automatisch gescand' : ''}`
+ `; categorie ${v.taxCategory ?? 'volgt asset'}, belastingen ${v.taxScope ?? 'volgt asset'}`
+ `, vermogen ${v.wealthScope ?? 'eigen'}`;
}).lines;
const gebruik = new Map(labelUsage().map(r => [r.labelId, Number(r.walletTransactions)]));
const labels = limitedList(alleLabels, l => {
const n = gebruik.get(l.id) ?? 0;
const hoe = n === 0 ? 'nergens gebruikt'
: n === 1 ? 'gebruikt op 1 beweging'
: `gebruikt op ${n} bewegingen`;
const v = t.toAi(l, { entity: 'label' });
return `- ${v.id}${naamErbij(v)}: ${hoe}`;
}).lines;
const c = counts();
const alleProducten = listMetalProducts();
const perAsset = new Map();
for (const p of alleProducten) {
const key = p.asset_symbol;
if (!perAsset.has(key)) perAsset.set(key, new Map());
const vormen = perAsset.get(key);
vormen.set(p.form, (vormen.get(p.form) ?? 0) + 1);
}
const producten = onbewerkt
? limitedList(alleProducten, p =>
`- ${p.asset_symbol} (${p.name}): ${p.form}, ${fromBaseByFactor(p.fineGrams, 'g')} g fijn`).lines
: [...perAsset].map(([symbool, vormen]) =>
`- ${symbool}: ${[...vormen].map(([vorm, n]) => `${n}× ${vorm}`).join(', ')}`);
const profielen = listProfiles().map(p =>
`- ${onbewerkt && p.name ? p.name : `profiel ${p.sortOrder}`}: `
+ `${p.isActive ? 'actief' : 'niet actief'}, ${listWidgets(p.id).length} widgets`);
const instellingen = [];
for (const scope of CONFIG.AI.SHARED_PREF_SCOPES) {
for (const key of Object.keys(PREFERENCES[scope] ?? {})) {
if (key === '*') continue;
const v = t.toAi({ scope, key, value: getPref(scope, key) }, { table: 'Preference' });
if (v.value === undefined) continue;
const waarde = v.value === null || v.value === '' ? 'leeg' : v.value;
instellingen.push(`- ${v.scope}.${v.key} = ${waarde}`);
}
}
return {
text: [
`Rekenvaluta: ${getBaseCurrency()}. Elke koers en elk bedrag in AssetApp staat in deze munt.`,
'',
`Assets (${alleAssets.length}):`,
assets.length ? assets.join('\n') : '- geen.',
'',
`Accounts (${alleRekeningen.length}):`,
rekeningen.length ? rekeningen.join('\n') : '- geen.',
'',
`Labels (${alleLabels.length}):`,
labels.length ? labels.join('\n') : '- geen.',
'',
`Vastgelegd: ${c.events} gebeurtenissen en ${c.valuations} balansen.`,
'',
`Metaalproducten (${alleProducten.length}):`,
producten.length ? producten.join('\n') : '- geen.',
'',
`Dashboardprofielen (${profielen.length}):`,
profielen.length ? profielen.join('\n') : '- geen.',
'',
`Instellingen (${instellingen.length}):`,
instellingen.length ? instellingen.join('\n') : '- geen.',
].join('\n'),
fieldsShared: ['baseCurrency', 'id', 'symbol', 'kind', 'unit', 'subunit', 'isOwnCustody', 'isActive', 'scanEnabled',
'taxCategory', 'taxScope', 'wealthScope', 'balanceCapture',
'labelId', 'form', 'sortOrder', 'profileId', 'scope', 'key', 'Preference.value',
...(onbewerkt ? ['name', 'fineGrams'] : [])],
};
},
});
const BEVINDING_TEKST = Object.freeze({
duplicate_events: 'gebeurtenissen die er twee of meer keer identiek in staan',
assets_without_balance: 'assets waarin geboekt is maar waarvoor nooit een balans is vastgelegd',
unreconciled_valuations: 'balansen die niet aansluiten op de boekingen eronder, en die nog niet zijn afgehandeld',
stale_prices: 'assets waarvan de koershistorie achterloopt op de laatste boeking, of ontbreekt',
unlinked_transactions: 'on-chain bewegingen zonder koppeling en zonder openstaand voorstel',
late_price_history: 'assets waarvan de koershistorie later begint dan de eerste boeking',
price_gaps: 'assets met een gat tussen twee koersen (dagen zonder koers, vanaf de eerste boeking), waardoor daar een oude koers wordt meegesleept',
price_stopped: 'assets in bezit waarvan de laatste koers ouder is dan de doortrekgrens van hun soort, dus nu niet te waarderen',
transfer_without_counterpart: 'verplaatsingen waarvan één kant ontbreekt zonder extern adres',
fee_without_effect: 'boekingen met kosten die geen berekening leest (geld bij rente, eenheden bij aankoop of rente)',
coverage_without_asset: 'koersbronnen die dekking claimen op een symbool dat hier geen asset is',
price_unit_swap: 'koersrijen die een factor 31 (gram tegen troy ounce) van de rij ervoor liggen',
price_outlier: 'koersrijen die een factor drie of meer van beide buren liggen, zoals een tikfout',
price_sources_disagree: 'dagen waarop twee bronnen een koers geven die ver uit elkaar ligt',
price_not_positive: 'koersrijen met een koers van nul of lager, die de app niet meetelt',
price_in_future: 'koersrijen met een moment in de toekomst',
booking_price_deviates: 'aan- en verkopen waarvan de prijs ver van de koers van die dag ligt (tikfout, datum of eenheid)',
price_origin_derivable: 'boekingen zonder herkomst waarvan de prijs de dagkoers is, in te vullen op Gezondheid',
});
const DAG_VELDEN = Object.freeze({
stale_prices: Object.freeze(['occurredAt', 'observedAt']),
unlinked_transactions: Object.freeze(['createdAt']),
price_unit_swap: Object.freeze(['observedAt']),
price_outlier: Object.freeze(['observedAt']),
price_sources_disagree: Object.freeze(['observedAt']),
price_not_positive: Object.freeze(['observedAt']),
price_in_future: Object.freeze(['observedAt']),
booking_price_deviates: Object.freeze(['occurredAt']),
price_origin_derivable: Object.freeze(['occurredAt']),
});
function opDag(waarde) {
return typeof waarde === 'string' && waarde.length >= 10 ? waarde.slice(0, 10) : waarde;
}
function grofPerBevinding(soort, rij) {
const velden = DAG_VELDEN[soort];
if (!velden) return rij;
const uit = { ...rij };
for (const veld of velden) if (veld in uit) uit[veld] = opDag(uit[veld]);
return uit;
}
const dataHealth = Object.freeze({
name: 'data_health',
title: 'Controle op de eigen administratie',
description:
'Zoekt inconsistenties in de administratie van AssetApp en geeft per soort het aantal en de '
+ 'betrokken rijen terug: dubbele boekingen, assets zonder vastgelegde balans, balansen die niet '
+ 'aansluiten op de boekingen eronder, koershistorie die achterloopt, on-chain bewegingen die '
+ 'nergens aan hangen, en verwijzingen die nergens heen wijzen. Elke bevinding is een aantal of '
+ 'een verwijzing zoals account:a1, stabiel binnen dit gesprek. ' + GEEN_BEDRAGEN,
inputSchema: Object.freeze({ type: 'object', properties: {}, additionalProperties: false }),
scope: 'read',
async run(_args, context) {
assertScope(dataHealth.scope);
const t = await vertalerVoor(context);
t.beginCall();
const rapport = hygieneReport();
const blokken = rapport.findings.map(f => {
const kop = `${BEVINDING_TEKST[f.kind] || f.kind} (${f.kind}, ${f.severity}): ${f.total}`;
if (!f.total) return `${kop}.`;
const { rows: zichtbaar, omitted, capped } = limitRows(f.rows);
const grof = zichtbaar.map(r => grofPerBevinding(f.kind, r.row));
const veilig = t.toAi(grof);
const rijen = veilig.map((r, i) => {
const delen = Object.entries(r).map(([k, v]) => `${k}=${v}`);
for (const [k, v] of Object.entries(zichtbaar[i].counts || {})) delen.push(`${k}=${v}`);
return ` - ${delen.join(', ')}`;
});
if (capped) rijen.push(omittedNote(omitted, CONFIG.AI.LIMITS.MAX_ROWS_PER_LIST));
return `${kop}:\n${rijen.join('\n')}`;
});
return {
text: [
rapport.total === 0
? 'Er zijn geen inconsistenties gevonden.'
: `Er zijn ${rapport.total} bevindingen, verdeeld over ${rapport.findings.filter(f => f.total).length} soorten.`,
'',
...blokken,
].join('\n'),
fieldsShared: [...new Set([
...t.fieldsShared(),
...rapport.findings.filter(f => f.rows.length).flatMap(f => f.countsFrom || []),
])].sort(),
};
},
});
export const READ_TOOLS = Object.freeze([dataHealth, integrationHealth, adminOverview]);