@aria-framework/ask 0.0.0-stage → 0.1.1

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 ADDED
@@ -0,0 +1,218 @@
1
+ /**
2
+ * THE QUESTION MODEL — a factory over the two reference tables (lib/schema.js), for the app's worker registry:
3
+ *
4
+ * // models/AskQuestion.js
5
+ * module.exports = require('@aria-framework/ask').createAskQuestionModel({
6
+ * driver: () => require('../lib/database').driver(), // READ PER CALL — never captured at require time
7
+ * sql: require('../lib/sql'), // returningId(), andTenant(), now()
8
+ * publicId: () => publicId('ask_question'),
9
+ * currentTenant: require('@aria-framework/db-worker').currentTenant
10
+ * });
11
+ *
12
+ * Extracted from the hub's and soc's models/AskQuestion.js — the same SQL, plus Soc's `closeStale`.
13
+ *
14
+ * WHOSE ROWS. Both tables are PARTNER-scoped by the app's generated tenancy migration: a question lands in the partner it
15
+ * was ASKED IN. No method sets `partner_id`; the column defaults from the session.
16
+ *
17
+ * THE ASKER'S OWN ROWS SPAN PARTNERS, and that is the one widening: the reads keyed on the asker — countSince, historyFor,
18
+ * historyCountFor, oneFor, rate — run at PLATFORM scope and REFUSE any other, and every one is bounded by `asked_by = ?`,
19
+ * which the caller takes from the principal, never from the request. The review list, markReviewed, purgeBefore and
20
+ * closeStale are the platform's alone for the same reason. partnerTokensSince STATES THE TENANT (sql.andTenant).
21
+ *
22
+ * `partners` is the app's table (id, name, is_house), joined for the partner a question was asked in.
23
+ */
24
+
25
+ 'use strict';
26
+
27
+ const OUTCOMES = Object.freeze(['working', 'answered', 'withheld', 'not_found', 'refused_write', 'cannot', 'busy', 'off', 'error']);
28
+ const CALL_OUTCOMES = Object.freeze(['ok', 'not_found', 'ambiguous', 'error']);
29
+ const REASONS = Object.freeze(['wrong_figure', 'wrong_record', 'no_answer', 'other']);
30
+ const UTC = /^\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}$/;
31
+
32
+ const intOf = (v) => Math.max(0, Math.trunc(Number(v)) || 0);
33
+ const json = (v) => (v == null ? null : JSON.stringify(v));
34
+ const parse = (t) => { if (t == null || t === '') return null; try { return JSON.parse(t); } catch (_) { return null; } };
35
+
36
+ function hydrate(r) {
37
+ if (!r) return null;
38
+ return {
39
+ ...r,
40
+ id: Number(r.id),
41
+ partner_id: r.partner_id == null ? null : Number(r.partner_id),
42
+ asked_by: Number(r.asked_by),
43
+ acting: Number(r.acting) === 1,
44
+ tokens: Number(r.tokens) || 0,
45
+ ms: Number(r.ms) || 0,
46
+ rating: r.rating == null ? null : Number(r.rating),
47
+ result: parse(r.result)
48
+ };
49
+ }
50
+
51
+ /**
52
+ * @param {object} o
53
+ * @param {Function} o.driver () → the db-worker driver (get/all/run)
54
+ * @param {object} [o.sql] { returningId(), andTenant(), now() } — returningId and now default to the driver's
55
+ * dialect; andTenant is the APP's (it knows its tenant expression) and is required
56
+ * @param {Function} o.publicId () → a new ask_question public id
57
+ * @param {Function} o.currentTenant () → { scope, partnerId } | null (db-worker's)
58
+ */
59
+ function createAskQuestionModel(o) {
60
+ if (typeof o.driver !== 'function') throw new Error('ask model: driver must be a FUNCTION returning the driver (read per call)');
61
+ if (typeof o.publicId !== 'function') throw new Error('ask model: publicId() is required');
62
+ if (typeof o.currentTenant !== 'function') throw new Error('ask model: currentTenant() is required');
63
+ if (!o.sql || typeof o.sql.andTenant !== 'function') throw new Error('ask model: sql.andTenant() is required (the app states its tenant)');
64
+ const d = () => o.driver();
65
+ const returningId = () => (o.sql.returningId ? o.sql.returningId() : (d().dialect && d().dialect.returning ? d().dialect.returning('id') : ''));
66
+ const now = () => (o.sql.now ? o.sql.now() : d().dialect.now());
67
+ const platformOnly = (fn, why) => {
68
+ const t = o.currentTenant();
69
+ if (!t || t.scope !== 'platform') {
70
+ throw new Error(`AskQuestion.${fn} must run at PLATFORM scope — ${why} (it was called at ${t ? `scope "${t.scope}"` : 'no scope'})`);
71
+ }
72
+ };
73
+ const SPANS = 'one person\'s questions span partners';
74
+ const FLEET = 'the review list spans every partner';
75
+ const WIDE = 'retention is installation-wide';
76
+
77
+ return {
78
+ OUTCOMES, CALL_OUTCOMES, REASONS,
79
+
80
+ /** Open the row — outcome `working` until `finish`. Written BEFORE the model is called. */
81
+ async open({ asked_by, asked_by_name, acting = false, context_kind = null, context_ref = null, question, prompt_hash = null }) {
82
+ const ref = o.publicId();
83
+ const r = await d().run(
84
+ `INSERT INTO ask_questions (public_id, asked_by, asked_by_name, acting, context_kind, context_ref, question, outcome, prompt_hash)
85
+ VALUES (?, ?, ?, ?, ?, ?, ?, 'working', ?)${returningId()}`,
86
+ [ref, Number(asked_by), String(asked_by_name).slice(0, 250), acting ? 1 : 0, context_kind, context_ref,
87
+ String(question), prompt_hash]);
88
+ return { id: Number(r.lastId), public_id: ref };
89
+ },
90
+
91
+ /** One look-up, as it ran. `args` is the VALIDATED argument object, never the model's raw text. */
92
+ async addCall(question_id, { position, tool, args, outcome, row_count = 0, ms = 0 }) {
93
+ if (!CALL_OUTCOMES.includes(outcome)) throw new TypeError(`AskQuestion.addCall: unknown outcome ${JSON.stringify(outcome)}`);
94
+ await d().run(
95
+ 'INSERT INTO ask_tool_calls (question_id, position, tool, args, outcome, row_count, ms) VALUES (?, ?, ?, ?, ?, ?, ?)',
96
+ [Number(question_id), intOf(position), String(tool), JSON.stringify(args || {}), outcome, intOf(row_count), intOf(ms)]);
97
+ },
98
+
99
+ /** Close the row: what came of the question, the answer as shown, and who served it. */
100
+ async finish(id, { outcome, answer = null, result = null, endpoint = null, model = null, tokens = 0, ms = 0 }) {
101
+ if (!OUTCOMES.includes(outcome) || outcome === 'working') throw new TypeError(`AskQuestion.finish: unknown outcome ${JSON.stringify(outcome)}`);
102
+ await d().run(
103
+ 'UPDATE ask_questions SET outcome = ?, answer = ?, result = ?, endpoint = ?, model = ?, tokens = ?, ms = ? WHERE id = ?',
104
+ [outcome, answer == null ? null : String(answer).slice(0, 4000), json(result), endpoint, model, intOf(tokens), intOf(ms), Number(id)]);
105
+ },
106
+
107
+ /** How many questions this person asked since a UTC instant, in EVERY partner. PLATFORM ONLY, bounded by asked_by. */
108
+ async countSince(asked_by, since) {
109
+ platformOnly('countSince', SPANS);
110
+ const r = await d().get('SELECT COUNT(*) AS n FROM ask_questions WHERE asked_by = ? AND created_at >= ?', [Number(asked_by), String(since)]);
111
+ return Number((r && r.n) || 0);
112
+ },
113
+
114
+ /** Tokens spent on questions asked in THIS tenant since a UTC instant — the per-partner budget. */
115
+ async partnerTokensSince(since) {
116
+ const r = await d().get(
117
+ `SELECT CAST(COALESCE(SUM(tokens), 0) AS BIGINT) AS n FROM ask_questions WHERE created_at >= ?${o.sql.andTenant()}`, [String(since)]);
118
+ return Number((r && r.n) || 0);
119
+ },
120
+
121
+ /** This person's last questions, newest first, with the partner each was asked in. PLATFORM ONLY. */
122
+ async historyFor(asked_by, { limit = 20 } = {}) {
123
+ platformOnly('historyFor', SPANS);
124
+ const n = Math.min(Math.max(intOf(limit), 1), 50);
125
+ const rows = await d().all(
126
+ `SELECT q.id, q.public_id, q.partner_id, p.name AS partner_name, q.acting, q.context_kind, q.context_ref, q.question,
127
+ q.outcome, q.rating, q.created_at
128
+ FROM ask_questions q
129
+ JOIN partners p ON p.id = q.partner_id
130
+ WHERE q.asked_by = ? AND q.outcome <> 'working'
131
+ ORDER BY q.created_at DESC, q.id DESC
132
+ LIMIT ?`, [Number(asked_by), n]);
133
+ return rows.map(hydrate);
134
+ },
135
+
136
+ /** How many finished questions this person has on record. PLATFORM ONLY. */
137
+ async historyCountFor(asked_by) {
138
+ platformOnly('historyCountFor', SPANS);
139
+ const r = await d().get("SELECT COUNT(*) AS n FROM ask_questions WHERE asked_by = ? AND outcome <> 'working'", [Number(asked_by)]);
140
+ return Number((r && r.n) || 0);
141
+ },
142
+
143
+ /** One of this person's questions by its ref, with its look-ups — or null (anybody else's reads as none). */
144
+ async oneFor(asked_by, ref) {
145
+ platformOnly('oneFor', SPANS);
146
+ const q = hydrate(await d().get(
147
+ `SELECT q.*, p.name AS partner_name FROM ask_questions q JOIN partners p ON p.id = q.partner_id
148
+ WHERE q.public_id = ? AND q.asked_by = ?`, [String(ref), Number(asked_by)]));
149
+ if (!q) return null;
150
+ q.calls = await d().all('SELECT position, tool, args, outcome, row_count, ms FROM ask_tool_calls WHERE question_id = ? ORDER BY position, id', [q.id]);
151
+ return q;
152
+ },
153
+
154
+ /** The asker's thumbs mark on one of THEIR questions. Returns whether a row changed. PLATFORM ONLY. */
155
+ async rate(asked_by, ref, { rating, reason = null, text = null }) {
156
+ platformOnly('rate', SPANS);
157
+ if (rating !== 1 && rating !== -1) throw new TypeError('AskQuestion.rate: rating is 1 or -1');
158
+ if (reason != null && !REASONS.includes(reason)) throw new TypeError(`AskQuestion.rate: unknown reason ${JSON.stringify(reason)}`);
159
+ const r = await d().run(
160
+ `UPDATE ask_questions SET rating = ?, feedback_reason = ?, feedback_text = ?, reviewed_at = NULL
161
+ WHERE public_id = ? AND asked_by = ? AND outcome <> 'working'`,
162
+ [rating, rating === -1 ? reason : null, rating === -1 && text ? String(text).slice(0, 300) : null, String(ref), Number(asked_by)]);
163
+ return Number(r.changes || 0) > 0;
164
+ },
165
+
166
+ /** The thumbs-down list, newest first, with each row's look-ups. PLATFORM ONLY. */
167
+ async reviewList({ state = 'open', partner_id = null, reason = null, limit = 200 } = {}) {
168
+ platformOnly('reviewList', FLEET);
169
+ const where = ['q.rating = -1'];
170
+ const params = [];
171
+ if (state === 'open') where.push('q.reviewed_at IS NULL');
172
+ else if (state === 'reviewed') where.push('q.reviewed_at IS NOT NULL');
173
+ if (partner_id != null) { where.push('q.partner_id = ?'); params.push(Number(partner_id)); }
174
+ if (reason != null) {
175
+ if (!REASONS.includes(reason)) throw new TypeError(`AskQuestion.reviewList: unknown reason ${JSON.stringify(reason)}`);
176
+ where.push('q.feedback_reason = ?'); params.push(reason);
177
+ }
178
+ const n = Math.min(Math.max(intOf(limit), 1), 500);
179
+ const rows = (await d().all(
180
+ `SELECT q.*, p.name AS partner_name, p.is_house AS partner_is_house
181
+ FROM ask_questions q JOIN partners p ON p.id = q.partner_id
182
+ WHERE ${where.join(' AND ')}
183
+ ORDER BY q.created_at DESC, q.id DESC
184
+ LIMIT ?`, [...params, n])).map(hydrate);
185
+ if (!rows.length) return rows;
186
+ const calls = await d().all(
187
+ `SELECT question_id, position, tool, args, outcome, row_count, ms FROM ask_tool_calls
188
+ WHERE question_id IN (${rows.map(() => '?').join(', ')}) ORDER BY question_id, position, id`, rows.map((r) => r.id));
189
+ for (const r of rows) r.calls = calls.filter((c) => Number(c.question_id) === r.id);
190
+ return rows;
191
+ },
192
+
193
+ /** Mark one thumbs-down answer reviewed. PLATFORM ONLY. Returns whether a row changed. */
194
+ async markReviewed(ref) {
195
+ platformOnly('markReviewed', FLEET);
196
+ const r = await d().run(`UPDATE ask_questions SET reviewed_at = ${now()} WHERE public_id = ? AND rating = -1 AND reviewed_at IS NULL`, [String(ref)]);
197
+ return Number(r.changes || 0) > 0;
198
+ },
199
+
200
+ /** RETENTION: delete every question created before `cut` (UTC), in every partner; look-ups cascade. PLATFORM ONLY. */
201
+ async purgeBefore(cut) {
202
+ platformOnly('purgeBefore', WIDE);
203
+ if (!UTC.test(String(cut))) throw new TypeError('AskQuestion.purgeBefore: cut is YYYY-MM-DD HH:MM:SS (UTC)');
204
+ const r = await d().run('DELETE FROM ask_questions WHERE created_at < ?', [String(cut)]);
205
+ return Number(r.changes || 0);
206
+ },
207
+
208
+ /** THE BOUND ON A STUCK ROW: close every `working` row opened before `cut` as `error`. PLATFORM ONLY. */
209
+ async closeStale(cut) {
210
+ platformOnly('closeStale', WIDE);
211
+ if (!UTC.test(String(cut))) throw new TypeError('AskQuestion.closeStale: cut is YYYY-MM-DD HH:MM:SS (UTC)');
212
+ const r = await d().run("UPDATE ask_questions SET outcome = 'error' WHERE outcome = 'working' AND created_at < ?", [String(cut)]);
213
+ return Number(r.changes || 0);
214
+ }
215
+ };
216
+ }
217
+
218
+ module.exports = { createAskQuestionModel, OUTCOMES, CALL_OUTCOMES, REASONS };
package/lib/stream.js ADDED
@@ -0,0 +1,58 @@
1
+ /**
2
+ * THE ANSWER STREAM, VERSION `ask/1` (D-0169 item 8: written down now, so a later remote Ask returns exactly this).
3
+ *
4
+ * POST /ask answers with NDJSON — one JSON object per line, in this order:
5
+ *
6
+ * { type: 'start', v: 'ask/1', reading } the asker's own reading, never anything about the context
7
+ * { type: 'step', n, words, state: 'run' | 'done' } 0 = planning; 1…4 = each look-up; last = writing the answer
8
+ * { type: 'answer', answer: <render model> } FINAL — run.js's render model (below)
9
+ * { type: 'refused', kind?, text, resets? } FINAL, and the ONLY line (a 429 before any stream opened)
10
+ * { type: 'error', text } FINAL — the question failed past what run.js answers for
11
+ *
12
+ * THE RENDER MODEL: { outcome, by: 'ai' | 'fixed', text?, segments?, chips, quotes?, lookups, provenance, ref, question,
13
+ * context, reading, at, why?, whyCode?, checked?, link? }. Every href in it is APP-RELATIVE ('/customers/12'): a caller
14
+ * drawing another app's answer prefixes the service's own base URL from the registry. Chips stay app-drawn, so "no model
15
+ * links" holds across apps: a remote answer's words are drawn as text and its chips as that app's links.
16
+ *
17
+ * The `v` on the start line is new in the package; the apps' drawers ignore unknown keys, so adding it changes nothing
18
+ * they draw.
19
+ */
20
+
21
+ 'use strict';
22
+
23
+ const STREAM_VERSION = 'ask/1';
24
+ const TYPES = Object.freeze(['start', 'step', 'answer', 'refused', 'error']);
25
+ const FINAL = Object.freeze(['answer', 'refused', 'error']);
26
+
27
+ const line = (o) => `${JSON.stringify(o)}\n`;
28
+ const start = (reading) => ({ type: 'start', v: STREAM_VERSION, reading: reading == null ? null : String(reading) });
29
+ const step = (s) => ({ type: 'step', n: s.n, words: s.words, state: s.state });
30
+ const answer = (a) => ({ type: 'answer', answer: a });
31
+ const refused = (r) => ({ type: 'refused', ...(r.kind ? { kind: r.kind } : {}), text: r.text, ...(r.resets ? { resets: r.resets } : {}) });
32
+ const error = (text) => ({ type: 'error', text });
33
+
34
+ /**
35
+ * Read one line of an `ask/1` stream. Returns the object, or throws a sentence saying what is wrong — a caller drawing a
36
+ * remote app's stream refuses anything that is not this contract instead of guessing.
37
+ */
38
+ function parseLine(text) {
39
+ let o;
40
+ try { o = JSON.parse(String(text)); } catch (_) { throw new Error('ask/1: a line is not JSON'); }
41
+ if (!o || typeof o !== 'object' || Array.isArray(o) || !TYPES.includes(o.type)) throw new Error('ask/1: a line has no known type');
42
+ if (o.type === 'start' && o.v !== undefined && o.v !== STREAM_VERSION) throw new Error(`ask/1: the stream is ${String(o.v).slice(0, 20)}, not ${STREAM_VERSION}`);
43
+ if (o.type === 'step' && (!Number.isInteger(o.n) || typeof o.words !== 'string' || !['run', 'done'].includes(o.state))) throw new Error('ask/1: a step line is malformed');
44
+ if (o.type === 'answer' && (!o.answer || typeof o.answer !== 'object' || typeof o.answer.outcome !== 'string')) throw new Error('ask/1: an answer line has no render model');
45
+ if ((o.type === 'refused' || o.type === 'error') && typeof o.text !== 'string') throw new Error(`ask/1: a ${o.type} line has no text`);
46
+ return o;
47
+ }
48
+
49
+ /** A whole stream's text → its lines, checked: one start (unless refused alone), steps, exactly one final line, last. */
50
+ function parseStream(text) {
51
+ const lines = String(text).split('\n').filter((l) => l.trim()).map(parseLine);
52
+ const finals = lines.filter((l) => FINAL.includes(l.type));
53
+ if (finals.length !== 1 || !FINAL.includes(lines[lines.length - 1].type)) throw new Error('ask/1: a stream ends with exactly one final line');
54
+ if (lines[0].type !== 'start' && !(lines.length === 1 && lines[0].type === 'refused')) throw new Error('ask/1: a stream opens with its start line');
55
+ return lines;
56
+ }
57
+
58
+ module.exports = { STREAM_VERSION, TYPES, FINAL, line, start, step, answer, refused, error, parseLine, parseStream };
package/package.json CHANGED
@@ -1,6 +1,43 @@
1
1
  {
2
2
  "name": "@aria-framework/ask",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
6
- }
3
+ "version": "0.1.1",
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
+ "license": "UNLICENSED",
6
+ "private": false,
7
+ "publishConfig": {
8
+ "access": "public"
9
+ },
10
+ "main": "index.js",
11
+ "bin": {
12
+ "ask-migration": "bin/ask-migration.js"
13
+ },
14
+ "files": [
15
+ "index.js",
16
+ "lib/",
17
+ "bin/",
18
+ "views/",
19
+ "assets/",
20
+ "README.md",
21
+ "CHANGELOG.md"
22
+ ],
23
+ "peerDependencies": {
24
+ "@aria-framework/ai": ">=0.19.0",
25
+ "@aria-framework/db-worker": ">=0.19.0"
26
+ },
27
+ "peerDependenciesMeta": {
28
+ "@aria-framework/db-worker": {
29
+ "optional": true
30
+ }
31
+ },
32
+ "devDependencies": {
33
+ "@electric-sql/pglite": "^0.5.8",
34
+ "better-sqlite3": "^12.10.0",
35
+ "ejs": "^6.0.1",
36
+ "express": "^5.2.1",
37
+ "jsdom": "^30.0.1"
38
+ },
39
+ "scripts": {
40
+ "test": "node test/run.js",
41
+ "prepublishOnly": "node ../../test/packaging.js ask"
42
+ }
43
+ }
@@ -0,0 +1,153 @@
1
+ <%# ASK SETTINGS (@aria-framework/ask). Platform only — ai_config.view to see, ai_config.edit to change. What the route is,
2
+ what gates it (the bench), which machines serve it and in what order, the limits, retention and the tools. Every
3
+ label and help line is the package's CANONICAL wording (lib/settings.js CANON, D-0169 item 5) — the same in every
4
+ app. Every change is audited as ai_config.ask_settings. App locals used: can, csrfToken, exactTag; askLayout and
5
+ askAdminBase are optional. %>
6
+ <% var _L = (typeof askLayout !== 'undefined' && askLayout) ? askLayout : { head: 'partials/staff-head', tabs: 'partials/admin-tabs', foot: 'partials/staff-foot' }; %>
7
+ <% var _base = (typeof askAdminBase === 'string' && askAdminBase) ? askAdminBase : '/admin/ai/ask'; %>
8
+ <% var _c = {}; canon.forEach(function (x) { _c[x.field] = x; }); %>
9
+ <%- include(_L.head) %>
10
+
11
+ <%- include(_L.tabs) %>
12
+
13
+ <% var _can = (typeof can === 'function') ? can : function () { return false; }; %>
14
+ <% var _edit = _can('ai_config.edit'); %>
15
+ <% var _err = errors || {}; %>
16
+ <% var _tag = (typeof exactTag === 'function') ? exactTag : function (v) { return String(v == null ? '' : v).replace(/[&<>"']/g, function (ch) { return '&#' + ch.charCodeAt(0) + ';'; }); }; %>
17
+
18
+ <form method="POST" action="<%= _base %>">
19
+ <input type="hidden" name="_csrf" value="<%= csrfToken() %>">
20
+
21
+ <div class="card mb-3"><div class="card-body">
22
+ <div class="d-flex align-items-center gap-2 flex-wrap">
23
+ <h6 class="card-title mb-0 flex-grow-1">Ask</h6>
24
+ <div class="form-check form-switch mb-0">
25
+ <input class="form-check-input" type="checkbox" role="switch" id="ask-enabled" name="enabled" <%= ask.enabled ? 'checked' : '' %> <%= _edit ? '' : 'disabled' %>>
26
+ <label class="form-check-label" for="ask-enabled"><%= _c.enabled.label %></label>
27
+ </div>
28
+ </div>
29
+ <p class="text-body-secondary small mt-2 mb-2"><%= _c.enabled.help %></p>
30
+ <% if (invalid && invalid.length) { %>
31
+ <div class="alert alert-warning small">A stored Ask setting could not be read (<%= invalid.join(', ') %>), so its default is in force. Saving this form replaces it.</div>
32
+ <% } %>
33
+
34
+ <div id="ask-bench-live" data-live-topic="ask-bench:house">
35
+ <% if (failingControls && failingControls.length) { %>
36
+ <div class="alert alert-danger small mb-2" data-failing-controls><strong>Ask is off: a bench control no longer holds</strong> (<%= failingControls.join(', ') %>). The app's own fences changed since the bench; every endpoint is refused until they hold again.</div>
37
+ <% } %>
38
+ <% if (record) { %>
39
+ <% var _passed = endpoints.filter(function (e) { return e.bench && e.bench.passed; }); %>
40
+ <% var _refused = !!(failingControls && failingControls.length); %>
41
+ <div class="alert <%= _passed.length && !_refused ? 'alert-success' : 'alert-warning' %> small mb-2">
42
+ <% if (_passed.length && _refused) { %><strong>The stored bench passed, but the gate refuses it now</strong> — on<% } else if (_passed.length) { %><strong>Ask bench passed</strong> on <%= _passed.map(function (e) { return e.label; }).join(', ') %><% } else { %><strong>No endpoint has passed the ask bench</strong><% } %>
43
+ · run <%- _tag(record.at) %> · <%= record.cases %> injections, <%= record.controls %> controls, for prompt <code><%= hash.slice(0, 4) %>…<%= hash.slice(-4) %></code>.
44
+ The gate is binding: change the prompt, the tools or the scoring and the route stays off until the bench passes again.
45
+ </div>
46
+ <% } else { %>
47
+ <div class="alert alert-warning small mb-2">
48
+ <strong>The ask bench has not passed for the current prompt</strong><% if (staleRecord) { %> (the stored record is for an earlier prompt or tool list, or was scored the old way)<% } %>,
49
+ so Ask stays off for everyone — the box says “AI assistance is switched off”. <%= corpusSize %> injections and <%= controlsSize %> controls;
50
+ an endpoint passes at <%= passRate %> % resisted with every control held.
51
+ </div>
52
+ <% } %>
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>
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
+ <% } else if (benchState.error) { %>
57
+ <p class="small text-danger mb-2">The last bench run failed: <%= benchState.error %></p>
58
+ <% } %>
59
+ <% if (_edit) { %>
60
+ <button type="submit" class="btn btn-outline-primary btn-sm" formaction="<%= _base %>/bench" <%= benchState.running ? 'disabled' : '' %>>Run the bench now</button>
61
+ <% } %>
62
+ </div>
63
+ </div></div>
64
+
65
+ <div class="card mb-3"><div class="card-body" id="ask-endpoints-live" data-live-topic="ask-bench:house">
66
+ <h6 class="card-title">Endpoints for route ask <span class="cnt"><%= endpoints.length %></span></h6>
67
+ <p class="text-body-secondary small">Walked in this order. <% if (providerKinds) { %>Only endpoints of the kinds this route may use (<%= providerKinds %>) are used, and only<% } else { %>Only<% } %> ones chosen for this route — there is no default list. An endpoint without a passing ask bench is skipped and the answer says so. If every endpoint is skipped the person is told the AI is busy; nothing moves quietly to another model.</p>
68
+ <% if (!endpoints.length) { %>
69
+ <p class="small mb-0">No endpoint is assigned to Ask, so it is off.<% if (jobsHref) { %> Assign one<%= providerKinds ? ' (' + providerKinds + ')' : '' %> under <a href="<%= jobsHref %>">Jobs</a>.<% } %></p>
70
+ <% } else { %>
71
+ <div class="table-responsive"><table class="table table-sm align-middle mb-0">
72
+ <thead><tr><th>Order</th><th>Endpoint</th><th>Engine</th><th>Ask bench</th><th>Now</th></tr></thead>
73
+ <tbody>
74
+ <% endpoints.forEach(function (e) { %>
75
+ <tr>
76
+ <td><%= e.order %></td>
77
+ <td><%= e.label %> <span class="text-body-secondary small"><%= e.provider %></span></td>
78
+ <td class="small"><%= (e.bench && e.bench.model) || e.model || '—' %></td>
79
+ <td class="small"><% if (e.bench) { %>
80
+ <span class="tagpill sm <%= e.bench.passed ? 'ok' : 'warn' %>"><%= e.bench.passed ? 'passed' : 'not passed' %></span>
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>
83
+ <% if (e.bench.topReasons && e.bench.topReasons.length) { %>
84
+ <div class="text-body-secondary">Top reasons: <%= e.bench.topReasons.map(function (x) { return x.code + ' ×' + x.n; }).join(', ') %></div>
85
+ <% } %>
86
+ <% } else { %>
87
+ <%= e.bench.rate %> % · <%= e.bench.controlsHeld %> of <%= e.bench.controlsTotal %> controls
88
+ <% } %>
89
+ <% } else { %>not run<% } %></td>
90
+ <td class="small"><%= e.now %></td>
91
+ </tr>
92
+ <% }) %>
93
+ </tbody>
94
+ </table></div>
95
+ <% } %>
96
+ </div></div>
97
+
98
+ <div class="card mb-3"><div class="card-body">
99
+ <h6 class="card-title">Limits</h6>
100
+ <p class="text-body-secondary small">Over a limit, the person gets a plain line saying which one and when it resets.</p>
101
+ <div class="row g-3">
102
+ <% [['limitHour', 'ask-lh'], ['limitDay', 'ask-ld'], ['partnerTokensDay', 'ask-pt']].forEach(function (f) { var x = _c[f[0]]; %>
103
+ <div class="col-sm-4">
104
+ <label class="form-label" for="<%= f[1] %>"><%= x.label %></label>
105
+ <input class="form-control <%= _err[x.form] ? 'is-invalid' : '' %>" id="<%= f[1] %>" name="<%= x.form %>" inputmode="numeric" value="<%= ask[x.field] %>" <%= _edit ? '' : 'disabled' %>>
106
+ <% if (x.help) { %><div class="form-text"><%= x.help %></div><% } %>
107
+ <% if (_err[x.form]) { %><div class="invalid-feedback"><%= _err[x.form] %></div><% } %>
108
+ </div>
109
+ <% }) %>
110
+ </div>
111
+ <p class="small mt-2 mb-0">Questions at a time: <strong>1 per person</strong> · fixed.</p>
112
+ </div></div>
113
+
114
+ <div class="card mb-3"><div class="card-body">
115
+ <h6 class="card-title"><%= _c.retention.label %></h6>
116
+ <p class="text-body-secondary small"><%= _c.retention.help %></p>
117
+ <% _c.retention.options.forEach(function (r) { %>
118
+ <div class="form-check form-check-inline">
119
+ <input class="form-check-input" type="radio" name="retention" id="ask-ret-<%= r[0] %>" value="<%= r[0] %>" <%= String(ask.retention) === r[0] ? 'checked' : '' %> <%= _edit ? '' : 'disabled' %>>
120
+ <label class="form-check-label" for="ask-ret-<%= r[0] %>"><%= r[1] %></label>
121
+ </div>
122
+ <% }) %>
123
+ <% if (_err.retention) { %><div class="text-danger small"><%= _err.retention %></div><% } %>
124
+ <p class="small mt-2 mb-0">Each person’s history follows this setting.</p>
125
+ </div></div>
126
+
127
+ <div class="card mb-3"><div class="card-body">
128
+ <h6 class="card-title"><%= _c.toolsOff.label %> <span class="cnt"><%= tools.length %></span></h6>
129
+ <p class="text-body-secondary small"><%= _c.toolsOff.help %></p>
130
+ <div class="table-responsive"><table class="table table-sm align-middle mb-0">
131
+ <thead><tr><th>On</th><th>Tool</th><th>Needs the user’s permission</th></tr></thead>
132
+ <tbody>
133
+ <% tools.forEach(function (t) { %>
134
+ <tr<%= t.remote ? ' class="text-body-secondary"' : '' %>>
135
+ <td><input class="form-check-input" type="checkbox" name="tools" value="<%= t.id %>" id="ask-tool-<%= t.id %>" <%= t.on && !t.remote ? 'checked' : '' %> <%= _edit && !t.remote ? '' : 'disabled' %>></td>
136
+ <td><label for="ask-tool-<%= t.id %>"><%= t.label %></label> <code class="small"><%= t.id %></code><% if (t.remote) { %> <span class="small">another app’s Ask — not offered yet</span><% } %></td>
137
+ <td class="small"><code><%= t.permission %></code><% if (t.parts.length) { %> (+ parts: <%= t.parts.join(', ') %>)<% } %></td>
138
+ </tr>
139
+ <% }) %>
140
+ </tbody>
141
+ </table></div>
142
+ </div></div>
143
+
144
+ <% if (_edit) { %>
145
+ <div class="d-flex gap-2 align-items-center">
146
+ <button type="submit" class="btn btn-primary">Save Ask settings</button>
147
+ <a class="btn btn-link" href="<%= _base %>">Discard</a>
148
+ <span class="small text-body-secondary">Changes are recorded in the audit log.</span>
149
+ </div>
150
+ <% } %>
151
+ </form>
152
+
153
+ <%- include(_L.foot) %>
@@ -0,0 +1,59 @@
1
+ <%#
2
+ ASK ANYTHING — the drawer (@aria-framework/ask). Included by the app's staff-foot — include('ask/drawer', { askWords,
3
+ askSuggestions }) — and only for a viewer holding ai.ask, beside /js/ask/ask.css and /js/ask/ask.js (assetsDir).
4
+
5
+ Everything inside the conversation is drawn by assets/ask.js from the `ask/1` stream, as TEXT (textContent) — no model
6
+ words ever become HTML, and the only links are the app's own hrefs from the look-ups (chips) and the page context.
7
+
8
+ LOCALS (the app's words, D-0169 item 6):
9
+ askContext { kind, ref, name } set by a page — its ref and name, never page text; the route resolves it again
10
+ askWords { heading, intro } (default 'Ask' and a generic line)
11
+ askSuggestions { <context kind>: [{ title, items: [..] }], '*': [...] } — '{name}' in a title is the context's name
12
+ %>
13
+ <% var _ctx = (typeof askContext !== 'undefined' && askContext) ? askContext : null; %>
14
+ <% var _w = (typeof askWords !== 'undefined' && askWords) ? askWords : {}; %>
15
+ <% var _s = (typeof askSuggestions !== 'undefined' && askSuggestions) ? askSuggestions : {}; %>
16
+ <% var _groups = (_ctx && _s[_ctx.kind]) ? _s[_ctx.kind] : (_s['*'] || []); %>
17
+ <aside id="ask-drawer" class="ask-drawer" role="dialog" aria-modal="false" aria-labelledby="ask-title" hidden
18
+ data-context-kind="<%= _ctx ? _ctx.kind : '' %>" data-context-ref="<%= _ctx ? _ctx.ref : '' %>" data-context-name="<%= _ctx ? _ctx.name : '' %>"
19
+ data-history-note="<%= _w.historyNote || 'Your last 20 questions here · kept per the retention setting · only you see them.' %>" data-ask-base="<%= _w.base || '/ask' %>">
20
+ <div class="ask-head">
21
+ <h2 id="ask-title" class="ask-title"><i class="bi bi-stars" aria-hidden="true"></i> <span data-ask-heading><%= _w.heading || 'Ask' %></span></h2>
22
+ <div class="ask-tools">
23
+ <button type="button" class="btn btn-sm btn-link" data-ask-history>History</button>
24
+ <button type="button" class="btn btn-sm btn-link" data-ask-new>New question</button>
25
+ <button type="button" class="btn-close" data-ask-close aria-label="Close"></button>
26
+ </div>
27
+ </div>
28
+ <p class="ask-scope small text-body-secondary mb-0"><span data-ask-reading></span><span data-ask-reading-sep hidden> · </span>Answers use only what you can open. It can’t change anything.</p>
29
+ <div class="ask-acting small" data-ask-acting hidden></div>
30
+
31
+ <div class="ask-body" data-ask-body aria-live="polite" aria-busy="false">
32
+ <div class="ask-empty" data-ask-empty>
33
+ <p class="small text-body-secondary"><%= _w.intro || 'Ask about what you can open here.' %></p>
34
+ <div class="ask-sugg" data-ask-suggestions>
35
+ <% _groups.forEach(function (g) { %>
36
+ <h3><%= String(g.title || '').replace('{name}', _ctx ? _ctx.name : '') %></h3>
37
+ <ul>
38
+ <% (g.items || []).forEach(function (q) { %><li><button type="button" data-ask-suggest><%= q %></button></li><% }) %>
39
+ </ul>
40
+ <% }) %>
41
+ </div>
42
+ </div>
43
+ <ol class="ask-conv" data-ask-conv></ol>
44
+ <div class="ask-history" data-ask-history-panel hidden></div>
45
+ </div>
46
+
47
+ <form class="ask-form" data-ask-form autocomplete="off">
48
+ <% if (_ctx) { %>
49
+ <span class="ask-ctx" data-ask-ctx>About: <span><%= _ctx.name %></span>
50
+ <button type="button" class="cx" data-ask-ctx-clear aria-label="Ask without this page’s context">×</button></span>
51
+ <% } %>
52
+ <div class="ask-row">
53
+ <label for="ask-q" class="visually-hidden">Your question</label>
54
+ <textarea id="ask-q" name="question" rows="2" maxlength="500" placeholder="Your question" data-ask-input></textarea>
55
+ <button type="submit" class="btn btn-primary" data-ask-send>Ask</button>
56
+ </div>
57
+ <p class="small text-body-secondary mb-0" data-ask-hint>Enter sends · Shift+Enter for a new line · Esc closes</p>
58
+ </form>
59
+ </aside>
@@ -0,0 +1,90 @@
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
+ <%# ASK REVIEW (@aria-framework/ask): answers marked not helpful, for the platform (ai_config.view — partner admins do not
8
+ see this list). Every partner's rows, with a Partner filter. Question and feedback text are shown AS TYPED, escaped,
9
+ as data. Rows go when the question's retention ends. App locals used: can, csrfToken, whenTag; askLayout and
10
+ askReviewBase are optional. %>
11
+ <% var _can = (typeof can === 'function') ? can : function () { return false; }; %>
12
+
13
+ <div class="card mb-3"><div class="card-body">
14
+ <h6 class="card-title">Questions marked not helpful <span class="cnt"><%= rows.length %></span></h6>
15
+ <form method="GET" action="<%= _base %>" class="row g-2 align-items-end mb-3">
16
+ <div class="col-auto">
17
+ <label class="form-label small" for="rv-partner">Partner</label>
18
+ <select class="form-select form-select-sm" id="rv-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="rv-reason">Reason</label>
25
+ <select class="form-select form-select-sm" id="rv-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">
31
+ <label class="form-label small" for="rv-state">State</label>
32
+ <select class="form-select form-select-sm" id="rv-state" name="state">
33
+ <option value="open" <%= state === 'open' ? 'selected' : '' %>>Not reviewed</option>
34
+ <option value="reviewed" <%= state === 'reviewed' ? 'selected' : '' %>>Reviewed</option>
35
+ <option value="all" <%= state === 'all' ? 'selected' : '' %>>All</option>
36
+ </select>
37
+ </div>
38
+ <div class="col-auto"><button type="submit" class="btn btn-sm btn-outline-primary">Filter</button></div>
39
+ <div class="col-auto small text-body-secondary">Kept as long as the Ask retention setting — older questions are deleted.</div>
40
+ </form>
41
+
42
+ <% if (!rows.length) { %>
43
+ <p class="small mb-0">No answers marked not helpful<%= state === 'open' ? ' are waiting for review' : '' %>.</p>
44
+ <% } else { %>
45
+ <div class="table-responsive"><table class="table table-sm align-top mb-0">
46
+ <thead><tr><th>Time</th><th>User</th><th>Partner</th><th>App</th><th>Question</th><th>Tools</th><th>Outcome</th><th>Reason</th></tr></thead>
47
+ <tbody>
48
+ <% rows.forEach(function (r) { %>
49
+ <tr>
50
+ <td class="small text-nowrap"><%- whenTag(r.created_at) %></td>
51
+ <td class="small"><%= r.asked_by_name %><%= r.acting ? ' (working inside)' : '' %></td>
52
+ <td class="small"><%= r.partner_name %></td>
53
+ <td class="small"><%= typeof appName !== 'undefined' ? appName : '' %></td>
54
+ <td>
55
+ <details>
56
+ <summary><%= r.question %></summary>
57
+ <div class="small mt-2">
58
+ <div class="fw-semibold">Look-ups</div>
59
+ <% if (!r.calls.length) { %><div class="text-body-secondary">none ran</div><% } %>
60
+ <ol class="mb-2">
61
+ <% r.calls.forEach(function (c) { %>
62
+ <li><code><%= c.tool %></code> · <%= c.row_count %> row<%= Number(c.row_count) === 1 ? '' : 's' %> · <%= c.ms %> ms · <%= c.outcome %> <code class="d-block text-wrap"><%= c.args %></code></li>
63
+ <% }) %>
64
+ </ol>
65
+ <div class="fw-semibold">Answer and feedback</div>
66
+ <% if (r.answer) { %><p class="mb-1">“<%= r.answer %>”</p><% } else { %><p class="mb-1 text-body-secondary">Fixed wording (no AI answer).</p><% } %>
67
+ <p class="mb-1">Reason: <%= r.reasonWords || '—' %><% if (r.feedback_text) { %> · Their words: “<%= r.feedback_text %>”<% } %></p>
68
+ <p class="mb-2 text-body-secondary">Model <%= r.endpoint || '—' %> <%= r.model || '' %> · <%= (Number(r.ms) / 1000).toFixed(1) %> s · <%= Number(r.tokens).toLocaleString() %> tokens</p>
69
+ <% if (!r.reviewed_at && _can('ai_config.edit')) { %>
70
+ <form method="POST" action="<%= _base %>/<%= r.public_id %>/reviewed">
71
+ <input type="hidden" name="_csrf" value="<%= csrfToken() %>">
72
+ <button type="submit" class="btn btn-sm btn-outline-primary">Mark reviewed</button>
73
+ </form>
74
+ <% } else if (r.reviewed_at) { %>
75
+ <span class="tagpill sm ok">Reviewed</span>
76
+ <% } %>
77
+ </div>
78
+ </details>
79
+ </td>
80
+ <td class="small"><%= r.calls.length %></td>
81
+ <td class="small"><%= r.outcomeWords %></td>
82
+ <td class="small"><%= r.reasonWords || '—' %></td>
83
+ </tr>
84
+ <% }) %>
85
+ </tbody>
86
+ </table></div>
87
+ <% } %>
88
+ </div></div>
89
+
90
+ <%- include(_L.foot) %>