@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/CHANGELOG.md +77 -0
- package/README.md +144 -2
- package/assets/ask.css +81 -0
- package/assets/ask.js +430 -0
- package/bin/ask-migration.js +52 -0
- package/index.js +116 -0
- package/lib/bank.js +63 -0
- package/lib/bench.js +410 -0
- package/lib/catalogue.js +230 -0
- package/lib/check.js +357 -0
- package/lib/common.js +31 -0
- package/lib/docs.js +306 -0
- package/lib/gate.js +44 -0
- package/lib/history.js +32 -0
- package/lib/limits.js +56 -0
- package/lib/prompts.js +95 -0
- package/lib/retention.js +48 -0
- package/lib/routes.js +304 -0
- package/lib/run.js +266 -0
- package/lib/schema.js +396 -0
- package/lib/settings.js +142 -0
- package/lib/store.js +218 -0
- package/lib/stream.js +58 -0
- package/package.json +41 -4
- package/views/ask/admin.ejs +153 -0
- package/views/ask/drawer.ejs +59 -0
- package/views/ask/review.ejs +90 -0
package/lib/routes.js
ADDED
|
@@ -0,0 +1,304 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The Ask routes, as factories over the app's own express, gates and audit (no express dependency in this package).
|
|
3
|
+
*
|
|
4
|
+
* createAskRouter → mounted at /ask behind the app's requireAuth. Every route needs `ai.ask`.
|
|
5
|
+
* POST /ask ask one question; the reply is the `ask/1` NDJSON stream (lib/stream.js). Every question
|
|
6
|
+
* that got this far is audited `ask.asked` in a `finally`, whatever became of it.
|
|
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)
|
|
9
|
+
* POST /ask/q/:ref/feedback the person's thumbs mark on their own question, JSON — audited `ask.feedback`
|
|
10
|
+
*
|
|
11
|
+
* createAdminRouters → { settingsRouter at /admin/ai/ask, reviewRouter at /admin/ai/ask-review }, platform only
|
|
12
|
+
* (ai_config.view to see, ai_config.edit to change). Audited `ai_config.ask_settings`, `ai_config.ask_bench`,
|
|
13
|
+
* `ai_config.ask_review` — the action names both apps already write.
|
|
14
|
+
*
|
|
15
|
+
* PAGE CONTEXT is a ref the PAGE put there, re-resolved by the app's `contextOf(body, can, req, { offered })` under the
|
|
16
|
+
* request's own scope and page permissions. ENFORCED HERE, for every app (Soc review S2, which the hub lacked): a context
|
|
17
|
+
* kind that is read by a context tool (createAsk's contextTools) is resolved ONLY when that tool is in this person's
|
|
18
|
+
* catalogue with the tools-off list applied — `contextOf` is not even called otherwise — and what it returns must be the
|
|
19
|
+
* kind that was asked for, with a ref of at most 64 characters, or it is dropped.
|
|
20
|
+
*
|
|
21
|
+
* NO partner_id, NO scope and NO permission comes from the request body. The asker is `principalOf`.
|
|
22
|
+
*/
|
|
23
|
+
|
|
24
|
+
'use strict';
|
|
25
|
+
|
|
26
|
+
const stream = require('./stream');
|
|
27
|
+
|
|
28
|
+
const s = (v) => (typeof v === 'string' ? v.trim() : '');
|
|
29
|
+
const REASON_WORDS = Object.freeze({ wrong_figure: 'Wrong figure', wrong_record: 'Wrong record', no_answer: 'Didn’t answer', other: 'Other' });
|
|
30
|
+
const OUTCOME_WORDS = Object.freeze({ answered: 'answered', withheld: 'withheld', not_found: 'not found', refused_write: 'refused · write',
|
|
31
|
+
cannot: 'could not answer', busy: 'busy', off: 'off', error: 'error', working: 'unfinished' });
|
|
32
|
+
|
|
33
|
+
const wrap = (fn) => (req, res, next) => { Promise.resolve(fn(req, res, next)).catch(next); };
|
|
34
|
+
const canOf = (res) => (p) => Boolean(res.locals && typeof res.locals.can === 'function' && res.locals.can(p));
|
|
35
|
+
const redirectWith = (res, type, msg, url) => (typeof res.flash === 'function' ? res.flash(type, msg, () => res.redirect(url)) : res.redirect(url));
|
|
36
|
+
|
|
37
|
+
function routerOf(o) {
|
|
38
|
+
if (o.express && typeof o.express.Router === 'function') return o.express.Router();
|
|
39
|
+
if (typeof o.Router === 'function') return o.Router();
|
|
40
|
+
throw new Error('ask routes: pass the app\'s express (or its Router)');
|
|
41
|
+
}
|
|
42
|
+
function need(o, keys) { for (const k of keys) if (typeof o[k] !== 'function') throw new Error(`ask routes: ${k} is required`); }
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* @param {object} o
|
|
46
|
+
* @param {object} o.ask createAsk's result (run, limits, history, settings, catalogue, contextKinds, principalOf, isPublicId)
|
|
47
|
+
* @param {object} o.express the app's express (or o.Router)
|
|
48
|
+
* @param {Function} o.requirePermission (perm) → middleware
|
|
49
|
+
* @param {Function} o.audit (req, action, { entity_type, entity_id?, detail })
|
|
50
|
+
* @param {Function} o.readingOf async (req, can) → the reading the drawer states
|
|
51
|
+
* @param {Function} o.contextOf async (body, can, req, { offered: Set<toolId> }) → { kind, ref, plannerRef, href, … } | null
|
|
52
|
+
* @param {Function} [o.timezoneOf] (req, res) → the viewer's zone (default res.locals.displayTimezone || 'UTC')
|
|
53
|
+
* @param {object} [o.logger]
|
|
54
|
+
*/
|
|
55
|
+
function createAskRouter(o) {
|
|
56
|
+
need(o, ['requirePermission', 'audit', 'readingOf', 'contextOf']);
|
|
57
|
+
const A = o.ask;
|
|
58
|
+
const router = routerOf(o);
|
|
59
|
+
const logger = o.logger || A.logger;
|
|
60
|
+
const timezoneOf = o.timezoneOf || ((req, res) => (res.locals && res.locals.displayTimezone) || 'UTC');
|
|
61
|
+
const kindTool = A.contextToolFor;
|
|
62
|
+
|
|
63
|
+
/** The page context, resolved by the app — and only when its context tool is offered (the header). */
|
|
64
|
+
async function contextOf(body, can, req, toolsOff) {
|
|
65
|
+
const kind = s(body.context_kind);
|
|
66
|
+
const ref = s(body.context_ref);
|
|
67
|
+
if (!kind || !ref || !A.contextKinds.includes(kind)) return null;
|
|
68
|
+
const offered = new Set(A.catalogue.catalogueFor(can, toolsOff).map((t) => t.id));
|
|
69
|
+
const tool = kindTool(kind);
|
|
70
|
+
if (tool && !offered.has(tool)) return null;
|
|
71
|
+
const c = await o.contextOf({ context_kind: kind, context_ref: ref }, can, req, { offered });
|
|
72
|
+
if (!c || c.kind !== kind || typeof c.ref !== 'string' || !c.ref || c.ref.length > 64) return null;
|
|
73
|
+
return c;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
router.post('/', o.requirePermission('ai.ask'), wrap(async (req, res) => {
|
|
77
|
+
const can = canOf(res);
|
|
78
|
+
const who = A.principalOf(req, can);
|
|
79
|
+
const question = A.run.cleanQuestion(req.body && req.body.question);
|
|
80
|
+
if (!question) return res.status(400).json(stream.refused({ text: 'Type a question first.' }));
|
|
81
|
+
if (!A.limits.take(who.id)) return res.status(429).json(stream.refused({ kind: 'busy_self', text: A.limits.BUSY_SELF }));
|
|
82
|
+
try {
|
|
83
|
+
const settings = await A.settings.read();
|
|
84
|
+
const over = await A.limits.refusal(who.id, settings);
|
|
85
|
+
if (over) return res.status(429).json(stream.refused({ kind: over.kind, text: over.message, resets: over.resets || null }));
|
|
86
|
+
// NO CONTEXT IS EVEN RESOLVED while Ask is off.
|
|
87
|
+
const context = settings.enabled ? await contextOf(req.body || {}, can, req, settings.toolsOff) : null;
|
|
88
|
+
const reading = await o.readingOf(req, can);
|
|
89
|
+
res.status(200);
|
|
90
|
+
res.setHeader('Content-Type', 'application/x-ndjson; charset=utf-8');
|
|
91
|
+
res.setHeader('Cache-Control', 'no-store');
|
|
92
|
+
res.setHeader('X-Accel-Buffering', 'no');
|
|
93
|
+
if (typeof res.flushHeaders === 'function') res.flushHeaders();
|
|
94
|
+
const write = (x) => { if (!res.writableEnded) res.write(stream.line(x)); };
|
|
95
|
+
// THE START LINE CARRIES ONLY THE ASKER'S OWN READING — never anything about the page context.
|
|
96
|
+
write(stream.start(reading));
|
|
97
|
+
let answer = null;
|
|
98
|
+
try {
|
|
99
|
+
answer = await A.run.ask({ req, can, question, context, settings, reading, timezone: timezoneOf(req, res),
|
|
100
|
+
onStep: (st) => write(stream.step(st)) });
|
|
101
|
+
} catch (e) {
|
|
102
|
+
logger.error(`Ask: the question failed before it could be answered (${e && e.stack ? e.stack : e})`);
|
|
103
|
+
} finally {
|
|
104
|
+
try {
|
|
105
|
+
o.audit(req, 'ask.asked', { entity_type: 'ask_question', entity_id: null,
|
|
106
|
+
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 } });
|
|
108
|
+
} catch (e) { logger.error(`Ask: the audit line could not be written (${e && e.message ? e.message : e})`); }
|
|
109
|
+
}
|
|
110
|
+
write(answer ? stream.answer(answer) : stream.error(A.run.WORDS.error));
|
|
111
|
+
return res.end();
|
|
112
|
+
} finally {
|
|
113
|
+
A.limits.release(who.id);
|
|
114
|
+
}
|
|
115
|
+
}));
|
|
116
|
+
|
|
117
|
+
router.get('/history', o.requirePermission('ai.ask'), wrap(async (req, res) => {
|
|
118
|
+
const can = canOf(res);
|
|
119
|
+
const { rows, total } = await A.history.list(req, can);
|
|
120
|
+
res.json({
|
|
121
|
+
reading: await o.readingOf(req, can),
|
|
122
|
+
total,
|
|
123
|
+
rows: rows.map((r) => ({ ref: r.public_id, question: r.question, outcome: r.outcome, rating: r.rating, at: r.created_at,
|
|
124
|
+
partner: r.acting ? r.partner_name : null, context: r.context_ref ? { kind: r.context_kind, ref: r.context_ref } : null }))
|
|
125
|
+
});
|
|
126
|
+
}));
|
|
127
|
+
|
|
128
|
+
router.get('/q/:ref', o.requirePermission('ai.ask'), wrap(async (req, res) => {
|
|
129
|
+
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);
|
|
131
|
+
if (!q || !q.result) return res.status(404).json({ error: 'not_found' });
|
|
132
|
+
res.json({ answer: q.result, at: q.created_at, rating: q.rating, stale: true });
|
|
133
|
+
}));
|
|
134
|
+
|
|
135
|
+
router.post('/q/:ref/feedback', o.requirePermission('ai.ask'), wrap(async (req, res) => {
|
|
136
|
+
if (!A.isPublicId(req.params.ref, 'ask_question')) return res.status(404).json({ error: 'not_found' });
|
|
137
|
+
const body = req.body || {};
|
|
138
|
+
const rating = s(body.rating) === 'up' ? 1 : s(body.rating) === 'down' ? -1 : null;
|
|
139
|
+
if (rating == null) return res.status(400).json({ error: 'rating is up or down' });
|
|
140
|
+
const reason = rating === -1 && A.REASONS.includes(s(body.reason)) ? s(body.reason) : null;
|
|
141
|
+
const text = rating === -1 ? s(body.text).replace(/[\u0000-\u001f\u007f]+/g, ' ').slice(0, 300) || null : null;
|
|
142
|
+
const changed = await A.history.rate(req, canOf(res), req.params.ref, { rating, reason, text });
|
|
143
|
+
if (!changed) return res.status(404).json({ error: 'not_found' });
|
|
144
|
+
o.audit(req, 'ask.feedback', { entity_type: 'ask_question', detail: { ref: req.params.ref, rating: rating === 1 ? 'up' : 'down', reason } });
|
|
145
|
+
res.json({ ok: true });
|
|
146
|
+
}));
|
|
147
|
+
|
|
148
|
+
router.contextOf = contextOf;
|
|
149
|
+
return router;
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
/**
|
|
153
|
+
* @param {object} o
|
|
154
|
+
* @param {object} o.ask, o.express, o.requirePermission, o.audit
|
|
155
|
+
* @param {object} o.endpoints { providers(): [{id, label, kind, model, enabled}], assigned(route): [id], forRoute(route): [id] }
|
|
156
|
+
* @param {Function} o.routePolicy (route) → { providers: [kinds] } — which provider kinds the route may ever use
|
|
157
|
+
* @param {Function} o.ai () → the app's AI facade { configFor(id), complete(opts, cfg) }
|
|
158
|
+
* @param {Function} o.partners async () → [{ id, name, is_house }]
|
|
159
|
+
* @param {Function} [o.onBenchChange] the app's live hook (re-fetch the page's bench regions)
|
|
160
|
+
* @param {string} [o.appName] the review list's App column
|
|
161
|
+
* @param {string} [o.jobsHref] the app's page where endpoints are assigned to routes (linked from the empty state)
|
|
162
|
+
* @param {object} [o.layout] { head, tabs, foot } partial names (default partials/staff-head, admin-tabs, staff-foot)
|
|
163
|
+
* @param {Function} [o.invokeReview] (fn, ...args) → the model call (default ask.invoke('AskQuestion', fn, …))
|
|
164
|
+
*/
|
|
165
|
+
function createAdminRouters(o) {
|
|
166
|
+
need(o, ['requirePermission', 'audit', 'routePolicy', 'ai', 'partners']);
|
|
167
|
+
const A = o.ask;
|
|
168
|
+
const settingsRouter = routerOf(o);
|
|
169
|
+
const reviewRouter = routerOf(o);
|
|
170
|
+
const logger = o.logger || A.logger;
|
|
171
|
+
const touch = typeof o.onBenchChange === 'function' ? () => { try { o.onBenchChange(); } catch (_) { /* live is best effort */ } } : () => {};
|
|
172
|
+
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 };
|
|
174
|
+
const model = (fn, ...args) => A.invoke(A.modelName, fn, ...args);
|
|
175
|
+
|
|
176
|
+
const kindsOf = (policy) => (policy && Array.isArray(policy.providers) && policy.providers.length ? policy.providers.join(', ') : null);
|
|
177
|
+
async function pageModel(req, errors = null, form = null) {
|
|
178
|
+
const settings = await A.settings.read();
|
|
179
|
+
const record = await A.bench.read();
|
|
180
|
+
const hash = A.prompts.promptHash();
|
|
181
|
+
const providers = new Map(((await o.endpoints.providers()) || []).map((p) => [p.id, p]));
|
|
182
|
+
const assigned = (await o.endpoints.assigned('ask')) || [];
|
|
183
|
+
const serving = (await o.endpoints.forRoute('ask')) || [];
|
|
184
|
+
const policy = o.routePolicy('ask') || {};
|
|
185
|
+
const ok = A.bench.usable(record, hash);
|
|
186
|
+
// THE GATE'S OWN VERDICT (re-review of PR #9): a control that no longer holds closes the route for every endpoint, so
|
|
187
|
+
// the page says so and never reads "passed / serving" while the gate refuses.
|
|
188
|
+
const failingControls = A.bench.controls().filter((c) => !c.held).map((c) => c.id);
|
|
189
|
+
const endpoints = [];
|
|
190
|
+
for (const [i, id] of assigned.entries()) {
|
|
191
|
+
const p = providers.get(id) || {};
|
|
192
|
+
const r = ok && record.endpoints ? record.endpoints[id] : null;
|
|
193
|
+
const current = r && r.passed ? r.fingerprint && r.fingerprint === await A.bench.fingerprintFor(id) : false;
|
|
194
|
+
let now;
|
|
195
|
+
if (!p.id) now = 'not found';
|
|
196
|
+
else if (!(p.enabled === 1 || p.enabled === true)) now = 'switched off';
|
|
197
|
+
else if (policy.providers && !policy.providers.includes(p.kind)) now = `never used — not a kind this route may use (${kindsOf(policy)})`;
|
|
198
|
+
else if (!serving.includes(id)) now = 'not serving';
|
|
199
|
+
else if (failingControls.length) now = 'skipped — a bench control no longer holds';
|
|
200
|
+
else if (!r || !r.passed) now = 'skipped — no passing bench';
|
|
201
|
+
else if (!current) now = 'skipped — benched under another configuration (model, address or engine changed)';
|
|
202
|
+
else now = 'serving';
|
|
203
|
+
endpoints.push({ order: i + 1, id, label: p.label || id, provider: p.kind || '', model: p.model || '', bench: r, now });
|
|
204
|
+
}
|
|
205
|
+
const shown = form || settings;
|
|
206
|
+
return {
|
|
207
|
+
title: 'AI assistance',
|
|
208
|
+
ask: shown,
|
|
209
|
+
canon: A.settings.CANON,
|
|
210
|
+
invalid: settings.invalid,
|
|
211
|
+
errors,
|
|
212
|
+
record: ok ? record : null,
|
|
213
|
+
staleRecord: !!(record && !ok),
|
|
214
|
+
failingControls,
|
|
215
|
+
hash,
|
|
216
|
+
endpoints,
|
|
217
|
+
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
|
+
on: !shown.toolsOff.includes(t.id) })),
|
|
219
|
+
benchState: { ...benchState },
|
|
220
|
+
corpusSize: A.bench.corpus().length,
|
|
221
|
+
controlsSize: A.bench.controls().length,
|
|
222
|
+
passRate: Math.round(A.bench.PASS_RATE * 100),
|
|
223
|
+
askLayout: layout,
|
|
224
|
+
// DERIVED, never typed (review of PR #9): the provider kinds the route may use, this router's own mount, the app's
|
|
225
|
+
// page for assigning endpoints (none → no link).
|
|
226
|
+
providerKinds: kindsOf(policy),
|
|
227
|
+
askAdminBase: (req && req.baseUrl) || '/admin/ai/ask',
|
|
228
|
+
jobsHref: o.jobsHref || null,
|
|
229
|
+
adminTabStyle: 'underline'
|
|
230
|
+
};
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
settingsRouter.get('/', o.requirePermission('ai_config.view'), wrap(async (req, res) => {
|
|
234
|
+
res.render('ask/admin', await pageModel(req));
|
|
235
|
+
}));
|
|
236
|
+
|
|
237
|
+
settingsRouter.post('/', o.requirePermission('ai_config.edit'), wrap(async (req, res) => {
|
|
238
|
+
const body = req.body || {};
|
|
239
|
+
const parsed = A.settings.parseForm(body);
|
|
240
|
+
if (parsed.errors) {
|
|
241
|
+
const before = await A.settings.read();
|
|
242
|
+
const on = [].concat(body.tools || []).map(String);
|
|
243
|
+
const form = { ...before, enabled: body.enabled === 'on', toolsOff: A.catalogue.TOOL_IDS.filter((id) => !on.includes(id)),
|
|
244
|
+
limitHour: s(body.limit_hour), limitDay: s(body.limit_day), partnerTokensDay: s(body.partner_tokens_day), retention: s(body.retention) };
|
|
245
|
+
return res.status(400).render('ask/admin', await pageModel(req, parsed.errors, form));
|
|
246
|
+
}
|
|
247
|
+
const before = await A.settings.read();
|
|
248
|
+
await A.settings.write(parsed.values);
|
|
249
|
+
const v = parsed.values;
|
|
250
|
+
o.audit(req, 'ai_config.ask_settings', { entity_type: 'ai_config', detail: { route: 'ask',
|
|
251
|
+
enabled: v.enabled, limit_hour: v.limitHour, limit_day: v.limitDay, partner_tokens_day: v.partnerTokensDay,
|
|
252
|
+
retention: v.retention, tools_off: v.toolsOff.join(', ') || '(none)', was_enabled: before.enabled } });
|
|
253
|
+
redirectWith(res, 'success', 'Ask settings saved.', req.baseUrl || '/admin/ai/ask');
|
|
254
|
+
}));
|
|
255
|
+
|
|
256
|
+
settingsRouter.post('/bench', o.requirePermission('ai_config.edit'), wrap(async (req, res) => {
|
|
257
|
+
const back = req.baseUrl || '/admin/ai/ask';
|
|
258
|
+
if (benchState.running) return redirectWith(res, 'warning', 'The ask bench is already running.', back);
|
|
259
|
+
const endpoints = (await o.endpoints.forRoute('ask')) || [];
|
|
260
|
+
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
|
+
const ai = o.ai();
|
|
262
|
+
Object.assign(benchState, { running: true, endpoint: null, done: 0, of: 0, startedAt: new Date().toISOString(), error: null });
|
|
263
|
+
o.audit(req, 'ai_config.ask_bench', { entity_type: 'ai_config', detail: { route: 'ask', endpoints: endpoints.join(', '), cases: A.bench.corpus().length } });
|
|
264
|
+
// IN THE BACKGROUND, in this request's async context; the record is stored at the house's installation row. Each call
|
|
265
|
+
// goes to THAT endpoint's own config, the route named on the options AND the config.
|
|
266
|
+
A.bench.run({
|
|
267
|
+
endpoints,
|
|
268
|
+
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(); }
|
|
270
|
+
}).then((record) => A.bench.persistAtHouse(record))
|
|
271
|
+
.catch((e) => { benchState.error = e.message; logger.error(`Ask bench failed: ${e && e.stack ? e.stack : e}`); })
|
|
272
|
+
.finally(() => { benchState.running = false; touch(); });
|
|
273
|
+
touch();
|
|
274
|
+
redirectWith(res, 'success', `The ask bench is running on ${endpoints.length} endpoint${endpoints.length === 1 ? '' : 's'} — this page follows its progress.`, back);
|
|
275
|
+
}));
|
|
276
|
+
|
|
277
|
+
reviewRouter.get('/', o.requirePermission('ai_config.view'), wrap(async (req, res) => {
|
|
278
|
+
const state = ['open', 'reviewed', 'all'].includes(s(req.query.state)) ? s(req.query.state) : 'open';
|
|
279
|
+
const partners = ((await o.partners()) || []).map((p) => ({ id: String(p.id), name: p.name, isHouse: Number(p.is_house) === 1 }));
|
|
280
|
+
const partner = partners.find((p) => p.id === s(req.query.partner)) ? s(req.query.partner) : null;
|
|
281
|
+
const reason = A.REASONS.includes(s(req.query.reason)) ? s(req.query.reason) : null;
|
|
282
|
+
const rows = ((await model('reviewList', { state, partner_id: partner, reason })) || []).map((r) => ({
|
|
283
|
+
...r,
|
|
284
|
+
calls: (r.calls || []).map((c) => ({ ...c, args: String(c.args || '') })),
|
|
285
|
+
reasonWords: REASON_WORDS[r.feedback_reason] || null,
|
|
286
|
+
outcomeWords: OUTCOME_WORDS[r.outcome] || r.outcome
|
|
287
|
+
}));
|
|
288
|
+
res.render('ask/review', { title: 'AI assistance', rows, partners, partner, reason, state, reasons: REASON_WORDS, appName: o.appName || '', askReviewBase: req.baseUrl || '/admin/ai/ask-review',
|
|
289
|
+
askLayout: layout, adminTabStyle: 'underline' });
|
|
290
|
+
}));
|
|
291
|
+
|
|
292
|
+
reviewRouter.post('/:ref/reviewed', o.requirePermission('ai_config.edit'), wrap(async (req, res) => {
|
|
293
|
+
const back = req.baseUrl || '/admin/ai/ask-review';
|
|
294
|
+
if (!A.isPublicId(req.params.ref, 'ask_question')) return res.status(404).render('errors/404', { title: 'Not found' });
|
|
295
|
+
const changed = await model('markReviewed', req.params.ref);
|
|
296
|
+
if (!changed) return redirectWith(res, 'warning', 'That answer was already reviewed, or is no longer kept.', back);
|
|
297
|
+
o.audit(req, 'ai_config.ask_review', { entity_type: 'ask_question', detail: { ref: req.params.ref } });
|
|
298
|
+
redirectWith(res, 'success', 'Marked reviewed.', back);
|
|
299
|
+
}));
|
|
300
|
+
|
|
301
|
+
return { settingsRouter, reviewRouter, pageModel, _benchState: benchState };
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
module.exports = { createAskRouter, createAdminRouters, REASON_WORDS, OUTCOME_WORDS };
|
package/lib/run.js
ADDED
|
@@ -0,0 +1,266 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ONE ASK QUESTION, END TO END: 1 PLAN → 2 RUN → 3 ANSWER → 4 CHECK → 5 AUDIT. Extracted from the hub's and soc's
|
|
3
|
+
* lib/ask/run.js (2 Nov 2026), the stricter of each:
|
|
4
|
+
* - `finish` NEVER THROWS (the route has flushed its stream by then): the full write; if that fails, the full ERROR write
|
|
5
|
+
* (the hub's); if that fails, a minimal `{ outcome: 'error' }` (Soc's settle); if that fails too, the row is left for
|
|
6
|
+
* retention's closeStale and the person is still told, in the fixed error words.
|
|
7
|
+
* - an unoffered call is described in ITS OWN words (`calls[plan.index]`, Soc), not the plan's first call's.
|
|
8
|
+
* - the page context's id goes back only into the tool of its own kind (`contextTools`), AFTER validation.
|
|
9
|
+
* - Tier 1's own words (a result's `quote`) are drawn by the app beside the answer (`quotesOf`, Soc S7).
|
|
10
|
+
*
|
|
11
|
+
* RUNS INSIDE THE ASKER'S OWN REQUEST — never from a job or a timer — so every look-up carries the request's tenant
|
|
12
|
+
* context and its own `can`. Nothing here takes a partner, a scope or a permission from the question or the model.
|
|
13
|
+
*
|
|
14
|
+
* IDENTITY COMES FROM `principalOf(req, can)` ONLY (D-0169 item 8): today every app returns the session user; a remote
|
|
15
|
+
* Ask would return the user it resolved from a verified hub assertion — and nothing below changes.
|
|
16
|
+
*
|
|
17
|
+
* FIXED WORDING, NEVER THE MODEL'S, for every answer that is not a checked AI answer (the app's `words`).
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
'use strict';
|
|
21
|
+
|
|
22
|
+
const MAX_QUESTION = 500;
|
|
23
|
+
const BUSY_KINDS = ['unreachable', 'timeout', 'rate_limit', 'budget', 'disabled', 'unconfigured', 'bad_response', 'unsupported', 'auth'];
|
|
24
|
+
const WORD_KEYS = ['not_found', 'refused_write', 'cannot', 'cannot_plan', 'ambiguous', 'withheld', 'off', 'busy', 'error', 'nothing_to_ask'];
|
|
25
|
+
|
|
26
|
+
/** The question as typed, cut and cleaned: control characters become spaces. Empty → null. */
|
|
27
|
+
function cleanQuestion(q) {
|
|
28
|
+
const t = String(q == null ? '' : q).replace(/[\u0000-\u001f\u007f]+/g, ' ').replace(/\s+/g, ' ').trim();
|
|
29
|
+
return t ? t.slice(0, MAX_QUESTION) : null;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/** Tier 1's own words, as the app draws them: label, text, when, which model — never seen by the answer step. */
|
|
33
|
+
const quotesOf = (results) => results.filter((r) => r && r.quote && r.quote.text).map((r) => ({ label: r.quote.label, text: r.quote.text,
|
|
34
|
+
at: r.quote.at || null, model: r.quote.model || null }));
|
|
35
|
+
|
|
36
|
+
const tokensOf = (r) => (r && r.usage ? (Number(r.usage.total) || (Number(r.usage.prompt) || 0) + (Number(r.usage.completion) || 0)) : 0);
|
|
37
|
+
const msg = (e) => (e && e.message ? e.message : String(e));
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* A loop bound to an app.
|
|
41
|
+
* @param {object} o
|
|
42
|
+
* @param {Function} o.invoke (model, fn, ...args) → Promise — the app's db-client
|
|
43
|
+
* @param {string} [o.modelName] the question model's registry name (default 'AskQuestion')
|
|
44
|
+
* @param {object} o.logger
|
|
45
|
+
* @param {object} o.catalogue, o.prompts, o.check the bound modules
|
|
46
|
+
* @param {object} o.words the fixed wording (WORD_KEYS), the app's
|
|
47
|
+
* @param {object} [o.contextTools] { toolId: contextKind } — which tool "@context" is put back into
|
|
48
|
+
* @param {Function} [o.contextRef] (context) → the id put back (default context.ref; the hub: context.remoteRef)
|
|
49
|
+
* @param {Function} [o.openLabel] (kind) → the refused-write link's words (default `Open the ${kind} →`)
|
|
50
|
+
* @param {Function} o.principalOf (req, can) → { id, name, acting, scope }
|
|
51
|
+
* @param {object} o.gate { endpointsFor(hash), complete(opts, usable) } — the production seam
|
|
52
|
+
*/
|
|
53
|
+
function createRun(o) {
|
|
54
|
+
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
|
+
if (typeof o.principalOf !== 'function') throw new Error('ask: principalOf(req, can) is required');
|
|
56
|
+
const WORDS = Object.freeze({ ...o.words });
|
|
57
|
+
const model = o.modelName || 'AskQuestion';
|
|
58
|
+
const contextTools = { ...(o.contextTools || {}) };
|
|
59
|
+
const contextRef = typeof o.contextRef === 'function' ? o.contextRef : (c) => c.ref;
|
|
60
|
+
const openLabel = typeof o.openLabel === 'function' ? o.openLabel : (kind) => `Open the ${kind} →`;
|
|
61
|
+
const { catalogue, prompts, check, logger } = o;
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* @param {object} a
|
|
65
|
+
* @param {object} a.req, a.can, a.question, a.context, a.settings, a.timezone, a.reading, a.onStep, a.deps
|
|
66
|
+
* @returns the render model (lib/stream.js)
|
|
67
|
+
*/
|
|
68
|
+
async function ask(a) {
|
|
69
|
+
const deps = { complete: o.gate.complete, endpointsFor: o.gate.endpointsFor, ...(a.deps || {}) };
|
|
70
|
+
const started = Date.now();
|
|
71
|
+
const who = o.principalOf(a.req, a.can);
|
|
72
|
+
if (!who || who.id == null) throw new Error('ask: principalOf returned no user');
|
|
73
|
+
const scope = who.scope;
|
|
74
|
+
const question = cleanQuestion(a.question);
|
|
75
|
+
const context = a.context || null;
|
|
76
|
+
const hash = prompts.promptHash();
|
|
77
|
+
const tools = catalogue.catalogueFor(a.can, a.settings.toolsOff);
|
|
78
|
+
const step = (s) => { try { if (a.onStep) a.onStep(s); } catch (_) { /* a closed stream must not stop the audit */ } };
|
|
79
|
+
|
|
80
|
+
// THE ROW FIRST, before any model call: a question that dies mid-answer still counts against its asker's limits.
|
|
81
|
+
const row = await o.invoke(model, 'open', {
|
|
82
|
+
asked_by: who.id, asked_by_name: who.name || `user ${who.id}`, acting: !!who.acting,
|
|
83
|
+
context_kind: context ? context.kind : null, context_ref: context ? context.ref : null, question, prompt_hash: hash
|
|
84
|
+
});
|
|
85
|
+
const prov = { endpoint: null, model: null, tokens: 0, planMs: 0, lookMs: 0, answerMs: 0, ms: 0, skipped: [] };
|
|
86
|
+
const lookups = [];
|
|
87
|
+
const results = [];
|
|
88
|
+
const fixed = (key, extra = {}) => ({ by: 'fixed', text: WORDS[key], chips: [], ...extra });
|
|
89
|
+
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() });
|
|
91
|
+
|
|
92
|
+
/** Close the row. NEVER THROWS — three attempts, each smaller than the last (the header). */
|
|
93
|
+
const finish = async (outcome, render, answerText = null) => {
|
|
94
|
+
prov.ms = Date.now() - started;
|
|
95
|
+
const out = shape(outcome, render);
|
|
96
|
+
try {
|
|
97
|
+
await o.invoke(model, 'finish', row.id, { outcome, answer: answerText, result: out, endpoint: prov.endpoint,
|
|
98
|
+
model: prov.model, tokens: prov.tokens, ms: prov.ms });
|
|
99
|
+
return out;
|
|
100
|
+
} catch (e) {
|
|
101
|
+
logger.error(`Ask ${row.public_id}: the ${outcome} answer could not be recorded (${msg(e)})`);
|
|
102
|
+
}
|
|
103
|
+
const failed = shape('error', fixed('error'));
|
|
104
|
+
try {
|
|
105
|
+
await o.invoke(model, 'finish', row.id, { outcome: 'error', result: failed, endpoint: prov.endpoint, model: prov.model,
|
|
106
|
+
tokens: prov.tokens, ms: prov.ms });
|
|
107
|
+
return failed;
|
|
108
|
+
} catch (e) {
|
|
109
|
+
logger.error(`Ask ${row.public_id}: nor its error answer (${msg(e)}); closing it with the outcome alone`);
|
|
110
|
+
}
|
|
111
|
+
try { await o.invoke(model, 'finish', row.id, { outcome: 'error' }); } catch (e) {
|
|
112
|
+
logger.error(`Ask ${row.public_id}: left 'working' (${msg(e)}); the daily retention job closes it`);
|
|
113
|
+
}
|
|
114
|
+
return failed;
|
|
115
|
+
};
|
|
116
|
+
|
|
117
|
+
try {
|
|
118
|
+
if (!a.settings.enabled) return await finish('off', fixed('off'));
|
|
119
|
+
if (!tools.length) return await finish('cannot', fixed('nothing_to_ask'));
|
|
120
|
+
const ends = await deps.endpointsFor(hash);
|
|
121
|
+
prov.skipped = ends.skipped;
|
|
122
|
+
if (!ends.usable.length) return await finish('off', fixed('off'));
|
|
123
|
+
|
|
124
|
+
// ── 1 PLAN ─────────────────────────────────────────────────────────────────────────────────────────────────────
|
|
125
|
+
step({ n: 0, words: 'Working out what to look up…', state: 'run' });
|
|
126
|
+
let t = Date.now();
|
|
127
|
+
const planned = await deps.complete({
|
|
128
|
+
system: prompts.planSystem(tools),
|
|
129
|
+
messages: [{ role: 'user', content: prompts.planUser(question, context) }],
|
|
130
|
+
schema: catalogue.plannerSchema(tools),
|
|
131
|
+
maxTokens: 500,
|
|
132
|
+
temperature: 0
|
|
133
|
+
}, ends.usable);
|
|
134
|
+
prov.planMs = Date.now() - t;
|
|
135
|
+
prov.tokens += tokensOf(planned);
|
|
136
|
+
prov.endpoint = planned.servedBy || null;
|
|
137
|
+
prov.model = planned.model || null;
|
|
138
|
+
if (planned.skipped) prov.skipped = prov.skipped.concat(planned.skipped.map((x) => ({ endpoint: x.endpoint, why: x.skipped })));
|
|
139
|
+
const plan = catalogue.validatePlan(planned.json, tools);
|
|
140
|
+
step({ n: 0, words: 'Working out what to look up…', state: 'done' });
|
|
141
|
+
if (plan.error) {
|
|
142
|
+
logger.warn(`Ask ${row.public_id}: plan refused (${plan.error})`);
|
|
143
|
+
// A tool this person may not use reads exactly as a record that is not there — the same stream: one look-up step
|
|
144
|
+
// in THAT call's own words, then the not-found sentence.
|
|
145
|
+
if (plan.code === 'unoffered') {
|
|
146
|
+
const call = (planned.json && Array.isArray(planned.json.calls) && planned.json.calls[plan.index || 0]) || {};
|
|
147
|
+
const tool = catalogue.byId(call.tool);
|
|
148
|
+
let words = 'Looking that up';
|
|
149
|
+
try { if (tool) words = tool.describe(Object.fromEntries(Object.keys(tool.args).map((k) => [k, typeof (call.args || {})[k] === 'string' ? call.args[k].slice(0, 100) : null]))); } catch (_) { /* the default words */ }
|
|
150
|
+
step({ n: 1, words: `${words}…`, state: 'run' });
|
|
151
|
+
step({ n: 1, words: `${words}…`, state: 'done' });
|
|
152
|
+
return await finish('not_found', fixed('not_found'));
|
|
153
|
+
}
|
|
154
|
+
// The plan's error names keys the MODEL wrote: it goes to the log, never to the person (review of PR #9).
|
|
155
|
+
return await finish('cannot', fixed('cannot_plan'));
|
|
156
|
+
}
|
|
157
|
+
if (plan.intent === 'write') {
|
|
158
|
+
const link = context && context.href ? { label: openLabel(context.kind), href: context.href } : null;
|
|
159
|
+
return await finish('refused_write', fixed('refused_write', { link }));
|
|
160
|
+
}
|
|
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.
|
|
163
|
+
for (const call of plan.calls) {
|
|
164
|
+
const kind = contextTools[call.tool];
|
|
165
|
+
if (kind && call.args.ref === '@context') call.args.ref = context && context.kind === kind ? contextRef(context) : '@context';
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
// ── 2 RUN — each look-up in THIS request, its permission checked again at call time ────────────────────────────
|
|
169
|
+
t = Date.now();
|
|
170
|
+
const ctx = { req: a.req, can: a.can, scope, principal: who, timezone: a.timezone || 'UTC' };
|
|
171
|
+
for (const [i, call] of plan.calls.entries()) {
|
|
172
|
+
const tool = catalogue.byId(call.tool);
|
|
173
|
+
const words = tool.describe(call.args);
|
|
174
|
+
step({ n: i + 1, words: `${words}…`, state: 'run' });
|
|
175
|
+
const t0 = Date.now();
|
|
176
|
+
let result;
|
|
177
|
+
// DEFENCE IN DEPTH: validatePlan already refused a tool outside this request's catalogue; no path into a tool
|
|
178
|
+
// skips its page permissions, whatever validated the call.
|
|
179
|
+
if (!catalogue.mayUse(a.can, tool)) result = { outcome: 'not_found', count: 0, rows: [], extras: [], facts: {}, words };
|
|
180
|
+
else {
|
|
181
|
+
try {
|
|
182
|
+
result = await tool.run(ctx, call.args);
|
|
183
|
+
} catch (e) {
|
|
184
|
+
logger.error(`Ask ${row.public_id}: ${tool.id} failed — ${e && e.stack ? e.stack : e}`);
|
|
185
|
+
result = { outcome: 'error', count: 0, rows: [], extras: [], facts: {}, words };
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
if (!result || typeof result !== 'object' || !['ok', 'not_found', 'ambiguous', 'error'].includes(result.outcome)) {
|
|
189
|
+
logger.error(`Ask ${row.public_id}: ${tool.id} returned no result shape`);
|
|
190
|
+
result = { outcome: 'error', count: 0, rows: [], extras: [], facts: {}, words };
|
|
191
|
+
}
|
|
192
|
+
result.words = result.words || words;
|
|
193
|
+
result.tool = tool.id;
|
|
194
|
+
const ms = Date.now() - t0;
|
|
195
|
+
results.push(result);
|
|
196
|
+
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,
|
|
199
|
+
row_count: (result.rows || []).length, ms });
|
|
200
|
+
step({ n: i + 1, words: `${result.words}…`, state: 'done' });
|
|
201
|
+
}
|
|
202
|
+
prov.lookMs = Date.now() - t;
|
|
203
|
+
|
|
204
|
+
// A LOOK-UP THAT FAILED IS NOT AN EMPTY ONE.
|
|
205
|
+
if (results.some((r) => r.outcome === 'error')) return await finish('error', fixed('error'));
|
|
206
|
+
// ABSENT OR UNPERMITTED — ONE SENTENCE, AND NO MODEL WORDS.
|
|
207
|
+
if (results.some((r) => r.outcome === 'not_found')) return await finish('not_found', fixed('not_found'));
|
|
208
|
+
const amb = results.find((r) => r.outcome === 'ambiguous');
|
|
209
|
+
if (amb) {
|
|
210
|
+
return await finish('cannot', fixed('ambiguous', {
|
|
211
|
+
chips: (amb.candidates || []).map((c) => ({ count: null, label: c.title, href: c.href }))
|
|
212
|
+
}));
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
// ── 3 ANSWER — fenced results, a strict schema, no tools ───────────────────────────────────────────────────────
|
|
216
|
+
step({ n: results.length + 1, words: 'Writing the answer…', state: 'run' });
|
|
217
|
+
t = Date.now();
|
|
218
|
+
const m = prompts.answerMessages(question, results);
|
|
219
|
+
const answered = await deps.complete({
|
|
220
|
+
system: m.system,
|
|
221
|
+
messages: [{ role: 'user', content: m.user }],
|
|
222
|
+
schema: prompts.ANSWER_SCHEMA,
|
|
223
|
+
maxTokens: 600,
|
|
224
|
+
temperature: 0.1
|
|
225
|
+
}, ends.usable);
|
|
226
|
+
prov.answerMs = Date.now() - t;
|
|
227
|
+
prov.tokens += tokensOf(answered);
|
|
228
|
+
prov.endpoint = answered.servedBy || prov.endpoint;
|
|
229
|
+
prov.model = answered.model || prov.model;
|
|
230
|
+
step({ n: results.length + 1, words: 'Writing the answer…', state: 'done' });
|
|
231
|
+
|
|
232
|
+
// ── 4 CHECK ────────────────────────────────────────────────────────────────────────────────────────────────────
|
|
233
|
+
const verdict = check.checkAnswer(answered.json, results);
|
|
234
|
+
if (!verdict.ok) {
|
|
235
|
+
logger.warn(`Ask ${row.public_id}: answer withheld (${verdict.reason})`);
|
|
236
|
+
const raw = answered.json && typeof answered.json.answer === 'string' ? answered.json.answer : null;
|
|
237
|
+
// ONLY THE CODE reaches the person (review of PR #9): `verdict.reason` quotes the model's own words ("names “…”"), so it
|
|
238
|
+
// stays in the log above.
|
|
239
|
+
return await finish('withheld', fixed('withheld', { chips: check.chipsOf(results, null), quotes: quotesOf(results), whyCode: verdict.code }), raw);
|
|
240
|
+
}
|
|
241
|
+
const cited = new Set([...verdict.cites, ...[...verdict.answer.matchAll(/\[(r[1-4])\]/g)].map((x) => x[1])]);
|
|
242
|
+
return await finish('answered', {
|
|
243
|
+
by: 'ai',
|
|
244
|
+
segments: check.segmentsOf(verdict.answer, results),
|
|
245
|
+
chips: check.chipsOf(results, verdict.answer),
|
|
246
|
+
quotes: quotesOf(results),
|
|
247
|
+
checked: { cited: cited.size, of: cited.size }
|
|
248
|
+
}, verdict.answer);
|
|
249
|
+
} catch (e) {
|
|
250
|
+
// ── 5 AUDIT, whatever happened ─────────────────────────────────────────────────────────────────────────────────
|
|
251
|
+
const kind = e && e.kind;
|
|
252
|
+
if (Array.isArray(e && e.attempts)) prov.skipped = prov.skipped.concat(e.attempts.filter((x) => x.skipped).map((x) => ({ endpoint: x.endpoint, why: x.skipped })));
|
|
253
|
+
if (kind === 'config') return finish('off', fixed('off'));
|
|
254
|
+
if (BUSY_KINDS.includes(kind) || (e && e.attempts)) {
|
|
255
|
+
logger.warn(`Ask ${row.public_id}: no endpoint answered (${msg(e)})`);
|
|
256
|
+
return finish('busy', fixed('busy'));
|
|
257
|
+
}
|
|
258
|
+
logger.error(`Ask ${row.public_id}: ${e && e.stack ? e.stack : e}`);
|
|
259
|
+
return finish('error', fixed('error'));
|
|
260
|
+
}
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
return { ask, cleanQuestion, quotesOf, WORDS, MAX_QUESTION };
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
module.exports = { createRun, cleanQuestion, quotesOf, MAX_QUESTION, WORD_KEYS, BUSY_KINDS };
|