@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/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") and the tools this person
6
- * may use. It sees NO DATA, so nothing an attacker wrote can steer which look-ups run.
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 PROMPT HASH (`promptHash`) is `sha256(JSON.stringify({v:1, app, plan, answer, answerSchema, tools}))` — the hub's
12
- * and soc's exact document, with `app` undefined for the hub (JSON.stringify drops it) and 'soc' for Soc. So an app
13
- * that passes its own rule texts byte-for-byte keeps its hash, and its bench record, through adoption (test/hashes.js).
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
- function answerMessages(question, results) {
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
- return { system: `${o.answerRules}\n\n${FIELDS_SENTENCE} ${rule}`, user: block };
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
- * createAdminRouters → { settingsRouter at /admin/ai/ask, reviewRouter at /admin/ai/ask-review }, platform only
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
- answer = await A.run.ask({ req, can, question, context, settings, reading, timezone: timezoneOf(req, res),
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 q = await A.history.one(req, canOf(res), req.params.ref);
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
- const benchState = { running: false, endpoint: null, done: 0, of: 0, startedAt: null, error: null };
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
- Object.assign(benchState, { running: true, endpoint: null, done: 0, of: 0, startedAt: new Date().toISOString(), error: null });
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) => { Object.assign(benchState, { endpoint: p.endpoint, done: p.done, of: p.of }); touch(); }
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 WORDS = Object.freeze({ ...o.words });
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') return await finish('cannot', fixed('cannot'));
162
- // THE NEUTRAL TOKEN: the record's own id goes back AFTER validation, and only into the tool of its own kind.
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
- const words = tool.describe(call.args);
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, call.args);
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: call.args, outcome: result.outcome,
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: parse(r.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.1.2",
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,