Files
AtreidesAssetsFree/server/mcp/read-tools.mjs
T
2026-10-29 11:57:29 +01:00

415 lines
20 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// 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]);