// 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]);