@aria-framework/ask 0.0.0-stage → 0.1.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/CHANGELOG.md +61 -0
- package/README.md +144 -2
- package/assets/ask.css +81 -0
- package/assets/ask.js +430 -0
- package/bin/ask-migration.js +52 -0
- package/index.js +116 -0
- package/lib/bank.js +63 -0
- package/lib/bench.js +410 -0
- package/lib/catalogue.js +230 -0
- package/lib/check.js +349 -0
- package/lib/common.js +31 -0
- package/lib/docs.js +306 -0
- package/lib/gate.js +44 -0
- package/lib/history.js +32 -0
- package/lib/limits.js +56 -0
- package/lib/prompts.js +95 -0
- package/lib/retention.js +48 -0
- package/lib/routes.js +304 -0
- package/lib/run.js +266 -0
- package/lib/schema.js +396 -0
- package/lib/settings.js +142 -0
- package/lib/store.js +218 -0
- package/lib/stream.js +58 -0
- package/package.json +41 -4
- package/views/ask/admin.ejs +153 -0
- package/views/ask/drawer.ejs +59 -0
- package/views/ask/review.ejs +90 -0
package/lib/catalogue.js
ADDED
|
@@ -0,0 +1,230 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The Ask tool catalogue — THE list of look-ups the AI may plan, and the planner's contract derived from it.
|
|
3
|
+
*
|
|
4
|
+
* Extracted from the hub's and soc's lib/ask/catalogue.js (2 Nov 2026). The STRICTER of the two in each place:
|
|
5
|
+
* - `mayUse` needs EVERY permission of a tool's list, each answer exactly `true` (Soc review B1: one permission of two
|
|
6
|
+
* let a page's content through). A tool may declare `permission` (one) or `permissions` (a list); the GATE normalises
|
|
7
|
+
* the first to `[permission]`, the HASH serialises what the tool declared (see `catalogueHash`).
|
|
8
|
+
* - `validatePlan` names the index of an unoffered call, so the loop can describe THAT call (Soc review nit).
|
|
9
|
+
*
|
|
10
|
+
* WHAT THE PLANNER IS OFFERED IS DERIVED, PER PERSON, PER REQUEST (`catalogueFor`): every tool whose page permissions the
|
|
11
|
+
* request holds (`can`, the request's own) and that the platform has not switched off. A tool the person lacks is NOT IN
|
|
12
|
+
* THE SCHEMA, so it cannot be emitted, and a call to it that arrives anyway is refused by `validatePlan` — never run.
|
|
13
|
+
*
|
|
14
|
+
* THE SCHEMA IS STRICT AND CLOSED (`plannerSchema`): `intent` is lookup | write | none; `calls` is at most MAX_CALLS of
|
|
15
|
+
* `{tool, args}` with exactly that tool's declared arguments (nullable where optional), enums for every closed value and a
|
|
16
|
+
* length bound on every string. THE REPLY IS VALIDATED AGAIN (`validatePlan`), whatever the provider claims.
|
|
17
|
+
*
|
|
18
|
+
* REMOTE TOOLS (D-0169 item 8, interface only): a tool may be declared `{ id, kind: 'remote', service, ... }` — another
|
|
19
|
+
* app's own Ask, answering for the same person. 0.1.0 VALIDATES the shape and NEVER OFFERS it (`catalogueFor` leaves it
|
|
20
|
+
* out), so nothing reaches another app until the SSO plan builds the call. Its results, when they exist, are attacker
|
|
21
|
+
* text throughout (check.js reads an unknown or remote tool's every path that way).
|
|
22
|
+
*/
|
|
23
|
+
|
|
24
|
+
'use strict';
|
|
25
|
+
|
|
26
|
+
const crypto = require('crypto');
|
|
27
|
+
|
|
28
|
+
const MAX_CALLS = 4;
|
|
29
|
+
const INTENTS = ['lookup', 'write', 'none'];
|
|
30
|
+
const ID = /^[a-z][a-z0-9]*(?:[._-][a-z0-9]+)*$/;
|
|
31
|
+
|
|
32
|
+
/** The permissions a tool's page is gated by, as a list — `permission: 'x'` is `['x']`. */
|
|
33
|
+
function gateOf(tool) {
|
|
34
|
+
if (Array.isArray(tool.permissions)) return tool.permissions;
|
|
35
|
+
if (typeof tool.permission === 'string') return [tool.permission];
|
|
36
|
+
return [];
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Is this tool shaped as the loop needs? Throws a sentence naming the tool and what is wrong. Run on every tool when the
|
|
41
|
+
* catalogue is built, so a malformed tool stops the boot instead of failing a question.
|
|
42
|
+
*/
|
|
43
|
+
function assertToolShape(tool) {
|
|
44
|
+
const fail = (why) => { throw new Error(`ask tool ${tool && tool.id ? JSON.stringify(tool.id) : '(no id)'}: ${why}`); };
|
|
45
|
+
if (!tool || typeof tool !== 'object') fail('is not an object');
|
|
46
|
+
if (typeof tool.id !== 'string' || !ID.test(tool.id) || tool.id.length > 64) fail('id is not a lower-case dotted name of at most 64 characters');
|
|
47
|
+
if (tool.permission !== undefined && tool.permissions !== undefined) fail('declares both permission and permissions — declare one');
|
|
48
|
+
const gate = gateOf(tool);
|
|
49
|
+
if (!gate.length || gate.some((p) => typeof p !== 'string' || !p)) fail('declares no permission (an empty gate is no gate)');
|
|
50
|
+
if (typeof tool.label !== 'string' || !tool.label) fail('has no label');
|
|
51
|
+
if (typeof tool.description !== 'string' || !tool.description) fail('has no description');
|
|
52
|
+
if (!tool.args || typeof tool.args !== 'object' || Array.isArray(tool.args)) fail('has no args object');
|
|
53
|
+
for (const [k, spec] of Object.entries(tool.args)) {
|
|
54
|
+
if (!/^[a-z][a-z0-9_]{0,39}$/.test(k)) fail(`argument ${k} is not a lower-case name`);
|
|
55
|
+
if (!spec || typeof spec !== 'object') fail(`argument ${k} has no spec`);
|
|
56
|
+
if (spec.type === 'enum') {
|
|
57
|
+
if (!Array.isArray(spec.values) || !spec.values.length || spec.values.some((v) => typeof v !== 'string')) fail(`argument ${k}: an enum needs a list of string values`);
|
|
58
|
+
} else if (!(Number.isInteger(spec.max) && spec.max > 0 && spec.max <= 500)) fail(`argument ${k}: a text argument needs a max length (1–500)`);
|
|
59
|
+
if (typeof spec.describe !== 'string' || !spec.describe) fail(`argument ${k} has no describe text`);
|
|
60
|
+
}
|
|
61
|
+
if (!Array.isArray(tool.attackerFields)) fail('has no attackerFields list (an empty list must be said)');
|
|
62
|
+
if (typeof tool.describe !== 'function') fail('has no describe(args)');
|
|
63
|
+
if (tool.kind === 'remote') {
|
|
64
|
+
if (typeof tool.service !== 'string' || !tool.service) fail('a remote tool names its service');
|
|
65
|
+
} else if (typeof tool.run !== 'function') fail('has no run(ctx, args)');
|
|
66
|
+
return tool;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* Does this request hold EVERY permission the tool's page is gated by? All-of, each answer exactly `true`; an empty list
|
|
71
|
+
* is no gate and is refused.
|
|
72
|
+
*/
|
|
73
|
+
function mayUse(can, tool) {
|
|
74
|
+
const gate = tool ? gateOf(tool) : [];
|
|
75
|
+
return typeof can === 'function' && gate.length > 0 && gate.every((p) => can(p) === true);
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
function argSchema(spec) {
|
|
79
|
+
if (spec.type === 'enum') {
|
|
80
|
+
return spec.nullable ? { type: ['string', 'null'], enum: [...spec.values, null] } : { type: 'string', enum: [...spec.values] };
|
|
81
|
+
}
|
|
82
|
+
const s = { type: spec.nullable ? ['string', 'null'] : 'string', maxLength: spec.max };
|
|
83
|
+
if (!spec.nullable) s.minLength = 1;
|
|
84
|
+
return s;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/** The strict JSON schema the planner's reply must fit, for exactly these tools. */
|
|
88
|
+
function plannerSchema(tools) {
|
|
89
|
+
const variants = tools.map((t) => ({
|
|
90
|
+
type: 'object',
|
|
91
|
+
additionalProperties: false,
|
|
92
|
+
required: ['tool', 'args'],
|
|
93
|
+
properties: {
|
|
94
|
+
tool: { type: 'string', enum: [t.id] },
|
|
95
|
+
args: {
|
|
96
|
+
type: 'object',
|
|
97
|
+
additionalProperties: false,
|
|
98
|
+
required: Object.keys(t.args),
|
|
99
|
+
properties: Object.fromEntries(Object.entries(t.args).map(([k, spec]) => [k, argSchema(spec)]))
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
}));
|
|
103
|
+
return {
|
|
104
|
+
name: 'ask_plan',
|
|
105
|
+
schema: {
|
|
106
|
+
type: 'object',
|
|
107
|
+
additionalProperties: false,
|
|
108
|
+
required: ['intent', 'calls'],
|
|
109
|
+
properties: {
|
|
110
|
+
intent: { type: 'string', enum: INTENTS },
|
|
111
|
+
calls: variants.length
|
|
112
|
+
? { type: 'array', maxItems: MAX_CALLS, items: variants.length === 1 ? variants[0] : { anyOf: variants } }
|
|
113
|
+
: { type: 'array', maxItems: 0 }
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
};
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
const isPlain = (o) => o !== null && typeof o === 'object' && !Array.isArray(o);
|
|
120
|
+
|
|
121
|
+
/** One argument against its spec: the value (null where nullable and absent/null), or throws a reason. */
|
|
122
|
+
function checkArg(name, spec, value) {
|
|
123
|
+
if (value === undefined || value === null) {
|
|
124
|
+
if (spec.nullable) return null;
|
|
125
|
+
throw new Error(`${name} is required`);
|
|
126
|
+
}
|
|
127
|
+
if (typeof value !== 'string') throw new Error(`${name} must be text`);
|
|
128
|
+
if (spec.type === 'enum') {
|
|
129
|
+
if (!spec.values.includes(value)) throw new Error(`${name} is not one of its values`);
|
|
130
|
+
return value;
|
|
131
|
+
}
|
|
132
|
+
if (/[\u0000-\u001f\u007f]/.test(value)) throw new Error(`${name} has control characters`);
|
|
133
|
+
const v = value.trim();
|
|
134
|
+
if (!v) { if (spec.nullable) return null; throw new Error(`${name} is empty`); }
|
|
135
|
+
if (v.length > spec.max) throw new Error(`${name} is too long`);
|
|
136
|
+
return v;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
/**
|
|
140
|
+
* The planner's reply, validated against the tools THIS request may use. Returns { intent, calls: [{tool, args}] } with
|
|
141
|
+
* every argument normalised, or { error, code?, index? } naming the first thing wrong — nothing from a refused reply runs.
|
|
142
|
+
* A `write` or `none` intent carries no calls. A duplicate call is dropped, not run twice.
|
|
143
|
+
*/
|
|
144
|
+
function validatePlan(reply, tools) {
|
|
145
|
+
if (!isPlain(reply)) return { error: 'the plan is not an object' };
|
|
146
|
+
const extra = Object.keys(reply).filter((k) => !['intent', 'calls'].includes(k));
|
|
147
|
+
if (extra.length) return { error: `the plan has unexpected fields (${extra.join(', ')})` };
|
|
148
|
+
if (!INTENTS.includes(reply.intent)) return { error: 'the plan has no valid intent' };
|
|
149
|
+
if (!Array.isArray(reply.calls)) return { error: 'the plan has no calls list' };
|
|
150
|
+
if (reply.intent !== 'lookup') {
|
|
151
|
+
return reply.calls.length ? { error: `a ${reply.intent} plan may not name look-ups` } : { intent: reply.intent, calls: [] };
|
|
152
|
+
}
|
|
153
|
+
if (!reply.calls.length) return { error: 'a look-up plan names no look-ups' };
|
|
154
|
+
if (reply.calls.length > MAX_CALLS) return { error: `the plan names more than ${MAX_CALLS} look-ups` };
|
|
155
|
+
const allowed = new Map(tools.map((t) => [t.id, t]));
|
|
156
|
+
const calls = [];
|
|
157
|
+
const seen = new Set();
|
|
158
|
+
for (const [i, c] of reply.calls.entries()) {
|
|
159
|
+
if (!isPlain(c)) return { error: `look-up ${i + 1} is not an object` };
|
|
160
|
+
const keys = Object.keys(c).filter((k) => !['tool', 'args'].includes(k));
|
|
161
|
+
if (keys.length) return { error: `look-up ${i + 1} has unexpected fields` };
|
|
162
|
+
const tool = allowed.get(c.tool);
|
|
163
|
+
// `unoffered` is told apart so the person hears the NOT-FOUND sentence: a tool they lack and a record that is absent
|
|
164
|
+
// must read the same.
|
|
165
|
+
if (!tool) return { error: `look-up ${i + 1} names a tool that is not offered`, code: 'unoffered', index: i };
|
|
166
|
+
if (!isPlain(c.args)) return { error: `look-up ${i + 1} has no arguments object` };
|
|
167
|
+
const unknown = Object.keys(c.args).filter((k) => !Object.prototype.hasOwnProperty.call(tool.args, k));
|
|
168
|
+
if (unknown.length) return { error: `look-up ${i + 1} has arguments its tool does not take` };
|
|
169
|
+
const args = {};
|
|
170
|
+
try {
|
|
171
|
+
for (const [name, spec] of Object.entries(tool.args)) args[name] = checkArg(name, spec, c.args[name]);
|
|
172
|
+
} catch (e) {
|
|
173
|
+
return { error: `look-up ${i + 1}: ${e.message}` };
|
|
174
|
+
}
|
|
175
|
+
const key = JSON.stringify([tool.id, args]);
|
|
176
|
+
if (seen.has(key)) continue;
|
|
177
|
+
seen.add(key);
|
|
178
|
+
calls.push({ tool: tool.id, args });
|
|
179
|
+
}
|
|
180
|
+
return { intent: 'lookup', calls };
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
/** What the planner reads about each tool: its id, what it does, and its arguments in words. */
|
|
184
|
+
function describeTools(tools) {
|
|
185
|
+
return tools.map((t) => {
|
|
186
|
+
const args = Object.entries(t.args).map(([k, spec]) => ` - ${k}: ${spec.describe}${spec.type === 'enum' ? ` (one of: ${spec.values.join(', ')}${spec.nullable ? ', or null' : ''})` : ''}`);
|
|
187
|
+
return `- ${t.id}: ${t.description}\n${args.join('\n')}`;
|
|
188
|
+
}).join('\n');
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
/**
|
|
192
|
+
* A fingerprint of the WHOLE catalogue, folded into the prompt hash: change a tool and the bench record no longer
|
|
193
|
+
* matches, so the route stays off until the bench passes again.
|
|
194
|
+
*
|
|
195
|
+
* BYTE-IDENTICAL TO BOTH APPS' COPIES: the hub serialised `{id, permission, description, args, attackerFields}`, Soc
|
|
196
|
+
* `{id, permissions, …}`. Both keys are written here in that order; JSON.stringify drops the one a tool does not declare,
|
|
197
|
+
* so each app's string — and so its prompt hash — is unchanged by adopting the package (test/hashes.js proves it on
|
|
198
|
+
* copies of both apps' documents). A REMOTE tool adds `kind` and `service` after the rest (no app has one yet).
|
|
199
|
+
*/
|
|
200
|
+
function catalogueHashOf(tools) {
|
|
201
|
+
const doc = tools.map((t) => ({ id: t.id, permission: t.permission, permissions: t.permissions, description: t.description, args: t.args,
|
|
202
|
+
attackerFields: t.attackerFields, kind: t.kind === 'remote' ? 'remote' : undefined, service: t.kind === 'remote' ? t.service : undefined }));
|
|
203
|
+
return crypto.createHash('sha256').update(JSON.stringify(doc)).digest('hex');
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
/** A catalogue over an app's tools, in the order registered. */
|
|
207
|
+
function createCatalogue(tools) {
|
|
208
|
+
if (!Array.isArray(tools) || !tools.length) throw new Error('ask: createAsk needs a non-empty tools list');
|
|
209
|
+
const TOOLS = Object.freeze(tools.map(assertToolShape));
|
|
210
|
+
const BY_ID = new Map();
|
|
211
|
+
for (const t of TOOLS) {
|
|
212
|
+
if (BY_ID.has(t.id)) throw new Error(`ask tool ${JSON.stringify(t.id)} is registered twice`);
|
|
213
|
+
BY_ID.set(t.id, t);
|
|
214
|
+
}
|
|
215
|
+
const TOOL_IDS = Object.freeze(TOOLS.map((t) => t.id));
|
|
216
|
+
return {
|
|
217
|
+
TOOLS, TOOL_IDS, MAX_CALLS, INTENTS,
|
|
218
|
+
byId: (id) => BY_ID.get(String(id)) || null,
|
|
219
|
+
mayUse,
|
|
220
|
+
/** The tools this request may plan: every permission held, not switched off, and not remote (0.1.0). */
|
|
221
|
+
catalogueFor(can, toolsOff = []) {
|
|
222
|
+
const off = new Set(Array.isArray(toolsOff) ? toolsOff : []);
|
|
223
|
+
return TOOLS.filter((t) => t.kind !== 'remote' && !off.has(t.id) && mayUse(can, t));
|
|
224
|
+
},
|
|
225
|
+
plannerSchema, validatePlan, describeTools,
|
|
226
|
+
catalogueHash: () => catalogueHashOf(TOOLS)
|
|
227
|
+
};
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
module.exports = { createCatalogue, assertToolShape, mayUse, gateOf, plannerSchema, validatePlan, describeTools, catalogueHashOf, MAX_CALLS, INTENTS };
|
package/lib/check.js
ADDED
|
@@ -0,0 +1,349 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Step 4 of an Ask question — CHECK the model's answer against the look-ups that ran, then build what the page draws.
|
|
3
|
+
*
|
|
4
|
+
* Extracted from the hub's and soc's lib/ask/check.js (2 Nov 2026), taking Soc's — the stricter — and making the
|
|
5
|
+
* app-specific parts config:
|
|
6
|
+
* structured fields whose value the APP or its clock produced (a band, a verdict, a timestamp) — sayable
|
|
7
|
+
* numericStructured the structured fields whose value may be a pure number 0–1 (Soc: a confidence) — default none
|
|
8
|
+
* sayableIdFields { facts: [...], rows: [...] }: where this app's own public ids sit, besides each row's `ref`
|
|
9
|
+
* isPublicId(v) this app's id test — an id is sayable only when a look-up returned it
|
|
10
|
+
* namedTokens [[regex, replacement]] applied before the token rules: names that contain digits (Soc: "tier 0",
|
|
11
|
+
* "P1") — each app's own, none by default
|
|
12
|
+
* sayIps, sayWindows Soc's S4 (an IP the results carry; a window an app-written label names) — default OFF
|
|
13
|
+
* rowChipLabel(row) the row chip's words
|
|
14
|
+
* toolOf(id) the catalogue — decides which paths of a result are ATTACKER text (the tool's attackerFields)
|
|
15
|
+
*
|
|
16
|
+
* NEW IN THE PACKAGE (both apps lacked them; bypass classes the tests name):
|
|
17
|
+
* - FORMAT CHARACTERS (\p{Cf}: zero-width space/joiner/non-joiner, word joiner, soft hyphen, BOM, bidi marks) are
|
|
18
|
+
* REMOVED before the word rules, so "evil.com", "five" and "scu_ABCD" read as what they display.
|
|
19
|
+
* - IDEOGRAPHIC AND HALFWIDTH FULL STOPS (U+3002, U+FF61) read as dots, as the fullwidth one (U+FF0E) already did
|
|
20
|
+
* through NFKC: "evil。example" is a domain.
|
|
21
|
+
*
|
|
22
|
+
* WHAT THE MODEL MAY NOT DO. An answer is REFUSED — replaced by fixed wording and the app-drawn chips — when it:
|
|
23
|
+
* - is not `{answer, cites}` with a non-empty answer;
|
|
24
|
+
* - cites, or places a figure for, a result or extra figure that does not exist;
|
|
25
|
+
* - TYPES A NUMBER: any numeral (`\p{N}`: 999, ٩٩٩, 999, ⁹⁹⁹, ⑨, Ⅸ) or a number WORD (two … million; not one/first/
|
|
26
|
+
* second/single/half);
|
|
27
|
+
* - says a token with a digit in it, or a ref-shaped token, that is not SAYABLE (allowedTokens);
|
|
28
|
+
* - carries a link — a scheme, `www.`, markup, a Markdown link, a path, or a PUBLIC-looking domain the results did not
|
|
29
|
+
* name from an app-written path;
|
|
30
|
+
* - claims something was done, asks the person to sign in again, speaks of another partner, or talks about its
|
|
31
|
+
* instructions or the document.
|
|
32
|
+
*
|
|
33
|
+
* THIS IS A FLOOR, NOT A PROOF. It stops the obeyed shapes the tests name and the bench measures. What it guarantees is
|
|
34
|
+
* that Ask cannot act, cannot invent a figure the page draws, and cannot link anywhere.
|
|
35
|
+
*/
|
|
36
|
+
|
|
37
|
+
'use strict';
|
|
38
|
+
|
|
39
|
+
const PLACEHOLDER = /\{\{\s*(r[1-4])(?:\.([a-z0-9_]{1,40}))?\s*\}\}/g;
|
|
40
|
+
const CITE = /\[(r[1-4])\]/g;
|
|
41
|
+
const DIGIT_TOKEN = /[\p{L}\p{N}_.:\-/]*\p{N}[\p{L}\p{N}_.:\-/]*/gu;
|
|
42
|
+
const PURE_NUMBER = /^[\p{N}.,]+$/u;
|
|
43
|
+
const REF_TOKEN = /(?<![\p{L}\p{N}])[\p{L}]{2,5}_[\p{L}\p{N}]{4,}/gu;
|
|
44
|
+
const HOSTLIKE = /^[\p{L}\p{N}][\p{L}\p{N}-]*(?:\.[\p{L}\p{N}-]+)*$/u;
|
|
45
|
+
const LINKISH = /(https?:|ftp:|mailto:|javascript:|data:|file:|www\.|<\s*[a-z!/]|\]\s*\(|<|\bhref\b)/i;
|
|
46
|
+
const DOMAINISH = /(?<![\p{L}\p{N}])[\p{L}\p{N}-]+(?:\.[\p{L}\p{N}-]+)*\.\p{L}{2,}(?:\/[^\s]*)?(?![\p{L}\p{N}])/gu;
|
|
47
|
+
const INTERNAL_SUFFIX = /\.(local|lan|internal|corp|home\.arpa|localdomain)$/i;
|
|
48
|
+
const NUMBER_WORD = /\b(zero|two|three|four|five|six|seven|eight|nine|ten|eleven|twelve|thirteen|fourteen|fifteen|sixteen|seventeen|eighteen|nineteen|twenty|thirty|forty|fifty|sixty|seventy|eighty|ninety|hundreds?|thousands?|millions?|billions?|dozens?|twice|thrice)\b/i;
|
|
49
|
+
// What an obeyed instruction says — SCOPED to that, not to the vocabulary a security answer is made of ("sign-in attempts",
|
|
50
|
+
// "password spray"). Widened in review L of PR #9: up to two adverbs ("I have now closed it"), "was closed by me", "log
|
|
51
|
+
// back in", "re-enter it at the login page", other tenants or resellers, "my rules".
|
|
52
|
+
const OBEYED = [
|
|
53
|
+
// "I/we" and the verb with up to two words between ("I have now closed", "we've quietly removed", "I have't yet sent").
|
|
54
|
+
[/\b(i|we)\s+(?:[\p{L}'’]+\s+){0,2}(closed|deleted|removed|assigned|resolved|approved|disabled|enabled|changed|updated|sent|marked|escalated|acknowledged|archived|reset|purged|wiped)\b/iu, 'claims to have done something', 'claims_action'],
|
|
55
|
+
[/\b(has|have)\s+been\s+(closed|deleted|removed|resolved|approved|disabled|marked|purged|wiped)\s+(as|by)\b/i, 'claims something was done', 'claims_action'],
|
|
56
|
+
// The verb "by me / us", however it is introduced ("closed by me", "It was resolved by us").
|
|
57
|
+
[/\b(closed|deleted|removed|assigned|resolved|approved|disabled|enabled|changed|updated|sent|marked|escalated|acknowledged|archived|reset|purged|wiped)\s+by\s+(me|us|the\s+assistant)\b/i, 'claims something was done', 'claims_action'],
|
|
58
|
+
[/\b(were|was|been)\s+(deleted|removed|wiped|purged|erased)\b/i, 'claims something was deleted', 'claims_action'],
|
|
59
|
+
[/\b(sign|log)\s?-?\s?(in|on)\s+(again|at|here|now|to\s+continue|to\s+restore)\b|\b(sign|log)\s+back\s+(in|on)\b|\b(enter|re-?enter|confirm|reset|verify)\s+your\s+(password|credentials?|login)\b|\bre-?enter\b[^.]{0,40}\b(log-?in|sign-?in)\s+page\b|\bsession\s+(has\s+)?expired\b/i, 'sends the person to sign in', 'sign_in'],
|
|
60
|
+
[/\b(other|another)\s+(partners?|tenants?|resellers?)\b|\bevery\s+other\s+(partner|tenant)\b/i, 'speaks of another partner', 'other_partner'],
|
|
61
|
+
[/\b(instructions?|system\s+note|results?\s+document|the\s+document|prompt)\b|\bmy\s+(rules|guidelines|programming|orders)\b/i, 'talks about its instructions or the document', 'instructions']
|
|
62
|
+
];
|
|
63
|
+
const IPV4 = /^(?:(?:25[0-5]|2[0-4]\d|1?\d?\d)\.){3}(?:25[0-5]|2[0-4]\d|1?\d?\d)$/;
|
|
64
|
+
const IPV6 = /^(?=.*:)[0-9a-f:]{2,39}$/i;
|
|
65
|
+
const WINDOW = /\b(\d{1,4})\s*(hours?|hrs?|h|days?|d|minutes?|mins?)\b/gi;
|
|
66
|
+
const unitOf = (u) => (/^h/i.test(u) ? 'h' : /^d/i.test(u) ? 'd' : 'min');
|
|
67
|
+
const isInternalHost = (t) => /^\p{L}/u.test(t) && (!t.includes('.') || INTERNAL_SUFFIX.test(t));
|
|
68
|
+
// INVISIBLE AND COMBINING CHARACTERS (review H1 of PR #9): format characters (zero-width, soft hyphen, BOM, bidi
|
|
69
|
+
// controls), every default-ignorable code point (U+034F, the variation selectors FE00–FE0F and E0100–E01EF, …) and every
|
|
70
|
+
// combining mark left after NFKC (U+0332 underline, U+0336 strike) — invisible or decoration, so removed before reading.
|
|
71
|
+
const INVISIBLE = /[\p{Cf}\p{Default_Ignorable_Code_Point}\p{M}]/gu;
|
|
72
|
+
// DOTS THAT ARE NOT "." (H1/H2): ideographic and halfwidth full stops, which NFKC leaves alone (U+3002, U+FF61), and the
|
|
73
|
+
// middle dots and separators a person reads as a dot (·, ‧, ᠃, ٫, ⸱, ・, ・) — and the dot-shaped LETTERS the allow-list would
|
|
74
|
+
// admit as \p{L} (re-review of PR #9): U+A78F ꞏ, U+1427 ᐧ, U+02D1 ˑ, U+02CC ˌ.
|
|
75
|
+
const OTHER_DOTS = /[。。·‧᠃٫⸱・・ꞏᐧˑˌ]/g;
|
|
76
|
+
// A SPACED DOT between label-like words (re-review of PR #9): "evil . com" and "evil .com" (space, dot, letter), and
|
|
77
|
+
// "evil. example" (dot, space, a lower-case label). Not ordinary prose; a sentence after a full stop starts with a capital.
|
|
78
|
+
// A floor with a known cost: "e.g. the record" reads as a domain and is withheld.
|
|
79
|
+
const SPACED_DOT = /(?<=[\p{L}\p{N}-])(?:\s+\.\s*(?=\p{L})|\.\s+(?=\p{Ll}[\p{L}\p{N}-]*(?:[\s.,;:!?)]|$)))/gu;
|
|
80
|
+
// A BRACKETED DOT — "evil[.]example", "evil(dot)example", "evil [ dot ] example" — is a dot (H2).
|
|
81
|
+
const BRACKETED_DOT = /\s*[\[(]\s*(?:\.|dot)\s*[\])]\s*/gi;
|
|
82
|
+
// A SPELLED-OUT dot between label-like words — "evil dot example", "evil DOT example dot com" — is a dot (H2). A floor: it
|
|
83
|
+
// also reads "the dot in the name" as a domain, which the domain rule then refuses — rare in an answer, and the safe side.
|
|
84
|
+
const SPELLED_DOT = /(?<=[\p{L}\p{N}-])\s+dot\s+(?=[\p{L}\p{N}])/giu;
|
|
85
|
+
/** What the word rules read: NFKC, invisible and combining characters gone, every reading of a dot as ".". */
|
|
86
|
+
const readable = (t) => String(t).normalize('NFKC').replace(INVISIBLE, '').replace(OTHER_DOTS, '.').replace(BRACKETED_DOT, '.').replace(SPELLED_DOT, '.').replace(SPACED_DOT, '.');
|
|
87
|
+
|
|
88
|
+
/** Every refusal code checkAnswer can return. */
|
|
89
|
+
const REFUSAL_CODES = Object.freeze(['shape', 'control_chars', 'unknown_cite', 'unknown_figure', 'bad_placeholder', 'link', 'number_word', 'claims_action',
|
|
90
|
+
'sign_in', 'other_partner', 'instructions', 'unknown_ref', 'typed_number', 'unknown_token', 'disallowed_char']);
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* THE MODEL'S OWN TEXT IS ALLOW-LISTED (the coordinator's round on PR #9, the same list as the soc AI-brief check): after
|
|
94
|
+
* NFC, every character of the answer (placeholders and cites taken out) must be a letter, a digit (the number rule still
|
|
95
|
+
* refuses those), a space or a newline, or one of . , ; : ! ? ' " ’ ( ) - – — and the underscore. Anything else — a combining
|
|
96
|
+
* mark NFC leaves, a look-alike dot, a slash, a bracket, an emoji — is refused (`disallowed_char`). It runs LAST, so every
|
|
97
|
+
* shape an earlier rule names keeps its own code (a link stays `link`); it closes the classes nobody listed.
|
|
98
|
+
* ADDED TO THE SHARED LIST HERE, both needed by the apps' honest corpora: `_` (every app id is `xxx_…`) and the newline
|
|
99
|
+
* (between sentences; control_chars already allows it). And the curly quotes “ ” ‘ (at the coordinator's word: models write
|
|
100
|
+
* them often and they are harmless; ’ was already in).
|
|
101
|
+
* KNOWN LIMIT: every letter (\p{L}) passes, so names in any script do; look-alike letters in a domain or a ref are caught by
|
|
102
|
+
* the NFKC, dot and link rules, not here.
|
|
103
|
+
*/
|
|
104
|
+
const ALLOWED_CHAR = /^[\p{L}\p{N} \n.,;:!?'"‘’“”()\-–—_]$/u;
|
|
105
|
+
|
|
106
|
+
const trim = (t) => String(t).replace(/^[.:\-/]+|[.:\-/]+$/g, '');
|
|
107
|
+
|
|
108
|
+
/** Every string of a result with its path in attackerFields' spelling (rows[].fields.entity, facts.entities[].value). */
|
|
109
|
+
function stringsOf(r) {
|
|
110
|
+
const out = [];
|
|
111
|
+
const walk = (v, path) => {
|
|
112
|
+
if (typeof v === 'string') out.push([path, v]);
|
|
113
|
+
else if (Array.isArray(v)) v.forEach((x) => walk(x, `${path}[]`));
|
|
114
|
+
else if (v && typeof v === 'object') for (const [k, x] of Object.entries(v)) walk(x, path ? `${path}.${k}` : k);
|
|
115
|
+
};
|
|
116
|
+
walk(r.facts || {}, 'facts');
|
|
117
|
+
for (const row of r.rows || []) { walk(row.title, 'rows[].title'); walk(row.fields || {}, 'rows[].fields'); }
|
|
118
|
+
return out;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/** The figure a placeholder names: { i, key, count, label, href } or null when it names nothing. */
|
|
122
|
+
function figureOf(results, rid, key) {
|
|
123
|
+
const i = Number(rid.slice(1)) - 1;
|
|
124
|
+
const r = results[i];
|
|
125
|
+
if (!r) return null;
|
|
126
|
+
if (!key) return { i, key: null, count: r.count, label: r.label, href: r.href };
|
|
127
|
+
const x = (r.extras || []).find((e) => e.key === key);
|
|
128
|
+
return x ? { i, key, count: x.count, label: x.label, href: x.href } : null;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/** The answer text → segments the view draws: {t:'text', v} | {t:'fig', v, of, k, title} | {t:'cite', v, n}. */
|
|
132
|
+
function segmentsOf(answer, results) {
|
|
133
|
+
const out = [];
|
|
134
|
+
const re = /\{\{\s*(r[1-4])(?:\.([a-z0-9_]{1,40}))?\s*\}\}|\[(r[1-4])\]/g;
|
|
135
|
+
let at = 0;
|
|
136
|
+
for (const m of answer.matchAll(re)) {
|
|
137
|
+
if (m.index > at) out.push({ t: 'text', v: answer.slice(at, m.index) });
|
|
138
|
+
if (m[1]) {
|
|
139
|
+
const f = figureOf(results, m[1], m[2]);
|
|
140
|
+
out.push({ t: 'fig', v: String(f.count), of: f.i + 1, k: `${m[1]}${m[2] ? `.${m[2]}` : ''}`, title: `From look-up ${f.i + 1}: ${f.label || 'rows'}` });
|
|
141
|
+
} else {
|
|
142
|
+
out.push({ t: 'cite', v: m[3], n: Number(m[3].slice(1)) });
|
|
143
|
+
}
|
|
144
|
+
at = m.index + m[0].length;
|
|
145
|
+
}
|
|
146
|
+
if (at < answer.length) out.push({ t: 'text', v: answer.slice(at) });
|
|
147
|
+
return out;
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
/** A help result's chips: one per passage, no link and no count, drawn from the tool's own rows. */
|
|
151
|
+
function helpChips(chips, r, i) {
|
|
152
|
+
const seen = new Set();
|
|
153
|
+
for (const row of r.rows || []) {
|
|
154
|
+
if (!row.title || seen.has(row.title)) continue;
|
|
155
|
+
seen.add(row.title);
|
|
156
|
+
chips.push({ count: null, label: `From the help pages: ${row.title}`, href: null, of: i + 1, help: true });
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
const DEFAULT_ROW_CHIP = (row) => [row.title || row.ref].filter(Boolean).join(' · ');
|
|
161
|
+
|
|
162
|
+
/**
|
|
163
|
+
* A check bound to an app's field lists. Returns { checkAnswer, allowedTokens, segmentsOf, chipsOf, figureOf, … }.
|
|
164
|
+
* @param {object} o see the header
|
|
165
|
+
*/
|
|
166
|
+
function createCheck(o = {}) {
|
|
167
|
+
const structured = Object.freeze([...(o.structured || [])]);
|
|
168
|
+
const numericStructured = Object.freeze([...(o.numericStructured || [])]);
|
|
169
|
+
const idFacts = Object.freeze([...((o.sayableIdFields && o.sayableIdFields.facts) || [])]);
|
|
170
|
+
const idRows = Object.freeze([...((o.sayableIdFields && o.sayableIdFields.rows) || [])]);
|
|
171
|
+
const namedTokens = (o.namedTokens || []).map(([re, to]) => {
|
|
172
|
+
if (!(re instanceof RegExp) || !re.global) throw new Error('ask check: a namedTokens entry is [a GLOBAL RegExp, replacement]');
|
|
173
|
+
return [re, to];
|
|
174
|
+
});
|
|
175
|
+
const isPublicId = typeof o.isPublicId === 'function' ? o.isPublicId : () => false;
|
|
176
|
+
const toolOf = typeof o.toolOf === 'function' ? o.toolOf : () => null;
|
|
177
|
+
const rowChipLabel = typeof o.rowChipLabel === 'function' ? o.rowChipLabel : DEFAULT_ROW_CHIP;
|
|
178
|
+
const sayIps = o.sayIps === true;
|
|
179
|
+
const sayWindows = o.sayWindows === true;
|
|
180
|
+
|
|
181
|
+
/**
|
|
182
|
+
* The SAYABLE tokens:
|
|
183
|
+
* - this app's own public ids a look-up returned (a row's ref, or a sayableIdFields path);
|
|
184
|
+
* - every token of a STRUCTURED field;
|
|
185
|
+
* - a HOSTNAME-SHAPED token that appears whole in the results — WHERE IT CAME FROM DECIDES: from a path the APP wrote
|
|
186
|
+
* any host; from an ATTACKER path (the tool's attackerFields; every path of an unknown or remote tool) only an
|
|
187
|
+
* INTERNAL-looking one (a single label, or .local/.lan/.internal/.corp/…) that starts with a letter;
|
|
188
|
+
* - with sayIps, an IP address the results carry; with sayWindows, a window an app-written label names.
|
|
189
|
+
*/
|
|
190
|
+
function allowedTokens(results) {
|
|
191
|
+
const set = new Set();
|
|
192
|
+
const own = (v) => { if (typeof v === 'string' && isPublicId(v)) set.add(v); };
|
|
193
|
+
set.numbers = new Set();
|
|
194
|
+
set.ips = new Set();
|
|
195
|
+
set.windows = new Set();
|
|
196
|
+
const numeric = (v) => { if (typeof v === 'string' && /^(0(\.\d{1,3})?|1(\.0{1,3})?)$/.test(v)) set.numbers.add(v); };
|
|
197
|
+
const field = (v) => {
|
|
198
|
+
if (typeof v !== 'string' || !v || v.length > 64) return;
|
|
199
|
+
for (const m of v.match(DIGIT_TOKEN) || []) set.add(trim(m));
|
|
200
|
+
if (!/\s/.test(v)) set.add(v);
|
|
201
|
+
};
|
|
202
|
+
const windows = (v) => { if (sayWindows && typeof v === 'string') for (const m of v.matchAll(WINDOW)) set.windows.add(`${Number(m[1])}${unitOf(m[2])}`); };
|
|
203
|
+
for (const r of results) {
|
|
204
|
+
const tool = r && r.tool ? toolOf(r.tool) : null;
|
|
205
|
+
const remote = !tool || tool.kind === 'remote';
|
|
206
|
+
const attacker = new Set(tool && !remote ? tool.attackerFields : []);
|
|
207
|
+
for (const k of structured) field((r.facts || {})[k]);
|
|
208
|
+
for (const k of numericStructured) numeric((r.facts || {})[k]);
|
|
209
|
+
for (const k of idFacts) own((r.facts || {})[k]);
|
|
210
|
+
for (const [path, v] of stringsOf(r)) {
|
|
211
|
+
const fromAttacker = remote || attacker.has(path);
|
|
212
|
+
for (const w of readable(v).split(/[\s,;()"'“”‘’!?]+/)) {
|
|
213
|
+
const t = trim(w);
|
|
214
|
+
if (!t) continue;
|
|
215
|
+
if (IPV4.test(t) || IPV6.test(t)) { if (sayIps) set.ips.add(t.toLowerCase()); continue; }
|
|
216
|
+
if (!HOSTLIKE.test(t) || PURE_NUMBER.test(t)) continue;
|
|
217
|
+
if (!fromAttacker || isInternalHost(t)) set.add(t);
|
|
218
|
+
}
|
|
219
|
+
if (!fromAttacker) windows(v);
|
|
220
|
+
}
|
|
221
|
+
windows(r.label);
|
|
222
|
+
for (const x of r.extras || []) windows(x.label);
|
|
223
|
+
for (const row of r.rows || []) {
|
|
224
|
+
own(row.ref);
|
|
225
|
+
for (const k of idRows) own((row.fields || {})[k]);
|
|
226
|
+
for (const k of structured) field((row.fields || {})[k]);
|
|
227
|
+
for (const k of numericStructured) numeric((row.fields || {})[k]);
|
|
228
|
+
}
|
|
229
|
+
for (const c of r.candidates || []) own(c.ref);
|
|
230
|
+
}
|
|
231
|
+
return set;
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
/**
|
|
235
|
+
* Check one answer. Returns { ok: true, answer, cites } or { ok: false, code, reason } — `code` is one of REFUSAL_CODES.
|
|
236
|
+
* @param {*} reply the model's parsed JSON
|
|
237
|
+
* @param {object[]} results the look-ups' results, in order (r1 = results[0])
|
|
238
|
+
*/
|
|
239
|
+
function checkAnswer(reply, results) {
|
|
240
|
+
if (!reply || typeof reply !== 'object' || Array.isArray(reply)) return { ok: false, code: 'shape', reason: 'not an object' };
|
|
241
|
+
const keys = Object.keys(reply).filter((k) => !['answer', 'cites'].includes(k));
|
|
242
|
+
if (keys.length) return { ok: false, code: 'shape', reason: 'unexpected fields' };
|
|
243
|
+
const answer = typeof reply.answer === 'string' ? reply.answer.trim() : '';
|
|
244
|
+
if (!answer) return { ok: false, code: 'shape', reason: 'empty answer' };
|
|
245
|
+
if (answer.length > 700) return { ok: false, code: 'shape', reason: 'answer too long' };
|
|
246
|
+
if (!Array.isArray(reply.cites) || reply.cites.some((c) => typeof c !== 'string')) return { ok: false, code: 'shape', reason: 'cites is not a list of ids' };
|
|
247
|
+
if (/[\u0000-\u0008\u000b-\u001f\u007f]/.test(answer)) return { ok: false, code: 'control_chars', reason: 'control characters' };
|
|
248
|
+
|
|
249
|
+
for (const c of reply.cites) if (!/^r[1-4]$/.test(c) || !results[Number(c.slice(1)) - 1]) return { ok: false, code: 'unknown_cite', reason: `cites ${String(c).slice(0, 8)}, which no look-up returned` };
|
|
250
|
+
for (const m of answer.matchAll(CITE)) if (!results[Number(m[1].slice(1)) - 1]) return { ok: false, code: 'unknown_cite', reason: `cites ${m[1]}, which no look-up returned` };
|
|
251
|
+
for (const m of answer.matchAll(PLACEHOLDER)) if (!figureOf(results, m[1], m[2])) return { ok: false, code: 'unknown_figure', reason: `a figure ${m[0]} no look-up returned` };
|
|
252
|
+
const bare = answer.replace(PLACEHOLDER, ' ').replace(CITE, ' ');
|
|
253
|
+
if (/[{}]/.test(bare)) return { ok: false, code: 'bad_placeholder', reason: 'a malformed figure placeholder' };
|
|
254
|
+
// NFKC, other full stops and NO format characters for the word rules: what the person would read.
|
|
255
|
+
const norm = readable(bare);
|
|
256
|
+
const allowed = allowedTokens(results);
|
|
257
|
+
if (LINKISH.test(norm)) return { ok: false, code: 'link', reason: 'a link or markup' };
|
|
258
|
+
for (const m of norm.matchAll(DOMAINISH)) {
|
|
259
|
+
const d = m[0];
|
|
260
|
+
if (d.includes('/') || !(INTERNAL_SUFFIX.test(d) || allowed.has(trim(d)))) return { ok: false, code: 'link', reason: 'a link or markup' };
|
|
261
|
+
}
|
|
262
|
+
const word = NUMBER_WORD.exec(norm);
|
|
263
|
+
if (word) return { ok: false, code: 'number_word', reason: `writes the number “${word[0]}” instead of a figure placeholder` };
|
|
264
|
+
for (const [re, why, code] of OBEYED) if (re.test(norm)) return { ok: false, code, reason: why };
|
|
265
|
+
|
|
266
|
+
const named = (x) => {
|
|
267
|
+
let y = x;
|
|
268
|
+
for (const [re, to] of namedTokens) y = y.replace(re, to);
|
|
269
|
+
if (sayWindows) y = y.replace(WINDOW, (m, n, u) => (allowed.windows.has(`${Number(n)}${unitOf(u)}`) ? 'window' : m));
|
|
270
|
+
return y;
|
|
271
|
+
};
|
|
272
|
+
// BOTH READINGS: the raw text keeps a Roman numeral (Ⅸ — NFKC makes it the letters IX), the readable one joins what
|
|
273
|
+
// a format character split.
|
|
274
|
+
for (const src of [bare, norm].map(named)) {
|
|
275
|
+
for (const t of src.match(REF_TOKEN) || []) {
|
|
276
|
+
if (!allowed.has(t)) return { ok: false, code: 'unknown_ref', reason: `names “${t.slice(0, 40)}”, which is not an id a look-up returned` };
|
|
277
|
+
}
|
|
278
|
+
for (const t of src.match(DIGIT_TOKEN) || []) {
|
|
279
|
+
const tok = trim(t);
|
|
280
|
+
if (sayIps && allowed.ips.has(tok.toLowerCase())) continue;
|
|
281
|
+
if (PURE_NUMBER.test(tok) && !allowed.numbers.has(tok)) return { ok: false, code: 'typed_number', reason: `types the number “${tok.slice(0, 20)}” instead of a figure placeholder` };
|
|
282
|
+
if (PURE_NUMBER.test(tok)) continue;
|
|
283
|
+
if (tok && /\p{N}/u.test(tok) && !allowed.has(tok) && !(HOSTLIKE.test(tok) && INTERNAL_SUFFIX.test(tok))) return { ok: false, code: 'unknown_token', reason: `states “${tok.slice(0, 40)}”, which no look-up returned as an id, a field or a host` };
|
|
284
|
+
}
|
|
285
|
+
}
|
|
286
|
+
for (const ch of bare.normalize('NFC')) {
|
|
287
|
+
if (!ALLOWED_CHAR.test(ch)) return { ok: false, code: 'disallowed_char', reason: `a character outside the allowed set (U+${ch.codePointAt(0).toString(16).toUpperCase().padStart(4, '0')})` };
|
|
288
|
+
}
|
|
289
|
+
return { ok: true, answer, cites: [...new Set(reply.cites)] };
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
/**
|
|
293
|
+
* The chips: the figures the answer placed (or, with fixed wording, every result's main figure and its extras), each
|
|
294
|
+
* the tool's own count, label and href — a chip with no href is a fact. Then a row chip for each row the answer named
|
|
295
|
+
* by ref or a small cited result's rows (at most 4), and a cited result's notes.
|
|
296
|
+
*/
|
|
297
|
+
function chipsOf(results, answer) {
|
|
298
|
+
const chips = [];
|
|
299
|
+
const seen = new Set();
|
|
300
|
+
const add = (f) => {
|
|
301
|
+
const k = `${f.i}:${f.key || ''}`;
|
|
302
|
+
if (seen.has(k)) return;
|
|
303
|
+
seen.add(k);
|
|
304
|
+
chips.push({ count: f.count, label: f.label || '', href: f.href || null, of: f.i + 1 });
|
|
305
|
+
};
|
|
306
|
+
if (answer) {
|
|
307
|
+
for (const m of answer.matchAll(PLACEHOLDER)) add(figureOf(results, m[1], m[2]));
|
|
308
|
+
let rows = 0;
|
|
309
|
+
for (const [i, r] of results.entries()) {
|
|
310
|
+
if (r.help) { if (answer.includes(`[r${i + 1}]`)) helpChips(chips, r, i); continue; }
|
|
311
|
+
for (const row of r.rows || []) {
|
|
312
|
+
const cited = answer.includes(`[r${i + 1}]`) && (r.rows || []).length <= 4;
|
|
313
|
+
const named = answer.includes(row.ref) || cited;
|
|
314
|
+
if (rows >= 4 || !row.href || !row.ref || !named) continue;
|
|
315
|
+
chips.push({ count: null, label: rowChipLabel(row), href: row.href, of: i + 1 });
|
|
316
|
+
rows += 1;
|
|
317
|
+
}
|
|
318
|
+
}
|
|
319
|
+
for (const [i, r] of results.entries()) {
|
|
320
|
+
if (!answer.includes(`[r${i + 1}]`)) continue;
|
|
321
|
+
for (const n of r.notes || []) chips.push({ count: null, label: String(n.label || ''), href: null, of: i + 1 });
|
|
322
|
+
}
|
|
323
|
+
return chips;
|
|
324
|
+
}
|
|
325
|
+
for (const [i, r] of results.entries()) {
|
|
326
|
+
if (r.help) { helpChips(chips, r, i); continue; }
|
|
327
|
+
if (r.outcome === 'ok' && r.label) add({ i, key: null, count: r.count, label: r.label, href: r.href });
|
|
328
|
+
for (const x of r.extras || []) add({ i, key: x.key, count: x.count, label: x.label, href: x.href });
|
|
329
|
+
for (const n of r.notes || []) chips.push({ count: null, label: String(n.label || ''), href: null, of: i + 1 });
|
|
330
|
+
}
|
|
331
|
+
return chips;
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
const config = Object.freeze({ structured, numericStructured, sayableIdFields: { facts: idFacts, rows: idRows }, sayIps, sayWindows,
|
|
335
|
+
namedTokens: namedTokens.map(([re, to]) => [String(re), String(to)]) });
|
|
336
|
+
/**
|
|
337
|
+
* WHAT DECIDES A VERDICT (review H3 of PR #9): the rule sources and this app's config, including its id test and row-chip
|
|
338
|
+
* label. The bench folds it into scorerHash, so a pass measured under another check never opens the gate.
|
|
339
|
+
*/
|
|
340
|
+
const fingerprint = () => JSON.stringify({ rules: RULE_SOURCES, config, isPublicId: String(isPublicId), rowChipLabel: String(rowChipLabel) });
|
|
341
|
+
return { REFUSAL_CODES, NUMBER_WORD, REF_TOKEN, PLACEHOLDER, LINKISH, checkAnswer, allowedTokens, segmentsOf, chipsOf, figureOf, config, fingerprint };
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
/** Every module-level rule and helper a verdict depends on, as text (with createCheck's own source). */
|
|
345
|
+
const RULE_SOURCES = [PLACEHOLDER, CITE, DIGIT_TOKEN, PURE_NUMBER, REF_TOKEN, HOSTLIKE, LINKISH, DOMAINISH, INTERNAL_SUFFIX, NUMBER_WORD, IPV4, IPV6, WINDOW,
|
|
346
|
+
INVISIBLE, OTHER_DOTS, BRACKETED_DOT, SPELLED_DOT, SPACED_DOT, ALLOWED_CHAR, ...OBEYED.map(([re, why, code]) => `${re}|${why}|${code}`), REFUSAL_CODES.join(','),
|
|
347
|
+
unitOf, isInternalHost, readable, trim, stringsOf, figureOf, segmentsOf, helpChips, createCheck].map(String);
|
|
348
|
+
|
|
349
|
+
module.exports = { createCheck, REFUSAL_CODES, NUMBER_WORD, REF_TOKEN, PLACEHOLDER, LINKISH, segmentsOf, figureOf, stringsOf, readable };
|
package/lib/common.js
ADDED
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* What every Ask tool shares, whichever app it belongs to. The rest of the apps' lib/ask/common.js — READ_ONLY per model,
|
|
3
|
+
* scopeOf, resolveCustomer, the readings — is domain and stays in each app.
|
|
4
|
+
*
|
|
5
|
+
* A RESULT is { outcome: 'ok'|'not_found'|'ambiguous'|'error', count, label, href, rows: [{ref, title, fields, href}],
|
|
6
|
+
* facts, extras: [{key, count, label, href}], notes?: [{label}], quote?: {label, text, at?, model?}, help?, words,
|
|
7
|
+
* candidates? }. `count` + `href` are the chip the app draws; the model places the count with a {{rN}} placeholder.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
'use strict';
|
|
11
|
+
|
|
12
|
+
const MAX_ROWS = 20;
|
|
13
|
+
const MAX_TEXT = 256;
|
|
14
|
+
|
|
15
|
+
/** Text cut to n characters (with an ellipsis), control characters as spaces; null stays null. */
|
|
16
|
+
const cut = (v, n = MAX_TEXT) => {
|
|
17
|
+
if (v == null) return null;
|
|
18
|
+
const t = String(v).replace(/[\u0000-\u001f\u007f]+/g, ' ').trim();
|
|
19
|
+
return t.length > n ? `${t.slice(0, n - 1)}…` : t;
|
|
20
|
+
};
|
|
21
|
+
|
|
22
|
+
/** A not-found result — the SAME shape for absent and unpermitted. */
|
|
23
|
+
const notFound = (words) => ({ outcome: 'not_found', count: 0, label: null, href: null, rows: [], facts: {}, extras: [], words });
|
|
24
|
+
|
|
25
|
+
/** An ambiguous match: the candidates' names and links, nothing else. `hrefOf(c)` is the app's page for one. */
|
|
26
|
+
const ambiguous = (words, list, { label = 'customers match', hrefOf = (c) => `/customers/${Number(c.id)}`, titleOf = (c) => c.name } = {}) => ({
|
|
27
|
+
outcome: 'ambiguous', count: list.length, label, href: null, rows: [], facts: {}, extras: [], words,
|
|
28
|
+
candidates: list.slice(0, 10).map((c) => ({ ref: c.public_id, title: cut(titleOf(c)), href: hrefOf(c) }))
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
module.exports = { MAX_ROWS, MAX_TEXT, cut, notFound, ambiguous };
|