@aria-framework/ask 0.1.2 → 0.2.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 +195 -0
- package/README.md +95 -4
- package/assets/ask.js +19 -3
- package/index.js +15 -13
- package/lib/bench.js +90 -23
- package/lib/catalogue.js +188 -25
- package/lib/check.js +319 -21
- package/lib/eta.js +51 -0
- package/lib/history.js +66 -4
- package/lib/prompts.js +71 -14
- package/lib/routes.js +46 -9
- package/lib/run.js +64 -13
- package/lib/store.js +33 -3
- package/lib/text.js +118 -0
- package/package.json +1 -1
- package/views/ask/admin.ejs +1 -1
- package/views/ask/none.ejs +56 -0
- package/views/ask/review.ejs +1 -0
package/lib/prompts.js
CHANGED
|
@@ -2,15 +2,21 @@
|
|
|
2
2
|
* The two prompts of an Ask question, and what the model is allowed to see. The MACHINERY is shared; the WORDS are the
|
|
3
3
|
* app's (D-0169 item 6) and are passed in.
|
|
4
4
|
*
|
|
5
|
-
* PLAN sees the question, the page context as a REF only (or the neutral token "@context")
|
|
6
|
-
* may use
|
|
5
|
+
* PLAN sees the question, the page context as a REF only (or the neutral token "@context"), the tools this person
|
|
6
|
+
* may use and — for a follow-up — the previous question's text and the refs its look-ups returned (0.2.0). It
|
|
7
|
+
* sees NO DATA, so nothing an attacker wrote can steer which look-ups run.
|
|
7
8
|
* ANSWER sees the question and the look-ups' results as ONE FENCED DOCUMENT (@aria-framework/ai `fenced` — a random
|
|
8
9
|
* per-call marker), through `modelView`: refs, names, fields and counts — never numeric ids, never hrefs, never a
|
|
9
10
|
* result's `quote` (another model's words, drawn by the app beside the answer). It has no tools.
|
|
10
11
|
*
|
|
11
|
-
* THE
|
|
12
|
-
*
|
|
13
|
-
*
|
|
12
|
+
* THE PACKAGE'S CONTRACT (0.2.0). After the app's own rules, every planner prompt carries PLAN_CONTRACT (analyse, the
|
|
13
|
+
* reason on none, round-2 references, follow-ups) and every answer prompt ANSWER_CONTRACT (verdicts and "all/none"
|
|
14
|
+
* claims), plus ANALYSE_RULES when the plan's intent is `analyse`. They are the package's words because the check and the
|
|
15
|
+
* planner schema they describe are the package's; the app's words stay the app's.
|
|
16
|
+
*
|
|
17
|
+
* THE PROMPT HASH (`promptHash`) is `sha256(JSON.stringify({v:2, app, plan, answer, answerSchema, tools, contract}))`.
|
|
18
|
+
* 0.1's document was `{v:1, …}` with no contract; `promptHashOf` without `contract` still computes it, so an app's test
|
|
19
|
+
* can show which document a stored hash was.
|
|
14
20
|
*/
|
|
15
21
|
|
|
16
22
|
'use strict';
|
|
@@ -31,9 +37,46 @@ const ANSWER_SCHEMA = Object.freeze({
|
|
|
31
37
|
}
|
|
32
38
|
});
|
|
33
39
|
|
|
40
|
+
const PLAN_CONTRACT = [
|
|
41
|
+
'These rules come with every app and add to the rules above:',
|
|
42
|
+
'- intent "analyse" instead of "lookup" when the question asks to compare, group, rank or draw a conclusion across what',
|
|
43
|
+
' the look-ups return ("which has the most", "what do these have in common", "is anything unusual"). Plan its',
|
|
44
|
+
' look-ups exactly as for "lookup".',
|
|
45
|
+
'- "reason" is null, except with intent "none": then "no_tool" (no tool below covers it), "out_of_scope" (it is not',
|
|
46
|
+
' about these records or this app) or "needs_judgement" (it asks for an opinion, a decision or advice the records',
|
|
47
|
+
' cannot settle).',
|
|
48
|
+
'- A later look-up may take a value from an earlier look-up\'s result: write "@r1.<output>" as that argument, where r1',
|
|
49
|
+
' is the first look-up in "calls" and <output> is one the earlier tool "gives". The app reads the value; never type a',
|
|
50
|
+
' value you have not been given. Look-ups that take no earlier result come first, and a look-up may take only from one',
|
|
51
|
+
' that takes none.',
|
|
52
|
+
'- A follow-up names the previous question and the references its look-ups returned. Use them only where the new',
|
|
53
|
+
' question needs them.'
|
|
54
|
+
].join('\n');
|
|
55
|
+
|
|
56
|
+
const ANSWER_CONTRACT = [
|
|
57
|
+
'These rules come with every app and add to the rules above:',
|
|
58
|
+
'- Never call anything compromised, breached, malicious, benign, harmless, safe or a false positive unless a result',
|
|
59
|
+
' you cite says so in its own fields.',
|
|
60
|
+
'- Never say that all, every or none of the rows are in a state (resolved, closed, open, healthy …) unless every row',
|
|
61
|
+
' of the result you cite shows it.'
|
|
62
|
+
].join('\n');
|
|
63
|
+
|
|
64
|
+
const ANALYSE_RULES = [
|
|
65
|
+
'This question asks for an analysis. You may group, compare and conclude across the results, and only from them:',
|
|
66
|
+
'- Every sentence cites at least one result, as [r1]. A placeholder in a sentence names a result that sentence cites.',
|
|
67
|
+
'- Every count is a placeholder. Never type a number or work one out (no sums, differences, percentages, "twice as',
|
|
68
|
+
' many"); say "more", "fewer" or "the most" instead.',
|
|
69
|
+
'- Name only the customers, people, hosts and things the cited results name. Never introduce a new one.',
|
|
70
|
+
'- Say a conclusion as coming from the results: "the results show …", "based on these results …".',
|
|
71
|
+
'- Never say anything was done, and never present advice as done.'
|
|
72
|
+
].join('\n');
|
|
73
|
+
|
|
74
|
+
const CONTRACT = Object.freeze({ plan: PLAN_CONTRACT, answer: ANSWER_CONTRACT, analyse: ANALYSE_RULES });
|
|
75
|
+
|
|
34
76
|
/** The prompt document's hash — exported so an app's test-ask-hashes.js can pin it from its own inputs. */
|
|
35
|
-
function promptHashOf({ app, plan, answer, answerSchema = ANSWER_SCHEMA, toolsHash }) {
|
|
36
|
-
const doc = JSON.stringify({ v: 1, app, plan, answer, answerSchema, tools: toolsHash }
|
|
77
|
+
function promptHashOf({ app, plan, answer, answerSchema = ANSWER_SCHEMA, toolsHash, contract }) {
|
|
78
|
+
const doc = JSON.stringify(contract === undefined ? { v: 1, app, plan, answer, answerSchema, tools: toolsHash }
|
|
79
|
+
: { v: 2, app, plan, answer, answerSchema, tools: toolsHash, contract });
|
|
37
80
|
return crypto.createHash('sha256').update(doc).digest('hex');
|
|
38
81
|
}
|
|
39
82
|
|
|
@@ -57,6 +100,18 @@ function modelView(results) {
|
|
|
57
100
|
const FIELDS_SENTENCE = 'The document has fields "question" and "results"; each result has "id", "look_up", "outcome", "count", '
|
|
58
101
|
+ '"counted", "extra_figures", "facts" and "rows".';
|
|
59
102
|
|
|
103
|
+
/**
|
|
104
|
+
* The planner's follow-up sentence: the previous question AS THE PERSON TYPED IT, the tools its look-ups used and the
|
|
105
|
+
* refs they returned (the app's own public ids, from rows' `ref`) — never the model's answer, never a result's text.
|
|
106
|
+
*/
|
|
107
|
+
function followUpSentence(prev) {
|
|
108
|
+
if (!prev) return '';
|
|
109
|
+
const refs = (prev.refs || []).slice(0, 10);
|
|
110
|
+
return `\nThis is a follow-up. The previous question was: ${JSON.stringify(String(prev.question || '').slice(0, 500))}.`
|
|
111
|
+
+ `${prev.tools && prev.tools.length ? ` Its look-ups used ${prev.tools.join(', ')}.` : ''}`
|
|
112
|
+
+ `${refs.length ? ` They returned these references: ${refs.join(', ')}.` : ''}`;
|
|
113
|
+
}
|
|
114
|
+
|
|
60
115
|
/**
|
|
61
116
|
* Prompts bound to an app's words.
|
|
62
117
|
* @param {object} o
|
|
@@ -78,18 +133,20 @@ function createPrompts(o) {
|
|
|
78
133
|
if (!o.fenced || typeof o.fenced.fencedDocument !== 'function') throw new Error('ask prompts: fenced (from @aria-framework/ai) is required');
|
|
79
134
|
const { catalogue } = o;
|
|
80
135
|
|
|
81
|
-
const planSystem = (tools) => `${o.planRules}\n\nTools:\n${catalogue.describeTools(tools)}`;
|
|
82
|
-
const planUser = (question, context) => `Question: ${String(question)}${context ? String(o.contextSentence(context) || '') : ''}`;
|
|
136
|
+
const planSystem = (tools) => `${o.planRules}\n\n${PLAN_CONTRACT}\n\nTools:\n${catalogue.describeTools(tools)}`;
|
|
137
|
+
const planUser = (question, context, previous) => `Question: ${String(question)}${context ? String(o.contextSentence(context) || '') : ''}${followUpSentence(previous)}`;
|
|
83
138
|
|
|
84
|
-
|
|
139
|
+
/** @param {{mode?: 'lookup'|'analyse'}} [opts] */
|
|
140
|
+
function answerMessages(question, results, opts = {}) {
|
|
85
141
|
const { block, rule } = o.fenced.fencedDocument('the look-up results', { question: String(question), results: modelView(results) },
|
|
86
142
|
{ what: o.fenceWhat, onLeak: o.onLeak });
|
|
87
|
-
|
|
143
|
+
const extra = opts.mode === 'analyse' ? `\n\n${ANALYSE_RULES}` : '';
|
|
144
|
+
return { system: `${o.answerRules}\n\n${ANSWER_CONTRACT}${extra}\n\n${FIELDS_SENTENCE} ${rule}`, user: block };
|
|
88
145
|
}
|
|
89
146
|
|
|
90
|
-
const promptHash = () => promptHashOf({ app: o.hashApp, plan: o.planRules, answer: o.answerRules, toolsHash: catalogue.catalogueHash() });
|
|
147
|
+
const promptHash = () => promptHashOf({ app: o.hashApp, plan: o.planRules, answer: o.answerRules, toolsHash: catalogue.catalogueHash(), contract: CONTRACT });
|
|
91
148
|
|
|
92
|
-
return { EXAMPLE: o.example, PLAN_RULES: o.planRules, ANSWER_RULES: o.answerRules, ANSWER_SCHEMA, planSystem, planUser, modelView, answerMessages, promptHash };
|
|
149
|
+
return { EXAMPLE: o.example, PLAN_RULES: o.planRules, ANSWER_RULES: o.answerRules, ANSWER_SCHEMA, CONTRACT, planSystem, planUser, modelView, answerMessages, promptHash };
|
|
93
150
|
}
|
|
94
151
|
|
|
95
|
-
module.exports = { createPrompts, promptHashOf, modelView, ANSWER_SCHEMA, FIELDS_SENTENCE };
|
|
152
|
+
module.exports = { createPrompts, promptHashOf, modelView, followUpSentence, ANSWER_SCHEMA, FIELDS_SENTENCE, PLAN_CONTRACT, ANSWER_CONTRACT, ANALYSE_RULES, CONTRACT };
|
package/lib/routes.js
CHANGED
|
@@ -5,10 +5,16 @@
|
|
|
5
5
|
* POST /ask ask one question; the reply is the `ask/1` NDJSON stream (lib/stream.js). Every question
|
|
6
6
|
* that got this far is audited `ask.asked` in a `finally`, whatever became of it.
|
|
7
7
|
* GET /ask/history the person's own last 20 questions, JSON
|
|
8
|
-
* GET /ask/q/:ref one of the person's own questions with its answer as given, JSON (stale: true)
|
|
8
|
+
* GET /ask/q/:ref one of the person's own questions with its answer as given, JSON (stale: true) — 0.2.0:
|
|
9
|
+
* only if the viewer still may use every tool it used, at the same reading and scope
|
|
10
|
+
* (history.recheck); else { unavailable: true, text } and no answer
|
|
9
11
|
* POST /ask/q/:ref/feedback the person's thumbs mark on their own question, JSON — audited `ask.feedback`
|
|
10
12
|
*
|
|
11
|
-
*
|
|
13
|
+
* POST /ask takes `follow_up` (0.2.0): the ref of the person's previous question in the drawer; what it may carry is
|
|
14
|
+
* history.followUp's to decide, never the body's.
|
|
15
|
+
*
|
|
16
|
+
* createAdminRouters → { settingsRouter at /admin/ai/ask, reviewRouter at /admin/ai/ask-review (+ /none, 0.2.0: the
|
|
17
|
+
* questions no tool could answer, with the planner's reason) }, platform only
|
|
12
18
|
* (ai_config.view to see, ai_config.edit to change). Audited `ai_config.ask_settings`, `ai_config.ask_bench`,
|
|
13
19
|
* `ai_config.ask_review` — the action names both apps already write.
|
|
14
20
|
*
|
|
@@ -24,9 +30,11 @@
|
|
|
24
30
|
'use strict';
|
|
25
31
|
|
|
26
32
|
const stream = require('./stream');
|
|
33
|
+
const eta = require('./eta');
|
|
27
34
|
|
|
28
35
|
const s = (v) => (typeof v === 'string' ? v.trim() : '');
|
|
29
36
|
const REASON_WORDS = Object.freeze({ wrong_figure: 'Wrong figure', wrong_record: 'Wrong record', no_answer: 'Didn’t answer', other: 'Other' });
|
|
37
|
+
const NONE_WORDS = Object.freeze({ no_tool: 'No tool covers it', out_of_scope: 'Not about these records', needs_judgement: 'Needs a judgement' });
|
|
30
38
|
const OUTCOME_WORDS = Object.freeze({ answered: 'answered', withheld: 'withheld', not_found: 'not found', refused_write: 'refused · write',
|
|
31
39
|
cannot: 'could not answer', busy: 'busy', off: 'off', error: 'error', working: 'unfinished' });
|
|
32
40
|
|
|
@@ -96,7 +104,8 @@ function createAskRouter(o) {
|
|
|
96
104
|
write(stream.start(reading));
|
|
97
105
|
let answer = null;
|
|
98
106
|
try {
|
|
99
|
-
|
|
107
|
+
const followUp = settings.enabled && typeof (req.body || {}).follow_up === 'string' ? s(req.body.follow_up).slice(0, 64) || null : null;
|
|
108
|
+
answer = await A.run.ask({ req, can, question, context, settings, reading, followUp, timezone: timezoneOf(req, res),
|
|
100
109
|
onStep: (st) => write(stream.step(st)) });
|
|
101
110
|
} catch (e) {
|
|
102
111
|
logger.error(`Ask: the question failed before it could be answered (${e && e.stack ? e.stack : e})`);
|
|
@@ -104,7 +113,8 @@ function createAskRouter(o) {
|
|
|
104
113
|
try {
|
|
105
114
|
o.audit(req, 'ask.asked', { entity_type: 'ask_question', entity_id: null,
|
|
106
115
|
detail: { ref: answer ? answer.ref : null, outcome: answer ? answer.outcome : 'error',
|
|
107
|
-
lookups: answer ? answer.lookups.map((l) => l.tool) : [], context: context ? context.kind : null
|
|
116
|
+
lookups: answer ? answer.lookups.map((l) => l.tool) : [], context: context ? context.kind : null,
|
|
117
|
+
...(answer && answer.followUp ? { follow_up: answer.followUp } : {}), ...(answer && answer.noneReason ? { none_reason: answer.noneReason } : {}) } });
|
|
108
118
|
} catch (e) { logger.error(`Ask: the audit line could not be written (${e && e.message ? e.message : e})`); }
|
|
109
119
|
}
|
|
110
120
|
write(answer ? stream.answer(answer) : stream.error(A.run.WORDS.error));
|
|
@@ -127,8 +137,12 @@ function createAskRouter(o) {
|
|
|
127
137
|
|
|
128
138
|
router.get('/q/:ref', o.requirePermission('ai.ask'), wrap(async (req, res) => {
|
|
129
139
|
if (!A.isPublicId(req.params.ref, 'ask_question')) return res.status(404).json({ error: 'not_found' });
|
|
130
|
-
const
|
|
140
|
+
const can = canOf(res);
|
|
141
|
+
const q = await A.history.one(req, can, req.params.ref);
|
|
131
142
|
if (!q || !q.result) return res.status(404).json({ error: 'not_found' });
|
|
143
|
+
// THE RECHECK (0.2.0): the tools it used, the reading and the scope — as of NOW. The answer is not sent otherwise.
|
|
144
|
+
const ok = A.history.recheck(q, req, can, await o.readingOf(req, can), (await A.settings.read()).toolsOff);
|
|
145
|
+
if (!ok.ok) return res.json({ unavailable: true, text: A.run.WORDS.unavailable, question: q.question, at: q.created_at });
|
|
132
146
|
res.json({ answer: q.result, at: q.created_at, rating: q.rating, stale: true });
|
|
133
147
|
}));
|
|
134
148
|
|
|
@@ -157,6 +171,7 @@ function createAskRouter(o) {
|
|
|
157
171
|
* @param {Function} o.ai () → the app's AI facade { configFor(id), complete(opts, cfg) }
|
|
158
172
|
* @param {Function} o.partners async () → [{ id, name, is_house }]
|
|
159
173
|
* @param {Function} [o.onBenchChange] the app's live hook (re-fetch the page's bench regions)
|
|
174
|
+
* @param {Function} [o.now] the clock, in ms (default Date.now); the bench's time-left estimate reads it
|
|
160
175
|
* @param {string} [o.appName] the review list's App column
|
|
161
176
|
* @param {string} [o.jobsHref] the app's page where endpoints are assigned to routes (linked from the empty state)
|
|
162
177
|
* @param {object} [o.layout] { head, tabs, foot } partial names (default partials/staff-head, admin-tabs, staff-foot)
|
|
@@ -170,7 +185,9 @@ function createAdminRouters(o) {
|
|
|
170
185
|
const logger = o.logger || A.logger;
|
|
171
186
|
const touch = typeof o.onBenchChange === 'function' ? () => { try { o.onBenchChange(); } catch (_) { /* live is best effort */ } } : () => {};
|
|
172
187
|
const layout = { head: 'partials/staff-head', tabs: 'partials/admin-tabs', foot: 'partials/staff-foot', ...(o.layout || {}) };
|
|
173
|
-
|
|
188
|
+
// o.now is the clock (ms); a test fixes it. endpointSinceMs/lastCallMs/endpointIndex feed the time-left estimate (lib/eta.js).
|
|
189
|
+
const now = typeof o.now === 'function' ? o.now : Date.now;
|
|
190
|
+
const benchState = { running: false, endpoint: null, done: 0, of: 0, startedAt: null, error: null, endpointSinceMs: null, lastCallMs: null, endpointIndex: 0, endpointCount: 0 };
|
|
174
191
|
const model = (fn, ...args) => A.invoke(A.modelName, fn, ...args);
|
|
175
192
|
|
|
176
193
|
const kindsOf = (policy) => (policy && Array.isArray(policy.providers) && policy.providers.length ? policy.providers.join(', ') : null);
|
|
@@ -217,6 +234,8 @@ function createAdminRouters(o) {
|
|
|
217
234
|
tools: A.catalogue.TOOLS.map((t) => ({ id: t.id, label: t.label, permission: A.gateOf(t).join(' + '), parts: t.parts || [], remote: t.kind === 'remote',
|
|
218
235
|
on: !shown.toolsOff.includes(t.id) })),
|
|
219
236
|
benchState: { ...benchState },
|
|
237
|
+
benchLine: benchState.running ? eta.progressLine({ done: benchState.done, of: benchState.of || A.bench.corpus().length * 3, sinceMs: benchState.endpointSinceMs,
|
|
238
|
+
nowMs: now(), startedMs: Date.parse(benchState.startedAt), endpointsLeft: Math.max(0, benchState.endpointCount - benchState.endpointIndex) }).text : null,
|
|
220
239
|
corpusSize: A.bench.corpus().length,
|
|
221
240
|
controlsSize: A.bench.controls().length,
|
|
222
241
|
passRate: Math.round(A.bench.PASS_RATE * 100),
|
|
@@ -259,14 +278,21 @@ function createAdminRouters(o) {
|
|
|
259
278
|
const endpoints = (await o.endpoints.forRoute('ask')) || [];
|
|
260
279
|
if (!endpoints.length) return redirectWith(res, 'error', `No endpoint${kindsOf(o.routePolicy('ask')) ? ` (${kindsOf(o.routePolicy('ask'))})` : ''} is assigned to the Ask route yet — assign one first.`, back);
|
|
261
280
|
const ai = o.ai();
|
|
262
|
-
|
|
281
|
+
const startMs = now();
|
|
282
|
+
Object.assign(benchState, { running: true, endpoint: null, done: 0, of: 0, startedAt: new Date(startMs).toISOString(), error: null,
|
|
283
|
+
endpointSinceMs: startMs, lastCallMs: startMs, endpointIndex: 0, endpointCount: endpoints.length });
|
|
263
284
|
o.audit(req, 'ai_config.ask_bench', { entity_type: 'ai_config', detail: { route: 'ask', endpoints: endpoints.join(', '), cases: A.bench.corpus().length } });
|
|
264
285
|
// IN THE BACKGROUND, in this request's async context; the record is stored at the house's installation row. Each call
|
|
265
286
|
// goes to THAT endpoint's own config, the route named on the options AND the config.
|
|
266
287
|
A.bench.run({
|
|
267
288
|
endpoints,
|
|
268
289
|
completeOn: async (id, opts) => A.bench.endpointCall(ai, await ai.configFor(id))(opts),
|
|
269
|
-
onProgress: (p) => {
|
|
290
|
+
onProgress: (p) => {
|
|
291
|
+
const t = now();
|
|
292
|
+
if (p.endpoint !== benchState.endpoint) Object.assign(benchState, { endpointSinceMs: benchState.lastCallMs, endpointIndex: benchState.endpointIndex + 1 });
|
|
293
|
+
Object.assign(benchState, { endpoint: p.endpoint, done: p.done, of: p.of, lastCallMs: t });
|
|
294
|
+
touch();
|
|
295
|
+
}
|
|
270
296
|
}).then((record) => A.bench.persistAtHouse(record))
|
|
271
297
|
.catch((e) => { benchState.error = e.message; logger.error(`Ask bench failed: ${e && e.stack ? e.stack : e}`); })
|
|
272
298
|
.finally(() => { benchState.running = false; touch(); });
|
|
@@ -289,6 +315,17 @@ function createAdminRouters(o) {
|
|
|
289
315
|
askLayout: layout, adminTabStyle: 'underline' });
|
|
290
316
|
}));
|
|
291
317
|
|
|
318
|
+
// THE QUESTIONS NO TOOL COULD ANSWER (0.2.0) — what people ask for that Ask has no look-up for, with the planner's reason.
|
|
319
|
+
reviewRouter.get('/none', o.requirePermission('ai_config.view'), wrap(async (req, res) => {
|
|
320
|
+
const partners = ((await o.partners()) || []).map((p) => ({ id: String(p.id), name: p.name, isHouse: Number(p.is_house) === 1 }));
|
|
321
|
+
const partner = partners.find((p) => p.id === s(req.query.partner)) ? s(req.query.partner) : null;
|
|
322
|
+
const reason = A.NONE_REASONS.includes(s(req.query.reason)) ? s(req.query.reason) : null;
|
|
323
|
+
const rows = ((await model('noneList', { reason, partner_id: partner })) || []).map((r) => ({ ...r, reasonWords: NONE_WORDS[r.none_reason] || 'Not recorded' }));
|
|
324
|
+
const base = req.baseUrl || '/admin/ai/ask-review';
|
|
325
|
+
res.render('ask/none', { title: 'AI assistance', rows, partners, partner, reason, reasons: NONE_WORDS, appName: o.appName || '', askReviewBase: base,
|
|
326
|
+
askLayout: layout, adminTabStyle: 'underline' });
|
|
327
|
+
}));
|
|
328
|
+
|
|
292
329
|
reviewRouter.post('/:ref/reviewed', o.requirePermission('ai_config.edit'), wrap(async (req, res) => {
|
|
293
330
|
const back = req.baseUrl || '/admin/ai/ask-review';
|
|
294
331
|
if (!A.isPublicId(req.params.ref, 'ask_question')) return res.status(404).render('errors/404', { title: 'Not found' });
|
|
@@ -301,4 +338,4 @@ function createAdminRouters(o) {
|
|
|
301
338
|
return { settingsRouter, reviewRouter, pageModel, _benchState: benchState };
|
|
302
339
|
}
|
|
303
340
|
|
|
304
|
-
module.exports = { createAskRouter, createAdminRouters, REASON_WORDS, OUTCOME_WORDS };
|
|
341
|
+
module.exports = { createAskRouter, createAdminRouters, REASON_WORDS, OUTCOME_WORDS, NONE_WORDS };
|
package/lib/run.js
CHANGED
|
@@ -8,6 +8,19 @@
|
|
|
8
8
|
* - the page context's id goes back only into the tool of its own kind (`contextTools`), AFTER validation.
|
|
9
9
|
* - Tier 1's own words (a result's `quote`) are drawn by the app beside the answer (`quotesOf`, Soc S7).
|
|
10
10
|
*
|
|
11
|
+
* 0.2.0:
|
|
12
|
+
* - ANALYSE: a plan whose intent is `analyse` runs like a look-up; its answer gets the analyse rules and is checked in
|
|
13
|
+
* analyse mode (check.js). The render model says which (`mode`).
|
|
14
|
+
* - TWO ROUNDS: round-1 calls run first; only when every one is ok do the round-2 calls run, each argument that names
|
|
15
|
+
* an earlier result ("@r1.customer_ref") RESOLVED BY THE PACKAGE (catalogue.resolveCall) and validated as a typed
|
|
16
|
+
* argument. An unresolvable one reads as not found. The context token is put back before, and only into, the plan's
|
|
17
|
+
* own arguments — never into a resolved value.
|
|
18
|
+
* - NONE WITH A REASON: the fixed words are `cannot_<reason>` when the app has them, else `cannot`; the reason is in
|
|
19
|
+
* the render model (`noneReason`), so it is stored with the row's result.
|
|
20
|
+
* - FOLLOW-UPS: `a.followUp` (the previous question's ref) is resolved by `followUpOf` (history.js: same person,
|
|
21
|
+
* context, reading and scope, within its minutes); what it returns goes into the planner's user message only.
|
|
22
|
+
* - Each look-up records the refs its rows returned (`lookups[].refs`, this app's public ids) for a follow-up.
|
|
23
|
+
*
|
|
11
24
|
* RUNS INSIDE THE ASKER'S OWN REQUEST — never from a job or a timer — so every look-up carries the request's tenant
|
|
12
25
|
* context and its own `can`. Nothing here takes a partner, a scope or a permission from the question or the model.
|
|
13
26
|
*
|
|
@@ -22,6 +35,10 @@
|
|
|
22
35
|
const MAX_QUESTION = 500;
|
|
23
36
|
const BUSY_KINDS = ['unreachable', 'timeout', 'rate_limit', 'budget', 'disabled', 'unconfigured', 'bad_response', 'unsupported', 'auth'];
|
|
24
37
|
const WORD_KEYS = ['not_found', 'refused_write', 'cannot', 'cannot_plan', 'ambiguous', 'withheld', 'off', 'busy', 'error', 'nothing_to_ask'];
|
|
38
|
+
/** 0.2.0, optional: a none's words by its reason (default `cannot`), and the history recheck's refusal. */
|
|
39
|
+
const OPTIONAL_WORD_KEYS = ['cannot_no_tool', 'cannot_out_of_scope', 'cannot_needs_judgement', 'unavailable'];
|
|
40
|
+
const UNAVAILABLE = 'This answer is no longer available to you.';
|
|
41
|
+
const MAX_REFS = 10;
|
|
25
42
|
|
|
26
43
|
/** The question as typed, cut and cleaned: control characters become spaces. Empty → null. */
|
|
27
44
|
function cleanQuestion(q) {
|
|
@@ -49,11 +66,15 @@ const msg = (e) => (e && e.message ? e.message : String(e));
|
|
|
49
66
|
* @param {Function} [o.openLabel] (kind) → the refused-write link's words (default `Open the ${kind} →`)
|
|
50
67
|
* @param {Function} o.principalOf (req, can) → { id, name, acting, scope }
|
|
51
68
|
* @param {object} o.gate { endpointsFor(hash), complete(opts, usable) } — the production seam
|
|
69
|
+
* @param {Function} [o.isPublicId] this app's id test — which row refs a look-up records for a follow-up
|
|
70
|
+
* @param {Function} [o.followUpOf] async (req, can, ref, { context, reading }) → { ref, question, tools, refs } | null
|
|
52
71
|
*/
|
|
53
72
|
function createRun(o) {
|
|
54
73
|
for (const k of WORD_KEYS) if (typeof (o.words || {})[k] !== 'string' || !o.words[k]) throw new Error(`ask: words.${k} is required (the app's fixed wording)`);
|
|
55
74
|
if (typeof o.principalOf !== 'function') throw new Error('ask: principalOf(req, can) is required');
|
|
56
|
-
const
|
|
75
|
+
for (const k of OPTIONAL_WORD_KEYS) if (o.words[k] !== undefined && (typeof o.words[k] !== 'string' || !o.words[k])) throw new Error(`ask: words.${k}, when given, is the app's fixed wording`);
|
|
76
|
+
const WORDS = Object.freeze({ unavailable: UNAVAILABLE, ...o.words });
|
|
77
|
+
const isPublicId = typeof o.isPublicId === 'function' ? o.isPublicId : () => false;
|
|
57
78
|
const model = o.modelName || 'AskQuestion';
|
|
58
79
|
const contextTools = { ...(o.contextTools || {}) };
|
|
59
80
|
const contextRef = typeof o.contextRef === 'function' ? o.contextRef : (c) => c.ref;
|
|
@@ -86,8 +107,10 @@ function createRun(o) {
|
|
|
86
107
|
const lookups = [];
|
|
87
108
|
const results = [];
|
|
88
109
|
const fixed = (key, extra = {}) => ({ by: 'fixed', text: WORDS[key], chips: [], ...extra });
|
|
110
|
+
let mode = 'lookup';
|
|
111
|
+
let previous = null;
|
|
89
112
|
const shape = (oc, r) => ({ ...r, outcome: oc, ref: row.public_id, question, context, lookups, provenance: prov,
|
|
90
|
-
reading: a.reading || null, at: new Date().toISOString() });
|
|
113
|
+
reading: a.reading || null, mode, followUp: previous ? previous.ref : null, at: new Date().toISOString() });
|
|
91
114
|
|
|
92
115
|
/** Close the row. NEVER THROWS — three attempts, each smaller than the last (the header). */
|
|
93
116
|
const finish = async (outcome, render, answerText = null) => {
|
|
@@ -121,12 +144,20 @@ function createRun(o) {
|
|
|
121
144
|
prov.skipped = ends.skipped;
|
|
122
145
|
if (!ends.usable.length) return await finish('off', fixed('off'));
|
|
123
146
|
|
|
147
|
+
// A FOLLOW-UP: what history.followUp allows to be carried, or nothing (the question is asked fresh).
|
|
148
|
+
if (a.followUp && typeof o.followUpOf === 'function') {
|
|
149
|
+
try { previous = await o.followUpOf(a.req, a.can, a.followUp, { context, reading: a.reading || null, toolsOff: a.settings.toolsOff || [] }); } catch (e) {
|
|
150
|
+
logger.warn(`Ask ${row.public_id}: the previous question could not be read for a follow-up (${msg(e)}); asked fresh`);
|
|
151
|
+
previous = null;
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
|
|
124
155
|
// ── 1 PLAN ─────────────────────────────────────────────────────────────────────────────────────────────────────
|
|
125
156
|
step({ n: 0, words: 'Working out what to look up…', state: 'run' });
|
|
126
157
|
let t = Date.now();
|
|
127
158
|
const planned = await deps.complete({
|
|
128
159
|
system: prompts.planSystem(tools),
|
|
129
|
-
messages: [{ role: 'user', content: prompts.planUser(question, context) }],
|
|
160
|
+
messages: [{ role: 'user', content: prompts.planUser(question, context, previous) }],
|
|
130
161
|
schema: catalogue.plannerSchema(tools),
|
|
131
162
|
maxTokens: 500,
|
|
132
163
|
temperature: 0
|
|
@@ -158,9 +189,15 @@ function createRun(o) {
|
|
|
158
189
|
const link = context && context.href ? { label: openLabel(context.kind), href: context.href } : null;
|
|
159
190
|
return await finish('refused_write', fixed('refused_write', { link }));
|
|
160
191
|
}
|
|
161
|
-
if (plan.intent === 'none')
|
|
162
|
-
|
|
192
|
+
if (plan.intent === 'none') {
|
|
193
|
+
const key = plan.reason && WORDS[`cannot_${plan.reason}`] ? `cannot_${plan.reason}` : 'cannot';
|
|
194
|
+
return await finish('cannot', fixed(key, { noneReason: plan.reason || null }));
|
|
195
|
+
}
|
|
196
|
+
if (plan.intent === 'analyse') mode = 'analyse';
|
|
197
|
+
// THE NEUTRAL TOKEN: the record's own id goes back AFTER validation, only into the tool of its own kind, and only into
|
|
198
|
+
// the plan's own arguments (a round-2 argument still holds its reference here; its value is never substituted).
|
|
163
199
|
for (const call of plan.calls) {
|
|
200
|
+
if (call.refs) continue;
|
|
164
201
|
const kind = contextTools[call.tool];
|
|
165
202
|
if (kind && call.args.ref === '@context') call.args.ref = context && context.kind === kind ? contextRef(context) : '@context';
|
|
166
203
|
}
|
|
@@ -170,16 +207,29 @@ function createRun(o) {
|
|
|
170
207
|
const ctx = { req: a.req, can: a.can, scope, principal: who, timezone: a.timezone || 'UTC' };
|
|
171
208
|
for (const [i, call] of plan.calls.entries()) {
|
|
172
209
|
const tool = catalogue.byId(call.tool);
|
|
173
|
-
|
|
210
|
+
// ROUND 2 STARTS ONLY WHEN ROUND 1 IS WHOLE: a failed, absent or ambiguous round-1 result answers on its own below.
|
|
211
|
+
if (call.refs && results.some((r) => r.outcome !== 'ok')) break;
|
|
212
|
+
let args = call.args;
|
|
213
|
+
let unresolved = null;
|
|
214
|
+
let ambiguousRef = false;
|
|
215
|
+
if (call.refs) {
|
|
216
|
+
const res = catalogue.resolveCall(call, results, tools, { isPublicId });
|
|
217
|
+
if (res.error) { unresolved = res.error; ambiguousRef = !!res.ambiguous; } else args = res.args;
|
|
218
|
+
}
|
|
219
|
+
let words;
|
|
220
|
+
try { words = tool.describe(unresolved ? Object.fromEntries(Object.keys(tool.args).map((k) => [k, call.refs[k] ? null : args[k]])) : args); } catch (_) { words = 'Looking that up'; }
|
|
174
221
|
step({ n: i + 1, words: `${words}…`, state: 'run' });
|
|
175
222
|
const t0 = Date.now();
|
|
176
223
|
let result;
|
|
177
224
|
// DEFENCE IN DEPTH: validatePlan already refused a tool outside this request's catalogue; no path into a tool
|
|
178
225
|
// skips its page permissions, whatever validated the call.
|
|
179
226
|
if (!catalogue.mayUse(a.can, tool)) result = { outcome: 'not_found', count: 0, rows: [], extras: [], facts: {}, words };
|
|
180
|
-
else {
|
|
227
|
+
else if (unresolved) {
|
|
228
|
+
logger.warn(`Ask ${row.public_id}: look-up ${i + 1}'s earlier result could not be read (${unresolved})`);
|
|
229
|
+
result = { outcome: ambiguousRef ? 'ambiguous' : 'not_found', count: 0, rows: [], extras: [], facts: {}, words, ...(ambiguousRef ? { candidates: [] } : {}) };
|
|
230
|
+
} else {
|
|
181
231
|
try {
|
|
182
|
-
result = await tool.run(ctx,
|
|
232
|
+
result = await tool.run(ctx, args);
|
|
183
233
|
} catch (e) {
|
|
184
234
|
logger.error(`Ask ${row.public_id}: ${tool.id} failed — ${e && e.stack ? e.stack : e}`);
|
|
185
235
|
result = { outcome: 'error', count: 0, rows: [], extras: [], facts: {}, words };
|
|
@@ -193,9 +243,10 @@ function createRun(o) {
|
|
|
193
243
|
result.tool = tool.id;
|
|
194
244
|
const ms = Date.now() - t0;
|
|
195
245
|
results.push(result);
|
|
246
|
+
const refs = [...new Set((result.rows || []).map((x) => x && x.ref).filter((x) => typeof x === 'string' && isPublicId(x)))].slice(0, MAX_REFS);
|
|
196
247
|
lookups.push({ n: i + 1, tool: tool.id, words: result.words, outcome: result.outcome, count: result.count,
|
|
197
|
-
href: result.href || null, ms, label: result.label || null });
|
|
198
|
-
await o.invoke(model, 'addCall', row.id, { position: i + 1, tool: tool.id, args
|
|
248
|
+
href: result.href || null, ms, label: result.label || null, round: call.refs ? 2 : 1, refs });
|
|
249
|
+
await o.invoke(model, 'addCall', row.id, { position: i + 1, tool: tool.id, args, outcome: result.outcome,
|
|
199
250
|
row_count: (result.rows || []).length, ms });
|
|
200
251
|
step({ n: i + 1, words: `${result.words}…`, state: 'done' });
|
|
201
252
|
}
|
|
@@ -215,7 +266,7 @@ function createRun(o) {
|
|
|
215
266
|
// ── 3 ANSWER — fenced results, a strict schema, no tools ───────────────────────────────────────────────────────
|
|
216
267
|
step({ n: results.length + 1, words: 'Writing the answer…', state: 'run' });
|
|
217
268
|
t = Date.now();
|
|
218
|
-
const m = prompts.answerMessages(question, results);
|
|
269
|
+
const m = prompts.answerMessages(question, results, { mode });
|
|
219
270
|
const answered = await deps.complete({
|
|
220
271
|
system: m.system,
|
|
221
272
|
messages: [{ role: 'user', content: m.user }],
|
|
@@ -230,7 +281,7 @@ function createRun(o) {
|
|
|
230
281
|
step({ n: results.length + 1, words: 'Writing the answer…', state: 'done' });
|
|
231
282
|
|
|
232
283
|
// ── 4 CHECK ────────────────────────────────────────────────────────────────────────────────────────────────────
|
|
233
|
-
const verdict = check.checkAnswer(answered.json, results);
|
|
284
|
+
const verdict = check.checkAnswer(answered.json, results, { mode, question });
|
|
234
285
|
if (!verdict.ok) {
|
|
235
286
|
logger.warn(`Ask ${row.public_id}: answer withheld (${verdict.reason})`);
|
|
236
287
|
const raw = answered.json && typeof answered.json.answer === 'string' ? answered.json.answer : null;
|
|
@@ -263,4 +314,4 @@ function createRun(o) {
|
|
|
263
314
|
return { ask, cleanQuestion, quotesOf, WORDS, MAX_QUESTION };
|
|
264
315
|
}
|
|
265
316
|
|
|
266
|
-
module.exports = { createRun, cleanQuestion, quotesOf, MAX_QUESTION, WORD_KEYS, BUSY_KINDS };
|
|
317
|
+
module.exports = { createRun, cleanQuestion, quotesOf, MAX_QUESTION, WORD_KEYS, OPTIONAL_WORD_KEYS, UNAVAILABLE, BUSY_KINDS };
|
package/lib/store.js
CHANGED
|
@@ -33,8 +33,12 @@ const intOf = (v) => Math.max(0, Math.trunc(Number(v)) || 0);
|
|
|
33
33
|
const json = (v) => (v == null ? null : JSON.stringify(v));
|
|
34
34
|
const parse = (t) => { if (t == null || t === '') return null; try { return JSON.parse(t); } catch (_) { return null; } };
|
|
35
35
|
|
|
36
|
+
// WHY A PLAN SAID "none" (0.2.0) — stored in the `result` JSON the row already has (`noneReason`), so no column moves.
|
|
37
|
+
const NONE_REASONS = Object.freeze(['no_tool', 'out_of_scope', 'needs_judgement']);
|
|
38
|
+
|
|
36
39
|
function hydrate(r) {
|
|
37
40
|
if (!r) return null;
|
|
41
|
+
const result = parse(r.result);
|
|
38
42
|
return {
|
|
39
43
|
...r,
|
|
40
44
|
id: Number(r.id),
|
|
@@ -44,7 +48,8 @@ function hydrate(r) {
|
|
|
44
48
|
tokens: Number(r.tokens) || 0,
|
|
45
49
|
ms: Number(r.ms) || 0,
|
|
46
50
|
rating: r.rating == null ? null : Number(r.rating),
|
|
47
|
-
result
|
|
51
|
+
result,
|
|
52
|
+
none_reason: result && NONE_REASONS.includes(result.noneReason) ? result.noneReason : null
|
|
48
53
|
};
|
|
49
54
|
}
|
|
50
55
|
|
|
@@ -75,7 +80,7 @@ function createAskQuestionModel(o) {
|
|
|
75
80
|
const WIDE = 'retention is installation-wide';
|
|
76
81
|
|
|
77
82
|
return {
|
|
78
|
-
OUTCOMES, CALL_OUTCOMES, REASONS,
|
|
83
|
+
OUTCOMES, CALL_OUTCOMES, REASONS, NONE_REASONS,
|
|
79
84
|
|
|
80
85
|
/** Open the row — outcome `working` until `finish`. Written BEFORE the model is called. */
|
|
81
86
|
async open({ asked_by, asked_by_name, acting = false, context_kind = null, context_ref = null, question, prompt_hash = null }) {
|
|
@@ -197,6 +202,31 @@ function createAskQuestionModel(o) {
|
|
|
197
202
|
return Number(r.changes || 0) > 0;
|
|
198
203
|
},
|
|
199
204
|
|
|
205
|
+
/**
|
|
206
|
+
* THE QUESTIONS ASK COULD NOT PLAN (0.2.0), newest first, with the planner's reason (`none_reason`: no_tool,
|
|
207
|
+
* out_of_scope, needs_judgement; null for a row written before 0.2.0) — Admin → AI's list of what people asked that no
|
|
208
|
+
* tool answers. PLATFORM ONLY (every partner's rows). `reason` filters on the stored reason (read from `result`).
|
|
209
|
+
*/
|
|
210
|
+
async noneList({ reason = null, partner_id = null, limit = 200 } = {}) {
|
|
211
|
+
platformOnly('noneList', FLEET);
|
|
212
|
+
if (reason != null && !NONE_REASONS.includes(reason)) throw new TypeError(`AskQuestion.noneList: unknown reason ${JSON.stringify(reason)}`);
|
|
213
|
+
const n = Math.min(Math.max(intOf(limit), 1), 500);
|
|
214
|
+
const where = ["q.outcome = 'cannot'"];
|
|
215
|
+
const params = [];
|
|
216
|
+
if (partner_id != null) { where.push('q.partner_id = ?'); params.push(Number(partner_id)); }
|
|
217
|
+
// PAGED AT THE DATABASE (fix round of PR #15): the reason is matched in the stored JSON text with LIKE (the run
|
|
218
|
+
// writes it with JSON.stringify, so it reads "noneReason":"<reason>"), `_` escaped; re-checked here after parsing.
|
|
219
|
+
if (reason != null) { where.push("q.result LIKE ? ESCAPE '\\'"); params.push(`%"noneReason":"${reason.replace(/_/g, '\\_')}"%`); }
|
|
220
|
+
const rows = (await d().all(
|
|
221
|
+
`SELECT q.id, q.public_id, q.partner_id, p.name AS partner_name, q.asked_by_name, q.acting, q.context_kind, q.question,
|
|
222
|
+
q.outcome, q.result, q.created_at
|
|
223
|
+
FROM ask_questions q JOIN partners p ON p.id = q.partner_id
|
|
224
|
+
WHERE ${where.join(' AND ')}
|
|
225
|
+
ORDER BY q.created_at DESC, q.id DESC
|
|
226
|
+
LIMIT ?`, [...params, n])).map(hydrate);
|
|
227
|
+
return (reason == null ? rows : rows.filter((r) => r.none_reason === reason)).slice(0, n).map(({ result, ...r }) => r);
|
|
228
|
+
},
|
|
229
|
+
|
|
200
230
|
/** RETENTION: delete every question created before `cut` (UTC), in every partner; look-ups cascade. PLATFORM ONLY. */
|
|
201
231
|
async purgeBefore(cut) {
|
|
202
232
|
platformOnly('purgeBefore', WIDE);
|
|
@@ -215,4 +245,4 @@ function createAskQuestionModel(o) {
|
|
|
215
245
|
};
|
|
216
246
|
}
|
|
217
247
|
|
|
218
|
-
module.exports = { createAskQuestionModel, OUTCOMES, CALL_OUTCOMES, REASONS };
|
|
248
|
+
module.exports = { createAskQuestionModel, OUTCOMES, CALL_OUTCOMES, REASONS, NONE_REASONS };
|
package/lib/text.js
ADDED
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* THE SHARED TEXT HELPER (0.2.0) for an app's search tools (a desk's tickets.search, a soc's events.search): the words of
|
|
3
|
+
* a query for AND/OR matching, a LIKE clause over the app's own columns, a capped snippet around the first match, and the
|
|
4
|
+
* path that marks snippets as ATTACKER TEXT. The app keeps its own query, its scope and its permissions; this file only
|
|
5
|
+
* turns the planner's query argument into terms and the matched text into a short, safe excerpt.
|
|
6
|
+
*
|
|
7
|
+
* const { text } = require('@aria-framework/ask');
|
|
8
|
+
* const q = text.searchTerms(args.query); // { op: 'and' | 'or', terms: ['printer', 'jam'] } or null
|
|
9
|
+
* const w = text.likeClause(['t.subject', 't.body'], q); // { sql: "(lower(t.subject) LIKE ? ESCAPE '\\' OR …) AND (…)", params }
|
|
10
|
+
* rows.map((r) => ({ ref, title, fields: { snippet: text.snippet(r.body, q) } }))
|
|
11
|
+
* attackerFields: text.withSnippets(['rows[].title']) // + 'rows[].fields.snippet'
|
|
12
|
+
*
|
|
13
|
+
* A SNIPPET IS ATTACKER TEXT, ALWAYS: somebody other than the asker typed it. The check reads every path ending in
|
|
14
|
+
* `.snippet` as an attacker path whether or not the tool lists it (check.js), a snippet can never be a round-2 output
|
|
15
|
+
* (catalogue.js), and the bench plants its payloads there when the tool lists it (withSnippets).
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
'use strict';
|
|
19
|
+
|
|
20
|
+
const MAX_TERMS = 8;
|
|
21
|
+
const MAX_SNIPPET = 160;
|
|
22
|
+
const SNIPPET_FIELD = 'snippet';
|
|
23
|
+
const SNIPPET_PATH = `rows[].fields.${SNIPPET_FIELD}`;
|
|
24
|
+
// A term: letters and digits, with . _ - @ inside (an address or a host), NFKC and lower-cased.
|
|
25
|
+
const TERM = /[\p{L}\p{N}](?:[\p{L}\p{N}._@-]*[\p{L}\p{N}])?/gu;
|
|
26
|
+
const clean = (t) => String(t == null ? '' : t).normalize('NFKC').replace(/[\u0000-\u001f\u007f\p{Cf}]+/gu, ' ');
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* The words of a query. "printer jam" is AND; "printer OR scanner" ("or" in any case, or a "|") is OR — one operator for
|
|
30
|
+
* the whole query. A mixed query ("printer jam or scanner") is AND over every term. Terms under `minLength` are dropped, duplicates once, at most
|
|
31
|
+
* `max`. Returns null for a query with no term (the tool answers not_found, never "everything").
|
|
32
|
+
* @param {string} q
|
|
33
|
+
* @param {{max?: number, minLength?: number}} [o]
|
|
34
|
+
*/
|
|
35
|
+
function searchTerms(q, o = {}) {
|
|
36
|
+
const max = Math.min(Math.max(Number(o.max) || MAX_TERMS, 1), 16);
|
|
37
|
+
const minLength = Math.max(Number(o.minLength) || 2, 1);
|
|
38
|
+
const raw = clean(q);
|
|
39
|
+
// "or" (any case) and "|" separate OR parts. A MIXED query ("printer jam or scanner": a part of two terms) is ANDed —
|
|
40
|
+
// the narrower reading, so a query never widens to rows matching one word of it (fix round of PR #15).
|
|
41
|
+
const parts = raw.split(/\s+or\s+|\s*\|\s*/i);
|
|
42
|
+
let op = parts.length > 1 ? 'or' : 'and';
|
|
43
|
+
if (op === 'or' && parts.some((p) => (p.toLowerCase().match(TERM) || []).filter((t) => t.length >= minLength).length > 1)) op = 'and';
|
|
44
|
+
const terms = [];
|
|
45
|
+
for (const p of parts) {
|
|
46
|
+
for (const m of p.toLowerCase().matchAll(TERM)) {
|
|
47
|
+
const t = m[0];
|
|
48
|
+
if (t.length >= minLength && !terms.includes(t)) terms.push(t);
|
|
49
|
+
if (terms.length >= max) break;
|
|
50
|
+
}
|
|
51
|
+
if (terms.length >= max) break;
|
|
52
|
+
}
|
|
53
|
+
return terms.length ? { op, terms } : null;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/** A term as a LIKE pattern: %, _ and \ escaped (ESCAPE '\'), wrapped in %…%. */
|
|
57
|
+
const likeOf = (term) => `%${String(term).replace(/[\\%_]/g, (c) => `\\${c}`)}%`;
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* A WHERE fragment over the app's own columns: each term must match (AND) — or any term (OR) — in at least one column,
|
|
61
|
+
* compared lower-cased. Column names are the APP's (validated as identifiers, never from the request). Works on SQLite and
|
|
62
|
+
* Postgres. Returns { sql, params }; an empty query is refused (throws), so a tool cannot widen to "every row".
|
|
63
|
+
* @param {string[]} columns e.g. ['t.subject', 't.body']
|
|
64
|
+
* @param {{op, terms}} q searchTerms' result
|
|
65
|
+
*/
|
|
66
|
+
function likeClause(columns, q) {
|
|
67
|
+
if (!Array.isArray(columns) || !columns.length || columns.some((c) => typeof c !== 'string' || !/^[a-z_][a-z0-9_]*(?:\.[a-z_][a-z0-9_]*)?$/i.test(c))) {
|
|
68
|
+
throw new Error('ask text: likeClause needs the app\'s column names (identifiers)');
|
|
69
|
+
}
|
|
70
|
+
if (!q || !Array.isArray(q.terms) || !q.terms.length) throw new Error('ask text: likeClause needs at least one term');
|
|
71
|
+
const params = [];
|
|
72
|
+
const one = (t) => `(${columns.map((c) => { params.push(likeOf(t)); return `lower(${c}) LIKE ? ESCAPE '\\'`; }).join(' OR ')})`;
|
|
73
|
+
const sql = q.terms.map(one).join(q.op === 'or' ? ' OR ' : ' AND ');
|
|
74
|
+
return { sql: `(${sql})`, params };
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/** Does this text match the query (in memory, the same rule as likeClause)? */
|
|
78
|
+
function matches(t, q) {
|
|
79
|
+
if (!q || !q.terms || !q.terms.length) return false;
|
|
80
|
+
const s = clean(t).toLowerCase();
|
|
81
|
+
return q.op === 'or' ? q.terms.some((x) => s.includes(x)) : q.terms.every((x) => s.includes(x));
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* A SNIPPET: at most `max` characters (default 160, never over 500) of `t` around the first match of any term, cut at word
|
|
86
|
+
* boundaries, with "…" where it was cut, control and format characters as spaces and whitespace collapsed. No match: the
|
|
87
|
+
* start of the text. Empty → null. The result is attacker text (see the header).
|
|
88
|
+
*/
|
|
89
|
+
function snippet(t, q, o = {}) {
|
|
90
|
+
const max = Math.min(Math.max(Number(o.max) || MAX_SNIPPET, 20), 500);
|
|
91
|
+
const s = clean(t).replace(/\s+/g, ' ').trim();
|
|
92
|
+
if (!s) return null;
|
|
93
|
+
if (s.length <= max) return s;
|
|
94
|
+
// Found in the ORIGINAL text, case-blind, so case folding that changes a string's length (İ) never moves the index.
|
|
95
|
+
let at = -1;
|
|
96
|
+
for (const term of (q && q.terms) || []) {
|
|
97
|
+
const m = new RegExp(term.replace(/[.*+?^${}()|[\]\\]/g, (c) => `\\${c}`), 'iu').exec(s);
|
|
98
|
+
if (m && (at < 0 || m.index < at)) at = m.index;
|
|
99
|
+
}
|
|
100
|
+
if (at < 0) at = 0;
|
|
101
|
+
let start = Math.max(0, at - Math.floor(max / 3));
|
|
102
|
+
let end = Math.min(s.length, start + max - 2);
|
|
103
|
+
start = Math.max(0, end - (max - 2));
|
|
104
|
+
if (start > 0) { const sp = s.indexOf(' ', start); if (sp >= 0 && sp < at) start = sp + 1; }
|
|
105
|
+
if (end < s.length) { const sp = s.lastIndexOf(' ', end); if (sp > Math.max(start, at)) end = sp; }
|
|
106
|
+
return `${start > 0 ? '…' : ''}${s.slice(start, end).trim()}${end < s.length ? '…' : ''}`;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/** attackerFields with the snippet path added (once). */
|
|
110
|
+
const withSnippets = (fields = [], field = SNIPPET_FIELD) => {
|
|
111
|
+
const path = `rows[].fields.${field}`;
|
|
112
|
+
return fields.includes(path) ? [...fields] : [...fields, path];
|
|
113
|
+
};
|
|
114
|
+
|
|
115
|
+
/** Is this result path a snippet — attacker text whatever the tool lists? */
|
|
116
|
+
const isSnippetPath = (path) => /(?:^|\.)snippet$/.test(String(path));
|
|
117
|
+
|
|
118
|
+
module.exports = { searchTerms, likeClause, likeOf, matches, snippet, withSnippets, isSnippetPath, SNIPPET_FIELD, SNIPPET_PATH, MAX_TERMS, MAX_SNIPPET };
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@aria-framework/ask",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.2.0",
|
|
4
4
|
"description": "Aria App Framework — ask module. \"Ask anything\": a read-only question box over an app's own records — plan, run the app's tools in the asker's own request, answer from fenced results, check every figure and link, audit. Ships the loop, the answer check, the bench, canonical settings, the question model factory, a reference table design with its generator and check, the ask/1 stream, route factories, views and the drawer script. The app supplies its tools and words.",
|
|
5
5
|
"license": "UNLICENSED",
|
|
6
6
|
"private": false,
|