@apiosk/mcp 1.3.1 → 2.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (92) hide show
  1. package/README.md +84 -537
  2. package/assets/brand/apiosk-a-20260921.png +0 -0
  3. package/assets/brand/apple-touch-icon.png +0 -0
  4. package/assets/brand/favicon-dark.ico +0 -0
  5. package/assets/brand/favicon-light.ico +0 -0
  6. package/assets/brand/favicon.ico +0 -0
  7. package/assets/brand/icon-192.png +0 -0
  8. package/assets/brand/icon-512.png +0 -0
  9. package/assets/brand/icon-maskable-512.png +0 -0
  10. package/assets/brand/inter-OFL.txt +93 -0
  11. package/assets/brand/inter-latin-400-normal.woff2 +0 -0
  12. package/assets/brand/inter-latin-500-normal.woff2 +0 -0
  13. package/assets/brand/inter-latin-600-normal.woff2 +0 -0
  14. package/assets/brand/mark-20260905-transparent.svg +1 -0
  15. package/assets/brand/mark-20260918.svg +1 -0
  16. package/assets/brand/mark-black-20260918.svg +1 -0
  17. package/assets/brand/mark-dark-20260905-transparent.png +0 -0
  18. package/assets/brand/mark-dark-20260918.png +0 -0
  19. package/assets/brand/mark-dark-96.png +0 -0
  20. package/assets/brand/mark-light-20260905-transparent.png +0 -0
  21. package/assets/brand/mark-light-20260918.png +0 -0
  22. package/assets/brand/mark-light-96.png +0 -0
  23. package/assets/brand/wordmark-black-320.png +0 -0
  24. package/assets/brand/wordmark-white-320.png +0 -0
  25. package/docs/branding.md +11 -0
  26. package/docs/marketplace-submission-2026-04-08.md +4 -0
  27. package/docs/marketplace-submission-2026-08-20.md +157 -0
  28. package/docs/openai-plugin-submission-2026-09-05.md +143 -0
  29. package/docs/sepa-rail.md +13 -13
  30. package/dxt.json +26 -22
  31. package/index.mjs +3 -1
  32. package/logo-optimized-light.png +0 -0
  33. package/package.json +15 -13
  34. package/plugin/apiosk/.codex-plugin/plugin.json +45 -0
  35. package/plugin/apiosk/.mcp.json +8 -0
  36. package/plugin/apiosk/assets/icon-dark.png +0 -0
  37. package/plugin/apiosk/assets/icon.png +0 -0
  38. package/plugin/apiosk/assets/icon.svg +1 -0
  39. package/plugin/apiosk/assets/logo.png +0 -0
  40. package/plugin/apiosk/skills/apiosk/SKILL.md +33 -0
  41. package/plugin/apiosk/skills/apiosk/agents/openai.yaml +12 -0
  42. package/server.json +98 -9
  43. package/server.mjs +258 -71
  44. package/src/approval-feedback.mjs +21 -0
  45. package/src/brand-routes.mjs +28 -0
  46. package/src/create-server.mjs +168 -9
  47. package/src/display-money.mjs +20 -0
  48. package/src/display-text.mjs +67 -0
  49. package/src/gateway-client.mjs +42 -0
  50. package/src/gateway-v2-ask.mjs +50 -0
  51. package/src/gateway-v2-card-account.mjs +23 -0
  52. package/src/gateway-v2-card-actions.mjs +67 -0
  53. package/src/gateway-v2-card-answer-text.mjs +130 -0
  54. package/src/gateway-v2-card-answer.mjs +68 -0
  55. package/src/gateway-v2-card-blocks.mjs +166 -0
  56. package/src/gateway-v2-card-body.mjs +259 -0
  57. package/src/gateway-v2-card-budget.mjs +21 -0
  58. package/src/gateway-v2-card-cbs.mjs +56 -0
  59. package/src/gateway-v2-card-choices.mjs +4 -0
  60. package/src/gateway-v2-card-clarification.mjs +25 -0
  61. package/src/gateway-v2-card-compact.mjs +34 -0
  62. package/src/gateway-v2-card-events.mjs +41 -0
  63. package/src/gateway-v2-card-presentation.mjs +125 -0
  64. package/src/gateway-v2-card-research.mjs +63 -0
  65. package/src/gateway-v2-card-result.mjs +32 -0
  66. package/src/gateway-v2-card-search.mjs +39 -0
  67. package/src/gateway-v2-card-sources.mjs +32 -0
  68. package/src/gateway-v2-card-style.mjs +76 -0
  69. package/src/gateway-v2-card-verdict.mjs +66 -0
  70. package/src/gateway-v2-card.mjs +91 -0
  71. package/src/gateway-v2-contracts.json +66 -0
  72. package/src/gateway-v2-instructions.md +113 -0
  73. package/src/gateway-v2-recovery.mjs +18 -0
  74. package/src/gateway-v2-report-links.mjs +14 -0
  75. package/src/gateway-v2-workflows.mjs +17 -0
  76. package/src/gateway-v2.mjs +179 -0
  77. package/src/oauth.mjs +603 -364
  78. package/src/observability.mjs +210 -0
  79. package/src/result-presentation.mjs +9 -0
  80. package/src/runtime.mjs +24 -3091
  81. package/src/settlement-disclosure.mjs +26 -0
  82. package/src/source-groups.mjs +91 -0
  83. package/src/source-value-format.mjs +25 -0
  84. package/src/tool-result.mjs +20 -0
  85. package/src/ui-bridge.mjs +185 -0
  86. package/src/well-known-routes.mjs +132 -0
  87. package/src/funding-options.mjs +0 -255
  88. package/src/gateway-management.mjs +0 -107
  89. package/src/listing-metadata.mjs +0 -287
  90. package/src/local-config.mjs +0 -256
  91. package/src/payment-guidance.mjs +0 -307
  92. package/src/wallet-store.mjs +0 -476
