@aria-framework/ask 0.1.1 → 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/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.1",
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,
@@ -51,7 +51,7 @@
51
51
  </div>
52
52
  <% } %>
53
53
  <% if (benchState.running) { %>
54
- <p class="small mb-2"><span class="tagpill sm">Running</span> <%= benchState.endpoint || 'starting' %> · <%= benchState.done %> of <%= benchState.of || corpusSize * 3 %> calls (each case runs three times: twice without its payload, once with it).</p>
54
+ <p class="small mb-2"><span class="tagpill sm">Running</span> <%= benchState.endpoint || 'starting' %> · <span data-bench-eta><%= benchLine %></span> (calls; each case runs three times: twice without its payload, once with it).</p>
55
55
  <progress class="d-block w-100 mb-2" data-bench-progress max="<%= benchState.of || corpusSize * 3 %>" value="<%= benchState.done %>"><%= benchState.done %> of <%= benchState.of || corpusSize * 3 %></progress>
56
56
  <% } else if (benchState.error) { %>
57
57
  <p class="small text-danger mb-2">The last bench run failed: <%= benchState.error %></p>
@@ -79,7 +79,7 @@
79
79
  <td class="small"><% if (e.bench) { %>
80
80
  <span class="tagpill sm <%= e.bench.passed ? 'ok' : 'warn' %>"><%= e.bench.passed ? 'passed' : 'not passed' %></span>
81
81
  <% if (e.bench.injectionRate != null) { %>
82
- <div>Injection resisted <strong><%= e.bench.injectionRate %> %</strong> · format followed <%= e.bench.formatRate %> % · <%= e.bench.controlsHeld %> of <%= e.bench.controlsTotal %> controls<% if (e.bench.counts && e.bench.counts.error) { %> · <span class="text-danger"><%= e.bench.counts.error %> cases unanswered (<%= e.bench.errorRate %> %)</span><% } %><% if (e.bench.changedDuringRun) { %> · <span class="text-danger">configuration changed during the run</span><% } %></div>
82
+ <div>Injection resisted <strong><%= e.bench.injectionRate %> %</strong> · format followed <%= e.bench.formatRate %> % · <%= e.bench.controlsHeld %> of <%= e.bench.controlsTotal %> controls<% if (e.bench.counts && e.bench.counts.error) { %> · <span class="text-danger"><%= e.bench.counts.error %> cases unanswered (<%= e.bench.errorRate %> %)</span><% } %><% if (e.bench.reached) { %> · <span class="text-danger"><%= e.bench.reached %> attacker-planted addresses reached the person</span><% } %><% if (e.bench.changedDuringRun) { %> · <span class="text-danger">configuration changed during the run</span><% } %></div>
83
83
  <% if (e.bench.topReasons && e.bench.topReasons.length) { %>
84
84
  <div class="text-body-secondary">Top reasons: <%= e.bench.topReasons.map(function (x) { return x.code + ' ×' + x.n; }).join(', ') %></div>
85
85
  <% } %>
@@ -0,0 +1,56 @@
1
+ <% var _L = (typeof askLayout !== 'undefined' && askLayout) ? askLayout : { head: 'partials/staff-head', tabs: 'partials/admin-tabs', foot: 'partials/staff-foot' }; %>
2
+ <% var _base = (typeof askReviewBase === 'string' && askReviewBase) ? askReviewBase : '/admin/ai/ask-review'; %>
3
+ <%- include(_L.head) %>
4
+
5
+ <%- include(_L.tabs) %>
6
+
7
+ <%# QUESTIONS ASK COULD NOT ANSWER (@aria-framework/ask 0.2.0): every question whose plan said "none", with the planner's
8
+ reason — no tool covers it, it is not about these records, or it asks for a judgement. For the platform
9
+ (ai_config.view): every partner's rows, with a Partner filter. Question text AS TYPED, escaped, as data. Rows go when
10
+ the question's retention ends. App locals used: whenTag; askLayout and askReviewBase are optional. %>
11
+
12
+ <div class="card mb-3"><div class="card-body">
13
+ <h6 class="card-title">Questions Ask could not answer <span class="cnt"><%= rows.length %></span></h6>
14
+ <p class="small text-body-secondary">What people asked that no look-up answers, and why the planner said so. <a href="<%= _base %>">Answers marked not helpful →</a></p>
15
+ <form method="GET" action="<%= _base %>/none" class="row g-2 align-items-end mb-3">
16
+ <div class="col-auto">
17
+ <label class="form-label small" for="nn-partner">Partner</label>
18
+ <select class="form-select form-select-sm" id="nn-partner" name="partner">
19
+ <option value="">All partners</option>
20
+ <% partners.forEach(function (p) { %><option value="<%= p.id %>" <%= partner === p.id ? 'selected' : '' %>><%= p.name %><%= p.isHouse ? ' (house)' : '' %></option><% }) %>
21
+ </select>
22
+ </div>
23
+ <div class="col-auto">
24
+ <label class="form-label small" for="nn-reason">Reason</label>
25
+ <select class="form-select form-select-sm" id="nn-reason" name="reason">
26
+ <option value="">Any reason</option>
27
+ <% Object.keys(reasons).forEach(function (k) { %><option value="<%= k %>" <%= reason === k ? 'selected' : '' %>><%= reasons[k] %></option><% }) %>
28
+ </select>
29
+ </div>
30
+ <div class="col-auto"><button type="submit" class="btn btn-sm btn-outline-primary">Filter</button></div>
31
+ <div class="col-auto small text-body-secondary">Kept as long as the Ask retention setting — older questions are deleted.</div>
32
+ </form>
33
+
34
+ <% if (!rows.length) { %>
35
+ <p class="small mb-0">No questions Ask could not answer<%= reason ? ' for this reason' : '' %>.</p>
36
+ <% } else { %>
37
+ <div class="table-responsive"><table class="table table-sm align-top mb-0">
38
+ <thead><tr><th>Time</th><th>User</th><th>Partner</th><th>App</th><th>Question</th><th>Page</th><th>Reason</th></tr></thead>
39
+ <tbody>
40
+ <% rows.forEach(function (r) { %>
41
+ <tr>
42
+ <td class="small text-nowrap"><%- whenTag(r.created_at) %></td>
43
+ <td class="small"><%= r.asked_by_name %><%= r.acting ? ' (working inside)' : '' %></td>
44
+ <td class="small"><%= r.partner_name %></td>
45
+ <td class="small"><%= typeof appName !== 'undefined' ? appName : '' %></td>
46
+ <td><%= r.question %></td>
47
+ <td class="small"><%= r.context_kind || '—' %></td>
48
+ <td class="small" data-none-reason="<%= r.none_reason || '' %>"><%= r.reasonWords %></td>
49
+ </tr>
50
+ <% }) %>
51
+ </tbody>
52
+ </table></div>
53
+ <% } %>
54
+ </div></div>
55
+
56
+ <%- include(_L.foot) %>
@@ -12,6 +12,7 @@
12
12
 
13
13
  <div class="card mb-3"><div class="card-body">
14
14
  <h6 class="card-title">Questions marked not helpful <span class="cnt"><%= rows.length %></span></h6>
15
+ <p class="small text-body-secondary"><a href="<%= _base %>/none">Questions Ask could not answer, and why →</a></p>
15
16
  <form method="GET" action="<%= _base %>" class="row g-2 align-items-end mb-3">
16
17
  <div class="col-auto">
17
18
  <label class="form-label small" for="rv-partner">Partner</label>