@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.
- package/README.md +84 -537
- package/assets/brand/apiosk-a-20260921.png +0 -0
- package/assets/brand/apple-touch-icon.png +0 -0
- package/assets/brand/favicon-dark.ico +0 -0
- package/assets/brand/favicon-light.ico +0 -0
- package/assets/brand/favicon.ico +0 -0
- package/assets/brand/icon-192.png +0 -0
- package/assets/brand/icon-512.png +0 -0
- package/assets/brand/icon-maskable-512.png +0 -0
- package/assets/brand/inter-OFL.txt +93 -0
- package/assets/brand/inter-latin-400-normal.woff2 +0 -0
- package/assets/brand/inter-latin-500-normal.woff2 +0 -0
- package/assets/brand/inter-latin-600-normal.woff2 +0 -0
- package/assets/brand/mark-20260905-transparent.svg +1 -0
- package/assets/brand/mark-20260918.svg +1 -0
- package/assets/brand/mark-black-20260918.svg +1 -0
- package/assets/brand/mark-dark-20260905-transparent.png +0 -0
- package/assets/brand/mark-dark-20260918.png +0 -0
- package/assets/brand/mark-dark-96.png +0 -0
- package/assets/brand/mark-light-20260905-transparent.png +0 -0
- package/assets/brand/mark-light-20260918.png +0 -0
- package/assets/brand/mark-light-96.png +0 -0
- package/assets/brand/wordmark-black-320.png +0 -0
- package/assets/brand/wordmark-white-320.png +0 -0
- package/docs/branding.md +11 -0
- package/docs/marketplace-submission-2026-04-08.md +4 -0
- package/docs/marketplace-submission-2026-08-20.md +157 -0
- package/docs/openai-plugin-submission-2026-09-05.md +143 -0
- package/docs/sepa-rail.md +13 -13
- package/dxt.json +26 -22
- package/index.mjs +3 -1
- package/logo-optimized-light.png +0 -0
- package/package.json +15 -13
- package/plugin/apiosk/.codex-plugin/plugin.json +45 -0
- package/plugin/apiosk/.mcp.json +8 -0
- package/plugin/apiosk/assets/icon-dark.png +0 -0
- package/plugin/apiosk/assets/icon.png +0 -0
- package/plugin/apiosk/assets/icon.svg +1 -0
- package/plugin/apiosk/assets/logo.png +0 -0
- package/plugin/apiosk/skills/apiosk/SKILL.md +33 -0
- package/plugin/apiosk/skills/apiosk/agents/openai.yaml +12 -0
- package/server.json +98 -9
- package/server.mjs +258 -71
- package/src/approval-feedback.mjs +21 -0
- package/src/brand-routes.mjs +28 -0
- package/src/create-server.mjs +168 -9
- package/src/display-money.mjs +20 -0
- package/src/display-text.mjs +67 -0
- package/src/gateway-client.mjs +42 -0
- package/src/gateway-v2-ask.mjs +50 -0
- package/src/gateway-v2-card-account.mjs +23 -0
- package/src/gateway-v2-card-actions.mjs +67 -0
- package/src/gateway-v2-card-answer-text.mjs +130 -0
- package/src/gateway-v2-card-answer.mjs +68 -0
- package/src/gateway-v2-card-blocks.mjs +166 -0
- package/src/gateway-v2-card-body.mjs +259 -0
- package/src/gateway-v2-card-budget.mjs +21 -0
- package/src/gateway-v2-card-cbs.mjs +56 -0
- package/src/gateway-v2-card-choices.mjs +4 -0
- package/src/gateway-v2-card-clarification.mjs +25 -0
- package/src/gateway-v2-card-compact.mjs +34 -0
- package/src/gateway-v2-card-events.mjs +41 -0
- package/src/gateway-v2-card-presentation.mjs +125 -0
- package/src/gateway-v2-card-research.mjs +63 -0
- package/src/gateway-v2-card-result.mjs +32 -0
- package/src/gateway-v2-card-search.mjs +39 -0
- package/src/gateway-v2-card-sources.mjs +32 -0
- package/src/gateway-v2-card-style.mjs +76 -0
- package/src/gateway-v2-card-verdict.mjs +66 -0
- package/src/gateway-v2-card.mjs +91 -0
- package/src/gateway-v2-contracts.json +66 -0
- package/src/gateway-v2-instructions.md +113 -0
- package/src/gateway-v2-recovery.mjs +18 -0
- package/src/gateway-v2-report-links.mjs +14 -0
- package/src/gateway-v2-workflows.mjs +17 -0
- package/src/gateway-v2.mjs +179 -0
- package/src/oauth.mjs +603 -364
- package/src/observability.mjs +210 -0
- package/src/result-presentation.mjs +9 -0
- package/src/runtime.mjs +24 -3091
- package/src/settlement-disclosure.mjs +26 -0
- package/src/source-groups.mjs +91 -0
- package/src/source-value-format.mjs +25 -0
- package/src/tool-result.mjs +20 -0
- package/src/ui-bridge.mjs +185 -0
- package/src/well-known-routes.mjs +132 -0
- package/src/funding-options.mjs +0 -255
- package/src/gateway-management.mjs +0 -107
- package/src/listing-metadata.mjs +0 -287
- package/src/local-config.mjs +0 -256
- package/src/payment-guidance.mjs +0 -307
- package/src/wallet-store.mjs +0 -476
package/src/create-server.mjs
CHANGED
|
@@ -2,36 +2,195 @@ import { Server } from "@modelcontextprotocol/sdk/server/index.js";
|
|
|
2
2
|
import {
|
|
3
3
|
CallToolRequestSchema,
|
|
4
4
|
ListToolsRequestSchema,
|
|
5
|
+
ListResourcesRequestSchema,
|
|
6
|
+
ReadResourceRequestSchema,
|
|
7
|
+
ListPromptsRequestSchema,
|
|
8
|
+
GetPromptRequestSchema,
|
|
5
9
|
} from "@modelcontextprotocol/sdk/types.js";
|
|
6
|
-
import { createApioskMcpRuntime } from "./runtime.mjs";
|
|
10
|
+
import { createApioskMcpRuntime, resolveGatewayV2Url } from "./runtime.mjs";
|
|
11
|
+
import { V2_INSTRUCTIONS, V2_DESCRIPTION, V2_RESOURCE } from "./gateway-v2.mjs";
|
|
12
|
+
import { APIO_V2_CARD_URI, APIO_V2_CHATGPT_CARD_URI, APIO_V2_CARD_LEGACY_URIS, APIO_V2_MODERN_CARD_URIS, gatewayV2CardHtml, gatewayV2CardMeta } from "./gateway-v2-card.mjs";
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* One sentence, defined once.
|
|
16
|
+
*
|
|
17
|
+
* Registries take a server's description from wherever they can find it: the
|
|
18
|
+
* server card, `serverInfo`, or by scraping the HTML at the root. So every one
|
|
19
|
+
* of those surfaces reads this constant, and changing the pitch means changing
|
|
20
|
+
* it in src/gateway-v2.mjs.
|
|
21
|
+
*/
|
|
22
|
+
export const SERVER_DESCRIPTION = V2_DESCRIPTION;
|
|
23
|
+
|
|
24
|
+
// Base version, kept in step with the published manifests (package.json etc.).
|
|
25
|
+
export const SERVER_BASE_VERSION = "2.0.0";
|
|
26
|
+
|
|
27
|
+
// The millisecond timestamp encoded in the first 10 chars of a ULID (Crockford
|
|
28
|
+
// base32). Fly's FLY_MACHINE_VERSION is a ULID that changes on every deploy, and
|
|
29
|
+
// its timestamp is monotonically increasing, which is exactly the "counter"
|
|
30
|
+
// property a version needs. Returns null for anything that is not a ULID.
|
|
31
|
+
function ulidTimestampMs(ulid) {
|
|
32
|
+
const B32 = "0123456789ABCDEFGHJKMNPQRSTVWXYZ";
|
|
33
|
+
const head = String(ulid || "").toUpperCase().slice(0, 10);
|
|
34
|
+
if (head.length < 10) return null;
|
|
35
|
+
let ms = 0;
|
|
36
|
+
for (const ch of head) {
|
|
37
|
+
const v = B32.indexOf(ch);
|
|
38
|
+
if (v < 0) return null;
|
|
39
|
+
ms = ms * 32 + v;
|
|
40
|
+
}
|
|
41
|
+
return ms;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
// The version a client reads on `initialize`. It must move on every `fly deploy`
|
|
45
|
+
// so a client that caches tool definitions can tell it is looking at a new build
|
|
46
|
+
// after a redeploy or reconnect. Fly provides no plain release counter, but it
|
|
47
|
+
// does set FLY_MACHINE_VERSION (a ULID) which changes each deploy; its timestamp
|
|
48
|
+
// (in seconds) becomes the patch, so each deploy reads as a strictly newer
|
|
49
|
+
// semver (the patch is respected where build metadata after '+' would be
|
|
50
|
+
// ignored). Falls back to the base version locally; APIOSK_MCP_VERSION pins it.
|
|
51
|
+
export function resolveServerVersion(env = process.env) {
|
|
52
|
+
const explicit = typeof env.APIOSK_MCP_VERSION === "string" ? env.APIOSK_MCP_VERSION.trim() : "";
|
|
53
|
+
if (explicit) return explicit;
|
|
54
|
+
const [major = "2", minor = "0"] = SERVER_BASE_VERSION.split(".");
|
|
55
|
+
const ms = ulidTimestampMs(env.FLY_MACHINE_VERSION || env.FLY_IMAGE_REF?.split("deployment-")?.[1]);
|
|
56
|
+
return ms ? `${major}.${minor}.${Math.floor(ms / 1000)}` : SERVER_BASE_VERSION;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/** Transparent brand mark, shared by initialize and the published server card.
|
|
60
|
+
* SVG follows the host color scheme; PNG fallbacks explicitly name their theme. */
|
|
61
|
+
export const SERVER_ICONS = [
|
|
62
|
+
{
|
|
63
|
+
src: "https://mcp.apiosk.com/brand/apiosk-a-20260921.png",
|
|
64
|
+
mimeType: "image/png",
|
|
65
|
+
sizes: ["1254x1254"],
|
|
66
|
+
theme: "light",
|
|
67
|
+
},
|
|
68
|
+
{
|
|
69
|
+
"src": "https://mcp.apiosk.com/brand/mark-20260918.svg",
|
|
70
|
+
"mimeType": "image/svg+xml",
|
|
71
|
+
"sizes": [
|
|
72
|
+
"any"
|
|
73
|
+
]
|
|
74
|
+
},
|
|
75
|
+
{
|
|
76
|
+
"src": "https://mcp.apiosk.com/brand/mark-light-20260918.png",
|
|
77
|
+
"mimeType": "image/png",
|
|
78
|
+
"sizes": [
|
|
79
|
+
"512x512"
|
|
80
|
+
],
|
|
81
|
+
"theme": "light"
|
|
82
|
+
},
|
|
83
|
+
{
|
|
84
|
+
"src": "https://mcp.apiosk.com/brand/mark-dark-20260918.png",
|
|
85
|
+
"mimeType": "image/png",
|
|
86
|
+
"sizes": [
|
|
87
|
+
"512x512"
|
|
88
|
+
],
|
|
89
|
+
"theme": "dark"
|
|
90
|
+
}
|
|
91
|
+
];
|
|
7
92
|
|
|
8
93
|
export const SERVER_INFO = {
|
|
9
94
|
name: "apiosk-mcp",
|
|
10
|
-
version:
|
|
95
|
+
version: resolveServerVersion(),
|
|
96
|
+
/**
|
|
97
|
+
* The word a host puts after "from" on its consent card — "Claude wants to
|
|
98
|
+
* use Plan a data request from Apiosk". The tool titles carry the verb, so
|
|
99
|
+
* the server carries only the brand.
|
|
100
|
+
*/
|
|
101
|
+
title: "Apiosk",
|
|
102
|
+
description: SERVER_DESCRIPTION,
|
|
103
|
+
websiteUrl: "https://apiosk.com",
|
|
104
|
+
icons: SERVER_ICONS,
|
|
11
105
|
};
|
|
12
106
|
|
|
107
|
+
// Shown to every connecting MCP client/agent as server-level guidance: the
|
|
108
|
+
// Gateway v2 host contract, synced from gateway/contracts/host-instructions.md.
|
|
109
|
+
export const SERVER_INSTRUCTIONS = V2_INSTRUCTIONS;
|
|
110
|
+
|
|
13
111
|
function resolveRuntime(options = {}) {
|
|
14
112
|
return options.runtime || createApioskMcpRuntime(options);
|
|
15
113
|
}
|
|
16
114
|
|
|
17
115
|
export async function listApioskTools(options = {}) {
|
|
18
|
-
return resolveRuntime(options).listTools(
|
|
116
|
+
return resolveRuntime(options).listTools();
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
export function resolveServerPresentation(env = process.env) {
|
|
120
|
+
return {
|
|
121
|
+
info: { ...SERVER_INFO, version: resolveServerVersion(env) },
|
|
122
|
+
description: SERVER_DESCRIPTION,
|
|
123
|
+
instructions: SERVER_INSTRUCTIONS,
|
|
124
|
+
};
|
|
19
125
|
}
|
|
20
126
|
|
|
21
127
|
export function createApioskMcpServer(options = {}) {
|
|
22
128
|
const runtime = resolveRuntime(options);
|
|
129
|
+
const env = options.env || process.env;
|
|
130
|
+
const gatewayUrl = resolveGatewayV2Url(env);
|
|
131
|
+
const cardHtml = gatewayV2CardHtml(gatewayUrl);
|
|
132
|
+
const cardMeta = gatewayV2CardMeta(gatewayUrl);
|
|
23
133
|
const server = new Server(
|
|
24
|
-
|
|
25
|
-
|
|
134
|
+
resolveServerPresentation(env).info,
|
|
135
|
+
// `prompts` is declared because it is implemented, as an empty list.
|
|
136
|
+
// Leaving it out made prompts/list answer -32601 Method not found, which a
|
|
137
|
+
// scanner reads as a broken server rather than a server without prompts.
|
|
138
|
+
{
|
|
139
|
+
capabilities: { tools: {}, resources: {}, prompts: {} },
|
|
140
|
+
instructions: SERVER_INSTRUCTIONS,
|
|
141
|
+
}
|
|
26
142
|
);
|
|
27
143
|
|
|
28
|
-
|
|
29
|
-
|
|
144
|
+
/**
|
|
145
|
+
* The card, and the one MIME question.
|
|
146
|
+
*
|
|
147
|
+
* MCP Apps (SEP-1865) reads `text/html;profile=mcp-app` and OpenAI's Apps SDK
|
|
148
|
+
* reads `text/html+skybridge`. The current card has a separate, stable URI
|
|
149
|
+
* for each. Only previously issued compatible URIs keep the old
|
|
150
|
+
* host-dependent label: modern ChatGPT also sends an OpenAI user agent, so
|
|
151
|
+
* the user agent is not a MIME signal for the new URIs.
|
|
152
|
+
*/
|
|
153
|
+
const legacyMime = () => (options.legacyUiMime ? "text/html+skybridge" : "text/html;profile=mcp-app");
|
|
154
|
+
const cardMime = (uri) => uri.endsWith("-chatgpt.html")
|
|
155
|
+
? "text/html+skybridge"
|
|
156
|
+
: APIO_V2_MODERN_CARD_URIS.includes(uri) ? "text/html;profile=mcp-app" : legacyMime();
|
|
157
|
+
|
|
158
|
+
server.setRequestHandler(ListResourcesRequestSchema, async () => ({
|
|
159
|
+
resources: [
|
|
160
|
+
V2_RESOURCE,
|
|
161
|
+
{ uri: APIO_V2_CARD_URI, name: "Apiosk Gateway v2 interactive card", mimeType: "text/html;profile=mcp-app", _meta: cardMeta },
|
|
162
|
+
{ uri: APIO_V2_CHATGPT_CARD_URI, name: "Apiosk card for ChatGPT", mimeType: "text/html+skybridge", _meta: cardMeta },
|
|
163
|
+
...APIO_V2_CARD_LEGACY_URIS.map((uri) => ({
|
|
164
|
+
uri,
|
|
165
|
+
name: "Apiosk Gateway v2 interactive card (compatible)",
|
|
166
|
+
mimeType: cardMime(uri),
|
|
167
|
+
_meta: cardMeta,
|
|
168
|
+
})),
|
|
169
|
+
],
|
|
30
170
|
}));
|
|
31
171
|
|
|
32
|
-
server.setRequestHandler(
|
|
33
|
-
|
|
172
|
+
server.setRequestHandler(ReadResourceRequestSchema, async (request) => {
|
|
173
|
+
const { uri } = request.params;
|
|
174
|
+
if (uri === V2_RESOURCE.uri) return { contents: [{ uri, mimeType: V2_RESOURCE.mimeType, text: V2_INSTRUCTIONS }] };
|
|
175
|
+
if (uri === APIO_V2_CHATGPT_CARD_URI || APIO_V2_MODERN_CARD_URIS.includes(uri) || APIO_V2_CARD_LEGACY_URIS.includes(uri)) {
|
|
176
|
+
return { contents: [{ uri, mimeType: cardMime(uri), text: cardHtml, _meta: cardMeta }] };
|
|
177
|
+
}
|
|
178
|
+
throw new Error("Unknown Apiosk resource");
|
|
179
|
+
});
|
|
180
|
+
|
|
181
|
+
server.setRequestHandler(ListPromptsRequestSchema, async () => ({ prompts: [] }));
|
|
182
|
+
|
|
183
|
+
server.setRequestHandler(GetPromptRequestSchema, async () => {
|
|
184
|
+
throw new Error("Use apiosk_discover with your question");
|
|
34
185
|
});
|
|
35
186
|
|
|
187
|
+
server.setRequestHandler(ListToolsRequestSchema, async () => ({
|
|
188
|
+
tools: await runtime.listTools(),
|
|
189
|
+
}));
|
|
190
|
+
|
|
191
|
+
server.setRequestHandler(CallToolRequestSchema, async (request, extra) =>
|
|
192
|
+
runtime.callTool(request.params.name, request.params.arguments || {}, extra.authInfo)
|
|
193
|
+
);
|
|
194
|
+
|
|
36
195
|
return server;
|
|
37
196
|
}
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
// Self-contained: this exact function is also embedded in the chat card.
|
|
2
|
+
// Conversion affects display only, never the amount submitted for approval.
|
|
3
|
+
export function formatDisplayMoney(atomic, currency = 'USD', display = null, ceiling = false) {
|
|
4
|
+
if (atomic == null || !/^-?\d+$/.test(String(atomic))) return null;
|
|
5
|
+
currency = !currency || currency === 'USDC' ? 'USD' : currency;
|
|
6
|
+
let value = BigInt(atomic);
|
|
7
|
+
if (currency === 'USD' && display?.base_currency === 'USD' && /^[A-Z]{3}$/.test(display.currency || '') && /^\d{1,18}(\.\d{1,18})?$/.test(display.rate || '')) {
|
|
8
|
+
const [whole, fraction = ''] = display.rate.split('.');
|
|
9
|
+
const numerator = BigInt(whole + fraction), denominator = 10n ** BigInt(fraction.length);
|
|
10
|
+
if (numerator > 0n) {
|
|
11
|
+
const product = value * numerator;
|
|
12
|
+
const negative = product < 0n, magnitude = negative ? -product : product;
|
|
13
|
+
const rounding = ceiling && !negative ? denominator - 1n : denominator / 2n;
|
|
14
|
+
value = (negative ? -1n : 1n) * ((magnitude + rounding) / denominator);
|
|
15
|
+
currency = display.currency;
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
const negative = value < 0n, magnitude = negative ? -value : value;
|
|
19
|
+
return `${negative ? '-' : ''}${magnitude / 1000000n}.${(magnitude % 1000000n).toString().padStart(6, '0').replace(/0+$/, '').padEnd(2, '0')} ${currency}`;
|
|
20
|
+
}
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
// Keep in sync with App src/lib/ui/display-text.ts.
|
|
2
|
+
const displayAbbreviations = new Set([
|
|
3
|
+
'BV', 'NV', 'VOF', 'CV', 'LLC', 'LLP', 'PLC', 'AG', 'SA', 'SAS', 'SE', 'ASA',
|
|
4
|
+
'IT', 'ICT', 'AI', 'IBM', 'ING', 'ABN', 'AMRO', 'ASML', 'KPN', 'TNO',
|
|
5
|
+
'EU', 'UK', 'US', 'USA', 'NL', 'EUR', 'USD', 'GBP', 'VAT', 'KVK', 'API', 'HTTP', 'HTTPS',
|
|
6
|
+
]);
|
|
7
|
+
/** Technical values stay literal regardless of the response's source or shape. */
|
|
8
|
+
export function isLiteralField(key) {
|
|
9
|
+
const words = key.replace(/([a-z])([A-Z])/g, '$1 $2').split(/[^a-zA-Z]+/).filter(Boolean);
|
|
10
|
+
return words.some(word => /^(?:id|ids|code|codes|ref|refs|reference|identifier|identifiers|token|key|hash|signature|url|uri|path|pointer|email|domain|postcode|postal|zip|iban|bic|phone|number|nummer|currency|unit|symbol|method)$/i.test(word));
|
|
11
|
+
}
|
|
12
|
+
/** Shared display rule for human-readable labels and values, independent of answer type. */
|
|
13
|
+
export function formatDisplayText(text, key = '') {
|
|
14
|
+
if (isLiteralField(key) || text.length > 160 || text !== text.toLocaleUpperCase('nl-NL') || !/\p{L}/u.test(text))
|
|
15
|
+
return text;
|
|
16
|
+
if (/[@:/_\n]|\p{L}\d|\d\p{L}/u.test(text) || /^\S+\.[A-Z]{2,}$/u.test(text))
|
|
17
|
+
return text;
|
|
18
|
+
if (!/\s/u.test(text) && text.length <= 4)
|
|
19
|
+
return text;
|
|
20
|
+
return text.replace(/\p{L}[\p{L}\p{M}]*(?:['’][\p{L}\p{M}]+)*/gu, word => {
|
|
21
|
+
if (displayAbbreviations.has(word))
|
|
22
|
+
return word;
|
|
23
|
+
if (word === 'GMBH')
|
|
24
|
+
return 'GmbH';
|
|
25
|
+
return word.charAt(0) + word.slice(1).toLocaleLowerCase('nl-NL');
|
|
26
|
+
});
|
|
27
|
+
}
|
|
28
|
+
/** A display copy for any nested JSON response. Never modifies the source or its keys. */
|
|
29
|
+
export function displayData(value, key = '', depth = 0) {
|
|
30
|
+
if (depth > 20 || value == null || isLiteralField(key))
|
|
31
|
+
return value;
|
|
32
|
+
if (typeof value === 'string')
|
|
33
|
+
return formatDisplayText(value, key);
|
|
34
|
+
if (Array.isArray(value))
|
|
35
|
+
return value.map(child => displayData(child, key, depth + 1));
|
|
36
|
+
if (typeof value !== 'object')
|
|
37
|
+
return value;
|
|
38
|
+
const record = value;
|
|
39
|
+
const field = record.key ?? record.field ?? record.type ?? record.factType ?? record.pointer;
|
|
40
|
+
return Object.fromEntries(Object.entries(record).map(([name, child]) => [name,
|
|
41
|
+
displayData(child, name === 'value' && typeof field === 'string' ? field : name, depth + 1),
|
|
42
|
+
]));
|
|
43
|
+
}
|
|
44
|
+
/** Use the same spelling in prose as in the displayed source values. */
|
|
45
|
+
export function formatDisplayNarrative(text, context) {
|
|
46
|
+
const replacements = new Map();
|
|
47
|
+
function collect(raw, shown, depth = 0) {
|
|
48
|
+
if (depth > 20 || raw == null || shown == null)
|
|
49
|
+
return;
|
|
50
|
+
if (typeof raw === 'string' && typeof shown === 'string' && raw !== shown)
|
|
51
|
+
replacements.set(raw, shown);
|
|
52
|
+
else if (Array.isArray(raw) && Array.isArray(shown))
|
|
53
|
+
raw.forEach((value, i) => collect(value, shown[i], depth + 1));
|
|
54
|
+
else if (typeof raw === 'object' && typeof shown === 'object') {
|
|
55
|
+
for (const [key, value] of Object.entries(raw))
|
|
56
|
+
collect(value, shown[key], depth + 1);
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
collect(context, displayData(context));
|
|
60
|
+
if (replacements.size) {
|
|
61
|
+
const pattern = [...replacements.keys()].sort((a, b) => b.length - a.length).map(value => value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')).join('|');
|
|
62
|
+
text = text.replace(new RegExp(`(?<![\\p{L}\\p{N}_/@.])(?:${pattern})(?![\\p{L}\\p{N}_/@]|\\.[\\p{L}\\p{N}])`, 'gu'), value => replacements.get(value));
|
|
63
|
+
}
|
|
64
|
+
return formatDisplayText(text);
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
export const DISPLAY_TEXT = ['const displayAbbreviations = new Set(' + JSON.stringify([...displayAbbreviations]) + ');', isLiteralField, formatDisplayText, displayData, formatDisplayNarrative].map(value => typeof value === 'function' ? value.toString() : value).join('\n');
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
// Where the agent gateway is, and which token speaks for the buyer.
|
|
2
|
+
//
|
|
3
|
+
// Two small answers the rest of the server shares. The Gateway v2 runtime
|
|
4
|
+
// (src/gateway-v2.mjs) sends the token as a Bearer credential; the hosted
|
|
5
|
+
// OAuth flow (src/oauth.mjs) and the served route index
|
|
6
|
+
// (src/well-known-routes.mjs) read the agent gateway's base URL.
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* The agent gateway: OAuth (/v1/oauth/*), connect and the skill. Data requests
|
|
10
|
+
* go to Gateway v2 (src/runtime.mjs), never here.
|
|
11
|
+
*/
|
|
12
|
+
export const DEFAULT_GATEWAY_BASE_URL =
|
|
13
|
+
"https://api.apiosk.com/functions/v1/agent-gateway";
|
|
14
|
+
|
|
15
|
+
function trimString(value) {
|
|
16
|
+
return String(value ?? "").trim();
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export function resolveGatewayBaseUrl(env = process.env) {
|
|
20
|
+
const configured =
|
|
21
|
+
trimString(env.APIOSK_GATEWAY_URL) ||
|
|
22
|
+
trimString(env.APIOSK_GATEWAY_BASE_URL) ||
|
|
23
|
+
trimString(env.APIOSK_BASE_URL);
|
|
24
|
+
return (configured || DEFAULT_GATEWAY_BASE_URL).replace(/\/+$/, "");
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* The token that names the buyer to the gateway.
|
|
29
|
+
*
|
|
30
|
+
* A request-scoped token (minted for this OAuth session, stashed on
|
|
31
|
+
* `authInfo.extra` by src/oauth.mjs) always beats the per-process env token:
|
|
32
|
+
* one server serves many buyers, and the caller's own connection is the only
|
|
33
|
+
* one allowed to spend. For stdio, `APIOSK_CONNECT_TOKEN` holds an Apiosk agent
|
|
34
|
+
* token (`apk_access_…` or `apk_live_…`).
|
|
35
|
+
*/
|
|
36
|
+
export function resolveConnectToken(authInfo = null, env = process.env) {
|
|
37
|
+
return (
|
|
38
|
+
trimString(authInfo?.extra?.apiosk_connect_token) ||
|
|
39
|
+
trimString(env.APIOSK_CONNECT_TOKEN) ||
|
|
40
|
+
""
|
|
41
|
+
);
|
|
42
|
+
}
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
// The Ask page's own steps for a connected chatbot. The chatbot fills in the
|
|
2
|
+
// capability object the Ask parser would produce (apiosk_search), then prepares
|
|
3
|
+
// one ranked endpoint with the inputs that endpoint declares (apiosk_prepare).
|
|
4
|
+
// The gateway ranks, prices and quotes; this module only shapes the calls and
|
|
5
|
+
// adds no planner, pricing or balance logic of its own.
|
|
6
|
+
import { randomUUID } from "node:crypto";
|
|
7
|
+
import schemas from "./gateway-v2-contracts.json" with { type: "json" };
|
|
8
|
+
|
|
9
|
+
export const ASK_TOOLS = Object.freeze(["apiosk_search", "apiosk_prepare"]);
|
|
10
|
+
|
|
11
|
+
const SEARCH_NOTICE = "Searching is free and buys nothing. Only candidates with availability \"supported\" can run: fill each required endpoint.inputs field from the person's words or earlier results, ask for anything missing, then call apiosk_prepare with that candidate's endpoint_id, capability and input.";
|
|
12
|
+
|
|
13
|
+
export function askDefinitions(errorFields, taskOutput) {
|
|
14
|
+
const searchOutput = {
|
|
15
|
+
type: "object", additionalProperties: false,
|
|
16
|
+
properties: {
|
|
17
|
+
protocol_version: { type: "string", const: "2" }, view: { type: "string", const: "source_search" },
|
|
18
|
+
parsed_request: { type: "object" }, matches: { type: "array", items: { type: "object", additionalProperties: true } },
|
|
19
|
+
apiosk_sources_searched: { type: "integer", minimum: 0 }, catalog_version: { type: "string" }, notice: { type: "string" }, ...errorFields,
|
|
20
|
+
},
|
|
21
|
+
anyOf: [{ required: ["protocol_version", "view", "parsed_request", "matches", "notice"] }, { required: ["error_code", "message"] }],
|
|
22
|
+
};
|
|
23
|
+
return [
|
|
24
|
+
{ name: "apiosk_search", title: "Search sources for a capability object",
|
|
25
|
+
description: "Search Apiosk's sources the way the Ask page does, with a capability object you fill in yourself (parsed_request, following the host instructions' parser rules). Slugs are English dot paths of letters and digits, such as location.time.current. Returns, per capability, the Ask page's ranked sources; each runnable candidate carries endpoint.inputs: the exact input keys, whether each is required, and its value schema for apiosk_prepare. Free: creates no task and buys nothing.",
|
|
26
|
+
inputSchema: schemas.search, outputSchema: searchOutput, annotations: { readOnlyHint: true, destructiveHint: false, openWorldHint: false } },
|
|
27
|
+
{ name: "apiosk_prepare", title: "Prepare one source",
|
|
28
|
+
description: "Prepare one runnable apiosk_search candidate: pass its endpoint_id, capability and an input object whose keys are that candidate's endpoint.inputs fields, filled only from the person's words or earlier results, never placeholders. Returns the same task, price ceiling and approval card as apiosk_discover; nothing is bought until the person approves. Continue with apiosk_execute and apiosk_status.",
|
|
29
|
+
inputSchema: schemas.prepare, outputSchema: taskOutput, annotations: { readOnlyHint: false, destructiveHint: false, openWorldHint: false } },
|
|
30
|
+
];
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/** The gateway request for an Ask tool: its exact wire body, nothing extra. */
|
|
34
|
+
export function askRequest(name, args) {
|
|
35
|
+
if (name === "apiosk_search") {
|
|
36
|
+
// The gateway records who parsed the object; a chatbot parse has no model hash.
|
|
37
|
+
const meta = { model: "connected-chatbot", parsed_at: new Date().toISOString(), text_sha256: "" };
|
|
38
|
+
return { path: "/v2/ask-v2/search", body: { parsed_request: { ...args.parsed_request, meta }, origin: "apiosk" } };
|
|
39
|
+
}
|
|
40
|
+
return { path: "/v2/ask-v2/prepare", body: { request_id: args.request_id || randomUUID(), endpoint_id: args.endpoint_id,
|
|
41
|
+
...(args.capability && { capability: args.capability }), input: args.input } };
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/** The card's source search view (`view: "source_search"`). */
|
|
45
|
+
export function presentSearch(result, parsed) {
|
|
46
|
+
if (!Array.isArray(result?.matches)) throw new Error("Unexpected protocol");
|
|
47
|
+
return { protocol_version: "2", view: "source_search", parsed_request: parsed, matches: result.matches,
|
|
48
|
+
...(Number.isInteger(result.apiosk_sources_searched) && { apiosk_sources_searched: result.apiosk_sources_searched }),
|
|
49
|
+
...(typeof result.catalog_version === "string" && { catalog_version: result.catalog_version }), notice: SEARCH_NOTICE };
|
|
50
|
+
}
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
export const V2_ACCOUNT_MARKUP = `<nav class="account-links hidden" id="account-shortcuts" aria-label="Apiosk account shortcuts"><button type="button" id="balance-shortcut">Balance</button><button type="button" id="history-shortcut">History</button></nav>`;
|
|
2
|
+
|
|
3
|
+
// Workspace scoping and destinations remain Gateway output, never client input.
|
|
4
|
+
// Identity is deliberately not repeated in the embedded request header.
|
|
5
|
+
// Links are limited to the App origin that owns the plan approval and to the
|
|
6
|
+
// two exact read/manage destinations rendered by this card.
|
|
7
|
+
export const V2_CARD_ACCOUNT = `
|
|
8
|
+
function dataApprovalOrigin(){try{return new URL(output?.proposal?.approval_url||'https://app.apiosk.com').origin}catch{return 'https://app.apiosk.com'}}
|
|
9
|
+
function trustedShortcut(value,path){try{const url=new URL(value),approval=dataApprovalOrigin();return url.protocol==='https:'&&url.pathname===path&&!url.username&&!url.password&&(!approval||url.origin===approval)?url.href:null}catch{return null}}
|
|
10
|
+
function renderAccount(data){
|
|
11
|
+
const bar=byId('account-shortcuts'),workspace=data.context_view?.workspace||data.billing?.workspace;
|
|
12
|
+
if(!workspace?.workspace_id||workspace.kind==='personal'){
|
|
13
|
+
for(const id of ['balance-shortcut','history-shortcut']){const button=byId(id);button.classList.add('hidden');button.onclick=null}
|
|
14
|
+
bar.classList.add('hidden');return
|
|
15
|
+
}
|
|
16
|
+
const links=data.context_view?.navigation||{},balance=trustedShortcut(links.balance_url,'/settings/billing'),history=trustedShortcut(links.history_url,'/usage/history');
|
|
17
|
+
for(const [id,url] of [['balance-shortcut',balance],['history-shortcut',history]]){
|
|
18
|
+
const button=byId(id);button.classList.toggle('hidden',!url);
|
|
19
|
+
button.onclick=url?async()=>{if(!await window.apiosk.openLink(url))showFeedback('Open Apiosk to view this account.','error')}:null
|
|
20
|
+
}
|
|
21
|
+
bar.classList.toggle('hidden',!balance&&!history)
|
|
22
|
+
}
|
|
23
|
+
`;
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
import { approvalFeedback } from './approval-feedback.mjs';
|
|
2
|
+
import { V2_CARD_BUDGET } from './gateway-v2-card-budget.mjs';
|
|
3
|
+
// Runs inside the card: observe consent, then use only gateway-issued actions.
|
|
4
|
+
export const V2_CARD_ACTIONS = `
|
|
5
|
+
let approvalExpiryTimer=null;
|
|
6
|
+
const quoteExpired=data=>Date.parse(data.proposal?.expires_at)<=Date.now();
|
|
7
|
+
${approvalFeedback.toString()}
|
|
8
|
+
const responseMessage=(data,fallback)=>approvalFeedback(data,money,fallback);
|
|
9
|
+
${V2_CARD_BUDGET}
|
|
10
|
+
async function refreshPrice(data,button){
|
|
11
|
+
if(busy||output!==data)return;
|
|
12
|
+
busy=true;button.disabled=true;showFeedback('Refreshing the price. No purchase is being made…');
|
|
13
|
+
try{
|
|
14
|
+
const fresh=await window.apiosk.callTool('apiosk_status',{task_ref:data.state.state_ref});
|
|
15
|
+
if(!fresh?.state||fresh.state.state_ref!==data.state.state_ref)throw new Error('Could not recover this request. Check status and try again.');
|
|
16
|
+
if(!quoteExpired(fresh)||fresh.billing?.authorization_active||(fresh.billing?.executions||[]).length){acceptResponse(fresh);return}
|
|
17
|
+
const question=fresh.context_view?.conversation?.at(-1)?.question;
|
|
18
|
+
if(!question)throw new Error('The original question is unavailable. Ask the question again to get a new price.');
|
|
19
|
+
const next=await window.apiosk.callTool('apiosk_discover',{question,state:fresh.state});
|
|
20
|
+
acceptResponse(next);
|
|
21
|
+
if(next?.proposal&&!quoteExpired(next))showFeedback('Price refreshed. Review the plan and click Approve to continue.');
|
|
22
|
+
}catch(e){showFeedback(e&&e.message||'Could not refresh the price. Check status and try again.','error')}
|
|
23
|
+
finally{busy=false;button.disabled=false}
|
|
24
|
+
}
|
|
25
|
+
async function refreshTask(publish=true){if(busy||!output?.state?.state_ref)return;const before=output;if(publish)busy=true;try{const next=await window.apiosk.callTool('apiosk_status',{task_ref:before.state.state_ref});if(!next?.state||next.state.state_ref!==before.state.state_ref)throw new Error(next?.message||'Could not recover this request. Use Check status to try again.');if(publish)acceptResponse(next);else if(!busy&&output===before&&next?.state?.state_ref===before.state.state_ref&&Number(next.state.revision)>=Number(before.state.revision)){render(next);void window.apiosk.context(next).catch(()=>{})}return true}catch(e){if(publish)showFeedback(e&&e.message||'Could not refresh this request. Use Check status to try again.','error')}finally{if(publish)busy=false}}
|
|
26
|
+
async function approvePlan(data,button){
|
|
27
|
+
if(busy)return;
|
|
28
|
+
if(output!==data){showFeedback('This request was updated. Use the current plan below.','error');return}
|
|
29
|
+
if(quoteExpired(data)){render(data);showFeedback('This quote expired. Click Get new price, then review and approve the refreshed plan.','error');return}
|
|
30
|
+
busy=true;button.disabled=true;showFeedback('Saving your approval…');let refusal=null;
|
|
31
|
+
try{
|
|
32
|
+
const next=await window.apiosk.callTool('apiosk_approve',{state:data.state,quote_ref:data.proposal.quote_ref,max_total_atomic:data.proposal.max_total_atomic});
|
|
33
|
+
if(!next?.state||next.state.state_ref!==data.state.state_ref||!next.billing?.authorization_active||next.billing.quote_ref!==data.proposal.quote_ref||next.proposal?.max_total_atomic!==data.proposal.max_total_atomic){refusal=next;throw new Error(responseMessage(next,'Approval was not confirmed. Check status before continuing.'))}
|
|
34
|
+
watchUntil=Date.now()+300000;acceptResponse(next);
|
|
35
|
+
}catch(e){
|
|
36
|
+
watchUntil=0;showFeedback(e&&e.message||'Approval could not be confirmed. Check status before continuing.','error');renderApprovalRecovery(refusal,data);
|
|
37
|
+
}finally{busy=false;button.disabled=false}
|
|
38
|
+
}
|
|
39
|
+
function renderActions(data){
|
|
40
|
+
if(approvalExpiryTimer){clearTimeout(approvalExpiryTimer);approvalExpiryTimer=null}
|
|
41
|
+
const server=data.context_view?.execution_mode==='server';
|
|
42
|
+
const actions=data.next_actions||[],run=actions.find(a=>a.kind==='execute_quoted_step')||(server&&!data.context_view?.worker_active?actions.find(a=>a.kind==='poll'):null),poll=actions.find(a=>a.kind==='poll'),read=data.result||data.context_view?.results?.length?[]:actions.filter(a=>a.kind==='read_result'),cancel=actions.find(a=>a.kind==='cancel');
|
|
43
|
+
const approved=data.billing&&data.billing.authorization_active&&data.billing.quote_ref===(data.proposal&&data.proposal.quote_ref),enabled=data.context_view?.execution_enabled!==false;
|
|
44
|
+
if(run&&planSurface){
|
|
45
|
+
const wrap=el('div','actions plan-actions');
|
|
46
|
+
if(!approved&&enabled&&data.proposal?.approval_url){
|
|
47
|
+
const b=el('button','primary','Approve up to '+money(data.proposal.max_total_atomic,data.proposal.currency,true));
|
|
48
|
+
if(quoteExpired(data)){b.disabled=true;b.textContent='Quote expired — request a new price';const renew=el('button','primary','Get new price');renew.onclick=()=>refreshPrice(data,renew);wrap.append(renew)}
|
|
49
|
+
else{const remaining=Date.parse(data.proposal.expires_at)-Date.now();if(Number.isFinite(remaining)&&remaining>=0)approvalExpiryTimer=setTimeout(()=>{approvalExpiryTimer=null;if(output===data&&!busy){render(data);showFeedback('This quote expired. Get a new price to continue.')}},Math.min(remaining+1,2147483647))}
|
|
50
|
+
b.onclick=data.context_view?.approval_mode==='chatbot'?()=>approvePlan(data,b):async()=>{if(busy)return;watchUntil=Date.now()+300000;const opened=await window.apiosk.openLink(data.proposal.approval_url);showFeedback(opened?'Approve in Apiosk. This card will continue automatically.':'Open Apiosk to approve this request.',opened?'':'error');if(opened)await refreshTask()};wrap.append(b);
|
|
51
|
+
}else if(approved&&enabled&&server){
|
|
52
|
+
if(!attempted.has(run.action_id)){attempted.add(run.action_id);pollTimer=setTimeout(()=>{pollTimer=null;callAction(run)},0)}else{wrap.append(actionButton(run,'Resume request',true))}
|
|
53
|
+
}else if(approved&&enabled&&Date.now()<watchUntil){
|
|
54
|
+
if(attempted.has(run.action_id)||(data.errors||[]).length)wrap.append(actionButton(run,'Retry approved step',true));else wrap.append(el('span','notice','Approved. Continuing your request…'));
|
|
55
|
+
if(!attempted.has(run.action_id)&&!(data.errors||[]).length){pollTimer=setTimeout(()=>{pollTimer=null;if(busy)return;attempted.add(run.action_id);callAction(run)},350)}
|
|
56
|
+
}else if(approved&&enabled){
|
|
57
|
+
const b=el('button','primary','Continue approved request');b.onclick=()=>{watchUntil=Date.now()+300000;return refreshTask()};wrap.append(b);
|
|
58
|
+
}
|
|
59
|
+
if(data.state?.state_ref&&!server){const refresh=el('button','quiet','Check status');refresh.onclick=()=>{watchUntil=Date.now()+300000;return refreshTask()};wrap.append(refresh)}
|
|
60
|
+
if(wrap.children.length)planSurface.append(wrap);
|
|
61
|
+
if(!approved&&enabled&&Date.now()<watchUntil&&Date.parse(data.proposal?.expires_at)>Date.now())pollTimer=setTimeout(()=>{pollTimer=null;refreshTask()},2000);
|
|
62
|
+
}
|
|
63
|
+
if(poll&&!server){const wait=Math.max(350,Number(data.retry_after_ms)||900);pollTimer=setTimeout(()=>{pollTimer=null;callAction(poll)},wait)}
|
|
64
|
+
if(!(data.status==='succeeded'&&!data.context_view?.worker_active&&!(data.errors||[]).length)&&(data.state?.state_ref||read.length||poll||(!server&&cancel))){const s=el('section','section controls'),wrap=el('div','actions');sections.append(s);if(data.state?.state_ref){const check=el('button','quiet','Check status');check.onclick=()=>refreshTask();wrap.append(check)}if(read.length){const view=el('button','quiet','View saved result');view.onclick=()=>refreshTask();wrap.append(view)}if(cancel&&(run||poll||data.context_view?.worker_active))wrap.append(actionButton(cancel,'Cancel request'));s.append(wrap)}
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
`;
|
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
import { formatDisplayText, formatDisplayNarrative, isLiteralField } from './display-text.mjs';
|
|
2
|
+
import { sourceCurrency } from './source-value-format.mjs';
|
|
3
|
+
import { readBlocks } from './gateway-v2-card-body.mjs';
|
|
4
|
+
// The App's answer text (app/src/lib/domain/gateway-v2-answer.ts and
|
|
5
|
+
// answer-format.ts): every observation, period headings, source notes, every
|
|
6
|
+
// specific limitation and the follow-up question. Display only; the saved
|
|
7
|
+
// analysis and its evidence stay unchanged. Keep in sync with the App.
|
|
8
|
+
const ANSWER_GENERIC_LIMITATIONS = new Set([
|
|
9
|
+
'Some requested source calls did not return usable data. This analysis is incomplete.',
|
|
10
|
+
'Some source information is unavailable. The answer uses the available results.',
|
|
11
|
+
'The returned source response contains no usable records for this question. No finding can be supported from it.',
|
|
12
|
+
]);
|
|
13
|
+
const ANSWER_PERIOD_KEYS = ['fiscal_year', 'financial_year', 'year', 'fiscalYear', 'financialYear', 'period_end', 'periodEnd', 'fiscal_year_end', 'financialYearEnd', 'reporting_year', 'date_fin_exercice'];
|
|
14
|
+
|
|
15
|
+
function answerRecord(value) { return value && typeof value === 'object' && !Array.isArray(value) ? value : {}; }
|
|
16
|
+
export function answerLocale(question, analysis) {
|
|
17
|
+
const text = (analysis?.observations || []).map(o => o?.text || '').join(' ') || question || '';
|
|
18
|
+
const dutch = text.match(/\b(de|het|een|zijn|voor|van|heeft|bedraagt|jaarrekening|omzet|winst|volgens|toont|werden)\b/gi) ?? [];
|
|
19
|
+
return dutch.length >= 2 ? 'nl-NL' : 'en-US';
|
|
20
|
+
}
|
|
21
|
+
/** The App's formatSourceValue, with its locale: Dutch groups with dots. */
|
|
22
|
+
export function answerValue(value, key = '', currency = null, locale = 'en-US') {
|
|
23
|
+
const raw = String(value ?? '');
|
|
24
|
+
if (isLiteralField(key)) return raw;
|
|
25
|
+
if (typeof value === 'string') value = formatDisplayText(value, key);
|
|
26
|
+
const label = String(key).split('/').at(-1).replace(/[\s_.-]/g, '');
|
|
27
|
+
if (/year|date|code|identifier|kvk|postcode|postal|phone|iban/i.test(label) || /(?:^id$|Id$|ID$|Number$|Nummer$)/.test(label)) return raw;
|
|
28
|
+
if (!/^-?(?:0|[1-9]\d*)(?:\.\d+)?$/.test(raw)) return formatDisplayNarrative(String(value ?? ''));
|
|
29
|
+
const negative = raw.startsWith('-'), unsigned = negative ? raw.slice(1) : raw, [whole, fraction] = unsigned.split('.'), dutch = locale.startsWith('nl');
|
|
30
|
+
const grouped = whole.replace(/\B(?=(\d{3})+(?!\d))/g, dutch ? '.' : ',') + (fraction == null ? '' : (dutch ? ',' : '.') + fraction);
|
|
31
|
+
const monetary = /^(?:(?:total|net|gross))?(Assets|Liabilities|Equity|Receivables|Cash|Property|Provisions|Share|RetainedEarnings|FinancialIncome|Income|Result|Depreciation|EmployeeBenefits|Operating|GrossMargin|Impairment|SumOfExpenses|Revenue|Profit|Turnover|Amount|Balance|Price|Cost|Tax|Bedrag|Omzet|Winst|Kosten)/i.test(label) && !/ratio|percent|rate|count|margin|sharesoutstanding|sharesissued/i.test(label);
|
|
32
|
+
const code = monetary ? sourceCurrency(currency) : null, symbol = code && ({ EUR: '€', USD: '$', GBP: '£' }[code] || code);
|
|
33
|
+
return (negative ? '-' : '') + (symbol ? symbol + ' ' : '') + grouped;
|
|
34
|
+
}
|
|
35
|
+
/** Units and periods next to the exact cited field, never from another result. */
|
|
36
|
+
export function answerValueContext(evidence, results) {
|
|
37
|
+
const pointer = String(evidence?.pointer || ''), doc = (results || []).map(answerRecord).find(d => d.result_ref === evidence?.result_ref), parents = [];
|
|
38
|
+
let node = doc && { ...doc, data: doc.data ?? doc.preview_data };
|
|
39
|
+
for (const part of pointer.split('/').slice(1)) {
|
|
40
|
+
if (!Array.isArray(node)) parents.unshift(answerRecord(node));
|
|
41
|
+
node = node != null && typeof node === 'object' ? node[part.replaceAll('~1', '/').replaceAll('~0', '~')] : undefined;
|
|
42
|
+
}
|
|
43
|
+
const currencyOwner = parents.find(p => p.currency != null || p.currencyCode != null || p.unit != null);
|
|
44
|
+
const period = parents.flatMap(p => ANSWER_PERIOD_KEYS.map(k => p[k])).find(v => /^(?:19|20)\d{2}(?:-\d{2}-\d{2})?$/.test(String(v)));
|
|
45
|
+
return { currency: sourceCurrency(currencyOwner), period: period == null ? null : String(period), field: String(parents[0]?.key ?? pointer.split('/').at(-1) ?? '') };
|
|
46
|
+
}
|
|
47
|
+
export function formatAnswerObservation(observation, results, locale) {
|
|
48
|
+
const values = new Map(), evidence = Array.isArray(observation.evidence) ? observation.evidence : [];
|
|
49
|
+
for (const item of evidence) {
|
|
50
|
+
const raw = String(item?.value);
|
|
51
|
+
if (!/^-?(?:0|[1-9]\d*)(?:\.\d+)?$/.test(raw)) continue;
|
|
52
|
+
const context = answerValueContext(item, results), field = item.field ?? context.field, pointer = String(item.pointer || ''), parent = pointer.slice(0, pointer.lastIndexOf('/'));
|
|
53
|
+
const currency = context.currency ?? sourceCurrency(evidence.find(e => e?.result_ref === item.result_ref && e.pointer === parent + '/currency')?.value);
|
|
54
|
+
const display = answerValue(item.value, field, currency, locale), previous = values.get(raw);
|
|
55
|
+
values.set(raw, values.has(raw) && previous?.display !== display ? null : { display, currency });
|
|
56
|
+
}
|
|
57
|
+
const codes = 'EUR|USD|GBP|CHF|JPY|CAD|AUD|SEK|NOK|DKK';
|
|
58
|
+
const pattern = new RegExp(`(?<![\\p{L}\\p{N}_/@.:$€£+\\-])(?:(${codes}|[$€£])\\s*)?(-?(?:0|[1-9]\\d*)(?:\\.\\d+)?)(?:\\s*(${codes})(?!\\p{L}))?(?![\\p{L}\\p{N}_/@]|[.:,\\-]\\d)`, 'gu');
|
|
59
|
+
return String(observation.text).replace(pattern, (match, prefix, raw, suffix) => {
|
|
60
|
+
const value = values.get(raw);
|
|
61
|
+
if (!value) return match;
|
|
62
|
+
const explicit = prefix ?? suffix;
|
|
63
|
+
if (!explicit) return value.display;
|
|
64
|
+
const code = { '$': 'USD', '€': 'EUR', '£': 'GBP' }[explicit] ?? explicit;
|
|
65
|
+
return value.currency && value.currency !== code ? match : answerValue(raw, 'Amount', code, locale);
|
|
66
|
+
});
|
|
67
|
+
}
|
|
68
|
+
/** A heading may name only a period this observation actually cites. */
|
|
69
|
+
export function observationPeriod(observation, results) {
|
|
70
|
+
const periods = (observation.evidence || []).flatMap(e => {
|
|
71
|
+
const key = String(e?.field ?? e?.pointer ?? '').split('/').at(-1) ?? '';
|
|
72
|
+
if (ANSWER_PERIOD_KEYS.some(k => k.toLowerCase() === key.toLowerCase()) && /^(?:19|20)\d{2}(?:-\d{2}-\d{2})?$/.test(String(e.value))) return [String(e.value)];
|
|
73
|
+
const context = answerValueContext(e, results);
|
|
74
|
+
return context.period ? [context.period] : [];
|
|
75
|
+
});
|
|
76
|
+
const unique = [...new Set(periods)];
|
|
77
|
+
return unique.length === 1 ? unique[0] : null;
|
|
78
|
+
}
|
|
79
|
+
export function answerText(analysis, context) {
|
|
80
|
+
if (!analysis) return '';
|
|
81
|
+
const question = context?.question || '', results = context?.results || [], locale = answerLocale(question, analysis), nl = locale === 'nl-NL';
|
|
82
|
+
const observations = (analysis.observations || []).filter(o => typeof o?.text === 'string' && o.text.trim());
|
|
83
|
+
const limitations = (analysis.limitations || []).filter(text => typeof text === 'string' && text.trim() && !ANSWER_GENERIC_LIMITATIONS.has(text));
|
|
84
|
+
const structured = observations.length > 1 && !/\b(one sentence|single sentence|één zin|een zin)\b/i.test(question);
|
|
85
|
+
const blocks = [];
|
|
86
|
+
let previousHeading = null;
|
|
87
|
+
for (const observation of observations) {
|
|
88
|
+
if (structured) {
|
|
89
|
+
const period = observationPeriod(observation, results);
|
|
90
|
+
const heading = period ? (nl ? 'Verslagperiode ' : 'Reporting period ') + period : nl ? 'Belangrijkste bevindingen' : 'Key findings';
|
|
91
|
+
if (heading !== previousHeading) blocks.push('## ' + heading);
|
|
92
|
+
previousHeading = heading;
|
|
93
|
+
}
|
|
94
|
+
blocks.push(formatAnswerObservation(observation, results, locale));
|
|
95
|
+
}
|
|
96
|
+
if (limitations.length) {
|
|
97
|
+
if (observations.length) blocks.push('## ' + (nl ? 'Bronnen en beperkingen' : 'Source notes'));
|
|
98
|
+
blocks.push(...new Set(limitations));
|
|
99
|
+
}
|
|
100
|
+
if (analysis.follow_up_question) blocks.push(analysis.follow_up_question);
|
|
101
|
+
return blocks.length ? blocks.join('\n\n') : [...new Set((analysis.limitations || []).filter(text => typeof text === 'string'))].join('\n\n');
|
|
102
|
+
}
|
|
103
|
+
export function answerDocumentRequested(analysis, question) {
|
|
104
|
+
return analysis?.presentation?.kind === 'document' || /\b(document|pdf|rapport|report|dossier)\b/i.test(question || '');
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
// ---- DOM, run inside the card only --------------------------------------------
|
|
108
|
+
function answerInline(node, value) {
|
|
109
|
+
const parts = String(value).split(/(-?(?:[$€£]|USD|EUR|GBP|CHF|JPY|CAD|AUD|SEK|NOK|DKK)\s-?\d[\d.,]*)/g);
|
|
110
|
+
if (parts.length === 1) { node.textContent = value; return node; }
|
|
111
|
+
parts.forEach((part, index) => { if (part) node.append(el(index % 2 ? 'strong' : 'span', index % 2 ? 'answer-amount' : '', part)); });
|
|
112
|
+
return node;
|
|
113
|
+
}
|
|
114
|
+
/** The App's StreamingReply, settled: headings, paragraphs and lists. */
|
|
115
|
+
function renderAnswerText(value, nameContext) {
|
|
116
|
+
const wrap = el('div', 'answer-text');
|
|
117
|
+
for (const block of readBlocks(formatDisplayNarrative(value, nameContext))) {
|
|
118
|
+
if (block.kind === 'heading') { wrap.append(el('h3', 'answer-heading', block.text)); continue; }
|
|
119
|
+
if (block.kind === 'paragraph') { wrap.append(answerInline(el('p'), block.text)); continue; }
|
|
120
|
+
const list = el(block.kind === 'numbers' ? 'ol' : 'ul');
|
|
121
|
+
for (const item of block.items) list.append(answerInline(el('li'), item));
|
|
122
|
+
wrap.append(list);
|
|
123
|
+
}
|
|
124
|
+
return wrap;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
export const V2_CARD_ANSWER_TEXT = [
|
|
128
|
+
`const ANSWER_GENERIC_LIMITATIONS=new Set(${JSON.stringify([...ANSWER_GENERIC_LIMITATIONS])}),ANSWER_PERIOD_KEYS=${JSON.stringify(ANSWER_PERIOD_KEYS)};`,
|
|
129
|
+
answerRecord, answerLocale, answerValue, answerValueContext, formatAnswerObservation, observationPeriod, answerText, answerDocumentRequested, answerInline, renderAnswerText,
|
|
130
|
+
].map(value => typeof value === 'function' ? value.toString() : value).join('\n');
|