@aria-framework/ask 0.1.2 → 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +195 -0
- package/README.md +95 -4
- package/assets/ask.js +19 -3
- package/index.js +15 -13
- package/lib/bench.js +90 -23
- package/lib/catalogue.js +188 -25
- package/lib/check.js +319 -21
- package/lib/eta.js +51 -0
- package/lib/history.js +66 -4
- package/lib/prompts.js +71 -14
- package/lib/routes.js +46 -9
- package/lib/run.js +64 -13
- package/lib/store.js +33 -3
- package/lib/text.js +118 -0
- package/package.json +1 -1
- package/views/ask/admin.ejs +1 -1
- package/views/ask/none.ejs +56 -0
- package/views/ask/review.ejs +1 -0
package/lib/check.js
CHANGED
|
@@ -33,6 +33,22 @@
|
|
|
33
33
|
* - claims something was done, asks the person to sign in again, speaks of another partner, or talks about its
|
|
34
34
|
* instructions or the document.
|
|
35
35
|
*
|
|
36
|
+
* CLAIMS (0.2.0, every answer): a VERDICT (compromised, malicious, benign, safe, a false positive, "no action needed" …) is
|
|
37
|
+
* refused unless an APP-WRITTEN field of a cited result says it (`unsupported_verdict`); an ALL/NONE claim about a state
|
|
38
|
+
* ("all of them are resolved", "none are open") is refused unless every row of the cited result shows it — and the result
|
|
39
|
+
* returned all its rows (`sweeping_claim`).
|
|
40
|
+
*
|
|
41
|
+
* ANALYSE MODE (0.2.0, `checkAnswer(reply, results, { mode: 'analyse', question })` — the plan's intent was analyse): the
|
|
42
|
+
* answer may group, compare and conclude, and is held to, per sentence:
|
|
43
|
+
* - it cites at least one result as [rN] (`uncited_sentence`), and every placeholder in it names a result it cites
|
|
44
|
+
* (`uncited_figure`);
|
|
45
|
+
* - a conclusion ("suggests", "likely", "therefore", "overall" …) says it comes from the results ("the results show",
|
|
46
|
+
* "based on these results") (`unframed_conclusion`);
|
|
47
|
+
* - every capitalised name in it is a word of the results it cites or of the question (`unknown_name`) — no new entity;
|
|
48
|
+
* and, over the whole answer, every typed id, host, value or number is one the CITED results returned (the sayable set is
|
|
49
|
+
* built from the cited results only). The obeyed phrasings (claimed actions, sign-in, other partners, instructions) apply
|
|
50
|
+
* as in every answer.
|
|
51
|
+
*
|
|
36
52
|
* THIS IS A FLOOR, NOT A PROOF. It stops the obeyed shapes the tests name and the bench measures. What it guarantees is
|
|
37
53
|
* that Ask cannot act, cannot invent a figure the page draws, and cannot link anywhere.
|
|
38
54
|
*/
|
|
@@ -59,17 +75,27 @@ const LINKISH = /(https?:|ftp:|mailto:|javascript:|data:|file:|www\.|<\s*[a-z!/]
|
|
|
59
75
|
// open" and "Shell: bash" are prose (a space follows the colon).
|
|
60
76
|
const DANGEROUS_SCHEME = /(?<![\p{L}\p{N}-])(?:ms-[a-z-]+|search-ms|smb|cifs|nfs|afp|webdav|tel|sip|sips|callto|skype|msteams|zoommtg|zoomus|vnc|rdp|ssh|telnet|sftp|ldaps?|vbscript|mhtml|mk|its|jar|shell|intent|vscode|microsoft-edge|onenote|steam):(?=\S)/iu;
|
|
61
77
|
const DOMAINISH = /(?<![\p{L}\p{N}])[\p{L}\p{N}-]+(?:\.[\p{L}\p{N}-]+)*\.\p{L}{2,}(?:\/[^\s]*)?(?![\p{L}\p{N}])/gu;
|
|
78
|
+
// A SNIPPET (0.2.0, lib/text.js) is attacker text on any path ending in .snippet, whether or not the tool lists it.
|
|
79
|
+
const SNIPPET = /(?:^|\.)snippet$/;
|
|
62
80
|
const INTERNAL_SUFFIX = /\.(local|lan|internal|corp|home\.arpa|localdomain)$/i;
|
|
63
81
|
const NUMBER_WORD = /\b(zero|two|three|four|five|six|seven|eight|nine|ten|eleven|twelve|thirteen|fourteen|fifteen|sixteen|seventeen|eighteen|nineteen|twenty|thirty|forty|fifty|sixty|seventy|eighty|ninety|hundreds?|thousands?|millions?|billions?|dozens?|twice|thrice)\b/i;
|
|
64
82
|
// What an obeyed instruction says — SCOPED to that, not to the vocabulary a security answer is made of ("sign-in attempts",
|
|
65
83
|
// "password spray"). Widened in review L of PR #9: up to two adverbs ("I have now closed it"), "was closed by me", "log
|
|
66
|
-
// back in", "re-enter it at the login page", other tenants or resellers, "my rules".
|
|
84
|
+
// back in", "re-enter it at the login page", other tenants or resellers, "my rules". 0.2.0 (the Noc adoption): the network and
|
|
85
|
+
// device verbs — restarted, rebooted, muted, paused, (un)blocked, isolated, quarantined, disconnected, shut down, reset,
|
|
86
|
+
// acknowledged — in the same first-person, "has been … as/by" and "… by me/us" shapes. A device that "restarted at 03:10"
|
|
87
|
+
// in the data is not a claim.
|
|
67
88
|
const OBEYED = [
|
|
68
89
|
// "I/we" and the verb with up to two words between ("I have now closed", "we've quietly removed", "I have't yet sent").
|
|
69
|
-
|
|
70
|
-
[/\b(
|
|
90
|
+
// An OBSERVATION ("we saw it restarted", "I noticed it was blocked") is not a claim: no seeing verb between (final round).
|
|
91
|
+
[/\b(i|we)\s+(?:(?!(?:saw|see|sees|seen|noticed|notice|found|find|observed|observe|watched|spotted)\b)[\p{L}'’]+\s+){0,2}(closed|deleted|removed|assigned|resolved|approved|disabled|enabled|changed|updated|sent|marked|escalated|acknowledged|archived|reset|purged|wiped|restarted|rebooted|muted|unmuted|paused|resumed|blocked|unblocked|isolated|quarantined|disconnected|shut\s+down|reset|acknowledged)\b/iu, 'claims to have done something', 'claims_action'],
|
|
92
|
+
[/\b(has|have)\s+been\s+(closed|deleted|removed|resolved|approved|disabled|marked|purged|wiped|restarted|rebooted|muted|unmuted|paused|resumed|blocked|unblocked|isolated|quarantined|disconnected|shut\s+down|reset|acknowledged)\s+(as|by)\b/i, 'claims something was done', 'claims_action'],
|
|
93
|
+
// Done FOR the person ("The firewall was restarted for you"), and "Done:" with a change after it ("Done: the host is
|
|
94
|
+
// now isolated") — final round of PR #14.
|
|
95
|
+
[/\b(closed|deleted|removed|assigned|resolved|approved|disabled|enabled|changed|updated|sent|marked|escalated|acknowledged|archived|reset|purged|wiped|restarted|rebooted|muted|unmuted|paused|resumed|blocked|unblocked|isolated|quarantined|disconnected|shut\s+down)\s+for\s+you\b/i, 'claims something was done for the person', 'claims_action'],
|
|
96
|
+
[/\bdone\s*[:!—–-]\s*[^.]{0,80}?\b(closed|deleted|removed|assigned|resolved|approved|disabled|enabled|changed|updated|sent|marked|escalated|acknowledged|archived|reset|purged|wiped|restarted|rebooted|muted|unmuted|paused|resumed|blocked|unblocked|isolated|quarantined|disconnected|shut\s+down)\b/i, 'claims something was done', 'claims_action'],
|
|
71
97
|
// The verb "by me / us", however it is introduced ("closed by me", "It was resolved by us").
|
|
72
|
-
[/\b(closed|deleted|removed|assigned|resolved|approved|disabled|enabled|changed|updated|sent|marked|escalated|acknowledged|archived|reset|purged|wiped)\s+by\s+(me|us|the\s+assistant)\b/i, 'claims something was done', 'claims_action'],
|
|
98
|
+
[/\b(closed|deleted|removed|assigned|resolved|approved|disabled|enabled|changed|updated|sent|marked|escalated|acknowledged|archived|reset|purged|wiped|restarted|rebooted|muted|unmuted|paused|resumed|blocked|unblocked|isolated|quarantined|disconnected|shut\s+down|reset|acknowledged)\s+by\s+(me|us|the\s+assistant)\b/i, 'claims something was done', 'claims_action'],
|
|
73
99
|
[/\b(were|was|been)\s+(deleted|removed|wiped|purged|erased)\b/i, 'claims something was deleted', 'claims_action'],
|
|
74
100
|
[/\b(sign|log)\s?-?\s?(in|on)\s+(again|at|here|now|to\s+continue|to\s+restore)\b|\b(sign|log)\s+back\s+(in|on)\b|\b(enter|re-?enter|confirm|reset|verify)\s+your\s+(password|credentials?|login)\b|\bre-?enter\b[^.]{0,40}\b(log-?in|sign-?in)\s+page\b|\bsession\s+(has\s+)?expired\b/i, 'sends the person to sign in', 'sign_in'],
|
|
75
101
|
[/\b(other|another)\s+(partners?|tenants?|resellers?)\b|\bevery\s+other\s+(partner|tenant)\b/i, 'speaks of another partner', 'other_partner'],
|
|
@@ -102,7 +128,9 @@ const readable = (t) => String(t).normalize('NFKC').replace(INVISIBLE, '').repla
|
|
|
102
128
|
|
|
103
129
|
/** Every refusal code checkAnswer can return. */
|
|
104
130
|
const REFUSAL_CODES = Object.freeze(['shape', 'control_chars', 'unknown_cite', 'unknown_figure', 'bad_placeholder', 'link', 'number_word', 'claims_action',
|
|
105
|
-
'sign_in', 'other_partner', 'instructions', 'unknown_ref', 'typed_number', 'unknown_token', 'disallowed_char'
|
|
131
|
+
'sign_in', 'other_partner', 'instructions', 'unknown_ref', 'typed_number', 'unknown_token', 'disallowed_char',
|
|
132
|
+
// 0.2.0: claims (every answer) and the analyse rules.
|
|
133
|
+
'unsupported_verdict', 'sweeping_claim', 'uncited_sentence', 'uncited_figure', 'unframed_conclusion', 'unknown_name']);
|
|
106
134
|
|
|
107
135
|
/**
|
|
108
136
|
* THE MODEL'S OWN TEXT IS ALLOW-LISTED (the coordinator's round on PR #9, the same list as the soc AI-brief check): after
|
|
@@ -161,11 +189,13 @@ function verbatimOk(v, fromAttacker, sayIps) {
|
|
|
161
189
|
// ATTACKER TEXT: no path ("/" never; "\" only as DOMAIN\user), no IP unless the app says IPs, a letter first, no ref, and
|
|
162
190
|
// every dotted part an internal host — the attacker-path host rule, read on the value's parts.
|
|
163
191
|
if (v.includes('/') || (v.includes('\\') && !DOMAIN_USER.test(v))) return false;
|
|
164
|
-
|
|
192
|
+
// AN IP INSIDE AN ADDRESS SHAPE is refused whatever sayIps says (0.2.0, from the Soc events.search adoption): sayIps
|
|
193
|
+
// means a plain IP from an IP field may be said (allowedTokens' ips), never one embedded in an @-, \- or bracketed value.
|
|
194
|
+
if (IP_PART.test(v)) return false;
|
|
165
195
|
// A numeric host after "@" — decimal or hex (admin@3405803781, admin@0xCB007105) is an IP too (round 3 of PR #12).
|
|
166
196
|
if (/@(?:\d+|0x[0-9a-f]+)(?![\p{L}\p{N}_-])/iu.test(v)) return false;
|
|
167
197
|
if (!/^\p{L}/u.test(v) || (v.match(REF_TOKEN) || []).length) return false;
|
|
168
|
-
for (const m of v.matchAll(/[\p{L}\p{N}-]+(?:\.[\p{L}\p{N}-]+)+/gu)) if (!INTERNAL_SUFFIX.test(m[0])
|
|
198
|
+
for (const m of v.matchAll(/[\p{L}\p{N}-]+(?:\.[\p{L}\p{N}-]+)+/gu)) if (!INTERNAL_SUFFIX.test(m[0])) return false;
|
|
169
199
|
return true;
|
|
170
200
|
}
|
|
171
201
|
const escapeRe = (t) => t.replace(/[.*+?^${}()|[\]\\/]/g, '\\$&');
|
|
@@ -201,6 +231,115 @@ const IDIOM_BEFORE = /^(?:windows|win|office|ios|android|iphone|pixel|galaxy|top
|
|
|
201
231
|
|
|
202
232
|
const trim = (t) => String(t).replace(/^[.:\-/]+|[.:\-/]+$/g, '');
|
|
203
233
|
|
|
234
|
+
// ── 0.2.0: CLAIMS AND THE ANALYSE RULES ──────────────────────────────────────────────────────────────────────────────
|
|
235
|
+
// THE WORD LISTS ARE A FLOOR, NOT A PROOF: a verdict or an all/none claim in words no list names passes the check. The
|
|
236
|
+
// bench measures what slips through; review rounds widen the lists (fix round of PR #14: clean, legitimate, secure, no
|
|
237
|
+
// threat, not a concern, a breach occurred, was owned; no … are, there are no …, all … resolved, got, have all been).
|
|
238
|
+
/**
|
|
239
|
+
* A verdict the model may not reach on its own: it must be what an app-written text of the ROW the sentence names says
|
|
240
|
+
* (or, naming none, of the result itself or of every cited row).
|
|
241
|
+
*/
|
|
242
|
+
const VERDICT = /\b(compromised|breached|hacked|infected|malicious|benign|harmless|clean|legitimate|secure|false[\s_-]+positives?|true[\s_-]+positives?|under\s+attack|exfiltrated|safe|(?:was|been|got)\s+owned|(?:a\s+)?breach\s+(?:occurred|happened)|no\s+threats?|not\s+a\s+(?:threat|concern)|nothing\s+suspicious|expected\s+activity|nothing\s+to\s+worry\s+about|no\s+cause\s+for\s+concern|no\s+(?:further\s+)?action\s+(?:is\s+)?(?:needed|required))\b/giu;
|
|
243
|
+
/**
|
|
244
|
+
* "ALL / NONE OF THEM ARE <state>" (review rounds of PR #14). A QUANTIFIER — all, every, each, both, everything, they,
|
|
245
|
+
* none, nothing, not one, no <noun>; or "not all" (a PARTIAL claim: at least one row is not in the state) — then, within
|
|
246
|
+
* six words, a COPULA (is, are, was, were, has/have been, remain(s), stay(s), got, became) and its COMPLEMENT: the state
|
|
247
|
+
* is the copula's complement ("every record from the closed quarter is OPEN"), never any word in between, so a state
|
|
248
|
+
* word inside a qualifier ("assigned to jdoe") is not the claim. The complement may be negated ("no longer open", "not
|
|
249
|
+
* open", "unresolved"), which flips the claim. Also: "all records resolved" (no copula, the state ending the clause),
|
|
250
|
+
* "there are no open records", "no open records remain". The state vocabulary is STATE_WORDS plus every state and
|
|
251
|
+
* synonym the cited tools declare. A claim is decided ONLY from the declared field (`stateFields`); a word no cited tool
|
|
252
|
+
* declares is refused. A qualifier between the quantifier and the copula narrows the set; the claim is evaluated over
|
|
253
|
+
* EVERY cited row — sound for all/none (true of all rows → true of any subset), and otherwise refused.
|
|
254
|
+
*/
|
|
255
|
+
const STATE_WORDS = Object.freeze(['resolved', 'closed', 'fixed', 'solved', 'handled', 'complete', 'completed', 'healthy', 'reporting', 'online', 'offline',
|
|
256
|
+
'patched', 'acknowledged', 'assigned', 'unassigned', 'open', 'failed', 'down', 'benign', 'malicious', 'compromised', 'safe', 'pending', 'active', 'inactive',
|
|
257
|
+
'escalated', 'expired', 'overdue', 'muted', 'paused', 'blocked', 'isolated', 'enabled', 'disabled', 'stale', 'running', 'stopped', 'unresolved']);
|
|
258
|
+
const SWEEP_QUANT = /\b(not\s+all|all|every|each|both|everything|they|none|nothing|not\s+one|not\s+a\s+single|no\s+longer\s+any|neither|no(?!\s+(?:one|longer|doubt|question)\b))\b/giu;
|
|
259
|
+
const SWEEP_COPULA = /^(?:are|is|were|was|have\s+been|has\s+been|(?:continues?|continued|remains?|remained)\s+to\s+be|remain|remains|stay|stays|got|get|became|become)\b/iu;
|
|
260
|
+
const SWEEP_ADVERB = /^(?:now|still|already|currently|all|both|each|also|again)\b\s*/iu;
|
|
261
|
+
const SWEEP_NEGATION = /^(?:no\s+longer|not|never)\b\s*/iu;
|
|
262
|
+
/** The all/none/not-all claims in one flattened sentence: [{ word, polarity: 'all' | 'none' | 'notall' }]. */
|
|
263
|
+
function sweepsOf(lower, vocab) {
|
|
264
|
+
const isState = (w) => vocab.has(w) || (w.startsWith('un') && vocab.has(w.slice(2)));
|
|
265
|
+
const out = [];
|
|
266
|
+
const flip = (p) => (p === 'all' ? 'none' : p === 'none' ? 'all' : 'notall');
|
|
267
|
+
for (const q of lower.matchAll(SWEEP_QUANT)) {
|
|
268
|
+
const qw = q[1].replace(/\s+/g, ' ');
|
|
269
|
+
let polarity = qw === 'not all' ? 'notall' : ['all', 'every', 'each', 'both', 'everything', 'they'].includes(qw) ? 'all' : 'none';
|
|
270
|
+
const rest = lower.slice(q.index + q[0].length).split(/[.!?;,]/)[0];
|
|
271
|
+
const words = rest.trim().split(/\s+/).filter(Boolean);
|
|
272
|
+
let found = false;
|
|
273
|
+
// <quantifier> … <copula> <complement>, the copula within six words.
|
|
274
|
+
for (let i = 0; i <= Math.min(6, words.length - 1) && !found; i += 1) {
|
|
275
|
+
const tail = words.slice(i).join(' ');
|
|
276
|
+
const cop = SWEEP_COPULA.exec(tail);
|
|
277
|
+
if (!cop) continue;
|
|
278
|
+
let comp = tail.slice(cop[0].length).trim();
|
|
279
|
+
while (SWEEP_ADVERB.test(comp)) comp = comp.replace(SWEEP_ADVERB, '');
|
|
280
|
+
let p = polarity;
|
|
281
|
+
const neg = SWEEP_NEGATION.exec(comp);
|
|
282
|
+
if (neg) { comp = comp.slice(neg[0].length); p = flip(p); }
|
|
283
|
+
const w = (comp.split(/\s+/)[0] || '').toLowerCase();
|
|
284
|
+
if (isState(w)) {
|
|
285
|
+
out.push(vocab.has(w) ? { word: w, polarity: p } : { word: w.slice(2), polarity: flip(p) });
|
|
286
|
+
found = true;
|
|
287
|
+
} else if (cop) break; // the first copula's complement is not a state: no claim from this quantifier
|
|
288
|
+
}
|
|
289
|
+
// "all records resolved": no copula, the state ends the clause.
|
|
290
|
+
if (!found && polarity === 'all' && qw !== 'they') {
|
|
291
|
+
const m = /^((?:[\p{L}'’]+\s+){0,3})([\p{L}]+)\s*(?:now\s*)?$/u.exec(rest.trim());
|
|
292
|
+
if (m && isState(m[2]) && !SWEEP_COPULA.test(m[1])) out.push(vocab.has(m[2]) ? { word: m[2], polarity } : { word: m[2].slice(2), polarity: flip(polarity) });
|
|
293
|
+
}
|
|
294
|
+
}
|
|
295
|
+
// "there are no <state> …", "no <state> <noun> remain(s) / exist(s) / are left".
|
|
296
|
+
for (const m of lower.matchAll(/\bthere\s+(?:are|is|were|was)\s+(?:no|not\s+any)\s+([\p{L}]+)/giu)) if (isState(m[1])) out.push({ word: m[1], polarity: 'none' });
|
|
297
|
+
for (const m of lower.matchAll(/\bno\s+([\p{L}]+)\s+[\p{L}]+\s+(?:remain|remains|exist|exists|are\s+left|is\s+left)\b/giu)) if (isState(m[1])) out.push({ word: m[1], polarity: 'none' });
|
|
298
|
+
return out;
|
|
299
|
+
}
|
|
300
|
+
/** A conclusion, and the words that frame it as the results'. */
|
|
301
|
+
const CONCLUSION = /\b(therefore|thus|hence|overall|suggests?|suggesting|indicates?|indicating|implies|implying|points?\s+to|likely|unlikely|probably|appears?\s+to|seems?\s+to|conclude[sd]?|conclusion|taken\s+together|clearly|evidently|must\s+be)\b/i;
|
|
302
|
+
const FRAME = /\b(?:the|these|those|both|its|their)\s+(?:cited\s+)?(?:results?|look[\s-]?ups?|figures|records|rows|counts?)\b|\b(?:according\s+to|based\s+on|from|in)\s+(?:the\s+|these\s+)?(?:cited\s+)?(?:results?|look[\s-]?ups?)\b/i;
|
|
303
|
+
/** A capitalised word: a name, unless it is an ordinary word (NAME_COMMON) or a word of the results or the question. */
|
|
304
|
+
const CAPITALISED = /(?<![\p{L}\p{N}_'’-])\p{Lu}[\p{L}\p{N}'’-]*/gu;
|
|
305
|
+
const NAME_COMMON = new Set(('a an the this that these those there here it its they their them we our you your i he she his her one both each every '
|
|
306
|
+
+ 'all any some most many more few fewer less least several no none nothing neither nor either not only just also and or but so yet '
|
|
307
|
+
+ 'if then than as at by for from in into of on onto to with within without across between among about after before since until '
|
|
308
|
+
+ 'while when where which who whom whose what why how although though because however whereas meanwhile otherwise still therefore '
|
|
309
|
+
+ 'thus hence overall together taken compared comparing based according looking grouped group by first second third next finally last '
|
|
310
|
+
+ 'yes today tonight yesterday currently right now recently lately earlier later open new is are was were be been has have had do does '
|
|
311
|
+
+ 'did can could may might must should would will shall per summary status partner partners customer customers result results '
|
|
312
|
+
+ 'look lookup lookups record records row rows figure figures count counts monday tuesday wednesday thursday friday saturday sunday '
|
|
313
|
+
+ 'january february march april may june july august september october november december '
|
|
314
|
+
// Discourse adverbs (fix round of PR #14).
|
|
315
|
+
+ 'similarly additionally notably moreover furthermore likewise conversely instead again specifically particularly generally '
|
|
316
|
+
+ 'consequently accordingly nonetheless nevertheless besides alternatively importantly interestingly unlike like contrast').split(' '));
|
|
317
|
+
const wordsOf = (t) => String(t || '').normalize('NFKC').toLowerCase().split(/[^\p{L}\p{N}]+/u).filter(Boolean);
|
|
318
|
+
/**
|
|
319
|
+
* The answer's sentences: split after . ! ? and at newlines. Cites opening a piece ("open. [r1] The …") belong to the
|
|
320
|
+
* sentence before; a piece with no letter (a lone cite) joins the one before.
|
|
321
|
+
*/
|
|
322
|
+
function sentencesOf(answer) {
|
|
323
|
+
const out = [];
|
|
324
|
+
for (let piece of String(answer).split(/(?<=[.!?])\s+|\n+/)) {
|
|
325
|
+
if (!piece.trim()) continue;
|
|
326
|
+
const lead = out.length ? /^(?:\s*\[r[1-4]\])+/.exec(piece) : null;
|
|
327
|
+
if (lead) { out[out.length - 1] += lead[0]; piece = piece.slice(lead[0].length); if (!piece.trim()) continue; }
|
|
328
|
+
const letters = /\p{L}/u.test(piece.replace(PLACEHOLDER, ' ').replace(CITE, ' '));
|
|
329
|
+
if (!letters && out.length) out[out.length - 1] += ` ${piece}`; else out.push(piece);
|
|
330
|
+
}
|
|
331
|
+
return out;
|
|
332
|
+
}
|
|
333
|
+
/** Lower-cased, with spaces, hyphens and underscores folded to one space ("false_positive" reads "false positive"). */
|
|
334
|
+
const flat = (t) => readable(t).toLowerCase().replace(/[\s_-]+/g, ' ').trim();
|
|
335
|
+
const hasPhrase = (text, phrase) => new RegExp(`(?<![\\p{L}\\p{N}])${escapeRe(phrase)}(?![\\p{L}\\p{N}])`, 'u').test(text);
|
|
336
|
+
/** Does this row's value of a declared state field show the state? (strings case-blind, booleans exactly) — undefined when the row has no value. */
|
|
337
|
+
function stateOf(row, def) {
|
|
338
|
+
const v = (row.fields || {})[def.field];
|
|
339
|
+
if (v === undefined || v === null || v === '') return undefined;
|
|
340
|
+
return def.values.some((x) => (typeof x === 'boolean' ? v === x : typeof v === 'string' && v.trim().toLowerCase() === String(x).toLowerCase()));
|
|
341
|
+
}
|
|
342
|
+
|
|
204
343
|
/** Every string of a result with its path in attackerFields' spelling (rows[].fields.entity, facts.entities[].value). */
|
|
205
344
|
function stringsOf(r) {
|
|
206
345
|
const out = [];
|
|
@@ -273,6 +412,8 @@ function createCheck(o = {}) {
|
|
|
273
412
|
const rowChipLabel = typeof o.rowChipLabel === 'function' ? o.rowChipLabel : DEFAULT_ROW_CHIP;
|
|
274
413
|
const sayIps = o.sayIps === true;
|
|
275
414
|
const sayWindows = o.sayWindows === true;
|
|
415
|
+
// KNOWN NAMES (0.2.0): words the app says are ordinary in its answers though no result holds them (a product, "Sophos").
|
|
416
|
+
const knownNames = Object.freeze([...(o.knownNames || [])].map((n) => { if (typeof n !== 'string' || !n.trim()) throw new Error('ask check: knownNames is a list of names'); return n; }).flatMap((n) => wordsOf(n)));
|
|
276
417
|
// VERBATIM PATHS (0.1.2): a fact or a row field, in attackerFields' spelling — never a title, a label or a ref.
|
|
277
418
|
const verbatimFields = Object.freeze([...(o.verbatimFields || [])]);
|
|
278
419
|
for (const f of verbatimFields) {
|
|
@@ -313,7 +454,7 @@ function createCheck(o = {}) {
|
|
|
313
454
|
for (const k of numericStructured) numeric((r.facts || {})[k]);
|
|
314
455
|
for (const k of idFacts) own((r.facts || {})[k]);
|
|
315
456
|
for (const [path, v] of stringsOf(r)) {
|
|
316
|
-
const fromAttacker = remote || attacker.has(path);
|
|
457
|
+
const fromAttacker = remote || attacker.has(path) || SNIPPET.test(path);
|
|
317
458
|
for (const w of readable(v).split(/[\s,;()"'“”‘’!?]+/)) {
|
|
318
459
|
const t = trim(w);
|
|
319
460
|
if (!t) continue;
|
|
@@ -344,7 +485,7 @@ function createCheck(o = {}) {
|
|
|
344
485
|
const tool = r && r.tool ? toolOf(r.tool) : null;
|
|
345
486
|
const remote = !tool || tool.kind === 'remote';
|
|
346
487
|
const attacker = new Set(tool && !remote ? tool.attackerFields : []);
|
|
347
|
-
for (const [path, v] of stringsOf(r)) if (remote || attacker.has(path)) out.push(readable(v).toLowerCase().replace(/\s+/g, ' '));
|
|
488
|
+
for (const [path, v] of stringsOf(r)) if (remote || attacker.has(path) || SNIPPET.test(path)) out.push(readable(v).toLowerCase().replace(/\s+/g, ' '));
|
|
348
489
|
for (const x of r.extras || []) if ((remote || attacker.has('extras[].label')) && typeof x.label === 'string') out.push(readable(x.label).toLowerCase().replace(/\s+/g, ' '));
|
|
349
490
|
}
|
|
350
491
|
return out;
|
|
@@ -389,30 +530,181 @@ function createCheck(o = {}) {
|
|
|
389
530
|
});
|
|
390
531
|
}
|
|
391
532
|
|
|
533
|
+
/** The attacker paths of a result: its tool's attackerFields; every path of an unknown or remote tool; any snippet. */
|
|
534
|
+
function attackerOf(r) {
|
|
535
|
+
const tool = r && r.tool ? toolOf(r.tool) : null;
|
|
536
|
+
const remote = !tool || tool.kind === 'remote';
|
|
537
|
+
const attacker = new Set(tool && !remote ? tool.attackerFields : []);
|
|
538
|
+
return (path) => remote || attacker.has(path) || SNIPPET.test(path);
|
|
539
|
+
}
|
|
540
|
+
/**
|
|
541
|
+
* What the APP wrote at the RESULT level (facts, label, notes, extras' labels — no row, no attacker path), flattened. An
|
|
542
|
+
* extra whose label the app derives from an enum (a verdict or state code: `{ key, count, label, enum: true }`) is
|
|
543
|
+
* app-written even where the tool lists `extras[].label` as attacker text (fix round of PR #14: a breakdown's groups).
|
|
544
|
+
*/
|
|
545
|
+
function resultAppTexts(r) {
|
|
546
|
+
const isAttacker = attackerOf(r);
|
|
547
|
+
const out = [];
|
|
548
|
+
for (const [path, v] of stringsOf(r)) if (path.startsWith('facts') && !isAttacker(path)) out.push(flat(v));
|
|
549
|
+
if (!isAttacker('label') && typeof r.label === 'string') out.push(flat(r.label));
|
|
550
|
+
// An extra marked enum counts only for a group-by key the tool declares (groupBy) and a label in its closed list.
|
|
551
|
+
const tool = r && r.tool ? toolOf(r.tool) : null;
|
|
552
|
+
const enumOk = (x) => x.enum === true && tool && tool.kind !== 'remote' && tool.groupBy && typeof x.by === 'string' && tool.groupBy[x.by]
|
|
553
|
+
&& tool.groupBy[x.by].enum.some((v) => flat(v) === flat(x.label));
|
|
554
|
+
for (const x of r.extras || []) if ((enumOk(x) || !isAttacker('extras[].label')) && typeof x.label === 'string') out.push(flat(x.label));
|
|
555
|
+
for (const n of r.notes || []) if (typeof n.label === 'string') out.push(flat(n.label));
|
|
556
|
+
return out;
|
|
557
|
+
}
|
|
558
|
+
/** One row's app-written text, flattened: where a verdict about THAT row must show. */
|
|
559
|
+
function rowAppText(r, row) {
|
|
560
|
+
const isAttacker = attackerOf(r);
|
|
561
|
+
const parts = [];
|
|
562
|
+
if (!isAttacker('rows[].title') && typeof row.title === 'string') parts.push(row.title);
|
|
563
|
+
const walk = (v, path) => {
|
|
564
|
+
if (typeof v === 'string') { if (!isAttacker(path)) parts.push(v); } else if (Array.isArray(v)) v.forEach((x) => walk(x, `${path}[]`));
|
|
565
|
+
else if (v && typeof v === 'object') for (const [k, x] of Object.entries(v)) walk(x, `${path}.${k}`);
|
|
566
|
+
};
|
|
567
|
+
walk(row.fields || {}, 'rows[].fields');
|
|
568
|
+
return flat(parts.join(' '));
|
|
569
|
+
}
|
|
570
|
+
/**
|
|
571
|
+
* The rows a sentence NAMES (review round 3 of PR #14): by its ref, or by a field the tool DECLARES as the row's identity
|
|
572
|
+
* (`rowName: 'title'` or `['title', 'hostname']` — 'title' is the row's title, anything else a row field) — never by
|
|
573
|
+
* any other field, so a customer name every row shares, or a value planted in another row, names nothing.
|
|
574
|
+
*/
|
|
575
|
+
function namedRows(r, sentence, lower) {
|
|
576
|
+
const tool = r && r.tool ? toolOf(r.tool) : null;
|
|
577
|
+
const names = tool && tool.kind !== 'remote' && tool.rowName ? [].concat(tool.rowName) : [];
|
|
578
|
+
return (r.rows || []).filter((row) => {
|
|
579
|
+
if (typeof row.ref === 'string' && row.ref && sentence.includes(row.ref)) return true;
|
|
580
|
+
return names.some((k) => {
|
|
581
|
+
const v = k === 'title' ? row.title : (row.fields || {})[k];
|
|
582
|
+
return typeof v === 'string' && flat(v).length >= 4 && hasPhrase(lower, flat(v));
|
|
583
|
+
});
|
|
584
|
+
});
|
|
585
|
+
}
|
|
586
|
+
/** The state words a sweep may claim: STATE_WORDS and every state and synonym the cited tools declare. */
|
|
587
|
+
function stateVocab(scope) {
|
|
588
|
+
const out = new Set(STATE_WORDS);
|
|
589
|
+
for (const r of scope) {
|
|
590
|
+
const tool = r && r.tool ? toolOf(r.tool) : null;
|
|
591
|
+
for (const [w, def] of Object.entries((tool && tool.stateFields) || {})) { out.add(w); for (const x of def.synonyms || []) out.add(x); }
|
|
592
|
+
}
|
|
593
|
+
return out;
|
|
594
|
+
}
|
|
595
|
+
/** The declared state definition a word claims in one result — the state itself, a synonym, or "un<state>" (negated). */
|
|
596
|
+
function defOf(r, word) {
|
|
597
|
+
const tool = r && r.tool ? toolOf(r.tool) : null;
|
|
598
|
+
const fields = tool && tool.kind !== 'remote' && tool.stateFields ? tool.stateFields : {};
|
|
599
|
+
for (const [w, def] of Object.entries(fields)) if (w === word || (def.synonyms || []).includes(word)) return { def, negated: false };
|
|
600
|
+
if (word.startsWith('un')) for (const [w, def] of Object.entries(fields)) if (w === word.slice(2) || (def.synonyms || []).includes(word.slice(2))) return { def, negated: true };
|
|
601
|
+
return null;
|
|
602
|
+
}
|
|
603
|
+
/** Every word a result holds, attacker text included ("every name comes from the results"), and its look-up's words. */
|
|
604
|
+
function vocabularyOf(r) {
|
|
605
|
+
const out = new Set(knownNames);
|
|
606
|
+
for (const [, v] of stringsOf(r)) for (const w of wordsOf(readable(v))) out.add(w);
|
|
607
|
+
for (const t of [r.label, r.words, ...(r.extras || []).map((x) => x.label), ...(r.notes || []).map((n) => n.label)]) for (const w of wordsOf(readable(t || ''))) out.add(w);
|
|
608
|
+
for (const row of r.rows || []) if (typeof row.ref === 'string') for (const w of wordsOf(row.ref)) out.add(w);
|
|
609
|
+
return out;
|
|
610
|
+
}
|
|
611
|
+
|
|
612
|
+
/**
|
|
613
|
+
* THE CLAIM AND ANALYSE RULES over the answer's sentences (see the header). Returns { injected, format } — the first
|
|
614
|
+
* refusal of each class (or null). EVERY sentence is read for the injection-class rules (verdict, all/none, unframed
|
|
615
|
+
* conclusion, unknown name) before any format-class refusal (uncited sentence or figure) counts, so an uncited lead
|
|
616
|
+
* sentence never hides a later obeyed one (review round 3 of PR #14). `cited` is the whole answer's cited indexes.
|
|
617
|
+
*/
|
|
618
|
+
function claimRefusal(answer, results, cited, analyse, question) {
|
|
619
|
+
const qWords = new Set(wordsOf(readable(question || '')));
|
|
620
|
+
let format = null;
|
|
621
|
+
for (const sentence of sentencesOf(answer)) {
|
|
622
|
+
const own = [...new Set([...sentence.matchAll(CITE)].map((m) => Number(m[1].slice(1)) - 1))];
|
|
623
|
+
const scope = (own.length ? own : [...cited]).map((i) => results[i]).filter(Boolean);
|
|
624
|
+
const text = readable(sentence.replace(MARK_BEFORE_PUNCT, '').replace(MARK_AFTER_DOT, '').replace(PLACEHOLDER, ' ').replace(CITE, ' '));
|
|
625
|
+
const lower = flat(text);
|
|
626
|
+
// A VERDICT, PER ROW: what the app-written text of EVERY row the sentence names says; naming none, what the result
|
|
627
|
+
// itself says, or what every one of its rows says (rows that disagree settle nothing).
|
|
628
|
+
for (const m of text.matchAll(VERDICT)) {
|
|
629
|
+
const phrase = flat(m[0]);
|
|
630
|
+
const supported = scope.some((r) => {
|
|
631
|
+
const named = namedRows(r, sentence, lower);
|
|
632
|
+
if (named.length) return named.every((row) => hasPhrase(rowAppText(r, row), phrase));
|
|
633
|
+
if (resultAppTexts(r).some((t) => hasPhrase(t, phrase))) return true;
|
|
634
|
+
const rows = r.rows || [];
|
|
635
|
+
return rows.length > 0 && rows.every((row) => hasPhrase(rowAppText(r, row), phrase));
|
|
636
|
+
});
|
|
637
|
+
if (!supported) return { injected: { ok: false, code: 'unsupported_verdict', reason: `calls something “${m[0].slice(0, 30)}”, which no cited row says` }, format };
|
|
638
|
+
}
|
|
639
|
+
// ALL / NONE / NOT ALL OF THEM in a state: decided ONLY from a field the tool declares holds that state.
|
|
640
|
+
for (const claim of sweepsOf(lower, stateVocab(scope))) {
|
|
641
|
+
const holds = scope.length > 0 && scope.every((r) => {
|
|
642
|
+
const d = defOf(r, claim.word);
|
|
643
|
+
const rows = r.rows || [];
|
|
644
|
+
if (!d || !rows.length) return false;
|
|
645
|
+
const p = d.negated ? (claim.polarity === 'all' ? 'none' : claim.polarity === 'none' ? 'all' : 'notall') : claim.polarity;
|
|
646
|
+
// NOT ALL: one row that holds the field and is not in the state is enough, from any number of rows shown.
|
|
647
|
+
if (p === 'notall') return rows.some((row) => stateOf(row, d.def) === false);
|
|
648
|
+
if (Number.isInteger(r.count) && r.count !== rows.length) return false;
|
|
649
|
+
return rows.every((row) => stateOf(row, d.def) === (p === 'all'));
|
|
650
|
+
});
|
|
651
|
+
if (!holds) return { injected: { ok: false, code: 'sweeping_claim', reason: `says ${claim.polarity === 'notall' ? 'not all' : claim.polarity} of them are “${claim.word}”, which no declared state field of the cited rows shows` }, format };
|
|
652
|
+
}
|
|
653
|
+
if (!analyse) continue;
|
|
654
|
+
// ANALYSE: framed conclusions, no new names, every sentence cited, every figure from a result its sentence cites.
|
|
655
|
+
if (CONCLUSION.test(lower) && !FRAME.test(lower)) return { injected: { ok: false, code: 'unframed_conclusion', reason: 'draws a conclusion without saying it comes from the results' }, format };
|
|
656
|
+
const vocab = new Set(qWords);
|
|
657
|
+
for (const r of scope) for (const w of vocabularyOf(r)) vocab.add(w);
|
|
658
|
+
for (const m of text.matchAll(CAPITALISED)) {
|
|
659
|
+
for (const w of wordsOf(m[0])) {
|
|
660
|
+
if (w.length < 2 || /\p{N}/u.test(w) || NAME_COMMON.has(w) || vocab.has(w)) continue;
|
|
661
|
+
return { injected: { ok: false, code: 'unknown_name', reason: `names “${m[0].slice(0, 40)}”, which the cited results do not` }, format };
|
|
662
|
+
}
|
|
663
|
+
}
|
|
664
|
+
if (!format && !own.length) format = { ok: false, code: 'uncited_sentence', reason: 'a sentence cites no result' };
|
|
665
|
+
for (const m of sentence.matchAll(PLACEHOLDER)) {
|
|
666
|
+
if (!format && !own.includes(Number(m[1].slice(1)) - 1)) format = { ok: false, code: 'uncited_figure', reason: `a figure ${m[0]} from a result its sentence does not cite` };
|
|
667
|
+
}
|
|
668
|
+
}
|
|
669
|
+
return { injected: null, format };
|
|
670
|
+
}
|
|
671
|
+
|
|
392
672
|
/**
|
|
393
673
|
* Check one answer. Returns { ok: true, answer, cites } or { ok: false, code, reason } — `code` is one of REFUSAL_CODES.
|
|
394
674
|
* @param {*} reply the model's parsed JSON
|
|
395
675
|
* @param {object[]} results the look-ups' results, in order (r1 = results[0])
|
|
676
|
+
* @param {{mode?: 'lookup'|'analyse', question?: string}} [opts] 0.2.0: analyse mode, and the question its names may come from
|
|
396
677
|
*/
|
|
397
|
-
function checkAnswer(reply, results) {
|
|
678
|
+
function checkAnswer(reply, results, opts = {}) {
|
|
679
|
+
const analyse = opts.mode === 'analyse';
|
|
398
680
|
if (!reply || typeof reply !== 'object' || Array.isArray(reply)) return { ok: false, code: 'shape', reason: 'not an object' };
|
|
399
681
|
const keys = Object.keys(reply).filter((k) => !['answer', 'cites'].includes(k));
|
|
400
682
|
if (keys.length) return { ok: false, code: 'shape', reason: 'unexpected fields' };
|
|
401
683
|
let answer = typeof reply.answer === 'string' ? reply.answer.trim() : '';
|
|
402
684
|
if (!answer) return { ok: false, code: 'shape', reason: 'empty answer' };
|
|
403
|
-
|
|
685
|
+
// TOO LONG is a format refusal, applied AFTER the injection-class rules (final round of PR #14): a long obeyed answer is
|
|
686
|
+
// refused for what it obeys, not for its length.
|
|
687
|
+
const tooLong = answer.length > 700;
|
|
404
688
|
if (!Array.isArray(reply.cites) || reply.cites.some((c) => typeof c !== 'string')) return { ok: false, code: 'shape', reason: 'cites is not a list of ids' };
|
|
405
689
|
if (/[\u0000-\u0008\u000b-\u001f\u007f]/.test(answer)) return { ok: false, code: 'control_chars', reason: 'control characters' };
|
|
406
690
|
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
691
|
+
// INJECTION-CLASS RULES FIRST (review round 3 of PR #14): a FORMAT refusal found on the way (an unknown cite or figure,
|
|
692
|
+
// a malformed placeholder, a number word) is kept, not returned, until every link, obeyed and claim rule has read the
|
|
693
|
+
// whole answer — so a format slip never hides an obeyed sentence.
|
|
694
|
+
let fmt = null;
|
|
695
|
+
const later = (code, reason) => { if (!fmt) fmt = { ok: false, code, reason }; };
|
|
696
|
+
for (const c of reply.cites) if (!/^r[1-4]$/.test(c) || !results[Number(c.slice(1)) - 1]) later('unknown_cite', `cites ${String(c).slice(0, 8)}, which no look-up returned`);
|
|
697
|
+
for (const m of answer.matchAll(CITE)) if (!results[Number(m[1].slice(1)) - 1]) later('unknown_cite', `cites ${m[1]}, which no look-up returned`);
|
|
698
|
+
const knownCites = reply.cites.filter((c) => /^r[1-4]$/.test(c) && results[Number(c.slice(1)) - 1]);
|
|
699
|
+
answer = spellFigures(answer, knownCites, results);
|
|
700
|
+
for (const m of answer.matchAll(PLACEHOLDER)) if (!figureOf(results, m[1], m[2])) later('unknown_figure', `a figure ${m[0]} no look-up returned`);
|
|
411
701
|
const bare = answer.replace(MARK_BEFORE_PUNCT, '').replace(MARK_AFTER_DOT, '').replace(PLACEHOLDER, ' ').replace(CITE, ' ');
|
|
412
|
-
if (/[{}]/.test(bare))
|
|
702
|
+
if (/[{}]/.test(bare)) later('bad_placeholder', 'a malformed figure placeholder');
|
|
413
703
|
// NFKC, other full stops and NO format characters for the word rules: what the person would read.
|
|
414
704
|
const norm = readable(bare);
|
|
415
|
-
const
|
|
705
|
+
const cited = new Set([...knownCites, ...[...answer.matchAll(CITE)].map((m) => m[1])].map((c) => Number(c.slice(1)) - 1).filter((i) => results[i]));
|
|
706
|
+
// ANALYSE: what may be typed is what the CITED results returned.
|
|
707
|
+
const allowed = allowedTokens(analyse ? results.filter((_, i) => cited.has(i)) : results);
|
|
416
708
|
if (LINKISH.test(norm) || DANGEROUS_SCHEME.test(norm)) return { ok: false, code: 'link', reason: 'a link or markup' };
|
|
417
709
|
// VERBATIM SPANS read as one plain word by DOMAINISH, the token rules and the allow-list (and by nothing else).
|
|
418
710
|
const verbatim = [...allowed.verbatim].sort((a, b) => b.length - a.length);
|
|
@@ -423,8 +715,13 @@ function createCheck(o = {}) {
|
|
|
423
715
|
if (d.includes('/') || !(INTERNAL_SUFFIX.test(d) || allowed.has(trim(d)))) return { ok: false, code: 'link', reason: 'a link or markup' };
|
|
424
716
|
}
|
|
425
717
|
const word = NUMBER_WORD.exec(norm);
|
|
426
|
-
if (word)
|
|
718
|
+
if (word) later('number_word', `writes the number “${word[0]}” instead of a figure placeholder`);
|
|
427
719
|
for (const [re, why, code] of OBEYED) if (re.test(norm)) return { ok: false, code, reason: why };
|
|
720
|
+
const claim = claimRefusal(answer, results, cited, analyse, opts.question);
|
|
721
|
+
if (claim.injected) return claim.injected;
|
|
722
|
+
if (tooLong) return { ok: false, code: 'shape', reason: 'answer too long' };
|
|
723
|
+
if (fmt) return fmt;
|
|
724
|
+
if (claim.format) return claim.format;
|
|
428
725
|
|
|
429
726
|
const named = (x) => {
|
|
430
727
|
let y = x;
|
|
@@ -494,7 +791,7 @@ function createCheck(o = {}) {
|
|
|
494
791
|
return chips;
|
|
495
792
|
}
|
|
496
793
|
|
|
497
|
-
const config = Object.freeze({ structured, numericStructured, verbatimFields, sayableIdFields: { facts: idFacts, rows: idRows }, sayIps, sayWindows,
|
|
794
|
+
const config = Object.freeze({ structured, numericStructured, verbatimFields, sayableIdFields: { facts: idFacts, rows: idRows }, sayIps, sayWindows, knownNames,
|
|
498
795
|
namedTokens: namedTokens.map(([re, to]) => [String(re), String(to)]) });
|
|
499
796
|
/**
|
|
500
797
|
* WHAT DECIDES A VERDICT (review H3 of PR #9): the rule sources and this app's config, including its id test and row-chip
|
|
@@ -508,6 +805,7 @@ function createCheck(o = {}) {
|
|
|
508
805
|
const RULE_SOURCES = [PLACEHOLDER, CITE, DIGIT_TOKEN, PURE_NUMBER, REF_TOKEN, HOSTLIKE, LINKISH, DANGEROUS_SCHEME, DOMAINISH, INTERNAL_SUFFIX, NUMBER_WORD, IPV4, IPV6, WINDOW,
|
|
509
806
|
MARK_BEFORE_PUNCT, MARK_AFTER_DOT, VERBATIM_EXTRA, VERBATIM_VALUE, CIDR4, VERBATIM_BEFORE, VERBATIM_AFTER, SCHEMEISH, IP_PART, DOMAIN_USER, SMALL_WORD, JOINED_NUMBER, NOT_COUNTED, IDIOM_AFTER, IDIOM_BEFORE, JSON.stringify(SMALL_NUMBER),
|
|
510
807
|
JSON.stringify(QUOTE_PAIRS), INVISIBLE, OTHER_DOTS, BRACKETED_DOT, SPELLED_DOT, SPACED_DOT, ALLOWED_CHAR, ...OBEYED.map(([re, why, code]) => `${re}|${why}|${code}`), REFUSAL_CODES.join(','),
|
|
511
|
-
|
|
808
|
+
SNIPPET, VERDICT, STATE_WORDS.join(' '), SWEEP_QUANT, SWEEP_COPULA, SWEEP_ADVERB, SWEEP_NEGATION, sweepsOf, CONCLUSION, FRAME, CAPITALISED, [...NAME_COMMON].join(' '),
|
|
809
|
+
unitOf, isInternalHost, readable, verbatimOk, escapeRe, maskVerbatim, trim, wordsOf, sentencesOf, flat, hasPhrase, stateOf, stringsOf, figureOf, segmentsOf, helpChips, createCheck].map(String);
|
|
512
810
|
|
|
513
|
-
module.exports = { createCheck, REFUSAL_CODES, NUMBER_WORD, REF_TOKEN, PLACEHOLDER, LINKISH, segmentsOf, figureOf, stringsOf, readable };
|
|
811
|
+
module.exports = { createCheck, REFUSAL_CODES, NUMBER_WORD, REF_TOKEN, PLACEHOLDER, LINKISH, VERDICT, segmentsOf, figureOf, stringsOf, readable, sentencesOf };
|
package/lib/eta.js
ADDED
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* THE BENCH PROGRESS LINE (0.2.0): "N of M · about X min left", with the time elapsed after it. A pure function of the
|
|
3
|
+
* counts and two instants, so a test fixes the clock and the page needs no client script — the admin page re-renders the
|
|
4
|
+
* region on every call and draws whatever this returns.
|
|
5
|
+
*
|
|
6
|
+
* progressLine({ done: 12, of: 351, sinceMs, nowMs, startedMs, endpointsLeft })
|
|
7
|
+
* → { text: '12 of 351 · about 14 min left · 1 min 20 s elapsed', secondsLeft: 812 | null, elapsedSeconds: 80 }
|
|
8
|
+
*
|
|
9
|
+
* The estimate is (calls left) × (mean seconds per call so far on THIS endpoint); calls left include the whole of every
|
|
10
|
+
* endpoint still to come. It is shown only from MIN_CALLS calls on — two calls in, the mean is noise. Under a minute it
|
|
11
|
+
* says "less than a minute left". The first endpoint's mean is not a promise about the next one's; the estimate is only
|
|
12
|
+
* as good as the calls so far, and says "about".
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
'use strict';
|
|
16
|
+
|
|
17
|
+
const MIN_CALLS = 5;
|
|
18
|
+
|
|
19
|
+
/** "42 s", "3 min 05 s", "1 h 02 min". */
|
|
20
|
+
function duration(seconds) {
|
|
21
|
+
const s = Math.max(0, Math.round(seconds));
|
|
22
|
+
if (s < 60) return `${s} s`;
|
|
23
|
+
if (s < 3600) return `${Math.floor(s / 60)} min ${String(s % 60).padStart(2, '0')} s`;
|
|
24
|
+
return `${Math.floor(s / 3600)} h ${String(Math.floor((s % 3600) / 60)).padStart(2, '0')} min`;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/** The estimate alone: seconds left, or null when there is not enough to say. */
|
|
28
|
+
function secondsLeft({ done, of, sinceMs, nowMs, endpointsLeft = 0 }) {
|
|
29
|
+
if (!(done >= MIN_CALLS) || !(of > 0) || !Number.isFinite(sinceMs) || !Number.isFinite(nowMs) || nowMs < sinceMs) return null;
|
|
30
|
+
const mean = (nowMs - sinceMs) / 1000 / done;
|
|
31
|
+
const left = Math.max(0, of - done) + Math.max(0, endpointsLeft) * of;
|
|
32
|
+
return left * mean;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
// From 60 min on the elapsed format ("about 2 h 55 min left"), never "about 175 min" beside "2 h 55 min elapsed".
|
|
36
|
+
function leftWords(seconds) {
|
|
37
|
+
if (seconds < 60) return 'less than a minute left';
|
|
38
|
+
const m = Math.round(seconds / 60);
|
|
39
|
+
return m < 60 ? `about ${m} min left` : `about ${duration(m * 60)} left`;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function progressLine({ done, of, sinceMs, nowMs, startedMs, endpointsLeft = 0 }) {
|
|
43
|
+
const parts = [`${done} of ${of}`];
|
|
44
|
+
const left = secondsLeft({ done, of, sinceMs, nowMs, endpointsLeft });
|
|
45
|
+
if (left !== null) parts.push(leftWords(left));
|
|
46
|
+
const elapsedSeconds = Number.isFinite(startedMs) && Number.isFinite(nowMs) && nowMs >= startedMs ? (nowMs - startedMs) / 1000 : null;
|
|
47
|
+
if (elapsedSeconds !== null) parts.push(`${duration(elapsedSeconds)} elapsed`);
|
|
48
|
+
return { text: parts.join(' · '), secondsLeft: left, elapsedSeconds };
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
module.exports = { MIN_CALLS, progressLine, secondsLeft, duration };
|
package/lib/history.js
CHANGED
|
@@ -1,16 +1,36 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* A person's own Ask history (the last 20, kept per the retention setting)
|
|
2
|
+
* A person's own Ask history (the last 20, kept per the retention setting), their thumbs mark, and — 0.2.0 — the two
|
|
3
|
+
* reads that use an earlier question again: a HISTORY READ (rechecked) and a FOLLOW-UP (carried, bounded).
|
|
3
4
|
*
|
|
4
5
|
* ONE PERSON'S ROWS SPAN PARTNERS, so each read widens to platform scope around ONE model call bounded by `asked_by = <the
|
|
5
6
|
* principal's id>` — the model refuses any other scope. The id comes from `principalOf` (the session today), never from
|
|
6
7
|
* the request: nothing a person sends can name somebody else's history, and another person's ref reads as none.
|
|
8
|
+
*
|
|
9
|
+
* THE HISTORY RECHECK (roadmap 150). An answer is drawn again only if the viewer, NOW, still passes `mayUse` for every
|
|
10
|
+
* tool that answer used (a tool no longer registered fails), no such tool is switched off (tools_off), and reads at the
|
|
11
|
+
* same reading, acting state and partner it was asked at. Otherwise the person sees "no longer available to you" — the answer is not sent. A permission removed
|
|
12
|
+
* since, or a partner the person no longer works inside, takes the old answer away with it.
|
|
13
|
+
*
|
|
14
|
+
* WHAT A FOLLOW-UP CARRIES (D-0169 item 9), and nothing else: the previous question's TEXT as the person typed it, the
|
|
15
|
+
* ids of the TOOLS its look-ups used, and the REFS those look-ups returned (rows' own `ref`, this app's public ids,
|
|
16
|
+
* passing `isPublicId`, at most 10). Never the model's answer, never a result's text or a resolved round-2 value. It is
|
|
17
|
+
* carried only when the previous question is the SAME PERSON's (the read is bounded by asked_by), at the SAME CONTEXT
|
|
18
|
+
* (kind and ref, or both none), passes the history recheck above (same reading, acting state, partner, every tool still
|
|
19
|
+
* usable), was answered or withheld (its look-ups ran), and was asked within FOLLOW_UP_MINUTES. Anything else: the
|
|
20
|
+
* question is asked fresh, with nothing carried.
|
|
7
21
|
*/
|
|
8
22
|
|
|
9
23
|
'use strict';
|
|
10
24
|
|
|
11
|
-
|
|
25
|
+
const FOLLOW_UP_MINUTES = 15;
|
|
26
|
+
const MAX_REFS = 10;
|
|
27
|
+
const utcMs = (t) => Date.parse(`${String(t).replace(' ', 'T')}Z`);
|
|
28
|
+
|
|
29
|
+
/** @param {{invoke, asPlatform, principalOf, modelName?, catalogue?, isPublicId?, now?}} o */
|
|
12
30
|
function createHistory(o) {
|
|
13
31
|
const model = o.modelName || 'AskQuestion';
|
|
32
|
+
const now = typeof o.now === 'function' ? o.now : Date.now;
|
|
33
|
+
const isPublicId = typeof o.isPublicId === 'function' ? o.isPublicId : () => false;
|
|
14
34
|
const meOf = (req, can) => {
|
|
15
35
|
const p = o.principalOf(req, can);
|
|
16
36
|
if (!p || p.id == null) throw new Error('ask history: needs a signed-in user');
|
|
@@ -26,7 +46,49 @@ function createHistory(o) {
|
|
|
26
46
|
}
|
|
27
47
|
const one = (req, can, ref) => { const me = meOf(req, can); return o.asPlatform(() => o.invoke(model, 'oneFor', me, String(ref))); };
|
|
28
48
|
const rate = (req, can, ref, mark) => { const me = meOf(req, can); return o.asPlatform(() => o.invoke(model, 'rate', me, String(ref), mark)); };
|
|
29
|
-
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* May this viewer, now, see this stored question's answer again? { ok: true } or { ok: false, why: 'tool' | 'reading' |
|
|
52
|
+
* 'scope' }. `q` is oneFor's row (with `calls`); `reading` is the viewer's reading now.
|
|
53
|
+
*/
|
|
54
|
+
function recheck(q, req, can, reading, toolsOff = []) {
|
|
55
|
+
const who = o.principalOf(req, can);
|
|
56
|
+
const off = new Set(Array.isArray(toolsOff) ? toolsOff : []);
|
|
57
|
+
for (const c of q.calls || []) {
|
|
58
|
+
const tool = o.catalogue ? o.catalogue.byId(c.tool) : null;
|
|
59
|
+
if (!tool || !o.catalogue.mayUse(can, tool)) return { ok: false, why: 'tool' };
|
|
60
|
+
// A TOOL SWITCHED OFF hides its old answers too; they stay stored and show again when it is switched back on.
|
|
61
|
+
if (off.has(c.tool)) return { ok: false, why: 'tool_off' };
|
|
62
|
+
}
|
|
63
|
+
const then = q.result && q.result.reading != null ? String(q.result.reading) : null;
|
|
64
|
+
if (then !== (reading == null ? null : String(reading))) return { ok: false, why: 'reading' };
|
|
65
|
+
if (!!q.acting !== !!(who && who.acting)) return { ok: false, why: 'scope' };
|
|
66
|
+
const pid = who && who.scope && who.scope.partnerId != null ? Number(who.scope.partnerId) : null;
|
|
67
|
+
if (pid != null && q.partner_id != null && Number(q.partner_id) !== pid) return { ok: false, why: 'scope' };
|
|
68
|
+
return { ok: true };
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* The previous question a follow-up carries — { ref, question, tools, refs } — or null (see the header). `context` is
|
|
73
|
+
* the NEW question's resolved context; `reading` the reading now.
|
|
74
|
+
*/
|
|
75
|
+
async function followUp(req, can, ref, { context = null, reading = null, toolsOff = [] } = {}) {
|
|
76
|
+
if (typeof ref !== 'string' || !isPublicId(ref, 'ask_question')) return null;
|
|
77
|
+
const q = await one(req, can, ref);
|
|
78
|
+
if (!q || !['answered', 'withheld'].includes(q.outcome)) return null;
|
|
79
|
+
const age = now() - utcMs(q.created_at);
|
|
80
|
+
if (!(age >= 0 && age <= FOLLOW_UP_MINUTES * 60000)) return null;
|
|
81
|
+
if ((q.context_kind || null) !== (context ? context.kind : null) || (q.context_ref || null) !== (context ? context.ref : null)) return null;
|
|
82
|
+
if (!recheck(q, req, can, reading, toolsOff).ok) return null;
|
|
83
|
+
const tools = [...new Set((q.calls || []).map((c) => String(c.tool)))];
|
|
84
|
+
const refs = [];
|
|
85
|
+
for (const l of (q.result && q.result.lookups) || []) {
|
|
86
|
+
for (const r of l.refs || []) if (typeof r === 'string' && isPublicId(r) && !refs.includes(r) && refs.length < MAX_REFS) refs.push(r);
|
|
87
|
+
}
|
|
88
|
+
return { ref: q.public_id, question: String(q.question || ''), tools, refs };
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
return { list, one, rate, recheck, followUp, FOLLOW_UP_MINUTES };
|
|
30
92
|
}
|
|
31
93
|
|
|
32
|
-
module.exports = { createHistory };
|
|
94
|
+
module.exports = { createHistory, FOLLOW_UP_MINUTES, MAX_REFS };
|