@aria-framework/ask 0.1.1 → 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +278 -0
- package/README.md +97 -5
- package/assets/ask.js +19 -3
- package/index.js +15 -13
- package/lib/bench.js +146 -32
- package/lib/catalogue.js +188 -25
- package/lib/check.js +479 -25
- 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 +2 -2
- package/views/ask/none.ejs +56 -0
- package/views/ask/review.ejs +1 -0
package/lib/store.js
CHANGED
|
@@ -33,8 +33,12 @@ const intOf = (v) => Math.max(0, Math.trunc(Number(v)) || 0);
|
|
|
33
33
|
const json = (v) => (v == null ? null : JSON.stringify(v));
|
|
34
34
|
const parse = (t) => { if (t == null || t === '') return null; try { return JSON.parse(t); } catch (_) { return null; } };
|
|
35
35
|
|
|
36
|
+
// WHY A PLAN SAID "none" (0.2.0) — stored in the `result` JSON the row already has (`noneReason`), so no column moves.
|
|
37
|
+
const NONE_REASONS = Object.freeze(['no_tool', 'out_of_scope', 'needs_judgement']);
|
|
38
|
+
|
|
36
39
|
function hydrate(r) {
|
|
37
40
|
if (!r) return null;
|
|
41
|
+
const result = parse(r.result);
|
|
38
42
|
return {
|
|
39
43
|
...r,
|
|
40
44
|
id: Number(r.id),
|
|
@@ -44,7 +48,8 @@ function hydrate(r) {
|
|
|
44
48
|
tokens: Number(r.tokens) || 0,
|
|
45
49
|
ms: Number(r.ms) || 0,
|
|
46
50
|
rating: r.rating == null ? null : Number(r.rating),
|
|
47
|
-
result
|
|
51
|
+
result,
|
|
52
|
+
none_reason: result && NONE_REASONS.includes(result.noneReason) ? result.noneReason : null
|
|
48
53
|
};
|
|
49
54
|
}
|
|
50
55
|
|
|
@@ -75,7 +80,7 @@ function createAskQuestionModel(o) {
|
|
|
75
80
|
const WIDE = 'retention is installation-wide';
|
|
76
81
|
|
|
77
82
|
return {
|
|
78
|
-
OUTCOMES, CALL_OUTCOMES, REASONS,
|
|
83
|
+
OUTCOMES, CALL_OUTCOMES, REASONS, NONE_REASONS,
|
|
79
84
|
|
|
80
85
|
/** Open the row — outcome `working` until `finish`. Written BEFORE the model is called. */
|
|
81
86
|
async open({ asked_by, asked_by_name, acting = false, context_kind = null, context_ref = null, question, prompt_hash = null }) {
|
|
@@ -197,6 +202,31 @@ function createAskQuestionModel(o) {
|
|
|
197
202
|
return Number(r.changes || 0) > 0;
|
|
198
203
|
},
|
|
199
204
|
|
|
205
|
+
/**
|
|
206
|
+
* THE QUESTIONS ASK COULD NOT PLAN (0.2.0), newest first, with the planner's reason (`none_reason`: no_tool,
|
|
207
|
+
* out_of_scope, needs_judgement; null for a row written before 0.2.0) — Admin → AI's list of what people asked that no
|
|
208
|
+
* tool answers. PLATFORM ONLY (every partner's rows). `reason` filters on the stored reason (read from `result`).
|
|
209
|
+
*/
|
|
210
|
+
async noneList({ reason = null, partner_id = null, limit = 200 } = {}) {
|
|
211
|
+
platformOnly('noneList', FLEET);
|
|
212
|
+
if (reason != null && !NONE_REASONS.includes(reason)) throw new TypeError(`AskQuestion.noneList: unknown reason ${JSON.stringify(reason)}`);
|
|
213
|
+
const n = Math.min(Math.max(intOf(limit), 1), 500);
|
|
214
|
+
const where = ["q.outcome = 'cannot'"];
|
|
215
|
+
const params = [];
|
|
216
|
+
if (partner_id != null) { where.push('q.partner_id = ?'); params.push(Number(partner_id)); }
|
|
217
|
+
// PAGED AT THE DATABASE (fix round of PR #15): the reason is matched in the stored JSON text with LIKE (the run
|
|
218
|
+
// writes it with JSON.stringify, so it reads "noneReason":"<reason>"), `_` escaped; re-checked here after parsing.
|
|
219
|
+
if (reason != null) { where.push("q.result LIKE ? ESCAPE '\\'"); params.push(`%"noneReason":"${reason.replace(/_/g, '\\_')}"%`); }
|
|
220
|
+
const rows = (await d().all(
|
|
221
|
+
`SELECT q.id, q.public_id, q.partner_id, p.name AS partner_name, q.asked_by_name, q.acting, q.context_kind, q.question,
|
|
222
|
+
q.outcome, q.result, q.created_at
|
|
223
|
+
FROM ask_questions q JOIN partners p ON p.id = q.partner_id
|
|
224
|
+
WHERE ${where.join(' AND ')}
|
|
225
|
+
ORDER BY q.created_at DESC, q.id DESC
|
|
226
|
+
LIMIT ?`, [...params, n])).map(hydrate);
|
|
227
|
+
return (reason == null ? rows : rows.filter((r) => r.none_reason === reason)).slice(0, n).map(({ result, ...r }) => r);
|
|
228
|
+
},
|
|
229
|
+
|
|
200
230
|
/** RETENTION: delete every question created before `cut` (UTC), in every partner; look-ups cascade. PLATFORM ONLY. */
|
|
201
231
|
async purgeBefore(cut) {
|
|
202
232
|
platformOnly('purgeBefore', WIDE);
|
|
@@ -215,4 +245,4 @@ function createAskQuestionModel(o) {
|
|
|
215
245
|
};
|
|
216
246
|
}
|
|
217
247
|
|
|
218
|
-
module.exports = { createAskQuestionModel, OUTCOMES, CALL_OUTCOMES, REASONS };
|
|
248
|
+
module.exports = { createAskQuestionModel, OUTCOMES, CALL_OUTCOMES, REASONS, NONE_REASONS };
|
package/lib/text.js
ADDED
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* THE SHARED TEXT HELPER (0.2.0) for an app's search tools (a desk's tickets.search, a soc's events.search): the words of
|
|
3
|
+
* a query for AND/OR matching, a LIKE clause over the app's own columns, a capped snippet around the first match, and the
|
|
4
|
+
* path that marks snippets as ATTACKER TEXT. The app keeps its own query, its scope and its permissions; this file only
|
|
5
|
+
* turns the planner's query argument into terms and the matched text into a short, safe excerpt.
|
|
6
|
+
*
|
|
7
|
+
* const { text } = require('@aria-framework/ask');
|
|
8
|
+
* const q = text.searchTerms(args.query); // { op: 'and' | 'or', terms: ['printer', 'jam'] } or null
|
|
9
|
+
* const w = text.likeClause(['t.subject', 't.body'], q); // { sql: "(lower(t.subject) LIKE ? ESCAPE '\\' OR …) AND (…)", params }
|
|
10
|
+
* rows.map((r) => ({ ref, title, fields: { snippet: text.snippet(r.body, q) } }))
|
|
11
|
+
* attackerFields: text.withSnippets(['rows[].title']) // + 'rows[].fields.snippet'
|
|
12
|
+
*
|
|
13
|
+
* A SNIPPET IS ATTACKER TEXT, ALWAYS: somebody other than the asker typed it. The check reads every path ending in
|
|
14
|
+
* `.snippet` as an attacker path whether or not the tool lists it (check.js), a snippet can never be a round-2 output
|
|
15
|
+
* (catalogue.js), and the bench plants its payloads there when the tool lists it (withSnippets).
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
'use strict';
|
|
19
|
+
|
|
20
|
+
const MAX_TERMS = 8;
|
|
21
|
+
const MAX_SNIPPET = 160;
|
|
22
|
+
const SNIPPET_FIELD = 'snippet';
|
|
23
|
+
const SNIPPET_PATH = `rows[].fields.${SNIPPET_FIELD}`;
|
|
24
|
+
// A term: letters and digits, with . _ - @ inside (an address or a host), NFKC and lower-cased.
|
|
25
|
+
const TERM = /[\p{L}\p{N}](?:[\p{L}\p{N}._@-]*[\p{L}\p{N}])?/gu;
|
|
26
|
+
const clean = (t) => String(t == null ? '' : t).normalize('NFKC').replace(/[\u0000-\u001f\u007f\p{Cf}]+/gu, ' ');
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* The words of a query. "printer jam" is AND; "printer OR scanner" ("or" in any case, or a "|") is OR — one operator for
|
|
30
|
+
* the whole query. A mixed query ("printer jam or scanner") is AND over every term. Terms under `minLength` are dropped, duplicates once, at most
|
|
31
|
+
* `max`. Returns null for a query with no term (the tool answers not_found, never "everything").
|
|
32
|
+
* @param {string} q
|
|
33
|
+
* @param {{max?: number, minLength?: number}} [o]
|
|
34
|
+
*/
|
|
35
|
+
function searchTerms(q, o = {}) {
|
|
36
|
+
const max = Math.min(Math.max(Number(o.max) || MAX_TERMS, 1), 16);
|
|
37
|
+
const minLength = Math.max(Number(o.minLength) || 2, 1);
|
|
38
|
+
const raw = clean(q);
|
|
39
|
+
// "or" (any case) and "|" separate OR parts. A MIXED query ("printer jam or scanner": a part of two terms) is ANDed —
|
|
40
|
+
// the narrower reading, so a query never widens to rows matching one word of it (fix round of PR #15).
|
|
41
|
+
const parts = raw.split(/\s+or\s+|\s*\|\s*/i);
|
|
42
|
+
let op = parts.length > 1 ? 'or' : 'and';
|
|
43
|
+
if (op === 'or' && parts.some((p) => (p.toLowerCase().match(TERM) || []).filter((t) => t.length >= minLength).length > 1)) op = 'and';
|
|
44
|
+
const terms = [];
|
|
45
|
+
for (const p of parts) {
|
|
46
|
+
for (const m of p.toLowerCase().matchAll(TERM)) {
|
|
47
|
+
const t = m[0];
|
|
48
|
+
if (t.length >= minLength && !terms.includes(t)) terms.push(t);
|
|
49
|
+
if (terms.length >= max) break;
|
|
50
|
+
}
|
|
51
|
+
if (terms.length >= max) break;
|
|
52
|
+
}
|
|
53
|
+
return terms.length ? { op, terms } : null;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/** A term as a LIKE pattern: %, _ and \ escaped (ESCAPE '\'), wrapped in %…%. */
|
|
57
|
+
const likeOf = (term) => `%${String(term).replace(/[\\%_]/g, (c) => `\\${c}`)}%`;
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* A WHERE fragment over the app's own columns: each term must match (AND) — or any term (OR) — in at least one column,
|
|
61
|
+
* compared lower-cased. Column names are the APP's (validated as identifiers, never from the request). Works on SQLite and
|
|
62
|
+
* Postgres. Returns { sql, params }; an empty query is refused (throws), so a tool cannot widen to "every row".
|
|
63
|
+
* @param {string[]} columns e.g. ['t.subject', 't.body']
|
|
64
|
+
* @param {{op, terms}} q searchTerms' result
|
|
65
|
+
*/
|
|
66
|
+
function likeClause(columns, q) {
|
|
67
|
+
if (!Array.isArray(columns) || !columns.length || columns.some((c) => typeof c !== 'string' || !/^[a-z_][a-z0-9_]*(?:\.[a-z_][a-z0-9_]*)?$/i.test(c))) {
|
|
68
|
+
throw new Error('ask text: likeClause needs the app\'s column names (identifiers)');
|
|
69
|
+
}
|
|
70
|
+
if (!q || !Array.isArray(q.terms) || !q.terms.length) throw new Error('ask text: likeClause needs at least one term');
|
|
71
|
+
const params = [];
|
|
72
|
+
const one = (t) => `(${columns.map((c) => { params.push(likeOf(t)); return `lower(${c}) LIKE ? ESCAPE '\\'`; }).join(' OR ')})`;
|
|
73
|
+
const sql = q.terms.map(one).join(q.op === 'or' ? ' OR ' : ' AND ');
|
|
74
|
+
return { sql: `(${sql})`, params };
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/** Does this text match the query (in memory, the same rule as likeClause)? */
|
|
78
|
+
function matches(t, q) {
|
|
79
|
+
if (!q || !q.terms || !q.terms.length) return false;
|
|
80
|
+
const s = clean(t).toLowerCase();
|
|
81
|
+
return q.op === 'or' ? q.terms.some((x) => s.includes(x)) : q.terms.every((x) => s.includes(x));
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* A SNIPPET: at most `max` characters (default 160, never over 500) of `t` around the first match of any term, cut at word
|
|
86
|
+
* boundaries, with "…" where it was cut, control and format characters as spaces and whitespace collapsed. No match: the
|
|
87
|
+
* start of the text. Empty → null. The result is attacker text (see the header).
|
|
88
|
+
*/
|
|
89
|
+
function snippet(t, q, o = {}) {
|
|
90
|
+
const max = Math.min(Math.max(Number(o.max) || MAX_SNIPPET, 20), 500);
|
|
91
|
+
const s = clean(t).replace(/\s+/g, ' ').trim();
|
|
92
|
+
if (!s) return null;
|
|
93
|
+
if (s.length <= max) return s;
|
|
94
|
+
// Found in the ORIGINAL text, case-blind, so case folding that changes a string's length (İ) never moves the index.
|
|
95
|
+
let at = -1;
|
|
96
|
+
for (const term of (q && q.terms) || []) {
|
|
97
|
+
const m = new RegExp(term.replace(/[.*+?^${}()|[\]\\]/g, (c) => `\\${c}`), 'iu').exec(s);
|
|
98
|
+
if (m && (at < 0 || m.index < at)) at = m.index;
|
|
99
|
+
}
|
|
100
|
+
if (at < 0) at = 0;
|
|
101
|
+
let start = Math.max(0, at - Math.floor(max / 3));
|
|
102
|
+
let end = Math.min(s.length, start + max - 2);
|
|
103
|
+
start = Math.max(0, end - (max - 2));
|
|
104
|
+
if (start > 0) { const sp = s.indexOf(' ', start); if (sp >= 0 && sp < at) start = sp + 1; }
|
|
105
|
+
if (end < s.length) { const sp = s.lastIndexOf(' ', end); if (sp > Math.max(start, at)) end = sp; }
|
|
106
|
+
return `${start > 0 ? '…' : ''}${s.slice(start, end).trim()}${end < s.length ? '…' : ''}`;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/** attackerFields with the snippet path added (once). */
|
|
110
|
+
const withSnippets = (fields = [], field = SNIPPET_FIELD) => {
|
|
111
|
+
const path = `rows[].fields.${field}`;
|
|
112
|
+
return fields.includes(path) ? [...fields] : [...fields, path];
|
|
113
|
+
};
|
|
114
|
+
|
|
115
|
+
/** Is this result path a snippet — attacker text whatever the tool lists? */
|
|
116
|
+
const isSnippetPath = (path) => /(?:^|\.)snippet$/.test(String(path));
|
|
117
|
+
|
|
118
|
+
module.exports = { searchTerms, likeClause, likeOf, matches, snippet, withSnippets, isSnippetPath, SNIPPET_FIELD, SNIPPET_PATH, MAX_TERMS, MAX_SNIPPET };
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@aria-framework/ask",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.2.0",
|
|
4
4
|
"description": "Aria App Framework — ask module. \"Ask anything\": a read-only question box over an app's own records — plan, run the app's tools in the asker's own request, answer from fenced results, check every figure and link, audit. Ships the loop, the answer check, the bench, canonical settings, the question model factory, a reference table design with its generator and check, the ask/1 stream, route factories, views and the drawer script. The app supplies its tools and words.",
|
|
5
5
|
"license": "UNLICENSED",
|
|
6
6
|
"private": false,
|
package/views/ask/admin.ejs
CHANGED
|
@@ -51,7 +51,7 @@
|
|
|
51
51
|
</div>
|
|
52
52
|
<% } %>
|
|
53
53
|
<% if (benchState.running) { %>
|
|
54
|
-
<p class="small mb-2"><span class="tagpill sm">Running</span> <%= benchState.endpoint || 'starting' %> ·
|
|
54
|
+
<p class="small mb-2"><span class="tagpill sm">Running</span> <%= benchState.endpoint || 'starting' %> · <span data-bench-eta><%= benchLine %></span> (calls; each case runs three times: twice without its payload, once with it).</p>
|
|
55
55
|
<progress class="d-block w-100 mb-2" data-bench-progress max="<%= benchState.of || corpusSize * 3 %>" value="<%= benchState.done %>"><%= benchState.done %> of <%= benchState.of || corpusSize * 3 %></progress>
|
|
56
56
|
<% } else if (benchState.error) { %>
|
|
57
57
|
<p class="small text-danger mb-2">The last bench run failed: <%= benchState.error %></p>
|
|
@@ -79,7 +79,7 @@
|
|
|
79
79
|
<td class="small"><% if (e.bench) { %>
|
|
80
80
|
<span class="tagpill sm <%= e.bench.passed ? 'ok' : 'warn' %>"><%= e.bench.passed ? 'passed' : 'not passed' %></span>
|
|
81
81
|
<% if (e.bench.injectionRate != null) { %>
|
|
82
|
-
<div>Injection resisted <strong><%= e.bench.injectionRate %> %</strong> · format followed <%= e.bench.formatRate %> % · <%= e.bench.controlsHeld %> of <%= e.bench.controlsTotal %> controls<% if (e.bench.counts && e.bench.counts.error) { %> · <span class="text-danger"><%= e.bench.counts.error %> cases unanswered (<%= e.bench.errorRate %> %)</span><% } %><% if (e.bench.changedDuringRun) { %> · <span class="text-danger">configuration changed during the run</span><% } %></div>
|
|
82
|
+
<div>Injection resisted <strong><%= e.bench.injectionRate %> %</strong> · format followed <%= e.bench.formatRate %> % · <%= e.bench.controlsHeld %> of <%= e.bench.controlsTotal %> controls<% if (e.bench.counts && e.bench.counts.error) { %> · <span class="text-danger"><%= e.bench.counts.error %> cases unanswered (<%= e.bench.errorRate %> %)</span><% } %><% if (e.bench.reached) { %> · <span class="text-danger"><%= e.bench.reached %> attacker-planted addresses reached the person</span><% } %><% if (e.bench.changedDuringRun) { %> · <span class="text-danger">configuration changed during the run</span><% } %></div>
|
|
83
83
|
<% if (e.bench.topReasons && e.bench.topReasons.length) { %>
|
|
84
84
|
<div class="text-body-secondary">Top reasons: <%= e.bench.topReasons.map(function (x) { return x.code + ' ×' + x.n; }).join(', ') %></div>
|
|
85
85
|
<% } %>
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
<% var _L = (typeof askLayout !== 'undefined' && askLayout) ? askLayout : { head: 'partials/staff-head', tabs: 'partials/admin-tabs', foot: 'partials/staff-foot' }; %>
|
|
2
|
+
<% var _base = (typeof askReviewBase === 'string' && askReviewBase) ? askReviewBase : '/admin/ai/ask-review'; %>
|
|
3
|
+
<%- include(_L.head) %>
|
|
4
|
+
|
|
5
|
+
<%- include(_L.tabs) %>
|
|
6
|
+
|
|
7
|
+
<%# QUESTIONS ASK COULD NOT ANSWER (@aria-framework/ask 0.2.0): every question whose plan said "none", with the planner's
|
|
8
|
+
reason — no tool covers it, it is not about these records, or it asks for a judgement. For the platform
|
|
9
|
+
(ai_config.view): every partner's rows, with a Partner filter. Question text AS TYPED, escaped, as data. Rows go when
|
|
10
|
+
the question's retention ends. App locals used: whenTag; askLayout and askReviewBase are optional. %>
|
|
11
|
+
|
|
12
|
+
<div class="card mb-3"><div class="card-body">
|
|
13
|
+
<h6 class="card-title">Questions Ask could not answer <span class="cnt"><%= rows.length %></span></h6>
|
|
14
|
+
<p class="small text-body-secondary">What people asked that no look-up answers, and why the planner said so. <a href="<%= _base %>">Answers marked not helpful →</a></p>
|
|
15
|
+
<form method="GET" action="<%= _base %>/none" class="row g-2 align-items-end mb-3">
|
|
16
|
+
<div class="col-auto">
|
|
17
|
+
<label class="form-label small" for="nn-partner">Partner</label>
|
|
18
|
+
<select class="form-select form-select-sm" id="nn-partner" name="partner">
|
|
19
|
+
<option value="">All partners</option>
|
|
20
|
+
<% partners.forEach(function (p) { %><option value="<%= p.id %>" <%= partner === p.id ? 'selected' : '' %>><%= p.name %><%= p.isHouse ? ' (house)' : '' %></option><% }) %>
|
|
21
|
+
</select>
|
|
22
|
+
</div>
|
|
23
|
+
<div class="col-auto">
|
|
24
|
+
<label class="form-label small" for="nn-reason">Reason</label>
|
|
25
|
+
<select class="form-select form-select-sm" id="nn-reason" name="reason">
|
|
26
|
+
<option value="">Any reason</option>
|
|
27
|
+
<% Object.keys(reasons).forEach(function (k) { %><option value="<%= k %>" <%= reason === k ? 'selected' : '' %>><%= reasons[k] %></option><% }) %>
|
|
28
|
+
</select>
|
|
29
|
+
</div>
|
|
30
|
+
<div class="col-auto"><button type="submit" class="btn btn-sm btn-outline-primary">Filter</button></div>
|
|
31
|
+
<div class="col-auto small text-body-secondary">Kept as long as the Ask retention setting — older questions are deleted.</div>
|
|
32
|
+
</form>
|
|
33
|
+
|
|
34
|
+
<% if (!rows.length) { %>
|
|
35
|
+
<p class="small mb-0">No questions Ask could not answer<%= reason ? ' for this reason' : '' %>.</p>
|
|
36
|
+
<% } else { %>
|
|
37
|
+
<div class="table-responsive"><table class="table table-sm align-top mb-0">
|
|
38
|
+
<thead><tr><th>Time</th><th>User</th><th>Partner</th><th>App</th><th>Question</th><th>Page</th><th>Reason</th></tr></thead>
|
|
39
|
+
<tbody>
|
|
40
|
+
<% rows.forEach(function (r) { %>
|
|
41
|
+
<tr>
|
|
42
|
+
<td class="small text-nowrap"><%- whenTag(r.created_at) %></td>
|
|
43
|
+
<td class="small"><%= r.asked_by_name %><%= r.acting ? ' (working inside)' : '' %></td>
|
|
44
|
+
<td class="small"><%= r.partner_name %></td>
|
|
45
|
+
<td class="small"><%= typeof appName !== 'undefined' ? appName : '' %></td>
|
|
46
|
+
<td><%= r.question %></td>
|
|
47
|
+
<td class="small"><%= r.context_kind || '—' %></td>
|
|
48
|
+
<td class="small" data-none-reason="<%= r.none_reason || '' %>"><%= r.reasonWords %></td>
|
|
49
|
+
</tr>
|
|
50
|
+
<% }) %>
|
|
51
|
+
</tbody>
|
|
52
|
+
</table></div>
|
|
53
|
+
<% } %>
|
|
54
|
+
</div></div>
|
|
55
|
+
|
|
56
|
+
<%- include(_L.foot) %>
|
package/views/ask/review.ejs
CHANGED
|
@@ -12,6 +12,7 @@
|
|
|
12
12
|
|
|
13
13
|
<div class="card mb-3"><div class="card-body">
|
|
14
14
|
<h6 class="card-title">Questions marked not helpful <span class="cnt"><%= rows.length %></span></h6>
|
|
15
|
+
<p class="small text-body-secondary"><a href="<%= _base %>/none">Questions Ask could not answer, and why →</a></p>
|
|
15
16
|
<form method="GET" action="<%= _base %>" class="row g-2 align-items-end mb-3">
|
|
16
17
|
<div class="col-auto">
|
|
17
18
|
<label class="form-label small" for="rv-partner">Partner</label>
|