@@ -0,0 +1,68 @@
1
+ // Presentation of returned evidence only. This is not an onboarding approval,
2
+ // a compliance certification, or a replacement for the gateway's payment verdict.
3
+ export function ukSupplierSummary(data, results) {
4
+ const context = data?.context_view || {};
5
+ const question = context.conversation?.[0]?.question || '';
6
+ const notices = Array.isArray(context.coverage_notices) ? context.coverage_notices : [];
7
+ const ukCheck = notices.some(note => typeof note === 'string' && note.includes('complete roster of up to five active directors'))
8
+ || (/\b(?:UK|British|United Kingdom)\b/i.test(question) && /supplier|onboard/i.test(question) && /VAT/i.test(question) && /director|screening/i.test(question));
9
+ if (!ukCheck || context.worker_active || context.analyzing || context.result_is_historical || !['succeeded', 'partial'].includes(data.status)) return null;
10
+ const profiles = results.filter(r => r.source?.provider === 'companies-house' && typeof r.data?.company_number === 'string' && typeof r.data?.company_status === 'string');
11
+ if (profiles.length !== 1 || !profiles[0].subject?.entity_ref) return null;
12
+ const profile = profiles[0], company = profile.data;
13
+ const related = results.filter(r => r.subject?.entity_ref === profile.subject.entity_ref && r.coverage !== 'partial' && !r.error);
14
+ const completeProfile = related.includes(profile);
15
+ const psc = related.find(r => r.source?.provider === 'companies-house' && /persons-with-significant-control/.test(r.source.path || ''));
16
+ const pscCount = Number.isInteger(psc?.data?.total_results) ? psc.data.total_results : null;
17
+ const accounts = completeProfile ? company.accounts : null;
18
+ const period = accounts?.last_accounts?.made_up_to;
19
+ const overdue = accounts?.overdue === true || accounts?.next_accounts?.overdue === true;
20
+ const vat = related.find(r => r.source?.provider === 'ask-merlin');
21
+ const vatValue = vat?.data?.data?.valid ?? vat?.data?.valid;
22
+ const invalidCandidate = vatValue === false;
23
+ const screening = related.filter(r => r.source?.provider === 'opensanctions');
24
+ const responses = screening.flatMap(r => Object.values(r.data?.responses || {}));
25
+ const candidateCount = responses.reduce((count, response) => count + (Array.isArray(response?.results) ? response.results.length : 0), 0);
26
+ const noCandidates = responses.length > 0 && responses.every(response => Array.isArray(response?.results) && response.results.length === 0 && response.total?.value === 0 && !response.error);
27
+ const active = completeProfile && company.company_status === 'active';
28
+ const checks = [
29
+ {label:'UK entity', status:active?'pass':'review', detail:completeProfile ? company.company_status + ' · ' + company.company_number : 'Registration evidence incomplete'},
30
+ {label:'Ownership', status:pscCount>0?'info':'unknown', detail:pscCount>0 ? pscCount + ' PSC records · ownership still needs review' : pscCount===0 ? 'Not established · no PSC records returned' : 'Not verified · no complete PSC result'},
31
+ {label:'Filed accounts', status:overdue?'review':period?'info':'unknown', detail:period ? 'Period ' + period + (overdue?' · overdue':' · filing status only') : 'Not verified · no accounts period returned'},
32
+ {label:'VAT', status:vat&&!invalidCandidate?'review':'unknown', detail:invalidCandidate ? 'Candidate invalid · supplier VAT unverified' : vatValue===true ? 'Number valid · supplier identity match needs review' : vat ? 'Validation returned · review validity and identity match' : 'Not verified · no validation result'},
33
+ {label:'Director screening', status:screening.length?'review':'unknown', detail:candidateCount ? candidateCount + ' potential ' + (candidateCount===1?'match':'matches') + ' · manual review' : noCandidates ? 'No candidates returned · review roster coverage' : screening.length ? 'Screening returned · manual review required' : 'Not verified · no screening result'},
34
+ ];
35
+ const incomplete = data.status === 'partial' || checks.some(check => check.status === 'unknown');
36
+ return {subject:company.company_name || profile.subject.label, label:incomplete?'Not fully verified':'Review required', checks,
37
+ summary:active ? 'The UK entity is active. That does not establish full supplier-onboarding clearance.' : 'The saved checks do not establish an active, fully verified UK supplier.',
38
+ note:'Missing evidence is not a failed check. A screening candidate is not a confirmed sanctions finding.'};
39
+ }
40
+
41
+ // The generic answer mirrors the App's Ask task view (GatewayV2Outputs): the
42
+ // full answer text, then blocks (answer_schema_version 2) or the presentation
43
+ // table. Without an analysis there is no answer section: the source result is
44
+ // drawn directly (see gateway-v2-card-research.mjs).
45
+ export const V2_CARD_ANSWER = `
46
+ ${ukSupplierSummary.toString()}
47
+ function renderResearchAnswer(data,results){
48
+ const uk=ukSupplierSummary(data,results),analysis=data.context_view?.analysis;
49
+ if(!uk&&!analysis)return null;
50
+ let s;
51
+ if(uk){
52
+ s=section(formatDisplayNarrative(uk.subject,data));s.classList.add('answer-section','research-answer');
53
+ const headline=el('p','answer-verdict',uk.label);headline.setAttribute('role','status');s.append(headline,el('p','answer-summary',uk.summary));
54
+ const checks=el('dl','answer-checks');
55
+ for(const check of uk.checks){const row=el('div','answer-check '+check.status);row.append(el('dt','',check.label),el('dd','',formatDisplayNarrative(check.detail,data)));checks.append(row)}
56
+ s.append(checks,el('p','meta answer-caveat',uk.note));
57
+ }else{
58
+ s=el('section','section answer-section research-answer');s.setAttribute('aria-label','Answer');sections.append(s);
59
+ const question=data.context_view?.conversation?.at(-1)?.question||'',value=answerText(analysis,{results,question});
60
+ if(data.status==='partial'||analysis.status==='partial')s.append(el('p','meta answer-partial','Some information is missing. Review the available results in Sources and details.'));
61
+ if(value)s.append(renderAnswerText(value,{results,analysis}));
62
+ const detail=isBlocksAnswer(analysis)?renderAnswerBlocks(analysis):renderAnswerPresentation(question,results,analysis);
63
+ if(detail)s.append(detail);
64
+ }
65
+ const links=el('div','result-links');s.append(links);
66
+ return {section:s,links,title:uk?'Supplier verification':'Answer',subtitle:uk?'Based on saved source evidence.':'Based on the saved source results.'};
67
+ }
68
+ `;
@@ -0,0 +1,166 @@
1
+ // The App's versioned answer blocks (answer_schema_version 2): data tables,
2
+ // bar charts and the generic fallback that renders any other kind as facts
3
+ // (app/src/lib/domain/answer-blocks-view.ts, components/ask/answer-blocks).
4
+ // Every Fact arrives resolved by the gateway; the card only displays, sorts
5
+ // and filters it. Keep in sync with the App.
6
+ export function isBlocksAnswer(analysis) { return !!analysis && (analysis.answer_schema_version ?? 1) >= 2 && Array.isArray(analysis.blocks); }
7
+ export function factOrigin(fact, collection, rowIndex, relativePointer) {
8
+ if (fact?.result_ref && fact.pointer) return { result_ref: fact.result_ref, pointer: fact.pointer };
9
+ if (collection && rowIndex != null && relativePointer) return { result_ref: collection.result_ref, pointer: collection.rows_pointer + '/' + rowIndex + relativePointer };
10
+ return null;
11
+ }
12
+ /** Sorts the complete selected set, nulls last in both directions, before paging. */
13
+ export function sortTableRows(columns, rows, sort) {
14
+ const index = sort ? columns.findIndex(c => c.id === sort.column_id) : -1;
15
+ if (index < 0) return rows;
16
+ const direction = sort.direction === 'asc' ? 1 : -1, key = row => row?.cells?.[index]?.value ?? null;
17
+ return [...rows].sort((a, b) => {
18
+ const av = key(a), bv = key(b);
19
+ if (av == null && bv == null) return 0;
20
+ if (av == null) return 1;
21
+ if (bv == null) return -1;
22
+ return typeof av === 'number' && typeof bv === 'number' ? (av - bv) * direction : String(av).localeCompare(String(bv)) * direction;
23
+ });
24
+ }
25
+ export function filterTableRows(rows, query) {
26
+ const q = String(query || '').trim().toLowerCase();
27
+ return q ? rows.filter(row => (row?.cells || []).some(cell => cell?.value != null && String(cell.value).toLowerCase().includes(q))) : rows;
28
+ }
29
+ /** Fixed over every selected bar, so revealing more never rescales. */
30
+ export function barScale(block) {
31
+ if (block.scale) return block.scale;
32
+ const values = (block.bars || []).map(bar => typeof bar.fact?.value === 'number' ? bar.fact.value : null).filter(v => v != null);
33
+ return { min: Math.min(0, ...values), max: Math.max(0, ...values) };
34
+ }
35
+ export function rankingScopeLabel(scope) {
36
+ return scope === 'total_available' ? 'ranked across every available result' : scope === 'fetched' ? 'ranked across every fetched result' : 'ranked across the selected results';
37
+ }
38
+ function blockLabelOf(label, fact, id) { return label ? label : fact?.value != null ? String(fact.value) : id; }
39
+ /** Any kind's facts as labelled rows; unknown kinds render through this. */
40
+ export function blockFacts(block) {
41
+ const list = value => Array.isArray(value) ? value : [];
42
+ switch (block.kind) {
43
+ case 'summary': return list(block.conclusion?.facts).map((fact, i) => ({ label: 'Fact ' + (i + 1), fact }));
44
+ case 'data_table': return list(block.rows).flatMap((row, ri) => list(block.columns).map((column, ci) => ({ label: column.label, fact: row.cells?.[ci], collection: block.collection, rowIndex: row.row_index ?? ri, relativePointer: column.pointer })));
45
+ case 'comparison': return list(block.rows).flatMap(row => list(row.cells).map((fact, i) => ({ label: row.label + ' — ' + (block.entities?.[i]?.label ?? ''), fact })));
46
+ case 'kpi': return list(block.cards).flatMap(card => [{ label: card.label, fact: card.fact }, ...(card.change ? [{ label: card.label + ' (baseline)', fact: card.change.baseline }] : [])]);
47
+ case 'line_chart': return list(block.series).flatMap(series => list(series.points).map((point, pi) => ({ label: series.label + ' — ' + (point.period?.key ?? point.period?.value ?? ''), fact: point.fact ?? null, collection: series.collection, rowIndex: point.row_index ?? pi, relativePointer: series.value_pointer })));
48
+ case 'bar_chart': return list(block.bars).map((bar, bi) => ({ label: blockLabelOf(bar.label, bar.label_fact, 'Bar ' + (bi + 1)), fact: bar.fact ?? null, collection: block.collection, rowIndex: bar.row_index ?? bi, relativePointer: block.value_pointer }));
49
+ case 'map': return list(block.features).flatMap(feature => list(feature.facts).map(lf => ({ label: blockLabelOf(feature.label, feature.label_fact, feature.id) + ' — ' + lf.label, fact: lf.fact ?? null, collection: block.collection, rowIndex: feature.row_index })));
50
+ case 'timeline': return list(block.events).flatMap(event => [
51
+ { label: blockLabelOf(event.label, event.label_fact, event.id) + ' — date', fact: event.date ?? null, collection: block.collection, rowIndex: event.row_index, relativePointer: block.date_pointer },
52
+ ...list(event.facts).map(lf => ({ label: blockLabelOf(event.label, event.label_fact, event.id) + ' — ' + lf.label, fact: lf.fact ?? null, collection: block.collection, rowIndex: event.row_index }))]);
53
+ case 'profile': return [
54
+ ...(block.name ? [{ label: 'Name', fact: block.name }] : []),
55
+ ...list(block.identifiers).map(lf => ({ label: lf.label, fact: lf.fact ?? null })),
56
+ ...(block.status ? [{ label: block.status.label, fact: block.status.fact ?? null }] : []),
57
+ ...list(block.attributes).map(lf => ({ label: lf.label, fact: lf.fact ?? null }))];
58
+ case 'relationship': return list(block.edges).flatMap(edge => list(edge.evidence).map((fact, i) => ({ label: edge.label + ' (' + (i + 1) + ')', fact })));
59
+ default: return [];
60
+ }
61
+ }
62
+
63
+ // ---- DOM, run inside the card only --------------------------------------------
64
+ function renderAnswerBlocks(analysis) {
65
+ const wrap = el('div', 'answer-blocks');wrap.setAttribute('aria-label', 'Answer data');
66
+ // A narrative block is the observations, already shown as the answer text.
67
+ for (const block of analysis.blocks) if (block && block.kind !== 'narrative') wrap.append(block.kind === 'data_table' ? dataTableBlock(block, analysis) : block.kind === 'bar_chart' ? barChartBlock(block, analysis) : genericBlock(block, analysis));
68
+ return wrap.children.length ? wrap : null;
69
+ }
70
+ function blockFactValue(fact) {
71
+ if (!fact || fact.value == null) return el('span', 'body-muted', 'Unknown');
72
+ const value = answerValue(fact.value, fact.field ?? '', fact.currency?.value != null ? String(fact.currency.value) : null, 'nl-NL');
73
+ const labels = { estimated: 'Estimated', provisional: 'Provisional', revised: 'Revised', rounded: 'Rounded', confidential: 'Confidential' };
74
+ if (!fact.flags?.length) return el('span', 'fact-value', value);
75
+ const span = el('span', 'fact-value');span.append(el('span', '', value));
76
+ for (const flag of fact.flags) span.append(el('span', 'fact-flag', labels[flag?.kind] || text(flag?.kind)));
77
+ return span;
78
+ }
79
+ function blockFactSource(fact, sourceIndex, collection, rowIndex, relativePointer) {
80
+ const origin = factOrigin(fact, collection, rowIndex, relativePointer);
81
+ if (!origin) return fact?.derivation_id ? el('span', 'fact-source', 'Calculated') : null;
82
+ const entry = (sourceIndex || []).find(s => s.result_ref === origin.result_ref);
83
+ return el('span', 'fact-source', (entry?.name ?? entry?.provider ?? 'Source') + ' · ' + origin.pointer);
84
+ }
85
+ function blockCell(fact, sourceIndex, collection, rowIndex, relativePointer) {
86
+ const td = el('td'), source = blockFactSource(fact, sourceIndex, collection, rowIndex, relativePointer);
87
+ td.append(blockFactValue(fact));if (source) td.append(source);
88
+ return td;
89
+ }
90
+ function blockTable(label, headings, rows) {
91
+ const region = el('div', 'block-table-wrap'), table = el('table', 'block-table'), head = el('tr'), thead = el('thead'), tbody = el('tbody');
92
+ region.setAttribute('role', 'region');region.setAttribute('aria-label', label);region.tabIndex = 0;
93
+ for (const heading of headings) { const th = el('th', '', heading);th.setAttribute('scope', 'col');head.append(th); }
94
+ thead.append(head);tbody.append(...rows);table.append(thead, tbody);region.append(table);
95
+ return region;
96
+ }
97
+ function blockMore(label, onClick) { const b = el('button', 'quiet', label);b.type = 'button';b.onclick = () => { onClick();window.apiosk.resize(); };return b; }
98
+ function dataTableBlock(block, analysis) {
99
+ const s = el('section', 'answer-block'), columns = block.columns || [], all = block.rows || [], sourceIndex = analysis?.source_index || [];
100
+ const counts = el('p', 'meta'), region = el('div'), more = el('div'), controls = el('div', 'block-controls'), field = el('input', 'block-filter'), toggles = el('fieldset', 'block-columns');
101
+ const hidden = new Set(columns.filter(c => c.hidden).map(c => c.id)), sorted = sortTableRows(columns, all, block.sort);
102
+ let shown = 20, query = '';
103
+ s.setAttribute('aria-label', block.title ?? 'Data table');if (block.title) s.append(el('h3', '', block.title));
104
+ field.type = 'search';field.placeholder = 'Filter rows';field.setAttribute('aria-label', 'Filter rows');
105
+ const draw = () => {
106
+ const filtered = filterTableRows(sorted, query), visible = columns.filter(c => !hidden.has(c.id)), coverage = block.coverage;
107
+ counts.textContent = (coverage ? coverage.selected + ' selected · ' + coverage.fetched + ' fetched' + (coverage.total_available !== undefined ? ' · ' + coverage.total_available + ' total available' : '') : all.length + ' rows') + (filtered.length !== all.length ? ' · ' + filtered.length + ' match the filter' : '');
108
+ region.replaceChildren(visible.length ? blockTable((block.title ?? 'Data table') + ' table', visible.map(c => c.label), filtered.slice(0, shown).map((row, ri) => {
109
+ const tr = el('tr');
110
+ for (const column of visible) tr.append(blockCell(row.cells?.[columns.indexOf(column)], sourceIndex, block.collection, row.row_index ?? ri, column.pointer));
111
+ return tr;
112
+ })) : el('p', 'meta', 'Every column is hidden.'));
113
+ more.replaceChildren(...(shown < filtered.length ? [blockMore('Show more rows (' + (filtered.length - shown) + ' remaining)', () => { shown += 20;draw(); })] : []));
114
+ };
115
+ field.oninput = () => { query = field.value;shown = 20;draw();window.apiosk.resize(); };
116
+ toggles.append(el('legend', 'sr-only', 'Columns shown'));
117
+ for (const column of columns) {
118
+ const option = el('label'), box = el('input');box.type = 'checkbox';box.checked = !hidden.has(column.id);
119
+ box.onchange = () => { box.checked ? hidden.delete(column.id) : hidden.add(column.id);draw();window.apiosk.resize(); };
120
+ option.append(box, el('span', '', column.label));toggles.append(option);
121
+ }
122
+ controls.append(field, toggles);s.append(counts, controls, region, more);draw();
123
+ return s;
124
+ }
125
+ function barChartBlock(block, analysis) {
126
+ const figure = el('figure', 'answer-block bar-chart'), bars = block.bars || [], scale = barScale(block), range = (scale.max - scale.min) || 1, zero = (0 - scale.min) / range * 100;
127
+ const caption = el('figcaption', '', block.title ?? block.value_label), list = el('div', 'bar-list'), more = el('div');
128
+ if (block.ranking) caption.append(el('span', 'meta block-note', (block.ranking.direction === 'desc' ? 'Highest first, ' : 'Lowest first, ') + rankingScopeLabel(block.ranking.scope) + '.'));
129
+ figure.setAttribute('aria-label', block.title ?? block.value_label ?? 'Chart');
130
+ let shown = 10;
131
+ const draw = () => {
132
+ list.replaceChildren(...bars.slice(0, shown).map((bar, i) => {
133
+ const value = typeof bar.fact?.value === 'number' ? bar.fact.value : null, end = value == null ? zero : (value - scale.min) / range * 100;
134
+ const name = bar.label ?? (bar.label_fact?.value != null ? String(bar.label_fact.value) : 'Bar ' + (i + 1));
135
+ const row = el('div', 'bar-row'), line = el('div', 'bar-line'), source = blockFactSource(bar.fact, analysis?.source_index, block.collection, bar.row_index ?? i, block.value_pointer);
136
+ line.append(el('span', 'bar-label', (bar.rank != null ? '#' + bar.rank + ' ' : '') + name), blockFactValue(bar.fact));
137
+ row.append(line, answerBar(zero, value == null ? null : end));if (source) row.append(source);
138
+ return row;
139
+ }));
140
+ more.replaceChildren(...(shown < bars.length ? [blockMore('Show more bars (' + (bars.length - shown) + ' remaining)', () => { shown += 10;draw(); })] : []));
141
+ };
142
+ figure.append(caption, list, more);draw();
143
+ return figure;
144
+ }
145
+ function answerBar(zero, end) {
146
+ const track = el('div', 'bar-track'), axis = el('span', 'bar-zero');
147
+ track.setAttribute('aria-hidden', 'true');axis.setAttribute('style', 'left:' + zero + '%');track.append(axis);
148
+ if (end != null) { const fill = el('span', 'bar-fill');fill.setAttribute('style', 'left:' + Math.min(zero, end) + '%;width:' + Math.abs(end - zero) + '%');track.append(fill); }
149
+ return track;
150
+ }
151
+ function genericBlock(block, analysis) {
152
+ const rows = blockFacts(block), title = block.title ?? String(block.kind || 'block').replace('_', ' '), s = el('section', 'answer-block');
153
+ s.setAttribute('aria-label', title);s.append(el('h3', 'block-kind', title));
154
+ if (!rows.length) { s.append(el('p', 'meta', 'No details to show for this ' + String(block.kind || 'block').replace('_', ' ') + '.'));return s; }
155
+ s.append(blockTable(title + ' facts', ['Field', 'Value', 'Source'], rows.map(row => {
156
+ const tr = el('tr'), value = el('td'), source = el('td'), origin = blockFactSource(row.fact, analysis?.source_index, row.collection, row.rowIndex, row.relativePointer);
157
+ value.append(blockFactValue(row.fact));if (origin) source.append(origin);tr.append(el('td', '', row.label), value, source);
158
+ return tr;
159
+ })));
160
+ return s;
161
+ }
162
+
163
+ export const V2_CARD_BLOCKS = [
164
+ isBlocksAnswer, factOrigin, sortTableRows, filterTableRows, barScale, rankingScopeLabel, blockLabelOf, blockFacts,
165
+ renderAnswerBlocks, blockFactValue, blockFactSource, blockCell, blockTable, blockMore, dataTableBlock, barChartBlock, answerBar, genericBlock,
166
+ ].map(fn => fn.toString()).join('\n');
@@ -0,0 +1,259 @@
1
+ // The App's answer reader (app/src/components/discover/answer-view.tsx and
2
+ // app/src/lib/ui/answer.ts), for the card. A source result is drawn as what it
3
+ // is: records as rows, a record's name as its heading, links live, prose as
4
+ // prose. Nothing is summarised or dropped; the readable view contains no JSON.
5
+ // Rules are by key name and shape, never by value. Keep in sync with the App.
6
+ const BODY_NOISE_KEYS = new Set(["id", "uuid", "guid", "objectid", "key", "slug", "hash", "etag", "checksum", "signature", "nonce", "cursor", "pagetoken", "nextpagetoken", "page", "pagenumber", "pagina", "pagesize", "perpage", "resultsperpage", "resultatenperpagina", "offset", "hasmore", "hasnext", "hasnextpage", "nextpage", "prevpage", "previouspage", "contenttype", "mimetype", "charset", "encoding", "statuscode", "httpstatus", "requestid", "traceid", "correlationid", "apiversion", "schemaversion", "typename"]);
7
+ const BODY_LINK_OBJECT_KEYS = new Set(["href", "rel", "type", "title", "name", "method", "templated", "hreflang", "profile", "deprecation"]);
8
+ const BODY_ENVELOPE_KEYS = new Set(["data", "result", "results", "response", "payload", "body", "content", "items", "records", "rows", "list", "output", "value", "values", "fields", "opendatafields"]);
9
+ const BODY_NAME_KEYS = ["sourcename", "name", "title", "headline", "label", "provider", "symbol"];
10
+ const BODY_IMAGE_KEYS = ["sourceimg", "image", "img", "logo", "icon", "avatar", "thumbnail", "picture"];
11
+ const BODY_LINK_KEYS = ["weburl", "url", "link", "href", "website", "site"];
12
+
13
+ export function bodyIsRecord(value) { return typeof value === "object" && value !== null && !Array.isArray(value); }
14
+ export function bodyIsUrl(value) { return /^https?:\/\/\S+$/i.test(String(value).trim()); }
15
+ export function bodyIsImageUrl(value) { return /\.(png|jpe?g|gif|webp|avif|svg)(\?|#|$)/i.test(String(value).trim()); }
16
+ export function bodyAsDate(value) {
17
+ if (!/^\d{4}-\d{2}-\d{2}([T ]\d{2}:\d{2})?/.test(value)) return null;
18
+ const at = new Date(value);
19
+ return Number.isNaN(at.getTime()) ? null : at.toLocaleDateString("en-GB", { day: "numeric", month: "short", year: "numeric" });
20
+ }
21
+ export function bodyLabel(key) {
22
+ const words = String(key).replace(/^_+/, "").replace(/([a-z0-9])([A-Z])/g, "$1 $2").replace(/[_-]+/g, " ").trim().toLowerCase();
23
+ return words ? words.charAt(0).toUpperCase() + words.slice(1) : String(key);
24
+ }
25
+ export function bodyHost(url) { try { return new URL(url).host; } catch { return url; } }
26
+ function bodyFlatten(key) { return String(key).replace(/[\s_-]+/g, "").toLowerCase(); }
27
+ export function isNoiseKey(key) {
28
+ if (BODY_NOISE_KEYS.has(bodyFlatten(key))) return true;
29
+ const bare = String(key).replace(/^_+/, "");
30
+ return /[_\-\s](id|ids|uuid|guid|key|hash|token|checksum)$/i.test(bare) || /[a-z0-9](Id|Ids|UUID|Uuid|GUID|Guid|Key|Hash|Token|Checksum)$/.test(bare);
31
+ }
32
+ export function isEmptyValue(value) {
33
+ if (value === null || value === undefined) return true;
34
+ if (typeof value === "string") return value.trim() === "";
35
+ if (Array.isArray(value)) return value.length === 0;
36
+ if (bodyIsRecord(value)) return Object.keys(value).length === 0;
37
+ return false;
38
+ }
39
+ function isLinkObject(value) {
40
+ if (!bodyIsRecord(value)) return false;
41
+ const keys = Object.keys(value).map(bodyFlatten);
42
+ return keys.includes("href") && keys.every(key => BODY_LINK_OBJECT_KEYS.has(key));
43
+ }
44
+ export function isLinkPlumbing(value) {
45
+ if (isLinkObject(value)) return true;
46
+ if (Array.isArray(value)) return value.length > 0 && value.every(isLinkObject);
47
+ if (bodyIsRecord(value)) { const values = Object.values(value); return values.length > 0 && values.every(inner => isLinkPlumbing(inner)); }
48
+ return false;
49
+ }
50
+ /** Plumbing out, but never emptied: a record of only identifiers is drawn whole. */
51
+ export function withoutPlumbing(record) {
52
+ const kept = Object.entries(record).filter(([key, value]) => !isNoiseKey(key) && !isEmptyValue(value) && !isLinkPlumbing(value));
53
+ return kept.length ? Object.fromEntries(kept) : record;
54
+ }
55
+ /** Peel `{ data: ... }`-style envelopes while exactly one meaningful key remains. */
56
+ export function unwrapEnvelope(value, limit = 4) {
57
+ let current = value;
58
+ for (let i = 0; i < limit; i++) {
59
+ if (!bodyIsRecord(current)) return current;
60
+ const entries = Object.entries(current).filter(([key, inner]) => !isNoiseKey(key) && !isEmptyValue(inner) && !isLinkPlumbing(inner));
61
+ if (entries.length !== 1 || !BODY_ENVELOPE_KEYS.has(bodyFlatten(entries[0][0]))) return current;
62
+ current = entries[0][1];
63
+ }
64
+ return current;
65
+ }
66
+ function readPair(item) {
67
+ if (!bodyIsRecord(item)) return null;
68
+ let key = null, value, hasValue = false, children = null;
69
+ for (const [name, inner] of Object.entries(item)) {
70
+ const flat = bodyFlatten(name);
71
+ if (key === null && ["key", "name", "field"].includes(flat) && typeof inner === "string") { key = inner.trim(); continue; }
72
+ if (!hasValue && ["value", "val"].includes(flat)) { value = inner; hasValue = true; continue; }
73
+ if (children === null && Array.isArray(inner)) { children = inner; continue; }
74
+ return null;
75
+ }
76
+ if (!key || (!hasValue && children === null)) return null;
77
+ if (children !== null && children.length > 0) { const nested = pairsToRecord(children); return nested === null ? null : [key, nested]; }
78
+ return [key, hasValue ? value : null];
79
+ }
80
+ /** A list of `{ key, value }` pairs is one record, e.g. KVK annual accounts. */
81
+ export function pairsToRecord(items) {
82
+ if (!items.length) return null;
83
+ const held = new Map();
84
+ for (const item of items) {
85
+ const pair = readPair(item);
86
+ if (!pair) return null;
87
+ if (held.has(pair[0])) held.get(pair[0]).push(pair[1]); else held.set(pair[0], [pair[1]]);
88
+ }
89
+ return Object.fromEntries([...held].map(([key, values]) => [key, values.length === 1 ? values[0] : values]));
90
+ }
91
+ export function isProse(value) {
92
+ const text = String(value).trim();
93
+ return !!text && !bodyIsUrl(text) && (text.includes("\n") || text.length > 180);
94
+ }
95
+ /** Headings, paragraphs, bullets and numbered lists; no inline markup. */
96
+ export function readBlocks(text) {
97
+ const blocks = [];
98
+ let paragraph = [], items = [], listKind = null;
99
+ const endParagraph = () => { if (paragraph.length) blocks.push({ kind: "paragraph", text: paragraph.join("\n") }); paragraph = []; };
100
+ const endList = () => { if (listKind && items.length) blocks.push({ kind: listKind, items }); items = []; listKind = null; };
101
+ for (const line of String(text).replace(/\r\n?/g, "\n").split("\n")) {
102
+ if (!line.trim()) { endParagraph(); endList(); continue; }
103
+ const heading = /^ {0,3}(#{1,6})\s+(.+?)\s*#*\s*$/.exec(line);
104
+ if (heading) { endParagraph(); endList(); blocks.push({ kind: "heading", text: heading[2], level: Math.min(heading[1].length, 3) }); continue; }
105
+ const bullet = /^ {0,3}[-*•]\s+(.+)$/.exec(line), numbered = /^ {0,3}\d{1,3}[.)]\s+(.+)$/.exec(line);
106
+ if (bullet || numbered) {
107
+ const kind = bullet ? "bullets" : "numbers";
108
+ endParagraph(); if (listKind !== kind) endList(); listKind = kind; items.push((bullet || numbered)[1].trim()); continue;
109
+ }
110
+ endList(); paragraph.push(line.trimEnd());
111
+ }
112
+ endParagraph(); endList();
113
+ return blocks;
114
+ }
115
+
116
+ // ---- DOM, run inside the card only (uses the card's el/text/window.apiosk) ----
117
+ function answerBody(value) {
118
+ const body = unwrapEnvelope(displayData(value)), wrap = el("div", "answer-body");
119
+ if (bodyIsRecord(body) && Array.isArray(body.value) && Object.keys(body).some(key => key.startsWith("@odata."))) {
120
+ const { value: records, ...metadata } = body, meta = el("details", "body-meta");
121
+ meta.append(el("summary", "", "Source metadata"), bodyFields(metadata, 0)); meta.ontoggle = () => window.apiosk.resize();
122
+ wrap.append(bodyNode(records, 0), meta); return wrap;
123
+ }
124
+ wrap.append(bodyNode(body, 0)); return wrap;
125
+ }
126
+ function bodyMuted(value) { return el("span", "body-muted", value); }
127
+ function bodyNode(value, depth) {
128
+ if (value === null || value === undefined) return bodyMuted("null");
129
+ if (typeof value === "string") return isProse(value) ? bodyProse(value) : bodyScalar(value);
130
+ if (typeof value === "number" || typeof value === "boolean") return el("span", "body-number", String(value));
131
+ if (Array.isArray(value)) return bodyList(value, depth);
132
+ if (bodyIsRecord(value)) return bodyFields(value, depth);
133
+ return bodyMuted("not shown");
134
+ }
135
+ function bodyTableColumns(items) {
136
+ if (items.length < 2 || !items.every(bodyIsRecord)) return null;
137
+ const columns = Object.keys(items[0]);
138
+ if (!columns.length || columns.length > 10 || columns.some(key => BODY_NAME_KEYS.includes(key.toLowerCase()) || BODY_IMAGE_KEYS.includes(key.toLowerCase()))) return null;
139
+ return items.every(item => Object.keys(item).length === columns.length && columns.every(key => Object.hasOwn(item, key) && !bodyIsRecord(item[key]) && !Array.isArray(item[key]) && !(typeof item[key] === "string" && isProse(item[key])))) ? columns : null;
140
+ }
141
+ function bodyList(items, depth) {
142
+ if (!items.length) return bodyMuted("Empty list.");
143
+ const spelled = pairsToRecord(items);
144
+ if (spelled) return bodyFields(spelled, depth);
145
+ const scalars = items.every(item => !bodyIsRecord(item) && !Array.isArray(item));
146
+ if (scalars && items.some(item => typeof item === "string" && isProse(item))) {
147
+ const list = el("ul", "body-bullets");
148
+ for (const item of items) { const li = el("li"); li.append(el("span", "body-dot", "•"), typeof item === "string" ? el("span", "body-prose-item", item) : bodyNode(item, depth + 1)); list.append(li); }
149
+ return list;
150
+ }
151
+ if (scalars) { const list = el("ul", "body-chips"); for (const item of items) { const li = el("li"); li.append(bodyNode(item, depth + 1)); list.append(li); } return list; }
152
+ const wrap = el("div", "body-list"), columns = bodyTableColumns(items);
153
+ const draw = all => {
154
+ const shown = all ? items : items.slice(0, 20), rest = items.length - shown.length, parts = [];
155
+ if (columns) {
156
+ const region = el("div", "body-table-wrap"), table = el("table", "body-table"), head = el("tr"), thead = el("thead"), tbody = el("tbody");
157
+ region.setAttribute("role", "region"); region.setAttribute("aria-label", "Result records"); region.tabIndex = 0;
158
+ table.append(el("caption", "", items.length + " results"));
159
+ for (const key of columns) { const th = el("th", "", bodyLabel(key)); th.setAttribute("scope", "col"); head.append(th); }
160
+ thead.append(head);
161
+ for (const item of shown) { const tr = el("tr"); for (const key of columns) { const td = el("td"); td.append(bodyNode(item[key], depth + 1)); tr.append(td); } tbody.append(tr); }
162
+ table.append(thead, tbody); region.append(table); parts.push(region);
163
+ } else {
164
+ const list = el("ol", "body-records");
165
+ for (const item of shown) { const li = el("li"); li.append(bodyIsRecord(item) ? bodyRecord(item, depth + 1) : bodyNode(item, depth + 1)); list.append(li); }
166
+ parts.push(el("p", "body-count", items.length + (items.length === 1 ? " result" : " results")), list);
167
+ }
168
+ if (rest > 0) { const more = el("button", "body-more", "Show " + rest + " more"); more.type = "button"; more.onclick = () => { draw(true); window.apiosk.resize(); }; parts.push(more); }
169
+ wrap.replaceChildren(...parts);
170
+ };
171
+ draw(false);
172
+ return wrap;
173
+ }
174
+ function bodyRecord(record, depth) {
175
+ const entries = Object.entries(withoutPlumbing(record)), wrap = el("div", "body-record");
176
+ const nameKey = entries.find(([key, value]) => typeof value === "string" && BODY_NAME_KEYS.includes(key.toLowerCase()));
177
+ const imageKey = entries.find(([key, value]) => typeof value === "string" && BODY_IMAGE_KEYS.includes(key.toLowerCase()) && bodyIsUrl(value));
178
+ const linkKey = entries.find(([key, value]) => typeof value === "string" && BODY_LINK_KEYS.includes(key.toLowerCase()) && bodyIsUrl(value));
179
+ const rest = entries.filter(([key]) => key !== nameKey?.[0] && key !== imageKey?.[0]);
180
+ if (nameKey || imageKey) {
181
+ const head = el("div", "body-record-head"), copy = el("div", "body-record-copy");
182
+ if (imageKey) head.append(bodyThumb(imageKey[1]));
183
+ if (nameKey) copy.append(el("p", "body-record-name", nameKey[1]));
184
+ if (linkKey) copy.append(bodyLink(linkKey[1], bodyHost(linkKey[1])));
185
+ head.append(copy); wrap.append(head);
186
+ }
187
+ if (rest.length) wrap.append(bodyFields(Object.fromEntries(rest), depth));
188
+ return wrap;
189
+ }
190
+ function bodyFields(record, depth) {
191
+ const entries = Object.entries(withoutPlumbing(record));
192
+ if (!entries.length) return bodyMuted("Nothing in it.");
193
+ const grid = rows => {
194
+ const dl = el("dl", "body-fields");
195
+ for (const [key, value] of rows) {
196
+ const row = el("div", "result-row"), dd = el("dd", "value");
197
+ dd.append(depth >= 8 && (bodyIsRecord(value) || Array.isArray(value)) ? bodyMuted(bodySummarise(value)) : bodyNode(value, depth >= 8 ? depth : depth + 1));
198
+ row.append(el("dt", "key", bodyLabel(key)), dd); dl.append(row);
199
+ }
200
+ return dl;
201
+ };
202
+ if (depth >= 8) return grid(entries);
203
+ const wrap = el("div", "body-group");
204
+ const flat = entries.filter(([, value]) => !bodyIsRecord(value) && !Array.isArray(value) && !(typeof value === "string" && isProse(value)));
205
+ if (flat.length) wrap.append(grid(flat));
206
+ const prose = entries.filter(([, value]) => typeof value === "string" && isProse(value)), nested = entries.filter(([, value]) => bodyIsRecord(value) || Array.isArray(value));
207
+ for (const [key, value] of [...prose, ...nested]) {
208
+ const part = el("section", "body-section");
209
+ part.append(el("h4", "", bodyLabel(key)), typeof value === "string" ? bodyProse(value) : bodyNode(value, depth + 1));
210
+ wrap.append(part);
211
+ }
212
+ return wrap;
213
+ }
214
+ function bodyProse(value) {
215
+ const blocks = readBlocks(value), wrap = el("div", "body-prose");
216
+ if (!blocks.length) return bodyMuted("empty");
217
+ for (const block of blocks) {
218
+ if (block.kind === "heading") { wrap.append(el("p", "body-prose-heading", block.text)); continue; }
219
+ if (block.kind === "paragraph") { wrap.append(el("p", "", block.text)); continue; }
220
+ const list = el(block.kind === "numbers" ? "ol" : "ul");
221
+ for (const item of block.items) list.append(el("li", "", item));
222
+ wrap.append(list);
223
+ }
224
+ return wrap;
225
+ }
226
+ function bodyScalar(value) {
227
+ const trimmed = value.trim();
228
+ if (!trimmed) return bodyMuted("empty");
229
+ if (bodyIsUrl(trimmed)) {
230
+ if (!bodyIsImageUrl(trimmed)) return bodyLink(trimmed, trimmed);
231
+ const wrap = el("div", "body-image"); wrap.append(bodyThumb(trimmed), bodyLink(trimmed, trimmed)); return wrap;
232
+ }
233
+ const when = bodyAsDate(trimmed);
234
+ if (when) { const span = el("span", "", when); span.title = trimmed; return span; }
235
+ return el("span", "body-text", trimmed);
236
+ }
237
+ function bodyThumb(src) {
238
+ const img = el("img", "body-thumb"); img.alt = ""; img.loading = "lazy"; img.decoding = "async"; img.referrerPolicy = "no-referrer";
239
+ img.onerror = () => img.remove(); img.src = src; return img;
240
+ }
241
+ function bodyLink(href, label) {
242
+ const a = el("a", "body-link", label); a.href = href; a.target = "_blank"; a.rel = "noopener noreferrer";
243
+ a.onclick = event => { event?.preventDefault?.(); void window.apiosk.openLink(href); };
244
+ return a;
245
+ }
246
+ function bodySummarise(value) {
247
+ if (Array.isArray(value)) return value.length + (value.length === 1 ? " item" : " items");
248
+ const count = Object.keys(value).length;
249
+ return count + (count === 1 ? " detail" : " details");
250
+ }
251
+
252
+ const CONSTANTS = { BODY_NOISE_KEYS, BODY_LINK_OBJECT_KEYS, BODY_ENVELOPE_KEYS };
253
+ export const V2_CARD_BODY = [
254
+ ...Object.entries(CONSTANTS).map(([name, set]) => `const ${name}=new Set(${JSON.stringify([...set])});`),
255
+ `const BODY_NAME_KEYS=${JSON.stringify(BODY_NAME_KEYS)},BODY_IMAGE_KEYS=${JSON.stringify(BODY_IMAGE_KEYS)},BODY_LINK_KEYS=${JSON.stringify(BODY_LINK_KEYS)};`,
256
+ bodyIsRecord, bodyIsUrl, bodyIsImageUrl, bodyAsDate, bodyLabel, bodyHost, bodyFlatten, isNoiseKey, isEmptyValue, isLinkObject, isLinkPlumbing,
257
+ withoutPlumbing, unwrapEnvelope, readPair, pairsToRecord, isProse, readBlocks,
258
+ answerBody, bodyMuted, bodyNode, bodyTableColumns, bodyList, bodyRecord, bodyFields, bodyProse, bodyScalar, bodyThumb, bodyLink, bodySummarise,
259
+ ].map(value => typeof value === "function" ? value.toString() : value).join("\n");
@@ -0,0 +1,21 @@
1
+ // A budget refusal is not an expired login. Open the existing settings without
2
+ // revoking tokens, pretending to reconnect, or approving/replaying the task.
3
+ export const V2_CARD_BUDGET = `
4
+ function renderApprovalRecovery(response,data){
5
+ const codes=[response?.error_code,...(response?.errors||[]).map(error=>error.code)];
6
+ if(!codes.some(code=>['approval_per_request_limit','approval_daily_limit'].includes(code)))return;
7
+ const actions=byId('feedback-actions');
8
+ let url=null;try{url=trustedShortcut(new URL('/connections',dataApprovalOrigin()).href,'/connections')}catch{}
9
+ const button=el('button','primary','Set higher limits');button.type='button';
10
+ const help=el('p','meta','Choose your connection, change its per-request and daily limits, and save. Then return here and check status before approving.');
11
+ button.onclick=async()=>{
12
+ if(output!==data){showFeedback('This request was updated. Check status before continuing.','error');return}
13
+ if(!url||!await window.apiosk.openLink(url).catch(()=>false)){
14
+ help.textContent='Open Apiosk > Integrations, choose your connection, set higher spending limits and save. Then return here and check status before approving.';
15
+ window.apiosk.resize();
16
+ }
17
+ };
18
+ actions.append(button,help,el('p','meta','Or reconnect Apiosk in your chatbot and choose higher limits on the connection screen. After reconnecting, ask the question again using the new connection.'));
19
+ window.apiosk.resize();
20
+ }
21
+ `;
@@ -0,0 +1,56 @@
1
+ // Display only: join the returned CBS codebooks, never substitute missing values.
2
+ export function cbsAnnualView(data, results) {
3
+ const question = data.context_view?.conversation?.[0]?.question || '';
4
+ const years = [...new Set(question.match(/\b(?:19|20)\d{2}\b/g) || [])].sort();
5
+ if (!years.length || years.length > 2) return null;
6
+ const base = '/odata/v1/CBS/85809NED/';
7
+ const observations = results.find(r => r.source?.provider === 'cbs-statline' && r.source.path === base + 'Observations');
8
+ const entity = observations?.subject?.entity_ref;
9
+ if (!entity) return null;
10
+ const names = ['Observations', 'MeasureCodes', 'PeriodenCodes', 'BedrijfstakkenBranchesSBI2008Codes', 'BedrijfsgrootteCodes', 'Properties'];
11
+ const records = names.map(name => results.find(r => r.source?.provider === 'cbs-statline' && r.source.path === base + name && r.subject?.entity_ref === entity));
12
+ if (records.some(r => !r?.data || r.coverage === 'partial')) return null;
13
+ const [values, measures, periods, sectors, sizes, properties] = records.map(r => r.data);
14
+ if (![values, measures, periods, sectors, sizes].every(d => Array.isArray(d.value))) return null;
15
+ const selected = values.value.filter(r => years.some(year => r.Perioden === year + 'JJ00'));
16
+ if (!selected.length || years.some(year => !selected.some(r => r.Perioden === year + 'JJ00'))) return null;
17
+ const rows = [];
18
+ for (const row of selected) {
19
+ const measure = measures.value.find(v => v.Identifier === row.Measure);
20
+ const period = periods.value.find(v => v.Identifier === row.Perioden);
21
+ const sector = sectors.value.find(v => v.Identifier === row.BedrijfstakkenBranchesSBI2008);
22
+ const size = sizes.value.find(v => v.Identifier === row.Bedrijfsgrootte);
23
+ if (!measure?.Title || !measure.Unit || !period?.Title || !sector?.Title || !size?.Title) return null;
24
+ rows.push({period:period.Title.trim(), measure:measure.Title, unit:measure.Unit,
25
+ code:row.Measure, value:typeof row.Value === 'number' && Number.isFinite(row.Value) ? row.Value : 'Niet beschikbaar',
26
+ status:period.Status || 'Niet vermeld', sector:sector.Title, size:size.Title});
27
+ }
28
+ rows.sort((a,b) => a.period.localeCompare(b.period) || a.measure.localeCompare(b.measure));
29
+ const scopes = [...new Set(rows.map(r => r.sector + ' · ' + r.size))];
30
+ if (scopes.length !== 1) return null;
31
+ const url = properties.Distributions?.find(d => /^https:\/\/(?:dataportal|opendata)\.cbs\.nl\//.test(d.AccessUrl || ''))?.AccessUrl;
32
+ return {records, rows, subject:observations.subject.label, scope:scopes[0], title:properties.Title || observations.subject.label,
33
+ modified:properties.Modified || properties.ModificationDate, retrieved:observations.source.retrieved_at, url};
34
+ }
35
+
36
+ export const V2_CARD_CBS = `
37
+ ${cbsAnnualView.toString()}
38
+ function renderCbsAnnual(view){
39
+ const s=section(view.subject||view.title);s.classList.add('answer-section');
40
+ const number=value=>typeof value==='number'?new Intl.NumberFormat('nl-NL',{maximumFractionDigits:6}).format(value):value;
41
+ const growth=view.rows.filter(r=>r.code==='M004926'&&r.unit==='%'),latest=growth.at(-1);
42
+ if(latest){s.append(el('p','answer-number',(typeof latest.value==='number'&&latest.value>0?'+':'')+number(latest.value)+(typeof latest.value==='number'?'%':'')));s.append(el('p','answer-caption','Omzet in '+latest.period+' ten opzichte van '+(Number(latest.period)-1)));const previous=growth.at(-2);if(previous)s.append(el('p','meta','In '+previous.period+': '+number(previous.value)+(typeof previous.value==='number'?'%':'')+' ten opzichte van '+(Number(previous.period)-1)))}
43
+ const states=[...new Set(view.rows.map(r=>r.status))];s.append(el('p','answer-source','CBS StatLine'+(states.length===1?' · '+states[0]:'')));
44
+ const links=el('div','result-links');if(view.url){const link=el('button','text-action','Bron bekijken');link.onclick=()=>window.apiosk.openLink(view.url);links.append(link)}s.append(links);
45
+ const full=el('details','compact-details result-details');full.append(el('summary','','Details'));full.ontoggle=()=>window.apiosk.resize();
46
+ full.append(el('p','meta',view.scope));
47
+ const wrap=el('div','cbs-table-wrap'),table=el('table','cbs-table'),head=el('tr');for(const label of ['Jaar','Maatstaf','Waarde','Eenheid','Status'])head.append(el('th','',label));const thead=el('thead');thead.append(head);table.append(thead);const body=el('tbody');
48
+ for(const row of view.rows){const tr=el('tr');for(const value of [row.period,row.measure,number(row.value),row.unit,row.status])tr.append(el('td','',value));body.append(tr)}table.append(body);wrap.append(table);full.append(wrap);
49
+ if(view.modified)full.append(el('p','meta','CBS bijgewerkt: '+new Date(view.modified).toLocaleDateString('nl-NL')));
50
+ if(view.retrieved)full.append(el('p','meta','Opgehaald: '+new Date(view.retrieved).toLocaleString('nl-NL')));
51
+ const raw=el('details','full-result');raw.append(el('summary','','Brongegevens'),el('pre','',JSON.stringify(view.records,null,2)));full.append(raw);s.append(full);
52
+ if(!latest)full.open=true;
53
+ return {section:s,details:full,links};
54
+ }
55
+ `;
56
+
@@ -0,0 +1,4 @@
1
+ // Company selection changes only the visible name; entity references stay exact.
2
+ export const V2_CARD_CHOICES = `
3
+ function renderChoices(data){const select=(data.next_actions||[]).find(a=>a.kind==='select_entity'),candidates=data.context_view?.candidates;if(!select||!Array.isArray(candidates)||!candidates.length)return;const s=section('Which company do you mean?');s.classList.add('choice-section');s.setAttribute('role','region');s.setAttribute('aria-label','Choose the right company');s.append(el('p','meta','Choose once to continue within your approved amount.'));const list=el('div','choice-list');for(const c of candidates){const copy=el('span'),name=(c.facts||[]).find(f=>f.type==='company.name');copy.append(el('strong','',formatDisplayText(text(name?.value||c.label))));const identifiers=(c.facts||[]).filter(f=>/kvk|city|country|lei/i.test(f.type)).map(f=>(/kvk/i.test(f.type)?'KVK ':'')+text(f.value));copy.append(el('span','meta',identifiers.join(' · ')));const b=el('button');b.append(copy,el('span','','Select'));b.onclick=()=>callAction(select,{entity_ref:c.entity_ref});list.append(b)}s.append(list)}
4
+ `;
@@ -0,0 +1,25 @@
1
+ export const V2_CARD_CLARIFICATION = `
2
+ function renderInput(data){
3
+ const action=(data.next_actions||[]).find(a=>a.kind==='supply_input');
4
+ if(!action){renderClarification(data);return}
5
+ const schema=action.input_schema?.properties?.value||{},raw=text(action.label).replace(/^Provide\\s+/i,'').trim();
6
+ const names={'web.query':'Search query','web.source_search_query':'Search query','domain.name':'Website domain','company_registry.kvknummer':'KVK number','company.registration.lei':'LEI code','company.name':'Company name'};
7
+ const title=schema.title||names[raw]||(raw?pretty(raw):'');
8
+ if(!title){section('Request details unavailable').append(el('p','notice','The request did not identify the missing detail. Use Check status to reload the saved request.'));return}
9
+ const s=section(title),form=el('form','field actions'),field=el('input'),b=el('button','quiet','Continue');
10
+ const message=data.context_view?.clarification?.message||schema.description||(names[raw]==='Search query'?'What should the web search look for?':'Enter the '+title.toLowerCase()+' to continue.');
11
+ s.append(el('p','meta',message));field.id='requested-input';field.required=true;field.autocomplete='off';field.setAttribute('aria-label',title);
12
+ field.placeholder=Array.isArray(schema.examples)&&schema.examples.length?'For example: '+text(schema.examples[0]):title;
13
+ form.append(field,b);form.onsubmit=e=>{e.preventDefault();if(output!==data||busy||!field.value.trim())return;let value=field.value.trim();const type=schema.type;if(type==='integer'||type==='number')value=Number(value);else if(type==='boolean')value=value==='true';return callAction(action,{value})};s.append(form);
14
+ }
15
+ function renderClarification(data){
16
+ if(data.status!=='needs_input')return;
17
+ const turn=data.context_view?.conversation?.at(-1),message=data.context_view?.clarification?.message||turn?.reply;
18
+ const s=section('One detail is needed');s.append(el('p','notice',message||'Add the missing detail in your chat, or check the saved request status.'));
19
+ if(!message||!turn?.question||!data.state)return;
20
+ const form=el('form','field actions'),field=el('input'),b=el('button','quiet','Send answer');
21
+ field.required=true;field.autocomplete='off';field.placeholder='Your answer';field.setAttribute('aria-label','Your answer to the clarification');form.append(field,b);
22
+ form.onsubmit=async e=>{e.preventDefault();const answer=field.value.trim();if(!answer||busy||output!==data)return;busy=true;b.disabled=true;showFeedback('Updating your request…');try{const next=await window.apiosk.callTool('apiosk_discover',{question:turn.question+'\\n\\nUser clarification: '+answer,state:data.state});acceptResponse(next)}catch(error){showFeedback(error?.message||'Could not send your answer. Check status and try again.','error')}finally{busy=false;b.disabled=false}};
23
+ s.append(form);
24
+ }
25
+ `;