@yadsh/dsh-qa-surface 0.6.0 → 0.7.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/README.md +148 -9
- package/capability-policy.json +1 -1
- package/cordis.patch.yml +2 -1
- package/lib/access/capability-catalog.js +144 -0
- package/lib/access/capability-catalog.js.map +1 -0
- package/lib/access/model.js +379 -0
- package/lib/access/model.js.map +1 -0
- package/lib/access/role-repository.js +195 -0
- package/lib/access/role-repository.js.map +1 -0
- package/lib/access/service.js +380 -0
- package/lib/access/service.js.map +1 -0
- package/lib/access/skill-metadata.js +234 -0
- package/lib/access/skill-metadata.js.map +1 -0
- package/lib/account-remotes.js +20 -10
- package/lib/account-remotes.js.map +1 -1
- package/lib/accounts/errors.js.map +1 -1
- package/lib/accounts/file.js.map +1 -1
- package/lib/accounts/store.js +202 -3
- package/lib/accounts/store.js.map +1 -1
- package/lib/admin/conversation-log.js +256 -0
- package/lib/admin/conversation-log.js.map +1 -0
- package/lib/admin/index.js +17 -0
- package/lib/admin/index.js.map +1 -0
- package/lib/admin/metrics.js +103 -0
- package/lib/admin/metrics.js.map +1 -0
- package/lib/admin/paging.js +23 -0
- package/lib/admin/paging.js.map +1 -0
- package/lib/admin/permissions.js +50 -0
- package/lib/admin/permissions.js.map +1 -0
- package/lib/admin/quality-store.js +485 -0
- package/lib/admin/quality-store.js.map +1 -0
- package/lib/admin/queue.js +107 -0
- package/lib/admin/queue.js.map +1 -0
- package/lib/admin/redaction.js +45 -0
- package/lib/admin/redaction.js.map +1 -0
- package/lib/admin/service.js +897 -0
- package/lib/admin/service.js.map +1 -0
- package/lib/admin/session-log.js +142 -0
- package/lib/admin/session-log.js.map +1 -0
- package/lib/attestation.js +6 -0
- package/lib/attestation.js.map +1 -1
- package/lib/cli.js +9 -6
- package/lib/cli.js.map +1 -1
- package/lib/client/QaSessionController.js +65 -15
- package/lib/client/QaSessionController.js.map +1 -1
- package/lib/client/QaSurface.js +144 -6
- package/lib/client/QaSurface.js.map +1 -1
- package/lib/client/QaTranscriptAdapter.js +1 -0
- package/lib/client/QaTranscriptAdapter.js.map +1 -1
- package/lib/client/admin/QaAdmin.js +458 -0
- package/lib/client/admin/QaAdmin.js.map +1 -0
- package/lib/client/admin/format.js +177 -0
- package/lib/client/admin/format.js.map +1 -0
- package/lib/client/admin/pages/Conversations.js +114 -0
- package/lib/client/admin/pages/Conversations.js.map +1 -0
- package/lib/client/admin/pages/Overview.js +24 -0
- package/lib/client/admin/pages/Overview.js.map +1 -0
- package/lib/client/admin/pages/Quality.js +71 -0
- package/lib/client/admin/pages/Quality.js.map +1 -0
- package/lib/client/admin/pages/Review.js +93 -0
- package/lib/client/admin/pages/Review.js.map +1 -0
- package/lib/client/admin/pages/Users.js +95 -0
- package/lib/client/admin/pages/Users.js.map +1 -0
- package/lib/client/admin/routes.js +127 -0
- package/lib/client/admin/routes.js.map +1 -0
- package/lib/client/admin/shared.js +91 -0
- package/lib/client/admin/shared.js.map +1 -0
- package/lib/client/admin/styles.js +111 -0
- package/lib/client/admin/styles.js.map +1 -0
- package/lib/client/attestation.js +14 -2
- package/lib/client/attestation.js.map +1 -1
- package/lib/client/components/QaChangelog.js +48 -0
- package/lib/client/components/QaChangelog.js.map +1 -1
- package/lib/client/components/QaHeader.js +3 -3
- package/lib/client/components/QaHeader.js.map +1 -1
- package/lib/client/components/QaMessage.js +61 -2
- package/lib/client/components/QaMessage.js.map +1 -1
- package/lib/client/create-session.js +1 -1
- package/lib/client/create-session.js.map +1 -1
- package/lib/client/index.js +33 -1
- package/lib/client/index.js.map +1 -1
- package/lib/client/project-session-state.js +10 -5
- package/lib/client/project-session-state.js.map +1 -1
- package/lib/client/role/RoleSelector.js +31 -0
- package/lib/client/role/RoleSelector.js.map +1 -0
- package/lib/client/session-sources.js +37 -1
- package/lib/client/session-sources.js.map +1 -1
- package/lib/client/settings/card.js +2 -2
- package/lib/client/settings/card.js.map +1 -1
- package/lib/client/settings/sections.js +0 -1
- package/lib/client/settings/sections.js.map +1 -1
- package/lib/client/styles.js +32 -0
- package/lib/client/styles.js.map +1 -1
- package/lib/client/types.js +1 -0
- package/lib/client/types.js.map +1 -1
- package/lib/client.js +10786 -5684
- package/lib/client.js.map +1 -1
- package/lib/config-resolvers/defaults.js +0 -2
- package/lib/config-resolvers/defaults.js.map +1 -1
- package/lib/config.js +0 -249
- package/lib/config.js.map +1 -1
- package/lib/enforcement/skill-policy.js +172 -0
- package/lib/enforcement/skill-policy.js.map +1 -0
- package/lib/enforcement/tool-grants.js +151 -0
- package/lib/enforcement/tool-grants.js.map +1 -0
- package/lib/index.js +230 -44
- package/lib/index.js.map +1 -1
- package/lib/personal-skills/service.js +10 -3
- package/lib/personal-skills/service.js.map +1 -1
- package/lib/qa-tools/index.js +4 -0
- package/lib/qa-tools/index.js.map +1 -1
- package/lib/resolve-config.js +0 -2
- package/lib/resolve-config.js.map +1 -1
- package/lib/secure-session.js +111 -32
- package/lib/secure-session.js.map +1 -1
- package/lib/typert.host.js +2237 -202
- package/lib/typert.remote-client.d.ts +49 -3
- package/lib/typert.remote-client.js +1863 -183
- package/lib/types/access/capability-catalog.d.ts +37 -0
- package/lib/types/access/model.d.ts +60 -0
- package/lib/types/access/role-repository.d.ts +32 -0
- package/lib/types/access/service.d.ts +57 -0
- package/lib/types/access/skill-metadata.d.ts +53 -0
- package/lib/types/account-remotes.d.ts +6 -0
- package/lib/types/accounts/errors.d.ts +1 -1
- package/lib/types/accounts/file.d.ts +16 -5
- package/lib/types/accounts/store.d.ts +83 -3
- package/lib/types/admin/conversation-log.d.ts +39 -0
- package/lib/types/admin/index.d.ts +24 -0
- package/lib/types/admin/metrics.d.ts +29 -0
- package/lib/types/admin/paging.d.ts +33 -0
- package/lib/types/admin/permissions.d.ts +10 -0
- package/lib/types/admin/quality-store.d.ts +82 -0
- package/lib/types/admin/queue.d.ts +21 -0
- package/lib/types/admin/redaction.d.ts +29 -0
- package/lib/types/admin/service.d.ts +105 -0
- package/lib/types/admin/session-log.d.ts +53 -0
- package/lib/types/attestation.d.ts +2 -0
- package/lib/types/client/QaSessionController.d.ts +14 -1
- package/lib/types/client/QaSurface.d.ts +7 -1
- package/lib/types/client/admin/QaAdmin.d.ts +17 -0
- package/lib/types/client/admin/format.d.ts +43 -0
- package/lib/types/client/admin/pages/Conversations.d.ts +21 -0
- package/lib/types/client/admin/pages/Overview.d.ts +13 -0
- package/lib/types/client/admin/pages/Quality.d.ts +18 -0
- package/lib/types/client/admin/pages/Review.d.ts +15 -0
- package/lib/types/client/admin/pages/Users.d.ts +22 -0
- package/lib/types/client/admin/routes.d.ts +50 -0
- package/lib/types/client/admin/shared.d.ts +53 -0
- package/lib/types/client/admin/styles.d.ts +11 -0
- package/lib/types/client/attestation.d.ts +8 -1
- package/lib/types/client/components/QaHeader.d.ts +5 -1
- package/lib/types/client/components/QaMessage.d.ts +14 -1
- package/lib/types/client/create-session.d.ts +2 -0
- package/lib/types/client/project-session-state.d.ts +2 -0
- package/lib/types/client/role/RoleSelector.d.ts +14 -0
- package/lib/types/client/session-sources.d.ts +9 -0
- package/lib/types/client/settings/sections.d.ts +0 -1
- package/lib/types/client/types.d.ts +39 -2
- package/lib/types/enforcement/skill-policy.d.ts +29 -0
- package/lib/types/enforcement/tool-grants.d.ts +84 -0
- package/lib/types/index.d.ts +62 -14
- package/lib/types/qa-tools/index.d.ts +2 -0
- package/lib/types/secure-session.d.ts +19 -1
- package/lib/types/types.d.ts +574 -5
- package/package.json +3 -3
- package/lib/client/settings/sections/documents.js +0 -88
- package/lib/client/settings/sections/documents.js.map +0 -1
- package/lib/documents/artifacts/ids.js +0 -43
- package/lib/documents/artifacts/ids.js.map +0 -1
- package/lib/documents/artifacts/manifest.js +0 -66
- package/lib/documents/artifacts/manifest.js.map +0 -1
- package/lib/documents/artifacts/store.js +0 -169
- package/lib/documents/artifacts/store.js.map +0 -1
- package/lib/documents/capabilities.js +0 -93
- package/lib/documents/capabilities.js.map +0 -1
- package/lib/documents/config.js +0 -302
- package/lib/documents/config.js.map +0 -1
- package/lib/documents/defaults.js +0 -78
- package/lib/documents/defaults.js.map +0 -1
- package/lib/documents/errors.js +0 -101
- package/lib/documents/errors.js.map +0 -1
- package/lib/documents/index.js +0 -80
- package/lib/documents/index.js.map +0 -1
- package/lib/documents/inspect/facts.js +0 -161
- package/lib/documents/inspect/facts.js.map +0 -1
- package/lib/documents/inspect/zip.js +0 -108
- package/lib/documents/inspect/zip.js.map +0 -1
- package/lib/documents/markdown/directives.js +0 -135
- package/lib/documents/markdown/directives.js.map +0 -1
- package/lib/documents/markdown/frontmatter.js +0 -35
- package/lib/documents/markdown/frontmatter.js.map +0 -1
- package/lib/documents/markdown/normalize.js +0 -113
- package/lib/documents/markdown/normalize.js.map +0 -1
- package/lib/documents/orchestrator/assets.js +0 -243
- package/lib/documents/orchestrator/assets.js.map +0 -1
- package/lib/documents/orchestrator/convert-document.js +0 -186
- package/lib/documents/orchestrator/convert-document.js.map +0 -1
- package/lib/documents/orchestrator/create-document.js +0 -292
- package/lib/documents/orchestrator/create-document.js.map +0 -1
- package/lib/documents/orchestrator/extract-document.js +0 -162
- package/lib/documents/orchestrator/extract-document.js.map +0 -1
- package/lib/documents/orchestrator/inspect-document.js +0 -89
- package/lib/documents/orchestrator/inspect-document.js.map +0 -1
- package/lib/documents/orchestrator/runtime-deps.js +0 -59
- package/lib/documents/orchestrator/runtime-deps.js.map +0 -1
- package/lib/documents/orchestrator/scope.js +0 -132
- package/lib/documents/orchestrator/scope.js.map +0 -1
- package/lib/documents/providers/docling.js +0 -247
- package/lib/documents/providers/docling.js.map +0 -1
- package/lib/documents/providers/libreoffice.js +0 -130
- package/lib/documents/providers/libreoffice.js.map +0 -1
- package/lib/documents/providers/markitdown.js +0 -89
- package/lib/documents/providers/markitdown.js.map +0 -1
- package/lib/documents/providers/pandoc.js +0 -161
- package/lib/documents/providers/pandoc.js.map +0 -1
- package/lib/documents/providers/process.js +0 -164
- package/lib/documents/providers/process.js.map +0 -1
- package/lib/documents/providers/registry.js +0 -152
- package/lib/documents/providers/registry.js.map +0 -1
- package/lib/documents/providers/shared.js +0 -75
- package/lib/documents/providers/shared.js.map +0 -1
- package/lib/documents/runtime.js +0 -114
- package/lib/documents/runtime.js.map +0 -1
- package/lib/documents/security/file-types.js +0 -188
- package/lib/documents/security/file-types.js.map +0 -1
- package/lib/documents/security/limits.js +0 -78
- package/lib/documents/security/limits.js.map +0 -1
- package/lib/documents/security/mime.js +0 -43
- package/lib/documents/security/mime.js.map +0 -1
- package/lib/documents/security/paths.js +0 -133
- package/lib/documents/security/paths.js.map +0 -1
- package/lib/documents/templates/registry.js +0 -147
- package/lib/documents/templates/registry.js.map +0 -1
- package/lib/documents/templates/resolver.js +0 -59
- package/lib/documents/templates/resolver.js.map +0 -1
- package/lib/documents/tools/convert.js +0 -125
- package/lib/documents/tools/convert.js.map +0 -1
- package/lib/documents/tools/create.js +0 -187
- package/lib/documents/tools/create.js.map +0 -1
- package/lib/documents/tools/index.js +0 -41
- package/lib/documents/tools/index.js.map +0 -1
- package/lib/documents/tools/inspect.js +0 -135
- package/lib/documents/tools/inspect.js.map +0 -1
- package/lib/documents/tools/shared.js +0 -88
- package/lib/documents/tools/shared.js.map +0 -1
- package/lib/documents/tools/to-markdown.js +0 -125
- package/lib/documents/tools/to-markdown.js.map +0 -1
- package/lib/documents/types.js +0 -12
- package/lib/documents/types.js.map +0 -1
- package/lib/types/client/settings/sections/documents.d.ts +0 -13
- package/lib/types/documents/artifacts/ids.d.ts +0 -14
- package/lib/types/documents/artifacts/manifest.d.ts +0 -32
- package/lib/types/documents/artifacts/store.d.ts +0 -67
- package/lib/types/documents/capabilities.d.ts +0 -32
- package/lib/types/documents/config.d.ts +0 -27
- package/lib/types/documents/defaults.d.ts +0 -161
- package/lib/types/documents/errors.d.ts +0 -42
- package/lib/types/documents/index.d.ts +0 -63
- package/lib/types/documents/inspect/facts.d.ts +0 -43
- package/lib/types/documents/inspect/zip.d.ts +0 -28
- package/lib/types/documents/markdown/directives.d.ts +0 -39
- package/lib/types/documents/markdown/frontmatter.d.ts +0 -15
- package/lib/types/documents/markdown/normalize.d.ts +0 -30
- package/lib/types/documents/orchestrator/assets.d.ts +0 -55
- package/lib/types/documents/orchestrator/convert-document.d.ts +0 -25
- package/lib/types/documents/orchestrator/create-document.d.ts +0 -18
- package/lib/types/documents/orchestrator/extract-document.d.ts +0 -24
- package/lib/types/documents/orchestrator/inspect-document.d.ts +0 -12
- package/lib/types/documents/orchestrator/runtime-deps.d.ts +0 -43
- package/lib/types/documents/orchestrator/scope.d.ts +0 -52
- package/lib/types/documents/providers/docling.d.ts +0 -54
- package/lib/types/documents/providers/libreoffice.d.ts +0 -40
- package/lib/types/documents/providers/markitdown.d.ts +0 -28
- package/lib/types/documents/providers/pandoc.d.ts +0 -42
- package/lib/types/documents/providers/process.d.ts +0 -54
- package/lib/types/documents/providers/registry.d.ts +0 -68
- package/lib/types/documents/providers/shared.d.ts +0 -27
- package/lib/types/documents/runtime.d.ts +0 -59
- package/lib/types/documents/security/file-types.d.ts +0 -39
- package/lib/types/documents/security/limits.d.ts +0 -24
- package/lib/types/documents/security/mime.d.ts +0 -13
- package/lib/types/documents/security/paths.d.ts +0 -38
- package/lib/types/documents/templates/registry.d.ts +0 -50
- package/lib/types/documents/templates/resolver.d.ts +0 -30
- package/lib/types/documents/tools/convert.d.ts +0 -9
- package/lib/types/documents/tools/create.d.ts +0 -14
- package/lib/types/documents/tools/index.d.ts +0 -33
- package/lib/types/documents/tools/inspect.d.ts +0 -9
- package/lib/types/documents/tools/shared.d.ts +0 -77
- package/lib/types/documents/tools/to-markdown.d.ts +0 -12
- package/lib/types/documents/types.d.ts +0 -294
|
@@ -0,0 +1,897 @@
|
|
|
1
|
+
import { QaAccountsError } from "../accounts/store.js";
|
|
2
|
+
import { defaultCapabilityConfig, normalizeUserAccess, } from "../access/model.js";
|
|
3
|
+
import { allows } from "./permissions.js";
|
|
4
|
+
import { aggregateQuality } from "./metrics.js";
|
|
5
|
+
import { paginate } from "./paging.js";
|
|
6
|
+
import { deriveQueue } from "./queue.js";
|
|
7
|
+
import { defaultAdminRedactor } from "./redaction.js";
|
|
8
|
+
import { projectTranscript, } from "./conversation-log.js";
|
|
9
|
+
/**
|
|
10
|
+
* The administrative console's server side.
|
|
11
|
+
*
|
|
12
|
+
* Every entry point takes the caller's token and names the permission it
|
|
13
|
+
* needs; there is no `isAdmin` shortcut and no client-supplied identity. The
|
|
14
|
+
* browser may only ever read what a role grants, and every write lands in the
|
|
15
|
+
* audit trail with its before and after images.
|
|
16
|
+
*/
|
|
17
|
+
export const QA_ADMIN_PAGE_DEFAULT = 25;
|
|
18
|
+
export const QA_ADMIN_PAGE_MAX = 100;
|
|
19
|
+
/**
|
|
20
|
+
* How many conversations one metrics or search pass will open. Reading every
|
|
21
|
+
* stored log is unbounded work, so the aggregation covers the newest logs and
|
|
22
|
+
* says so; moving these counters onto a real index is the next iteration.
|
|
23
|
+
*/
|
|
24
|
+
export const QA_ADMIN_SCAN_LIMIT = 200;
|
|
25
|
+
/** How long one projected log may serve the list and metrics before re-reading. */
|
|
26
|
+
const TRANSCRIPT_TTL_MS = 30_000;
|
|
27
|
+
const TRANSCRIPT_CACHE_MAX = 128;
|
|
28
|
+
function isoOf(epochMs) {
|
|
29
|
+
return new Date(epochMs).toISOString();
|
|
30
|
+
}
|
|
31
|
+
function epochOf(iso) {
|
|
32
|
+
if (iso === undefined || iso === "")
|
|
33
|
+
return undefined;
|
|
34
|
+
const parsed = Date.parse(iso);
|
|
35
|
+
return Number.isNaN(parsed) ? undefined : parsed;
|
|
36
|
+
}
|
|
37
|
+
function clampLimit(value) {
|
|
38
|
+
if (value === undefined || !Number.isFinite(value))
|
|
39
|
+
return QA_ADMIN_PAGE_DEFAULT;
|
|
40
|
+
return Math.max(1, Math.min(QA_ADMIN_PAGE_MAX, Math.floor(value)));
|
|
41
|
+
}
|
|
42
|
+
function matches(search, ...values) {
|
|
43
|
+
const needle = search.trim().toLocaleLowerCase();
|
|
44
|
+
if (needle === "")
|
|
45
|
+
return true;
|
|
46
|
+
return values.some((value) => value?.toLocaleLowerCase().includes(needle) === true);
|
|
47
|
+
}
|
|
48
|
+
/**
|
|
49
|
+
* Treat an absent filter and an explicit null alike. The browser sends the
|
|
50
|
+
* field either way, and a null that reached a comparison would filter on the
|
|
51
|
+
* absence of a value instead of meaning "no filter at all".
|
|
52
|
+
*/
|
|
53
|
+
function given(value) {
|
|
54
|
+
return value === null ? undefined : value;
|
|
55
|
+
}
|
|
56
|
+
/**
|
|
57
|
+
* Review state of a conversation as a whole: a needs-followup verdict outranks
|
|
58
|
+
* a completed one, and no review at all is the unreviewed default.
|
|
59
|
+
*/
|
|
60
|
+
function reviewStatusOf(reviews, conversationId) {
|
|
61
|
+
const mine = reviews.filter((row) => row.conversationId === conversationId);
|
|
62
|
+
if (mine.length === 0)
|
|
63
|
+
return "unreviewed";
|
|
64
|
+
return mine.some((row) => row.status === "needs_followup")
|
|
65
|
+
? "needs_followup"
|
|
66
|
+
: "reviewed";
|
|
67
|
+
}
|
|
68
|
+
export class QaAdminService {
|
|
69
|
+
options;
|
|
70
|
+
redactor;
|
|
71
|
+
transcripts = new Map();
|
|
72
|
+
constructor(options) {
|
|
73
|
+
this.options = options;
|
|
74
|
+
this.redactor = options.redactor ?? defaultAdminRedactor;
|
|
75
|
+
}
|
|
76
|
+
// -------------------------------------------------------------------------
|
|
77
|
+
// Authorization
|
|
78
|
+
// -------------------------------------------------------------------------
|
|
79
|
+
requireAccounts() {
|
|
80
|
+
const accounts = this.options.accounts();
|
|
81
|
+
if (accounts === undefined) {
|
|
82
|
+
throw new Error("QA accounts are not enabled on this deployment.");
|
|
83
|
+
}
|
|
84
|
+
return accounts;
|
|
85
|
+
}
|
|
86
|
+
/**
|
|
87
|
+
* Resolve the caller and check one permission. The refusal carries the shared
|
|
88
|
+
* `forbidden` reason, so a browser shows the same audience-safe copy for
|
|
89
|
+
* every administrative surface it may not open.
|
|
90
|
+
*/
|
|
91
|
+
require(token, permission) {
|
|
92
|
+
const accounts = this.requireAccounts();
|
|
93
|
+
const actor = accounts.currentUser(token);
|
|
94
|
+
if (!allows(actor.role, permission)) {
|
|
95
|
+
this.options.logger.warn("admin.forbidden", {
|
|
96
|
+
userId: actor.id,
|
|
97
|
+
role: actor.role,
|
|
98
|
+
permission,
|
|
99
|
+
});
|
|
100
|
+
throw new QaAccountsError("forbidden", `the ${actor.role} role does not grant ${permission}`);
|
|
101
|
+
}
|
|
102
|
+
return { accounts, actor };
|
|
103
|
+
}
|
|
104
|
+
// -------------------------------------------------------------------------
|
|
105
|
+
// Conversation index
|
|
106
|
+
// -------------------------------------------------------------------------
|
|
107
|
+
/**
|
|
108
|
+
* The deployment's conversation index: one row per reserved QA session,
|
|
109
|
+
* enriched with the stored header when the deployment can list one. A chat
|
|
110
|
+
* that exists but has no readable log still appears — the reviewer must see
|
|
111
|
+
* that it happened.
|
|
112
|
+
*/
|
|
113
|
+
async index() {
|
|
114
|
+
const accounts = this.requireAccounts();
|
|
115
|
+
const directory = new Map(accounts.directory().map((user) => [user.id, user]));
|
|
116
|
+
const headers = new Map((await this.options.sessionLog.list()).map((header) => [
|
|
117
|
+
header.id,
|
|
118
|
+
header,
|
|
119
|
+
]));
|
|
120
|
+
return accounts
|
|
121
|
+
.listOwnershipRecords()
|
|
122
|
+
.map((record) => {
|
|
123
|
+
const header = headers.get(record.sessionId);
|
|
124
|
+
// A subagent's session is an implementation detail of one answer, not a
|
|
125
|
+
// conversation a user had.
|
|
126
|
+
if (header?.parentSessionId !== undefined) {
|
|
127
|
+
return undefined;
|
|
128
|
+
}
|
|
129
|
+
const createdAt = header?.createdAt ?? Date.parse(record.claimedAt);
|
|
130
|
+
const snapshot = record.capabilitySnapshot;
|
|
131
|
+
return {
|
|
132
|
+
conversationId: record.sessionId,
|
|
133
|
+
userId: record.userId,
|
|
134
|
+
displayName: directory.get(record.userId)?.displayName ?? record.userId,
|
|
135
|
+
email: directory.get(record.userId)?.email ?? "",
|
|
136
|
+
subroleId: record.subroleId ?? "",
|
|
137
|
+
adminPreview: record.adminPreview === true,
|
|
138
|
+
createdAt,
|
|
139
|
+
createdAtIso: isoOf(createdAt),
|
|
140
|
+
...(snapshot === undefined
|
|
141
|
+
? {}
|
|
142
|
+
: {
|
|
143
|
+
effectiveTools: snapshot.tools,
|
|
144
|
+
effectiveSkills: snapshot.skills,
|
|
145
|
+
}),
|
|
146
|
+
};
|
|
147
|
+
})
|
|
148
|
+
.filter((row) => row !== undefined);
|
|
149
|
+
}
|
|
150
|
+
/** One conversation's projected log, cached for the TTL unless asked fresh. */
|
|
151
|
+
async transcript(conversationId, fresh = false) {
|
|
152
|
+
const cached = this.transcripts.get(conversationId);
|
|
153
|
+
const now = this.options.clock?.now() ?? Date.now();
|
|
154
|
+
if (!fresh && cached !== undefined && now - cached.at < TRANSCRIPT_TTL_MS) {
|
|
155
|
+
return { ok: true, project: cached.project };
|
|
156
|
+
}
|
|
157
|
+
const result = await this.options.sessionLog.read(conversationId);
|
|
158
|
+
if (!result.ok) {
|
|
159
|
+
this.transcripts.delete(conversationId);
|
|
160
|
+
return { ok: false, reason: result.reason };
|
|
161
|
+
}
|
|
162
|
+
const project = projectTranscript(result.events, this.redactor);
|
|
163
|
+
this.transcripts.set(conversationId, { at: now, project });
|
|
164
|
+
if (this.transcripts.size > TRANSCRIPT_CACHE_MAX) {
|
|
165
|
+
const oldest = [...this.transcripts.entries()].sort((left, right) => left[1].at - right[1].at)[0];
|
|
166
|
+
if (oldest !== undefined)
|
|
167
|
+
this.transcripts.delete(oldest[0]);
|
|
168
|
+
}
|
|
169
|
+
return { ok: true, project };
|
|
170
|
+
}
|
|
171
|
+
async facts(rows) {
|
|
172
|
+
const facts = [];
|
|
173
|
+
for (const row of rows) {
|
|
174
|
+
const transcript = await this.transcript(row.conversationId);
|
|
175
|
+
facts.push({
|
|
176
|
+
conversationId: row.conversationId,
|
|
177
|
+
userId: row.userId,
|
|
178
|
+
subroleId: row.subroleId,
|
|
179
|
+
assistantMessages: transcript.ok
|
|
180
|
+
? transcript.project.messages.filter((message) => message.role === "assistant").length
|
|
181
|
+
: 0,
|
|
182
|
+
createdAt: row.createdAt,
|
|
183
|
+
});
|
|
184
|
+
}
|
|
185
|
+
return facts;
|
|
186
|
+
}
|
|
187
|
+
// -------------------------------------------------------------------------
|
|
188
|
+
// Overview and metrics
|
|
189
|
+
// -------------------------------------------------------------------------
|
|
190
|
+
async overview(token) {
|
|
191
|
+
this.require(token, "conversations.read.all");
|
|
192
|
+
const rows = await this.index();
|
|
193
|
+
const metrics = await this.metricsOf(rows);
|
|
194
|
+
const fresh = [...this.options.quality().allFeedback()].sort((left, right) => right.createdAt.localeCompare(left.createdAt));
|
|
195
|
+
const queue = await this.queueRows(rows);
|
|
196
|
+
return Object.freeze({
|
|
197
|
+
metrics,
|
|
198
|
+
alerts: this.alertsOf(metrics),
|
|
199
|
+
recentFeedback: this.feedbackRows(rows, fresh.slice(0, 5), this.options.quality().allReviews()),
|
|
200
|
+
queue: queue.slice(0, 5),
|
|
201
|
+
});
|
|
202
|
+
}
|
|
203
|
+
async metrics(token) {
|
|
204
|
+
this.require(token, "analytics.read");
|
|
205
|
+
return this.metricsOf(await this.index());
|
|
206
|
+
}
|
|
207
|
+
async metricsOf(rows) {
|
|
208
|
+
const quality = this.options.quality();
|
|
209
|
+
// Feedback and reviews are exact; the message counts come from the bounded
|
|
210
|
+
// log scan and are labelled as such where they are shown.
|
|
211
|
+
const scanned = [...rows]
|
|
212
|
+
.sort((left, right) => right.createdAt - left.createdAt)
|
|
213
|
+
.slice(0, QA_ADMIN_SCAN_LIMIT);
|
|
214
|
+
return aggregateQuality({
|
|
215
|
+
conversations: await this.facts(scanned),
|
|
216
|
+
feedback: quality.allFeedback(),
|
|
217
|
+
reviews: quality.allReviews(),
|
|
218
|
+
});
|
|
219
|
+
}
|
|
220
|
+
/** One attention line per condition worth an administrator's time. */
|
|
221
|
+
alertsOf(metrics) {
|
|
222
|
+
const alerts = [];
|
|
223
|
+
if (metrics.unreviewedNegatives > 0) {
|
|
224
|
+
alerts.push({
|
|
225
|
+
level: "critical",
|
|
226
|
+
code: "unreviewed-negatives",
|
|
227
|
+
count: metrics.unreviewedNegatives,
|
|
228
|
+
});
|
|
229
|
+
}
|
|
230
|
+
if (metrics.positiveRate !== null) {
|
|
231
|
+
for (const row of metrics.bySubrole) {
|
|
232
|
+
if (row.rated < 5 || row.positiveRate === null)
|
|
233
|
+
continue;
|
|
234
|
+
if (row.positiveRate <= metrics.positiveRate - 0.1) {
|
|
235
|
+
alerts.push({
|
|
236
|
+
level: "warning",
|
|
237
|
+
code: "subrole-satisfaction",
|
|
238
|
+
count: row.rated,
|
|
239
|
+
subject: row.key,
|
|
240
|
+
rate: row.positiveRate,
|
|
241
|
+
});
|
|
242
|
+
}
|
|
243
|
+
}
|
|
244
|
+
}
|
|
245
|
+
const reasons = new Map();
|
|
246
|
+
for (const row of this.options.quality().allFeedback()) {
|
|
247
|
+
if (row.rating !== "negative")
|
|
248
|
+
continue;
|
|
249
|
+
for (const reason of row.reasons ?? []) {
|
|
250
|
+
reasons.set(reason, (reasons.get(reason) ?? 0) + 1);
|
|
251
|
+
}
|
|
252
|
+
}
|
|
253
|
+
for (const [reason, count] of [...reasons].sort((left, right) => right[1] - left[1])) {
|
|
254
|
+
if (count >= 3) {
|
|
255
|
+
alerts.push({
|
|
256
|
+
level: "warning",
|
|
257
|
+
code: "recurring-reason",
|
|
258
|
+
count,
|
|
259
|
+
subject: reason,
|
|
260
|
+
});
|
|
261
|
+
}
|
|
262
|
+
}
|
|
263
|
+
return Object.freeze(alerts.map((alert) => Object.freeze(alert)));
|
|
264
|
+
}
|
|
265
|
+
// -------------------------------------------------------------------------
|
|
266
|
+
// Users
|
|
267
|
+
// -------------------------------------------------------------------------
|
|
268
|
+
async users(token, query, cursor, limit) {
|
|
269
|
+
const { accounts } = this.require(token, "users.read");
|
|
270
|
+
const config = this.options.roles().snapshot();
|
|
271
|
+
const rows = accounts.listAdminUsers((value) => normalizeUserAccess(value, config));
|
|
272
|
+
const feedback = this.options.quality().allFeedback();
|
|
273
|
+
const ownership = accounts.listOwnershipRecords();
|
|
274
|
+
const enriched = rows.map((user) => Object.freeze({
|
|
275
|
+
...user,
|
|
276
|
+
conversations: ownership.filter((row) => row.userId === user.id).length,
|
|
277
|
+
feedbackGiven: feedback.filter((row) => row.userId === user.id).length,
|
|
278
|
+
}));
|
|
279
|
+
const status = given(query.status);
|
|
280
|
+
const role = given(query.role);
|
|
281
|
+
const subroleId = given(query.subroleId);
|
|
282
|
+
const search = given(query.search) ?? "";
|
|
283
|
+
const filtered = enriched.filter((user) => {
|
|
284
|
+
if (status === "active" && user.disabled)
|
|
285
|
+
return false;
|
|
286
|
+
if (status === "disabled" && !user.disabled)
|
|
287
|
+
return false;
|
|
288
|
+
if (role !== undefined && user.role !== role)
|
|
289
|
+
return false;
|
|
290
|
+
if (subroleId !== undefined &&
|
|
291
|
+
!user.access.allowedSubroles.includes(subroleId)) {
|
|
292
|
+
return false;
|
|
293
|
+
}
|
|
294
|
+
return matches(search, user.displayName, user.fullName, user.email);
|
|
295
|
+
});
|
|
296
|
+
const sorted = [...filtered].sort((left, right) => left.displayName.localeCompare(right.displayName, "ru"));
|
|
297
|
+
return paginate({
|
|
298
|
+
rows: sorted,
|
|
299
|
+
keyOf: (row) => row.displayName.toLocaleLowerCase(),
|
|
300
|
+
idOf: (row) => row.id,
|
|
301
|
+
cursor,
|
|
302
|
+
limit: clampLimit(limit),
|
|
303
|
+
});
|
|
304
|
+
}
|
|
305
|
+
async user(token, userId) {
|
|
306
|
+
const { accounts } = this.require(token, "users.read");
|
|
307
|
+
const config = this.options.roles().snapshot();
|
|
308
|
+
const row = accounts.adminUser(userId, (value) => normalizeUserAccess(value, config));
|
|
309
|
+
if (row === undefined) {
|
|
310
|
+
throw new QaAccountsError("invalid-credentials", "no such account");
|
|
311
|
+
}
|
|
312
|
+
const feedback = this.options.quality().allFeedback();
|
|
313
|
+
const ownership = accounts
|
|
314
|
+
.listOwnershipRecords()
|
|
315
|
+
.filter((record) => record.userId === userId);
|
|
316
|
+
const catalog = await this.options.access().catalog.snapshot();
|
|
317
|
+
const effective = row.access.allowedSubroles.map((subroleId) => {
|
|
318
|
+
const role = config.subroles.find(({ id }) => id === subroleId);
|
|
319
|
+
const installed = (values, set) => [...new Set(values)].filter((id) => set.has(id)).length;
|
|
320
|
+
const toolIds = [
|
|
321
|
+
...config.common.tools.always,
|
|
322
|
+
...config.common.tools.skillGrantable,
|
|
323
|
+
...(role?.capabilities.tools.always ?? []),
|
|
324
|
+
...(role?.capabilities.tools.skillGrantable ?? []),
|
|
325
|
+
];
|
|
326
|
+
return Object.freeze({
|
|
327
|
+
subroleId,
|
|
328
|
+
name: role?.name ?? subroleId,
|
|
329
|
+
tools: installed(toolIds, catalog.toolIds),
|
|
330
|
+
skills: installed(config.common.skills, catalog.skillIds) +
|
|
331
|
+
installed(role?.capabilities.skills ?? [], catalog.skillIds),
|
|
332
|
+
});
|
|
333
|
+
});
|
|
334
|
+
let messages = 0;
|
|
335
|
+
for (const record of ownership.slice(0, QA_ADMIN_SCAN_LIMIT)) {
|
|
336
|
+
const transcript = await this.transcript(record.sessionId);
|
|
337
|
+
if (transcript.ok)
|
|
338
|
+
messages += transcript.project.messages.length;
|
|
339
|
+
}
|
|
340
|
+
const userFeedback = feedback.filter((item) => item.userId === userId);
|
|
341
|
+
return Object.freeze({
|
|
342
|
+
user: Object.freeze({
|
|
343
|
+
...row,
|
|
344
|
+
conversations: ownership.length,
|
|
345
|
+
feedbackGiven: userFeedback.length,
|
|
346
|
+
}),
|
|
347
|
+
effective: Object.freeze(effective),
|
|
348
|
+
activity: Object.freeze({
|
|
349
|
+
conversations: ownership.length,
|
|
350
|
+
messages,
|
|
351
|
+
positiveRatings: userFeedback.filter(({ rating }) => rating === "positive").length,
|
|
352
|
+
negativeRatings: userFeedback.filter(({ rating }) => rating === "negative").length,
|
|
353
|
+
}),
|
|
354
|
+
});
|
|
355
|
+
}
|
|
356
|
+
async updateUser(token, userId, update) {
|
|
357
|
+
const { accounts, actor } = this.require(token, "users.manage");
|
|
358
|
+
const config = this.options.roles().snapshot();
|
|
359
|
+
const before = accounts.adminUser(userId, (value) => normalizeUserAccess(value, config));
|
|
360
|
+
if (before === undefined) {
|
|
361
|
+
throw new QaAccountsError("invalid-credentials", "no such account");
|
|
362
|
+
}
|
|
363
|
+
const nextRole = given(update.role);
|
|
364
|
+
const nextDisabled = given(update.disabled);
|
|
365
|
+
const nextAccess = given(update.access);
|
|
366
|
+
if (nextRole !== undefined && nextRole !== before.role) {
|
|
367
|
+
this.assertAdminSurvives(accounts, userId, nextRole);
|
|
368
|
+
accounts.setAccessRole(userId, nextRole);
|
|
369
|
+
this.options.quality().appendAudit({
|
|
370
|
+
actorId: actor.id,
|
|
371
|
+
action: "authorization.changed",
|
|
372
|
+
targetType: "user",
|
|
373
|
+
targetId: userId,
|
|
374
|
+
before: before.role,
|
|
375
|
+
after: nextRole,
|
|
376
|
+
});
|
|
377
|
+
}
|
|
378
|
+
if (nextDisabled !== undefined && nextDisabled !== before.disabled) {
|
|
379
|
+
if (nextDisabled)
|
|
380
|
+
this.assertAdminSurvives(accounts, userId, "user");
|
|
381
|
+
accounts.setAccessDisabled(userId, nextDisabled);
|
|
382
|
+
this.options.quality().appendAudit({
|
|
383
|
+
actorId: actor.id,
|
|
384
|
+
action: nextDisabled ? "user.disabled" : "user.enabled",
|
|
385
|
+
targetType: "user",
|
|
386
|
+
targetId: userId,
|
|
387
|
+
});
|
|
388
|
+
}
|
|
389
|
+
if (nextAccess !== undefined) {
|
|
390
|
+
const access = this.exactAssignment(nextAccess, userId);
|
|
391
|
+
accounts.setAccess(userId, access);
|
|
392
|
+
this.options.quality().appendAudit({
|
|
393
|
+
actorId: actor.id,
|
|
394
|
+
action: "subrole.assignment.changed",
|
|
395
|
+
targetType: "user",
|
|
396
|
+
targetId: userId,
|
|
397
|
+
before: before.access,
|
|
398
|
+
after: access,
|
|
399
|
+
});
|
|
400
|
+
}
|
|
401
|
+
this.options.logger.info("admin.user-updated", { userId, actor: actor.id });
|
|
402
|
+
return this.user(token, userId);
|
|
403
|
+
}
|
|
404
|
+
/**
|
|
405
|
+
* Refuse a write that would leave the deployment without a usable admin.
|
|
406
|
+
* Granting the admin role carries no such risk; demoting or disabling the
|
|
407
|
+
* last enabled administrator locks everyone out of the console.
|
|
408
|
+
*/
|
|
409
|
+
assertAdminSurvives(accounts, targetId, nextRole) {
|
|
410
|
+
if (nextRole === "admin")
|
|
411
|
+
return;
|
|
412
|
+
const admins = accounts
|
|
413
|
+
.listAdminUsers((value) => normalizeUserAccess(value, defaultCapabilityConfig()))
|
|
414
|
+
.filter((user) => user.role === "admin" && !user.disabled);
|
|
415
|
+
const survivor = admins.some((user) => user.id !== targetId);
|
|
416
|
+
if (!survivor) {
|
|
417
|
+
throw new QaAccountsError("forbidden", "the deployment must keep at least one enabled administrator");
|
|
418
|
+
}
|
|
419
|
+
}
|
|
420
|
+
/** Validate an assignment against the enabled roles before storing it. */
|
|
421
|
+
exactAssignment(input, userId) {
|
|
422
|
+
const config = this.options.roles().snapshot();
|
|
423
|
+
const enabled = new Set(config.subroles.filter(({ enabled }) => enabled).map(({ id }) => id));
|
|
424
|
+
const allowed = [...new Set(input.allowedSubroles)];
|
|
425
|
+
if (allowed.length === 0 ||
|
|
426
|
+
!allowed.every((id) => enabled.has(id)) ||
|
|
427
|
+
!allowed.includes(input.defaultSubrole)) {
|
|
428
|
+
this.options.logger.warn("admin.assignment-rejected", { userId });
|
|
429
|
+
throw new QaAccountsError("invalid-role", "an assignment must name enabled roles and select one of them");
|
|
430
|
+
}
|
|
431
|
+
return Object.freeze({
|
|
432
|
+
allowedSubroles: Object.freeze(allowed),
|
|
433
|
+
defaultSubrole: input.defaultSubrole,
|
|
434
|
+
});
|
|
435
|
+
}
|
|
436
|
+
// -------------------------------------------------------------------------
|
|
437
|
+
// Conversations
|
|
438
|
+
// -------------------------------------------------------------------------
|
|
439
|
+
async conversations(token, query, cursor, limit) {
|
|
440
|
+
this.require(token, "conversations.read.all");
|
|
441
|
+
const rows = await this.index();
|
|
442
|
+
const quality = this.options.quality();
|
|
443
|
+
const feedback = quality.allFeedback();
|
|
444
|
+
const reviews = quality.allReviews();
|
|
445
|
+
const userIdFilter = given(query.userId);
|
|
446
|
+
const subroleFilter = given(query.subroleId);
|
|
447
|
+
const ratingFilter = given(query.rating);
|
|
448
|
+
const reviewFilter = given(query.reviewStatus);
|
|
449
|
+
const search = given(query.search) ?? "";
|
|
450
|
+
const from = epochOf(given(query.from) ?? undefined);
|
|
451
|
+
const to = epochOf(given(query.to) ?? undefined);
|
|
452
|
+
// A search may name a title, which only the log knows, so the candidate
|
|
453
|
+
// set is bounded by the newest logs before the text is available.
|
|
454
|
+
const searchable = search.trim() === ""
|
|
455
|
+
? rows
|
|
456
|
+
: [...rows]
|
|
457
|
+
.sort((left, right) => right.createdAt - left.createdAt)
|
|
458
|
+
.slice(0, QA_ADMIN_SCAN_LIMIT);
|
|
459
|
+
const candidates = [];
|
|
460
|
+
for (const row of searchable) {
|
|
461
|
+
if (userIdFilter !== undefined && row.userId !== userIdFilter)
|
|
462
|
+
continue;
|
|
463
|
+
if (subroleFilter !== undefined && row.subroleId !== subroleFilter) {
|
|
464
|
+
continue;
|
|
465
|
+
}
|
|
466
|
+
if (from !== undefined && row.createdAt < from)
|
|
467
|
+
continue;
|
|
468
|
+
if (to !== undefined && row.createdAt > to)
|
|
469
|
+
continue;
|
|
470
|
+
const mine = feedback.filter((item) => item.conversationId === row.conversationId);
|
|
471
|
+
if (ratingFilter !== undefined &&
|
|
472
|
+
!mine.some((item) => item.rating === ratingFilter)) {
|
|
473
|
+
continue;
|
|
474
|
+
}
|
|
475
|
+
if (reviewFilter !== undefined &&
|
|
476
|
+
reviewStatusOf(reviews, row.conversationId) !== reviewFilter) {
|
|
477
|
+
continue;
|
|
478
|
+
}
|
|
479
|
+
// One projection per candidate, reused by the search match and then by
|
|
480
|
+
// the summary of a page row: the read is cached either way.
|
|
481
|
+
const transcript = await this.transcript(row.conversationId);
|
|
482
|
+
const title = transcript.ok ? transcript.project.title : undefined;
|
|
483
|
+
if (!matches(search, row.displayName, row.email, row.userId, row.conversationId, title)) {
|
|
484
|
+
continue;
|
|
485
|
+
}
|
|
486
|
+
candidates.push(row);
|
|
487
|
+
}
|
|
488
|
+
const sorted = candidates.sort((left, right) => right.createdAt - left.createdAt);
|
|
489
|
+
const page = paginate({
|
|
490
|
+
rows: sorted,
|
|
491
|
+
keyOf: (row) => row.createdAtIso,
|
|
492
|
+
idOf: (row) => row.conversationId,
|
|
493
|
+
cursor,
|
|
494
|
+
limit: clampLimit(limit),
|
|
495
|
+
});
|
|
496
|
+
const items = [];
|
|
497
|
+
for (const row of page.items) {
|
|
498
|
+
items.push(await this.summaryOf(row, feedback, reviews));
|
|
499
|
+
}
|
|
500
|
+
return {
|
|
501
|
+
items: Object.freeze(items),
|
|
502
|
+
nextCursor: page.nextCursor,
|
|
503
|
+
total: page.total,
|
|
504
|
+
};
|
|
505
|
+
}
|
|
506
|
+
async summaryOf(row, feedback, reviews) {
|
|
507
|
+
const transcript = await this.transcript(row.conversationId);
|
|
508
|
+
const mine = feedback.filter((item) => item.conversationId === row.conversationId);
|
|
509
|
+
const lastActivity = transcript.ok
|
|
510
|
+
? transcript.project.lastActivity
|
|
511
|
+
: undefined;
|
|
512
|
+
const title = transcript.ok ? transcript.project.title : undefined;
|
|
513
|
+
return Object.freeze({
|
|
514
|
+
conversationId: row.conversationId,
|
|
515
|
+
userId: row.userId,
|
|
516
|
+
displayName: row.displayName,
|
|
517
|
+
subroleId: row.subroleId,
|
|
518
|
+
createdAt: row.createdAtIso,
|
|
519
|
+
updatedAt: isoOf(Math.max(row.createdAt, lastActivity ?? row.createdAt)),
|
|
520
|
+
...(title === undefined ? {} : { title }),
|
|
521
|
+
messageCount: transcript.ok ? transcript.project.messages.length : 0,
|
|
522
|
+
positiveFeedback: mine.filter(({ rating }) => rating === "positive")
|
|
523
|
+
.length,
|
|
524
|
+
negativeFeedback: mine.filter(({ rating }) => rating === "negative")
|
|
525
|
+
.length,
|
|
526
|
+
reviewStatus: reviewStatusOf(reviews, row.conversationId),
|
|
527
|
+
});
|
|
528
|
+
}
|
|
529
|
+
async conversation(token, conversationId) {
|
|
530
|
+
const { accounts, actor } = this.require(token, "conversations.read.own");
|
|
531
|
+
const owner = accounts.ownerIdOf(conversationId);
|
|
532
|
+
if (owner === undefined) {
|
|
533
|
+
throw new QaAccountsError("session-owned-elsewhere", "this conversation is unknown to the deployment");
|
|
534
|
+
}
|
|
535
|
+
// Reading someone else's conversation is exactly what
|
|
536
|
+
// `conversations.read.all` buys; owning it is what the base permission buys.
|
|
537
|
+
if (owner !== actor.id && !allows(actor.role, "conversations.read.all")) {
|
|
538
|
+
this.options.logger.warn("admin.conversation-forbidden", {
|
|
539
|
+
userId: actor.id,
|
|
540
|
+
conversationId,
|
|
541
|
+
});
|
|
542
|
+
throw new QaAccountsError("forbidden", "this conversation belongs to another account");
|
|
543
|
+
}
|
|
544
|
+
const rows = await this.index();
|
|
545
|
+
const row = rows.find((entry) => entry.conversationId === conversationId);
|
|
546
|
+
if (row === undefined) {
|
|
547
|
+
throw new QaAccountsError("session-owned-elsewhere", "this conversation is unknown to the deployment");
|
|
548
|
+
}
|
|
549
|
+
const quality = this.options.quality();
|
|
550
|
+
const feedback = quality.allFeedback();
|
|
551
|
+
const reviews = quality.allReviews();
|
|
552
|
+
const summary = await this.summaryOf(row, feedback, reviews);
|
|
553
|
+
const transcript = await this.transcript(conversationId, true);
|
|
554
|
+
const project = transcript.ok ? transcript.project : undefined;
|
|
555
|
+
const messages = (project?.messages ?? []).map((message) => Object.freeze({
|
|
556
|
+
...message,
|
|
557
|
+
feedback: Object.freeze(feedback.filter((item) => item.conversationId === conversationId &&
|
|
558
|
+
item.messageId === message.id)),
|
|
559
|
+
reviews: Object.freeze(reviews.filter((item) => item.conversationId === conversationId &&
|
|
560
|
+
item.messageId === message.id)),
|
|
561
|
+
}));
|
|
562
|
+
return Object.freeze({
|
|
563
|
+
conversationId,
|
|
564
|
+
summary,
|
|
565
|
+
runtime: Object.freeze({
|
|
566
|
+
subroleId: row.subroleId,
|
|
567
|
+
adminPreview: row.adminPreview,
|
|
568
|
+
...(project?.model === undefined ? {} : { model: project.model }),
|
|
569
|
+
...(project?.provider === undefined
|
|
570
|
+
? {}
|
|
571
|
+
: { provider: project.provider }),
|
|
572
|
+
...(row.effectiveTools === undefined
|
|
573
|
+
? {}
|
|
574
|
+
: { effectiveTools: row.effectiveTools }),
|
|
575
|
+
...(row.effectiveSkills === undefined
|
|
576
|
+
? {}
|
|
577
|
+
: { effectiveSkills: row.effectiveSkills }),
|
|
578
|
+
...(project === undefined
|
|
579
|
+
? {}
|
|
580
|
+
: { loadedSkills: project.loadedSkills }),
|
|
581
|
+
...(transcript.ok
|
|
582
|
+
? {}
|
|
583
|
+
: { transcriptUnavailable: transcript.reason }),
|
|
584
|
+
}),
|
|
585
|
+
messages: Object.freeze(messages),
|
|
586
|
+
reviews: Object.freeze(reviews.filter((item) => item.conversationId === conversationId)),
|
|
587
|
+
queueItems: Object.freeze(deriveQueue({
|
|
588
|
+
feedback,
|
|
589
|
+
reviews,
|
|
590
|
+
manual: quality.manualQueue(),
|
|
591
|
+
failures: this.failuresOf(conversationId, project),
|
|
592
|
+
}).filter((item) => item.conversationId === conversationId)),
|
|
593
|
+
});
|
|
594
|
+
}
|
|
595
|
+
failuresOf(conversationId, project) {
|
|
596
|
+
if (project === undefined)
|
|
597
|
+
return [];
|
|
598
|
+
const signals = [];
|
|
599
|
+
for (const message of project.messages) {
|
|
600
|
+
for (const call of message.toolCalls ?? []) {
|
|
601
|
+
if (call.error === undefined)
|
|
602
|
+
continue;
|
|
603
|
+
signals.push({
|
|
604
|
+
conversationId,
|
|
605
|
+
messageId: message.id,
|
|
606
|
+
at: isoOf(call.time ?? message.time),
|
|
607
|
+
});
|
|
608
|
+
}
|
|
609
|
+
}
|
|
610
|
+
return signals;
|
|
611
|
+
}
|
|
612
|
+
async failureSignals(rows) {
|
|
613
|
+
const signals = [];
|
|
614
|
+
for (const row of rows) {
|
|
615
|
+
const transcript = await this.transcript(row.conversationId);
|
|
616
|
+
if (!transcript.ok)
|
|
617
|
+
continue;
|
|
618
|
+
signals.push(...this.failuresOf(row.conversationId, transcript.project));
|
|
619
|
+
}
|
|
620
|
+
return signals;
|
|
621
|
+
}
|
|
622
|
+
// -------------------------------------------------------------------------
|
|
623
|
+
// Feedback
|
|
624
|
+
// -------------------------------------------------------------------------
|
|
625
|
+
/**
|
|
626
|
+
* Record the caller's verdict on one assistant message. The conversation must
|
|
627
|
+
* be the caller's own: a rating is the user's own signal, and nobody rates
|
|
628
|
+
* someone else's answer on their behalf.
|
|
629
|
+
*/
|
|
630
|
+
rateMessage(token, conversationId, messageId, input) {
|
|
631
|
+
const { accounts, actor } = this.require(token, "conversations.read.own");
|
|
632
|
+
if (accounts.ownerIdOf(conversationId) !== actor.id) {
|
|
633
|
+
throw new QaAccountsError("forbidden", "feedback may only be given on your own conversations");
|
|
634
|
+
}
|
|
635
|
+
const record = this.options
|
|
636
|
+
.quality()
|
|
637
|
+
.rateFeedback({ conversationId, messageId, userId: actor.id }, input);
|
|
638
|
+
this.options.logger.info("admin.feedback-recorded", {
|
|
639
|
+
conversationId,
|
|
640
|
+
messageId,
|
|
641
|
+
rating: record.rating,
|
|
642
|
+
userId: actor.id,
|
|
643
|
+
});
|
|
644
|
+
return record;
|
|
645
|
+
}
|
|
646
|
+
async feedback(token, query, cursor, limit) {
|
|
647
|
+
this.require(token, "reviews.read");
|
|
648
|
+
const rows = await this.index();
|
|
649
|
+
const quality = this.options.quality();
|
|
650
|
+
const reviews = quality.allReviews();
|
|
651
|
+
const ratingFilter = given(query.rating);
|
|
652
|
+
const userFilter = given(query.userId);
|
|
653
|
+
const subroleFilter = given(query.subroleId);
|
|
654
|
+
const reasonFilter = given(query.reason);
|
|
655
|
+
const reviewFilter = given(query.reviewStatus);
|
|
656
|
+
const from = epochOf(given(query.from) ?? undefined);
|
|
657
|
+
const to = epochOf(given(query.to) ?? undefined);
|
|
658
|
+
const conversations = new Map(rows.map((row) => [row.conversationId, row]));
|
|
659
|
+
const filtered = quality.allFeedback().filter((row) => {
|
|
660
|
+
const conversation = conversations.get(row.conversationId);
|
|
661
|
+
if (ratingFilter !== undefined && row.rating !== ratingFilter)
|
|
662
|
+
return false;
|
|
663
|
+
if (userFilter !== undefined && row.userId !== userFilter)
|
|
664
|
+
return false;
|
|
665
|
+
if (subroleFilter !== undefined &&
|
|
666
|
+
conversation?.subroleId !== subroleFilter) {
|
|
667
|
+
return false;
|
|
668
|
+
}
|
|
669
|
+
if (reasonFilter !== undefined &&
|
|
670
|
+
!(row.reasons ?? []).includes(reasonFilter)) {
|
|
671
|
+
return false;
|
|
672
|
+
}
|
|
673
|
+
const at = epochOf(row.createdAt);
|
|
674
|
+
if (from !== undefined && (at === undefined || at < from))
|
|
675
|
+
return false;
|
|
676
|
+
if (to !== undefined && (at === undefined || at > to))
|
|
677
|
+
return false;
|
|
678
|
+
if (reviewFilter !== undefined &&
|
|
679
|
+
reviewStatusOf(reviews, row.conversationId) !== reviewFilter) {
|
|
680
|
+
return false;
|
|
681
|
+
}
|
|
682
|
+
return true;
|
|
683
|
+
});
|
|
684
|
+
const sorted = [...filtered].sort((left, right) => right.createdAt.localeCompare(left.createdAt));
|
|
685
|
+
const page = paginate({
|
|
686
|
+
rows: sorted,
|
|
687
|
+
keyOf: (row) => row.createdAt,
|
|
688
|
+
idOf: (row) => row.id,
|
|
689
|
+
cursor,
|
|
690
|
+
limit: clampLimit(limit),
|
|
691
|
+
});
|
|
692
|
+
// Titles are read for the rows on this page only: a title lives in the
|
|
693
|
+
// conversation's log, and paging is what keeps that work bounded.
|
|
694
|
+
const titles = new Map();
|
|
695
|
+
for (const row of page.items) {
|
|
696
|
+
const transcript = await this.transcript(row.conversationId);
|
|
697
|
+
titles.set(row.conversationId, transcript.ok ? transcript.project.title : undefined);
|
|
698
|
+
}
|
|
699
|
+
return {
|
|
700
|
+
items: Object.freeze(this.feedbackRows(rows, page.items, reviews, titles)),
|
|
701
|
+
nextCursor: page.nextCursor,
|
|
702
|
+
total: page.total,
|
|
703
|
+
};
|
|
704
|
+
}
|
|
705
|
+
feedbackRows(rows, feedback, reviews, titles = new Map()) {
|
|
706
|
+
const conversations = new Map(rows.map((row) => [row.conversationId, row]));
|
|
707
|
+
return feedback.map((row) => {
|
|
708
|
+
const conversation = conversations.get(row.conversationId);
|
|
709
|
+
const title = titles.get(row.conversationId);
|
|
710
|
+
return Object.freeze({
|
|
711
|
+
...row,
|
|
712
|
+
displayName: conversation?.displayName ?? row.userId,
|
|
713
|
+
subroleId: conversation?.subroleId ?? "",
|
|
714
|
+
...(title === undefined ? {} : { conversationTitle: title }),
|
|
715
|
+
reviewStatus: reviewStatusOf(reviews, row.conversationId),
|
|
716
|
+
});
|
|
717
|
+
});
|
|
718
|
+
}
|
|
719
|
+
// -------------------------------------------------------------------------
|
|
720
|
+
// Review queue and results
|
|
721
|
+
// -------------------------------------------------------------------------
|
|
722
|
+
async queueRows(rows) {
|
|
723
|
+
const conversations = rows ?? (await this.index());
|
|
724
|
+
const quality = this.options.quality();
|
|
725
|
+
const reviews = quality.allReviews();
|
|
726
|
+
const byId = new Map(conversations.map((row) => [row.conversationId, row]));
|
|
727
|
+
const queue = deriveQueue({
|
|
728
|
+
feedback: quality.allFeedback(),
|
|
729
|
+
reviews,
|
|
730
|
+
manual: quality.manualQueue(),
|
|
731
|
+
failures: await this.failureSignals([...conversations]
|
|
732
|
+
.sort((left, right) => right.createdAt - left.createdAt)
|
|
733
|
+
.slice(0, QA_ADMIN_SCAN_LIMIT)),
|
|
734
|
+
});
|
|
735
|
+
const result = [];
|
|
736
|
+
for (const item of queue) {
|
|
737
|
+
const row = byId.get(item.conversationId);
|
|
738
|
+
if (row === undefined)
|
|
739
|
+
continue;
|
|
740
|
+
const transcript = await this.transcript(item.conversationId);
|
|
741
|
+
const review = reviews.find((entry) => entry.id === item.reviewId);
|
|
742
|
+
result.push(Object.freeze({
|
|
743
|
+
...item,
|
|
744
|
+
displayName: row.displayName,
|
|
745
|
+
subroleId: row.subroleId,
|
|
746
|
+
...(transcript.ok && transcript.project.title !== undefined
|
|
747
|
+
? { title: transcript.project.title }
|
|
748
|
+
: {}),
|
|
749
|
+
raisedAtLabel: item.raisedAt,
|
|
750
|
+
...(review === undefined
|
|
751
|
+
? {}
|
|
752
|
+
: {
|
|
753
|
+
issueSummary: review.issues,
|
|
754
|
+
severity: review.severity,
|
|
755
|
+
}),
|
|
756
|
+
}));
|
|
757
|
+
}
|
|
758
|
+
return Object.freeze(result);
|
|
759
|
+
}
|
|
760
|
+
async reviewQueue(token, cursor, limit) {
|
|
761
|
+
this.require(token, "reviews.read");
|
|
762
|
+
const rows = await this.queueRows();
|
|
763
|
+
return paginate({
|
|
764
|
+
rows,
|
|
765
|
+
keyOf: (row) => `${row.priority}:${row.raisedAt}`,
|
|
766
|
+
idOf: (row) => `${row.conversationId}:${row.messageId ?? ""}:${row.feedbackId ?? row.reviewId ?? ""}`,
|
|
767
|
+
cursor,
|
|
768
|
+
limit: clampLimit(limit),
|
|
769
|
+
});
|
|
770
|
+
}
|
|
771
|
+
/** Park a conversation for review without rating it. */
|
|
772
|
+
queueConversation(token, conversationId, messageId) {
|
|
773
|
+
const { accounts, actor } = this.require(token, "reviews.write");
|
|
774
|
+
if (accounts.ownerIdOf(conversationId) === undefined) {
|
|
775
|
+
throw new QaAccountsError("session-owned-elsewhere", "this conversation is unknown to the deployment");
|
|
776
|
+
}
|
|
777
|
+
const entry = this.options
|
|
778
|
+
.quality()
|
|
779
|
+
.enqueueReview(actor.id, conversationId, messageId);
|
|
780
|
+
this.options.quality().appendAudit({
|
|
781
|
+
actorId: actor.id,
|
|
782
|
+
action: "review.queued",
|
|
783
|
+
targetType: "conversation",
|
|
784
|
+
targetId: conversationId,
|
|
785
|
+
after: { messageId: entry.messageId ?? null },
|
|
786
|
+
});
|
|
787
|
+
return {
|
|
788
|
+
conversationId,
|
|
789
|
+
...(entry.messageId === undefined ? {} : { messageId: entry.messageId }),
|
|
790
|
+
reason: "manual",
|
|
791
|
+
priority: "normal",
|
|
792
|
+
status: "unreviewed",
|
|
793
|
+
raisedAt: entry.createdAt,
|
|
794
|
+
};
|
|
795
|
+
}
|
|
796
|
+
/** Save a reviewer's verdict, and take the item out of the queue when done. */
|
|
797
|
+
saveReview(token, input) {
|
|
798
|
+
const { accounts, actor } = this.require(token, "reviews.write");
|
|
799
|
+
if (accounts.ownerIdOf(input.conversationId) === undefined) {
|
|
800
|
+
throw new QaAccountsError("session-owned-elsewhere", "this conversation is unknown to the deployment");
|
|
801
|
+
}
|
|
802
|
+
const quality = this.options.quality();
|
|
803
|
+
const { review, created } = quality.saveReview(actor.id, input);
|
|
804
|
+
quality.appendAudit({
|
|
805
|
+
actorId: actor.id,
|
|
806
|
+
action: created ? "conversation.reviewed" : "review.updated",
|
|
807
|
+
targetType: "conversation",
|
|
808
|
+
targetId: input.conversationId,
|
|
809
|
+
after: review,
|
|
810
|
+
});
|
|
811
|
+
if (review.status === "needs_followup") {
|
|
812
|
+
quality.enqueueReview(actor.id, input.conversationId, input.messageId);
|
|
813
|
+
}
|
|
814
|
+
else {
|
|
815
|
+
quality.dequeueReview(input.conversationId, input.messageId);
|
|
816
|
+
}
|
|
817
|
+
this.options.logger.info("admin.review-saved", {
|
|
818
|
+
conversationId: input.conversationId,
|
|
819
|
+
reviewerId: actor.id,
|
|
820
|
+
status: review.status,
|
|
821
|
+
severity: review.severity,
|
|
822
|
+
});
|
|
823
|
+
return review;
|
|
824
|
+
}
|
|
825
|
+
// -------------------------------------------------------------------------
|
|
826
|
+
// Audit
|
|
827
|
+
// -------------------------------------------------------------------------
|
|
828
|
+
/**
|
|
829
|
+
* The audit trail as one table. Two writers keep records — the capability
|
|
830
|
+
* service, whose repository predates the console, and the quality store —
|
|
831
|
+
* and an administrator expects one timeline, so both are normalized here.
|
|
832
|
+
*/
|
|
833
|
+
async audit(token, query, cursor, limit) {
|
|
834
|
+
this.require(token, "audit.read");
|
|
835
|
+
const actorFilter = given(query.actorId);
|
|
836
|
+
const actionFilter = given(query.action);
|
|
837
|
+
const from = epochOf(given(query.from) ?? undefined);
|
|
838
|
+
const to = epochOf(given(query.to) ?? undefined);
|
|
839
|
+
const rows = [
|
|
840
|
+
...this.accessAuditEvents(),
|
|
841
|
+
...this.options.quality().auditEvents(),
|
|
842
|
+
]
|
|
843
|
+
.filter((row) => {
|
|
844
|
+
if (actorFilter !== undefined && row.actorId !== actorFilter) {
|
|
845
|
+
return false;
|
|
846
|
+
}
|
|
847
|
+
if (actionFilter !== undefined && row.action !== actionFilter) {
|
|
848
|
+
return false;
|
|
849
|
+
}
|
|
850
|
+
const at = epochOf(row.timestamp);
|
|
851
|
+
if (from !== undefined && (at === undefined || at < from))
|
|
852
|
+
return false;
|
|
853
|
+
if (to !== undefined && (at === undefined || at > to))
|
|
854
|
+
return false;
|
|
855
|
+
return true;
|
|
856
|
+
})
|
|
857
|
+
.sort((left, right) => right.timestamp === left.timestamp
|
|
858
|
+
? right.id.localeCompare(left.id)
|
|
859
|
+
: right.timestamp.localeCompare(left.timestamp));
|
|
860
|
+
return paginate({
|
|
861
|
+
rows,
|
|
862
|
+
keyOf: (row) => row.timestamp,
|
|
863
|
+
idOf: (row) => row.id,
|
|
864
|
+
cursor,
|
|
865
|
+
limit: clampLimit(limit),
|
|
866
|
+
});
|
|
867
|
+
}
|
|
868
|
+
/** Capability-policy events, reshaped into the console's audit vocabulary. */
|
|
869
|
+
accessAuditEvents() {
|
|
870
|
+
const actions = {
|
|
871
|
+
"subrole.created": "subrole.created",
|
|
872
|
+
"subrole.updated": "subrole.updated",
|
|
873
|
+
"subrole.deleted": "subrole.deleted",
|
|
874
|
+
"common.updated": "common_capabilities.updated",
|
|
875
|
+
"assignment.updated": "subrole.assignment.changed",
|
|
876
|
+
};
|
|
877
|
+
const events = [];
|
|
878
|
+
const source = this.options.roles().audit();
|
|
879
|
+
source.forEach((event, index) => {
|
|
880
|
+
const action = actions[event.action];
|
|
881
|
+
if (action === undefined)
|
|
882
|
+
return;
|
|
883
|
+
events.push(Object.freeze({
|
|
884
|
+
id: `policy:${index}:${event.timestamp}`,
|
|
885
|
+
timestamp: event.timestamp,
|
|
886
|
+
actorId: event.actorId,
|
|
887
|
+
action,
|
|
888
|
+
targetType: "capability",
|
|
889
|
+
...(event.targetId === undefined ? {} : { targetId: event.targetId }),
|
|
890
|
+
...(event.before === undefined ? {} : { before: event.before }),
|
|
891
|
+
...(event.after === undefined ? {} : { after: event.after }),
|
|
892
|
+
}));
|
|
893
|
+
});
|
|
894
|
+
return events;
|
|
895
|
+
}
|
|
896
|
+
}
|
|
897
|
+
//# sourceMappingURL=service.js.map
|