@pegma/support-desk-application 0.1.1 → 0.2.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +237 -7
- package/dist/assistant-turn-capacity.d.ts +13 -0
- package/dist/assistant-turn-capacity.d.ts.map +1 -0
- package/dist/assistant-turn-capacity.js +111 -0
- package/dist/assistant-turn-capacity.js.map +1 -0
- package/dist/assistant-turn-records.d.ts +68 -0
- package/dist/assistant-turn-records.d.ts.map +1 -0
- package/dist/assistant-turn-records.js +418 -0
- package/dist/assistant-turn-records.js.map +1 -0
- package/dist/assistant-turn-types.d.ts +133 -0
- package/dist/assistant-turn-types.d.ts.map +1 -0
- package/dist/assistant-turn-types.js +2 -0
- package/dist/assistant-turn-types.js.map +1 -0
- package/dist/assistant-turns.d.ts +42 -0
- package/dist/assistant-turns.d.ts.map +1 -0
- package/dist/assistant-turns.js +660 -0
- package/dist/assistant-turns.js.map +1 -0
- package/dist/engineering-evidence.d.ts +20 -0
- package/dist/engineering-evidence.d.ts.map +1 -0
- package/dist/engineering-evidence.js +899 -0
- package/dist/engineering-evidence.js.map +1 -0
- package/dist/engineering-followup-internal.d.ts +45 -0
- package/dist/engineering-followup-internal.d.ts.map +1 -0
- package/dist/engineering-followup-internal.js +2 -0
- package/dist/engineering-followup-internal.js.map +1 -0
- package/dist/engineering-followup-records.d.ts +209 -0
- package/dist/engineering-followup-records.d.ts.map +1 -0
- package/dist/engineering-followup-records.js +1434 -0
- package/dist/engineering-followup-records.js.map +1 -0
- package/dist/engineering-followup-source.d.ts +168 -0
- package/dist/engineering-followup-source.d.ts.map +1 -0
- package/dist/engineering-followup-source.js +1144 -0
- package/dist/engineering-followup-source.js.map +1 -0
- package/dist/engineering-followup-types.d.ts +364 -0
- package/dist/engineering-followup-types.d.ts.map +1 -0
- package/dist/engineering-followup-types.js +2 -0
- package/dist/engineering-followup-types.js.map +1 -0
- package/dist/engineering-followup-work.d.ts +70 -0
- package/dist/engineering-followup-work.d.ts.map +1 -0
- package/dist/engineering-followup-work.js +1153 -0
- package/dist/engineering-followup-work.js.map +1 -0
- package/dist/engineering-internal.d.ts +38 -0
- package/dist/engineering-internal.d.ts.map +1 -0
- package/dist/engineering-internal.js +11 -0
- package/dist/engineering-internal.js.map +1 -0
- package/dist/engineering-policy.d.ts +47 -0
- package/dist/engineering-policy.d.ts.map +1 -0
- package/dist/engineering-policy.js +579 -0
- package/dist/engineering-policy.js.map +1 -0
- package/dist/engineering-reconciliation.d.ts +6 -0
- package/dist/engineering-reconciliation.d.ts.map +1 -0
- package/dist/engineering-reconciliation.js +464 -0
- package/dist/engineering-reconciliation.js.map +1 -0
- package/dist/engineering-records.d.ts +227 -0
- package/dist/engineering-records.d.ts.map +1 -0
- package/dist/engineering-records.js +1272 -0
- package/dist/engineering-records.js.map +1 -0
- package/dist/engineering-types.d.ts +505 -0
- package/dist/engineering-types.d.ts.map +1 -0
- package/dist/engineering-types.js +2 -0
- package/dist/engineering-types.js.map +1 -0
- package/dist/engineering-work.d.ts +32 -0
- package/dist/engineering-work.d.ts.map +1 -0
- package/dist/engineering-work.js +1757 -0
- package/dist/engineering-work.js.map +1 -0
- package/dist/index.d.ts +609 -14
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +3680 -233
- package/dist/index.js.map +1 -1
- package/dist/resolution-learning-records.d.ts +154 -0
- package/dist/resolution-learning-records.d.ts.map +1 -0
- package/dist/resolution-learning-records.js +657 -0
- package/dist/resolution-learning-records.js.map +1 -0
- package/dist/resolution-learning-types.d.ts +356 -0
- package/dist/resolution-learning-types.d.ts.map +1 -0
- package/dist/resolution-learning-types.js +2 -0
- package/dist/resolution-learning-types.js.map +1 -0
- package/dist/resolution-learning-worker.d.ts +21 -0
- package/dist/resolution-learning-worker.d.ts.map +1 -0
- package/dist/resolution-learning-worker.js +1027 -0
- package/dist/resolution-learning-worker.js.map +1 -0
- package/dist/resolution-learning.d.ts +38 -0
- package/dist/resolution-learning.d.ts.map +1 -0
- package/dist/resolution-learning.js +860 -0
- package/dist/resolution-learning.js.map +1 -0
- package/package.json +6 -6
|
@@ -0,0 +1,1027 @@
|
|
|
1
|
+
import { hasPermission } from "@pegma/authorization-core";
|
|
2
|
+
import { MAX_SCAN_PAGE_SIZE } from "@pegma/storage-core";
|
|
3
|
+
import { SupportDeskAuthorizationError, SupportDeskConflictError, SupportDeskLimitError, SupportDeskNotFoundError, supportPermissions, } from "./index.js";
|
|
4
|
+
import { canonical, dataArray, encodedBytes, fields, fingerprint, integer, text, timestamp, } from "./assistant-turn-records.js";
|
|
5
|
+
import { safeLearningSnapshot as snapshot, assertResolutionLearningRecord, maximumResolutionLearningSourceBytes, resolutionLearningReviewKey, resolutionLearningSourceKey, resolutionLearningWorkKey, validateResolutionCandidate, validateResolutionRequest, validateResolutionTarget, } from "./resolution-learning-records.js";
|
|
6
|
+
export async function resolutionLearningManifestHash(manifest) {
|
|
7
|
+
const value = fields(manifest, "input manifest", [
|
|
8
|
+
"messages",
|
|
9
|
+
"workingSolutionMessageIds",
|
|
10
|
+
"product",
|
|
11
|
+
]);
|
|
12
|
+
const seen = new Set();
|
|
13
|
+
for (const item of dataArray(value.messages, "manifest.messages", 100)) {
|
|
14
|
+
const message = fields(item, "manifest message", ["messageId", "ordinal"]);
|
|
15
|
+
text(message.messageId, "messageId");
|
|
16
|
+
integer(message.ordinal, "ordinal", 1);
|
|
17
|
+
if (seen.has(message.messageId))
|
|
18
|
+
throw new TypeError("Duplicate manifest message");
|
|
19
|
+
seen.add(message.messageId);
|
|
20
|
+
}
|
|
21
|
+
for (const id of dataArray(value.workingSolutionMessageIds, "solution IDs", 16)) {
|
|
22
|
+
text(id, "solution ID");
|
|
23
|
+
if (!seen.has(id))
|
|
24
|
+
throw new TypeError("Working solution absent from manifest");
|
|
25
|
+
}
|
|
26
|
+
const product = fields(value.product, "manifest.product", [
|
|
27
|
+
"productId",
|
|
28
|
+
"version",
|
|
29
|
+
"buildId",
|
|
30
|
+
"binding",
|
|
31
|
+
"contextVersion",
|
|
32
|
+
]);
|
|
33
|
+
for (const key of Object.keys(product))
|
|
34
|
+
text(product[key], `manifest.product.${key}`);
|
|
35
|
+
return fingerprint(manifest);
|
|
36
|
+
}
|
|
37
|
+
function outcome(work) {
|
|
38
|
+
return {
|
|
39
|
+
ticketId: work.ticketId,
|
|
40
|
+
sourceGeneration: work.sourceGeneration,
|
|
41
|
+
workGeneration: work.workGeneration,
|
|
42
|
+
status: work.status,
|
|
43
|
+
...(work.stepId === undefined ? {} : { stepId: work.stepId }),
|
|
44
|
+
...(work.requestHash === undefined
|
|
45
|
+
? {}
|
|
46
|
+
: { requestHash: work.requestHash }),
|
|
47
|
+
...(work.candidateHash === undefined
|
|
48
|
+
? {}
|
|
49
|
+
: { candidateHash: work.candidateHash }),
|
|
50
|
+
...(work.target === undefined ? {} : { target: snapshot(work.target) }),
|
|
51
|
+
};
|
|
52
|
+
}
|
|
53
|
+
function processing(value) {
|
|
54
|
+
if (value === undefined)
|
|
55
|
+
return;
|
|
56
|
+
const object = fields(value, "processing", [], [
|
|
57
|
+
"provider",
|
|
58
|
+
"model",
|
|
59
|
+
"requestId",
|
|
60
|
+
"failureCategory",
|
|
61
|
+
"inputTokens",
|
|
62
|
+
"outputTokens",
|
|
63
|
+
"costMicros",
|
|
64
|
+
"currency",
|
|
65
|
+
]);
|
|
66
|
+
for (const [name, entry] of Object.entries(object))
|
|
67
|
+
if (["inputTokens", "outputTokens", "costMicros"].includes(name))
|
|
68
|
+
integer(entry, `processing.${name}`, 0);
|
|
69
|
+
else
|
|
70
|
+
text(entry, `processing.${name}`);
|
|
71
|
+
}
|
|
72
|
+
export async function resolutionLearningDeliveryBinding(input) {
|
|
73
|
+
validateResolutionRequest(input.exactRequest);
|
|
74
|
+
return {
|
|
75
|
+
candidateHash: input.candidateHash,
|
|
76
|
+
sourceBindingHash: input.sourceBindingHash,
|
|
77
|
+
policyVersion: input.policyVersion,
|
|
78
|
+
targetDecisionHash: input.targetDecisionHash,
|
|
79
|
+
applicabilityHash: await fingerprint(input.applicability),
|
|
80
|
+
requestHash: await fingerprint(input.exactRequest),
|
|
81
|
+
};
|
|
82
|
+
}
|
|
83
|
+
function permission(access, review = false) {
|
|
84
|
+
text(access.principalId, "principalId");
|
|
85
|
+
const name = review
|
|
86
|
+
? supportPermissions.learningReview
|
|
87
|
+
: supportPermissions.learningProcess;
|
|
88
|
+
if (!hasPermission(access, name))
|
|
89
|
+
throw new SupportDeskAuthorizationError(name);
|
|
90
|
+
if (review && !hasPermission(access, supportPermissions.queueRead))
|
|
91
|
+
throw new SupportDeskAuthorizationError(supportPermissions.queueRead);
|
|
92
|
+
}
|
|
93
|
+
function checkRow(row) {
|
|
94
|
+
if (encodedBytes(row) > 60_000)
|
|
95
|
+
throw new SupportDeskLimitError("serialized_record", 60_000);
|
|
96
|
+
if (row.kind === "resolution_learning_source" &&
|
|
97
|
+
maximumResolutionLearningSourceBytes(row) > 60_000)
|
|
98
|
+
throw new SupportDeskLimitError("serialized_record", 60_000);
|
|
99
|
+
assertResolutionLearningRecord(row, { partition: row.partition, id: row.id });
|
|
100
|
+
}
|
|
101
|
+
function validateFence(command) {
|
|
102
|
+
text(command.ticketId, "ticketId");
|
|
103
|
+
integer(command.sourceGeneration, "sourceGeneration", 1);
|
|
104
|
+
text(command.workerId, "workerId");
|
|
105
|
+
text(command.claimId, "claimId");
|
|
106
|
+
integer(command.claimGeneration, "claimGeneration", 1);
|
|
107
|
+
}
|
|
108
|
+
function sameClaim(work, command) {
|
|
109
|
+
return (work.claim !== undefined &&
|
|
110
|
+
canonical(work.claim) ===
|
|
111
|
+
canonical({
|
|
112
|
+
ticketId: command.ticketId,
|
|
113
|
+
sourceGeneration: command.sourceGeneration,
|
|
114
|
+
workerId: command.workerId,
|
|
115
|
+
claimId: command.claimId,
|
|
116
|
+
claimGeneration: command.claimGeneration,
|
|
117
|
+
}));
|
|
118
|
+
}
|
|
119
|
+
function current(loaded, work) {
|
|
120
|
+
return (loaded.source.value.state === "eligible" &&
|
|
121
|
+
loaded.source.value.sourceGeneration === work.sourceGeneration &&
|
|
122
|
+
loaded.source.value.resolutionCycle === work.resolutionCycle &&
|
|
123
|
+
loaded.source.value.sourceBindingHash === work.sourceBindingHash &&
|
|
124
|
+
loaded.ticket.value.ticket.resolvedAt === loaded.source.value.resolvedAt &&
|
|
125
|
+
["resolved", "closed"].includes(loaded.ticket.value.ticket.status));
|
|
126
|
+
}
|
|
127
|
+
function withoutLease(work) {
|
|
128
|
+
const { claim: _claim, leaseUntil: _lease, retryAt: _retry, ...rest } = work;
|
|
129
|
+
return rest;
|
|
130
|
+
}
|
|
131
|
+
function reserveReachable(row) {
|
|
132
|
+
// Construct legal states for this prepared stage. Model completion retains
|
|
133
|
+
// its candidate; KB delivery has replaced that body with the exact request.
|
|
134
|
+
// They never share a candidate plus a KB acknowledgement/target envelope.
|
|
135
|
+
const escaped = "\ud800".repeat(200);
|
|
136
|
+
const processing = {
|
|
137
|
+
provider: escaped,
|
|
138
|
+
model: escaped,
|
|
139
|
+
requestId: escaped,
|
|
140
|
+
failureCategory: escaped,
|
|
141
|
+
currency: escaped,
|
|
142
|
+
inputTokens: Number.MAX_SAFE_INTEGER,
|
|
143
|
+
outputTokens: Number.MAX_SAFE_INTEGER,
|
|
144
|
+
costMicros: Number.MAX_SAFE_INTEGER,
|
|
145
|
+
};
|
|
146
|
+
const fence = {
|
|
147
|
+
ticketId: row.ticketId,
|
|
148
|
+
sourceGeneration: row.sourceGeneration,
|
|
149
|
+
workerId: escaped,
|
|
150
|
+
claimId: escaped,
|
|
151
|
+
claimGeneration: Number.MAX_SAFE_INTEGER,
|
|
152
|
+
};
|
|
153
|
+
const attempts = (accounting) => row.modelAttempts.map((attempt, index) => index === row.modelAttempts.length - 1
|
|
154
|
+
? { ...attempt, accounting, processing }
|
|
155
|
+
: attempt);
|
|
156
|
+
const base = {
|
|
157
|
+
...withoutLease(row),
|
|
158
|
+
workGeneration: Number.MAX_SAFE_INTEGER,
|
|
159
|
+
};
|
|
160
|
+
const timestampEnvelope = "+275760-09-13T00:00:00.000Z";
|
|
161
|
+
const finishStates = (future) => {
|
|
162
|
+
const finished = {
|
|
163
|
+
...future,
|
|
164
|
+
status: "superseded",
|
|
165
|
+
heldReason: escaped,
|
|
166
|
+
retryAt: timestampEnvelope,
|
|
167
|
+
lastFinish: { fingerprint: "f".repeat(64) },
|
|
168
|
+
};
|
|
169
|
+
return [
|
|
170
|
+
finished,
|
|
171
|
+
{
|
|
172
|
+
...finished,
|
|
173
|
+
status: "claimed",
|
|
174
|
+
claim: fence,
|
|
175
|
+
claimGeneration: Number.MAX_SAFE_INTEGER,
|
|
176
|
+
leaseUntil: timestampEnvelope,
|
|
177
|
+
},
|
|
178
|
+
];
|
|
179
|
+
};
|
|
180
|
+
const reachable = [];
|
|
181
|
+
if (row.stage === "model") {
|
|
182
|
+
const completion = {
|
|
183
|
+
...base,
|
|
184
|
+
status: "candidate",
|
|
185
|
+
modelAttempts: attempts("accounted"),
|
|
186
|
+
candidate: "\ud800".repeat(Math.ceil(16_000 / 12)),
|
|
187
|
+
candidateHash: "f".repeat(64),
|
|
188
|
+
lastCompletion: { fingerprint: "f".repeat(64) },
|
|
189
|
+
};
|
|
190
|
+
// Completion permits accounted/unknown; only a finish before a candidate
|
|
191
|
+
// can introduce the longer not_called disposition for this attempt.
|
|
192
|
+
reachable.push(completion, ...finishStates(completion));
|
|
193
|
+
reachable.push(...finishStates({ ...base, modelAttempts: attempts("not_called") }));
|
|
194
|
+
}
|
|
195
|
+
else {
|
|
196
|
+
const completion = {
|
|
197
|
+
...base,
|
|
198
|
+
status: "completed",
|
|
199
|
+
lastCompletion: { fingerprint: "f".repeat(64) },
|
|
200
|
+
acknowledgement: {
|
|
201
|
+
id: escaped,
|
|
202
|
+
generation: Number.MAX_SAFE_INTEGER,
|
|
203
|
+
articleRevision: Number.MAX_SAFE_INTEGER,
|
|
204
|
+
contentHash: escaped,
|
|
205
|
+
state: escaped,
|
|
206
|
+
},
|
|
207
|
+
target: {
|
|
208
|
+
articleId: escaped,
|
|
209
|
+
collectionId: escaped,
|
|
210
|
+
articleRevision: Number.MAX_SAFE_INTEGER,
|
|
211
|
+
contentHash: escaped,
|
|
212
|
+
sourceSnapshotGeneration: Number.MAX_SAFE_INTEGER,
|
|
213
|
+
sourceSnapshotHash: escaped,
|
|
214
|
+
orderedReferencesHash: escaped,
|
|
215
|
+
},
|
|
216
|
+
};
|
|
217
|
+
reachable.push(completion, ...finishStates(completion));
|
|
218
|
+
}
|
|
219
|
+
for (const future of reachable)
|
|
220
|
+
if (encodedBytes(future) > 60_000)
|
|
221
|
+
throw new SupportDeskLimitError("serialized_record", 60_000);
|
|
222
|
+
checkRow(row);
|
|
223
|
+
}
|
|
224
|
+
export function createResolutionLearningWorkerServices(deps, load) {
|
|
225
|
+
const { records, clock, options } = deps;
|
|
226
|
+
async function workAt(key) {
|
|
227
|
+
const work = await records.getVersioned(resolutionLearningWorkKey(key.ticketId, key.sourceGeneration));
|
|
228
|
+
if (work?.value.kind !== "resolution_learning_work")
|
|
229
|
+
throw new SupportDeskNotFoundError();
|
|
230
|
+
return { value: work.value, version: work.version };
|
|
231
|
+
}
|
|
232
|
+
async function save(loaded, work, next, nextSource = loaded.source.value) {
|
|
233
|
+
checkRow(next);
|
|
234
|
+
checkRow(nextSource);
|
|
235
|
+
return (await records.transact(next.ticketId, [
|
|
236
|
+
{
|
|
237
|
+
action: "putIfUnchanged",
|
|
238
|
+
version: loaded.ticket.version,
|
|
239
|
+
value: loaded.ticket.value,
|
|
240
|
+
},
|
|
241
|
+
{
|
|
242
|
+
action: "putIfUnchanged",
|
|
243
|
+
version: loaded.source.version,
|
|
244
|
+
value: nextSource,
|
|
245
|
+
},
|
|
246
|
+
{ action: "putIfUnchanged", version: work.version, value: next },
|
|
247
|
+
])).committed;
|
|
248
|
+
}
|
|
249
|
+
function live(loaded, work, command) {
|
|
250
|
+
if (!sameClaim(work, command) ||
|
|
251
|
+
work.leaseUntil === undefined ||
|
|
252
|
+
timestamp(work.leaseUntil, "leaseUntil") <=
|
|
253
|
+
timestamp(clock.now(), "now") ||
|
|
254
|
+
!current(loaded, work))
|
|
255
|
+
throw new SupportDeskConflictError("Learning claim or source fence changed");
|
|
256
|
+
}
|
|
257
|
+
function claimView(loaded, work, recoveredModelPreparation) {
|
|
258
|
+
const seen = new Set();
|
|
259
|
+
const messages = loaded.rows
|
|
260
|
+
.flatMap((row) => {
|
|
261
|
+
if (row.kind !== "message" ||
|
|
262
|
+
row.message.visibility !== "customer" ||
|
|
263
|
+
row.ordinal > work.acceptedTicketRevision)
|
|
264
|
+
return [];
|
|
265
|
+
integer(row.ordinal, "ordinal", 1);
|
|
266
|
+
if (seen.has(row.ordinal))
|
|
267
|
+
throw new SupportDeskConflictError("Duplicate message ordinal");
|
|
268
|
+
seen.add(row.ordinal);
|
|
269
|
+
return [
|
|
270
|
+
{
|
|
271
|
+
messageId: row.message.id,
|
|
272
|
+
ordinal: row.ordinal,
|
|
273
|
+
speaker: row.message.authorKind === "customer"
|
|
274
|
+
? "customer"
|
|
275
|
+
: row.message.authorKind === "assistant"
|
|
276
|
+
? "assistant"
|
|
277
|
+
: row.message.authorKind === "system"
|
|
278
|
+
? "notification"
|
|
279
|
+
: "support",
|
|
280
|
+
body: row.message.body,
|
|
281
|
+
},
|
|
282
|
+
];
|
|
283
|
+
})
|
|
284
|
+
.sort((a, b) => a.ordinal - b.ordinal);
|
|
285
|
+
return {
|
|
286
|
+
fence: work.claim,
|
|
287
|
+
sourceFence: {
|
|
288
|
+
ticketId: work.ticketId,
|
|
289
|
+
sourceGeneration: work.sourceGeneration,
|
|
290
|
+
resolutionCycle: work.resolutionCycle,
|
|
291
|
+
sourceBindingHash: work.sourceBindingHash,
|
|
292
|
+
},
|
|
293
|
+
workGeneration: work.workGeneration,
|
|
294
|
+
leaseUntil: work.leaseUntil,
|
|
295
|
+
work: snapshot(work),
|
|
296
|
+
messages,
|
|
297
|
+
workingSolutionMessageIds: [...work.workingSolutionMessageIds],
|
|
298
|
+
recoveredModelPreparation,
|
|
299
|
+
};
|
|
300
|
+
}
|
|
301
|
+
return {
|
|
302
|
+
async scanResolutionLearningCandidates(access, input) {
|
|
303
|
+
permission(access);
|
|
304
|
+
const bounds = fields(input, "learning scan", ["limit"], ["cursor"]);
|
|
305
|
+
integer(bounds.limit, "limit", 1, MAX_SCAN_PAGE_SIZE);
|
|
306
|
+
if (bounds.cursor !== undefined)
|
|
307
|
+
text(bounds.cursor, "cursor", 20_000);
|
|
308
|
+
const page = await records.scan({
|
|
309
|
+
limit: bounds.limit,
|
|
310
|
+
...(bounds.cursor === undefined
|
|
311
|
+
? {}
|
|
312
|
+
: { cursor: bounds.cursor }),
|
|
313
|
+
});
|
|
314
|
+
const now = timestamp(clock.now(), "now");
|
|
315
|
+
const sources = new Map();
|
|
316
|
+
const candidates = [];
|
|
317
|
+
for (const row of page.records) {
|
|
318
|
+
if (row.value.kind !== "resolution_learning_work")
|
|
319
|
+
continue;
|
|
320
|
+
const work = row.value;
|
|
321
|
+
if (row.key.partition !== work.ticketId || row.key.id !== work.id)
|
|
322
|
+
throw new TypeError("Learning scan identity mismatch");
|
|
323
|
+
if (["completed", "superseded"].includes(work.status) ||
|
|
324
|
+
timestamp(work.deadline, "work.deadline") <= now)
|
|
325
|
+
continue;
|
|
326
|
+
let pending = sources.get(work.ticketId);
|
|
327
|
+
if (pending === undefined) {
|
|
328
|
+
pending = records
|
|
329
|
+
.get(resolutionLearningSourceKey(work.ticketId))
|
|
330
|
+
.then((value) => {
|
|
331
|
+
if (value === null)
|
|
332
|
+
return null;
|
|
333
|
+
if (value.kind !== "resolution_learning_source")
|
|
334
|
+
throw new SupportDeskConflictError("Learning source key contains another record");
|
|
335
|
+
return value;
|
|
336
|
+
});
|
|
337
|
+
sources.set(work.ticketId, pending);
|
|
338
|
+
}
|
|
339
|
+
const source = await pending;
|
|
340
|
+
if (source?.state === "eligible" &&
|
|
341
|
+
source.sourceGeneration === work.sourceGeneration &&
|
|
342
|
+
source.resolutionCycle === work.resolutionCycle &&
|
|
343
|
+
source.sourceBindingHash === work.sourceBindingHash)
|
|
344
|
+
candidates.push({
|
|
345
|
+
ticketId: work.ticketId,
|
|
346
|
+
sourceGeneration: work.sourceGeneration,
|
|
347
|
+
});
|
|
348
|
+
}
|
|
349
|
+
return {
|
|
350
|
+
candidates,
|
|
351
|
+
reconciliationSources: page.records.flatMap((row) => {
|
|
352
|
+
if (row.value.kind !== "resolution_learning_source" ||
|
|
353
|
+
!row.value.reconciliationPending)
|
|
354
|
+
return [];
|
|
355
|
+
if (row.key.partition !== row.value.ticketId ||
|
|
356
|
+
row.key.id !== "learning-source")
|
|
357
|
+
throw new TypeError("Learning reconciliation scan identity mismatch");
|
|
358
|
+
return [row.key.partition];
|
|
359
|
+
}),
|
|
360
|
+
nextCursor: page.nextCursor,
|
|
361
|
+
physicalRows: page.records.length,
|
|
362
|
+
pages: 1,
|
|
363
|
+
};
|
|
364
|
+
},
|
|
365
|
+
async claimResolutionLearningWork(access, input) {
|
|
366
|
+
permission(access);
|
|
367
|
+
const command = snapshot(fields(input, "claim learning", [
|
|
368
|
+
"ticketId",
|
|
369
|
+
"sourceGeneration",
|
|
370
|
+
"workerId",
|
|
371
|
+
"claimId",
|
|
372
|
+
"leaseMilliseconds",
|
|
373
|
+
]));
|
|
374
|
+
text(command.ticketId, "ticketId");
|
|
375
|
+
integer(command.sourceGeneration, "sourceGeneration", 1);
|
|
376
|
+
text(command.workerId, "workerId");
|
|
377
|
+
text(command.claimId, "claimId");
|
|
378
|
+
integer(command.leaseMilliseconds, "leaseMilliseconds", 1, options.maxLeaseMilliseconds);
|
|
379
|
+
if (!options.enabled)
|
|
380
|
+
return null;
|
|
381
|
+
for (let attempt = 0; attempt < deps.maxConflictAttempts; attempt += 1) {
|
|
382
|
+
const loaded = await load(command.ticketId);
|
|
383
|
+
const stored = await workAt(command);
|
|
384
|
+
const work = stored.value;
|
|
385
|
+
const now = timestamp(clock.now(), "now");
|
|
386
|
+
if (!current(loaded, work) ||
|
|
387
|
+
["completed", "superseded"].includes(work.status) ||
|
|
388
|
+
now >= timestamp(work.deadline, "deadline") ||
|
|
389
|
+
(work.retryAt !== undefined &&
|
|
390
|
+
now < timestamp(work.retryAt, "retryAt")))
|
|
391
|
+
return null;
|
|
392
|
+
if (work.claim !== undefined &&
|
|
393
|
+
work.leaseUntil !== undefined &&
|
|
394
|
+
timestamp(work.leaseUntil, "leaseUntil") > now) {
|
|
395
|
+
if (work.claim.workerId !== command.workerId ||
|
|
396
|
+
work.claim.claimId !== command.claimId)
|
|
397
|
+
return null;
|
|
398
|
+
return claimView(loaded, work, work.stage === "model" && work.stepId !== undefined);
|
|
399
|
+
}
|
|
400
|
+
const claimGeneration = work.claim !== undefined &&
|
|
401
|
+
work.claim.workerId === command.workerId &&
|
|
402
|
+
work.claim.claimId === command.claimId &&
|
|
403
|
+
work.leaseUntil !== undefined &&
|
|
404
|
+
timestamp(work.leaseUntil, "leaseUntil") > now
|
|
405
|
+
? work.claimGeneration
|
|
406
|
+
: work.claimGeneration + 1;
|
|
407
|
+
const fence = {
|
|
408
|
+
ticketId: command.ticketId,
|
|
409
|
+
sourceGeneration: command.sourceGeneration,
|
|
410
|
+
workerId: command.workerId,
|
|
411
|
+
claimId: command.claimId,
|
|
412
|
+
claimGeneration,
|
|
413
|
+
};
|
|
414
|
+
const next = {
|
|
415
|
+
...work,
|
|
416
|
+
claimGeneration,
|
|
417
|
+
claim: fence,
|
|
418
|
+
leaseUntil: new Date(Math.min(now + command.leaseMilliseconds, timestamp(work.deadline, "deadline"))).toISOString(),
|
|
419
|
+
workGeneration: work.workGeneration + 1,
|
|
420
|
+
status: "claimed",
|
|
421
|
+
};
|
|
422
|
+
if (await save(loaded, stored, next)) {
|
|
423
|
+
return claimView(loaded, next, work.stage === "model" &&
|
|
424
|
+
work.stepId !== undefined &&
|
|
425
|
+
(work.status === "prepared" || work.status === "claimed"));
|
|
426
|
+
}
|
|
427
|
+
}
|
|
428
|
+
throw new SupportDeskConflictError();
|
|
429
|
+
},
|
|
430
|
+
async prepareResolutionLearningStep(access, input) {
|
|
431
|
+
permission(access);
|
|
432
|
+
const command = snapshot(fields(input, "prepare learning", [
|
|
433
|
+
"ticketId",
|
|
434
|
+
"sourceGeneration",
|
|
435
|
+
"workerId",
|
|
436
|
+
"claimId",
|
|
437
|
+
"claimGeneration",
|
|
438
|
+
"step",
|
|
439
|
+
...(input.step === "model"
|
|
440
|
+
? ["binding"]
|
|
441
|
+
: [
|
|
442
|
+
"candidateHash",
|
|
443
|
+
"verifiedCheckReceipt",
|
|
444
|
+
"policyVersion",
|
|
445
|
+
"targetDecisionHash",
|
|
446
|
+
"exactRequest",
|
|
447
|
+
]),
|
|
448
|
+
]));
|
|
449
|
+
validateFence(command);
|
|
450
|
+
if (!options.enabled)
|
|
451
|
+
throw new SupportDeskConflictError("Learning processing disabled");
|
|
452
|
+
if (command.step !== "model" && command.step !== "kb_delivery")
|
|
453
|
+
throw new TypeError("invalid learning step");
|
|
454
|
+
for (let attempt = 0; attempt < deps.maxConflictAttempts; attempt += 1) {
|
|
455
|
+
const loaded = await load(command.ticketId);
|
|
456
|
+
const stored = await workAt(command);
|
|
457
|
+
const work = stored.value;
|
|
458
|
+
live(loaded, work, command);
|
|
459
|
+
let next;
|
|
460
|
+
let prepared;
|
|
461
|
+
let nextSource = loaded.source.value;
|
|
462
|
+
if (command.step === "model") {
|
|
463
|
+
const binding = fields(command.binding, "model binding", [
|
|
464
|
+
"inputManifest",
|
|
465
|
+
"inputFingerprint",
|
|
466
|
+
"inputManifestHash",
|
|
467
|
+
"configVersion",
|
|
468
|
+
"promptVersion",
|
|
469
|
+
"modelVersion",
|
|
470
|
+
"checkPolicyVersion",
|
|
471
|
+
"budgetsHash",
|
|
472
|
+
"usageReservationId",
|
|
473
|
+
"deadline",
|
|
474
|
+
]);
|
|
475
|
+
for (const name of Object.keys(binding).filter((key) => key !== "deadline" && key !== "inputManifest"))
|
|
476
|
+
text(binding[name], `binding.${name}`);
|
|
477
|
+
if ((await resolutionLearningManifestHash(command.binding.inputManifest)) !== command.binding.inputManifestHash)
|
|
478
|
+
throw new SupportDeskConflictError("Input manifest fingerprint mismatch");
|
|
479
|
+
const manifest = command.binding.inputManifest;
|
|
480
|
+
if (canonical(manifest.workingSolutionMessageIds) !==
|
|
481
|
+
canonical(work.workingSolutionMessageIds) ||
|
|
482
|
+
canonical(manifest.product) !==
|
|
483
|
+
canonical({
|
|
484
|
+
productId: work.product.productId,
|
|
485
|
+
version: work.product.version,
|
|
486
|
+
buildId: work.product.buildId,
|
|
487
|
+
binding: work.product.binding,
|
|
488
|
+
contextVersion: work.product.contextVersion,
|
|
489
|
+
}) ||
|
|
490
|
+
manifest.messages.some((reference) => !loaded.rows.some((row) => row.kind === "message" &&
|
|
491
|
+
row.message.visibility === "customer" &&
|
|
492
|
+
row.message.id === reference.messageId &&
|
|
493
|
+
row.ordinal === reference.ordinal &&
|
|
494
|
+
row.ordinal <= work.acceptedTicketRevision)))
|
|
495
|
+
throw new SupportDeskConflictError("Input manifest differs from authoritative source");
|
|
496
|
+
const deadline = timestamp(binding.deadline, "binding.deadline");
|
|
497
|
+
if (deadline <= timestamp(clock.now(), "now") ||
|
|
498
|
+
deadline > timestamp(work.deadline, "work.deadline") ||
|
|
499
|
+
deadline > timestamp(work.leaseUntil, "leaseUntil"))
|
|
500
|
+
throw new SupportDeskConflictError("Model deadline exceeds live claim");
|
|
501
|
+
const existing = work.modelAttempts.find((entry) => canonical(entry.binding) === canonical(command.binding));
|
|
502
|
+
if (existing !== undefined) {
|
|
503
|
+
if (existing.preparedClaimGeneration !== command.claimGeneration ||
|
|
504
|
+
existing.stepId !== work.stepId ||
|
|
505
|
+
work.stage !== "model")
|
|
506
|
+
throw new SupportDeskConflictError("Model preparation belongs to another invocation");
|
|
507
|
+
live(loaded, work, command);
|
|
508
|
+
if (await save(loaded, stored, work))
|
|
509
|
+
return {
|
|
510
|
+
step: "model",
|
|
511
|
+
stepId: existing.stepId,
|
|
512
|
+
requestHash: existing.requestHash,
|
|
513
|
+
maxCandidateBytes: 16_000,
|
|
514
|
+
};
|
|
515
|
+
continue;
|
|
516
|
+
}
|
|
517
|
+
if (work.modelAttempts.some((entry) => entry.preparedClaimGeneration === command.claimGeneration))
|
|
518
|
+
throw new SupportDeskConflictError("This invocation already prepared a model request");
|
|
519
|
+
if (work.exactRequest !== undefined ||
|
|
520
|
+
work.candidate !== undefined ||
|
|
521
|
+
work.modelAttempts.length >= options.maxModelAttempts)
|
|
522
|
+
throw new SupportDeskConflictError("Learning model attempt budget exhausted");
|
|
523
|
+
const requestHash = await fingerprint(command.binding);
|
|
524
|
+
const stepId = await fingerprint({
|
|
525
|
+
ticketId: work.ticketId,
|
|
526
|
+
sourceGeneration: work.sourceGeneration,
|
|
527
|
+
modelAttempt: work.modelAttempts.length + 1,
|
|
528
|
+
requestHash,
|
|
529
|
+
});
|
|
530
|
+
next = {
|
|
531
|
+
...work,
|
|
532
|
+
stage: "model",
|
|
533
|
+
status: "prepared",
|
|
534
|
+
stepId,
|
|
535
|
+
requestHash,
|
|
536
|
+
workGeneration: work.workGeneration + 1,
|
|
537
|
+
modelAttempts: [
|
|
538
|
+
...work.modelAttempts,
|
|
539
|
+
{
|
|
540
|
+
preparedClaimGeneration: command.claimGeneration,
|
|
541
|
+
stepId,
|
|
542
|
+
requestHash,
|
|
543
|
+
binding: command.binding,
|
|
544
|
+
accounting: "unknown",
|
|
545
|
+
},
|
|
546
|
+
],
|
|
547
|
+
};
|
|
548
|
+
prepared = {
|
|
549
|
+
step: "model",
|
|
550
|
+
stepId,
|
|
551
|
+
requestHash,
|
|
552
|
+
maxCandidateBytes: 16_000,
|
|
553
|
+
};
|
|
554
|
+
}
|
|
555
|
+
else {
|
|
556
|
+
validateResolutionRequest(command.exactRequest);
|
|
557
|
+
text(command.candidateHash, "candidateHash");
|
|
558
|
+
text(command.verifiedCheckReceipt, "verifiedCheckReceipt");
|
|
559
|
+
text(command.policyVersion, "policyVersion");
|
|
560
|
+
text(command.targetDecisionHash, "targetDecisionHash");
|
|
561
|
+
if (work.candidateHash !== command.candidateHash ||
|
|
562
|
+
(work.candidate === undefined && work.exactRequest === undefined))
|
|
563
|
+
throw new SupportDeskConflictError("Candidate changed");
|
|
564
|
+
const reference = command.exactRequest.sourceReferences.find((item) => item.id === loaded.source.value.sourceId);
|
|
565
|
+
if (reference === undefined ||
|
|
566
|
+
reference.system !== "pegma.support-desk" ||
|
|
567
|
+
reference.resourceId !== work.ticketId ||
|
|
568
|
+
reference.version !== work.sourceBindingHash)
|
|
569
|
+
throw new SupportDeskConflictError("Exact source reference missing");
|
|
570
|
+
const applicability = work.candidate?.applicability ?? {
|
|
571
|
+
productId: work.product.productId,
|
|
572
|
+
version: work.product.version,
|
|
573
|
+
buildId: work.product.buildId,
|
|
574
|
+
};
|
|
575
|
+
const binding = await resolutionLearningDeliveryBinding({
|
|
576
|
+
candidateHash: command.candidateHash,
|
|
577
|
+
sourceBindingHash: work.sourceBindingHash,
|
|
578
|
+
policyVersion: command.policyVersion,
|
|
579
|
+
targetDecisionHash: command.targetDecisionHash,
|
|
580
|
+
applicability,
|
|
581
|
+
exactRequest: command.exactRequest,
|
|
582
|
+
});
|
|
583
|
+
const verified = options.checks === undefined
|
|
584
|
+
? null
|
|
585
|
+
: await options.checks.verify(command.verifiedCheckReceipt, binding);
|
|
586
|
+
live(loaded, work, command);
|
|
587
|
+
if (verified === null ||
|
|
588
|
+
canonical(verified.binding) !== canonical(binding) ||
|
|
589
|
+
[
|
|
590
|
+
verified.privacy,
|
|
591
|
+
verified.factual,
|
|
592
|
+
verified.applicability,
|
|
593
|
+
verified.duplication,
|
|
594
|
+
].some((check) => check !== "passed") ||
|
|
595
|
+
timestamp(verified.validUntil, "check.validUntil") <
|
|
596
|
+
timestamp(clock.now(), "now"))
|
|
597
|
+
throw new SupportDeskConflictError("Independent learning checks missing or stale");
|
|
598
|
+
if (work.exactRequest !== undefined) {
|
|
599
|
+
if (work.requestHash !== binding.requestHash ||
|
|
600
|
+
canonical(work.exactRequest) !== canonical(command.exactRequest))
|
|
601
|
+
throw new SupportDeskConflictError("Prepared KB request cannot change");
|
|
602
|
+
if (await save(loaded, stored, work))
|
|
603
|
+
return {
|
|
604
|
+
step: "kb_delivery",
|
|
605
|
+
stepId: work.stepId,
|
|
606
|
+
requestHash: work.requestHash,
|
|
607
|
+
};
|
|
608
|
+
continue;
|
|
609
|
+
}
|
|
610
|
+
const destination = nextSource.destinations.find((target) => target.articleId === command.exactRequest.articleId);
|
|
611
|
+
if (destination?.acceptance === "unknown" &&
|
|
612
|
+
(destination.reconciliation !== "confirmed" ||
|
|
613
|
+
destination.lastReconciliation?.proof === undefined))
|
|
614
|
+
throw new SupportDeskConflictError("Prior destination write still requires reconciliation");
|
|
615
|
+
if (destination === undefined &&
|
|
616
|
+
nextSource.destinations.length >= options.maxTargets)
|
|
617
|
+
throw new SupportDeskLimitError("ticket_partition", deps.limits.maxRecordsPerTicket);
|
|
618
|
+
const stepId = await fingerprint({
|
|
619
|
+
ticketId: work.ticketId,
|
|
620
|
+
sourceGeneration: work.sourceGeneration,
|
|
621
|
+
requestHash: binding.requestHash,
|
|
622
|
+
});
|
|
623
|
+
const { candidate: _candidate, ...rest } = work;
|
|
624
|
+
next = {
|
|
625
|
+
...rest,
|
|
626
|
+
stage: "kb_delivery",
|
|
627
|
+
status: "prepared",
|
|
628
|
+
stepId,
|
|
629
|
+
requestHash: binding.requestHash,
|
|
630
|
+
exactRequest: command.exactRequest,
|
|
631
|
+
verifiedCheckReceipt: command.verifiedCheckReceipt,
|
|
632
|
+
checkPolicyVersion: command.policyVersion,
|
|
633
|
+
targetDecisionHash: command.targetDecisionHash,
|
|
634
|
+
workGeneration: work.workGeneration + 1,
|
|
635
|
+
};
|
|
636
|
+
const entry = {
|
|
637
|
+
articleId: command.exactRequest.articleId,
|
|
638
|
+
sourceGeneration: work.sourceGeneration,
|
|
639
|
+
stepId,
|
|
640
|
+
requestHash: binding.requestHash,
|
|
641
|
+
acceptance: "unknown",
|
|
642
|
+
reconciliation: "pending",
|
|
643
|
+
};
|
|
644
|
+
nextSource = {
|
|
645
|
+
...nextSource,
|
|
646
|
+
reconciliationPending: true,
|
|
647
|
+
destinations: [
|
|
648
|
+
...nextSource.destinations.filter((target) => target.articleId !== entry.articleId),
|
|
649
|
+
entry,
|
|
650
|
+
],
|
|
651
|
+
workGeneration: nextSource.workGeneration + 1,
|
|
652
|
+
};
|
|
653
|
+
prepared = {
|
|
654
|
+
step: "kb_delivery",
|
|
655
|
+
stepId,
|
|
656
|
+
requestHash: binding.requestHash,
|
|
657
|
+
};
|
|
658
|
+
}
|
|
659
|
+
reserveReachable(next);
|
|
660
|
+
checkRow(nextSource);
|
|
661
|
+
if (await save(loaded, stored, next, nextSource))
|
|
662
|
+
return prepared;
|
|
663
|
+
}
|
|
664
|
+
throw new SupportDeskConflictError();
|
|
665
|
+
},
|
|
666
|
+
async completeResolutionLearningStep(access, input, guard) {
|
|
667
|
+
permission(access);
|
|
668
|
+
if (typeof guard !== "function")
|
|
669
|
+
throw new TypeError("Learning completion requires a read-only current-input guard");
|
|
670
|
+
const command = snapshot(fields(input, "complete learning", [
|
|
671
|
+
"ticketId",
|
|
672
|
+
"sourceGeneration",
|
|
673
|
+
"workerId",
|
|
674
|
+
"claimId",
|
|
675
|
+
"claimGeneration",
|
|
676
|
+
"stepId",
|
|
677
|
+
"requestHash",
|
|
678
|
+
"step",
|
|
679
|
+
...(input.step === "model"
|
|
680
|
+
? ["candidate", "accounting"]
|
|
681
|
+
: ["historicalAck", "currentTargetBinding"]),
|
|
682
|
+
], input.step === "model" ? ["processing"] : []));
|
|
683
|
+
validateFence(command);
|
|
684
|
+
text(command.stepId, "stepId");
|
|
685
|
+
text(command.requestHash, "requestHash");
|
|
686
|
+
const completion = await fingerprint(command);
|
|
687
|
+
for (let attempt = 0; attempt < deps.maxConflictAttempts; attempt += 1) {
|
|
688
|
+
const loaded = await load(command.ticketId);
|
|
689
|
+
const stored = await workAt(command);
|
|
690
|
+
const work = stored.value;
|
|
691
|
+
if (work.lastCompletion?.fingerprint === completion) {
|
|
692
|
+
await guard();
|
|
693
|
+
return outcome(work);
|
|
694
|
+
}
|
|
695
|
+
live(loaded, work, command);
|
|
696
|
+
if (work.stepId !== command.stepId ||
|
|
697
|
+
work.requestHash !== command.requestHash ||
|
|
698
|
+
work.stage !== command.step)
|
|
699
|
+
throw new SupportDeskConflictError("Prepared learning step changed");
|
|
700
|
+
let next;
|
|
701
|
+
let nextSource = loaded.source.value;
|
|
702
|
+
if (command.step === "model") {
|
|
703
|
+
const preparedAttempt = work.modelAttempts.find((entry) => entry.stepId === command.stepId &&
|
|
704
|
+
entry.requestHash === command.requestHash);
|
|
705
|
+
if (preparedAttempt === undefined ||
|
|
706
|
+
preparedAttempt.preparedClaimGeneration !== command.claimGeneration)
|
|
707
|
+
throw new SupportDeskConflictError("Model completion belongs to another invocation");
|
|
708
|
+
validateResolutionCandidate(command.candidate);
|
|
709
|
+
if (!["accounted", "unknown"].includes(command.accounting))
|
|
710
|
+
throw new TypeError("Invalid model accounting");
|
|
711
|
+
processing(command.processing);
|
|
712
|
+
if (canonical(command.candidate.applicability) !==
|
|
713
|
+
canonical({
|
|
714
|
+
productId: work.product.productId,
|
|
715
|
+
version: work.product.version,
|
|
716
|
+
buildId: work.product.buildId,
|
|
717
|
+
}))
|
|
718
|
+
throw new SupportDeskConflictError("Candidate applicability differs from trusted product");
|
|
719
|
+
const candidateHash = await fingerprint(command.candidate);
|
|
720
|
+
next = {
|
|
721
|
+
...withoutLease(work),
|
|
722
|
+
status: "candidate",
|
|
723
|
+
candidate: command.candidate,
|
|
724
|
+
candidateHash,
|
|
725
|
+
workGeneration: work.workGeneration + 1,
|
|
726
|
+
lastCompletion: { fingerprint: completion },
|
|
727
|
+
modelAttempts: work.modelAttempts.map((entry) => entry.stepId !== command.stepId
|
|
728
|
+
? entry
|
|
729
|
+
: {
|
|
730
|
+
...entry,
|
|
731
|
+
accounting: command.accounting,
|
|
732
|
+
...(command.processing === undefined
|
|
733
|
+
? {}
|
|
734
|
+
: { processing: command.processing }),
|
|
735
|
+
}),
|
|
736
|
+
};
|
|
737
|
+
}
|
|
738
|
+
else {
|
|
739
|
+
validateResolutionTarget(command.currentTargetBinding);
|
|
740
|
+
const ack = fields(command.historicalAck, "KB acknowledgement", [
|
|
741
|
+
"id",
|
|
742
|
+
"generation",
|
|
743
|
+
"articleRevision",
|
|
744
|
+
"contentHash",
|
|
745
|
+
"state",
|
|
746
|
+
]);
|
|
747
|
+
for (const key of ["id", "contentHash", "state"])
|
|
748
|
+
text(ack[key], `ack.${key}`);
|
|
749
|
+
integer(ack.generation, "ack.generation", 1);
|
|
750
|
+
integer(ack.articleRevision, "ack.articleRevision", 1);
|
|
751
|
+
if (work.exactRequest === undefined ||
|
|
752
|
+
command.historicalAck.id !== work.exactRequest.articleId ||
|
|
753
|
+
command.currentTargetBinding.articleId !==
|
|
754
|
+
work.exactRequest.articleId)
|
|
755
|
+
throw new SupportDeskConflictError("KB acknowledgement target mismatch");
|
|
756
|
+
const agrees = command.historicalAck.articleRevision ===
|
|
757
|
+
command.currentTargetBinding.articleRevision &&
|
|
758
|
+
command.historicalAck.contentHash ===
|
|
759
|
+
command.currentTargetBinding.contentHash;
|
|
760
|
+
next = {
|
|
761
|
+
...withoutLease(work),
|
|
762
|
+
status: agrees ? "completed" : "held",
|
|
763
|
+
acknowledgement: command.historicalAck,
|
|
764
|
+
target: command.currentTargetBinding,
|
|
765
|
+
workGeneration: work.workGeneration + 1,
|
|
766
|
+
lastCompletion: { fingerprint: completion },
|
|
767
|
+
...(agrees ? {} : { heldReason: "kb_target_changed" }),
|
|
768
|
+
};
|
|
769
|
+
nextSource = {
|
|
770
|
+
...nextSource,
|
|
771
|
+
workGeneration: nextSource.workGeneration + 1,
|
|
772
|
+
destinations: nextSource.destinations.map((entry) => entry.articleId === command.historicalAck.id
|
|
773
|
+
? {
|
|
774
|
+
...entry,
|
|
775
|
+
acceptance: "accepted",
|
|
776
|
+
reconciliation: agrees
|
|
777
|
+
? "confirmed"
|
|
778
|
+
: "pending",
|
|
779
|
+
}
|
|
780
|
+
: entry),
|
|
781
|
+
};
|
|
782
|
+
nextSource = {
|
|
783
|
+
...nextSource,
|
|
784
|
+
reconciliationPending: nextSource.destinations.some((entry) => entry.reconciliation !== "confirmed"),
|
|
785
|
+
};
|
|
786
|
+
}
|
|
787
|
+
checkRow(next);
|
|
788
|
+
await guard();
|
|
789
|
+
live(loaded, work, command);
|
|
790
|
+
if (command.step === "model" &&
|
|
791
|
+
timestamp(work.modelAttempts.find((entry) => entry.stepId === command.stepId)
|
|
792
|
+
.binding.deadline, "model.deadline") <= timestamp(clock.now(), "now"))
|
|
793
|
+
throw new SupportDeskConflictError("Prepared model deadline expired");
|
|
794
|
+
if (await save(loaded, stored, next, nextSource))
|
|
795
|
+
return outcome(next);
|
|
796
|
+
}
|
|
797
|
+
throw new SupportDeskConflictError();
|
|
798
|
+
},
|
|
799
|
+
async finishResolutionLearningWork(access, input) {
|
|
800
|
+
permission(access);
|
|
801
|
+
const command = snapshot(fields(input, "finish learning", [
|
|
802
|
+
"ticketId",
|
|
803
|
+
"sourceGeneration",
|
|
804
|
+
"workerId",
|
|
805
|
+
"claimId",
|
|
806
|
+
"claimGeneration",
|
|
807
|
+
"disposition",
|
|
808
|
+
], [
|
|
809
|
+
"stepId",
|
|
810
|
+
"requestHash",
|
|
811
|
+
"accounting",
|
|
812
|
+
"retryAt",
|
|
813
|
+
"reason",
|
|
814
|
+
"processing",
|
|
815
|
+
]));
|
|
816
|
+
validateFence(command);
|
|
817
|
+
if ((command.stepId === undefined) !==
|
|
818
|
+
(command.requestHash === undefined))
|
|
819
|
+
throw new TypeError("Learning finish requires both stepId and requestHash");
|
|
820
|
+
if (command.stepId !== undefined) {
|
|
821
|
+
text(command.stepId, "stepId");
|
|
822
|
+
text(command.requestHash, "requestHash");
|
|
823
|
+
}
|
|
824
|
+
if (!["retry", "held", "unknown", "superseded"].includes(command.disposition))
|
|
825
|
+
throw new TypeError("invalid learning disposition");
|
|
826
|
+
if (command.reason !== undefined)
|
|
827
|
+
text(command.reason, "reason");
|
|
828
|
+
processing(command.processing);
|
|
829
|
+
if (command.accounting !== undefined &&
|
|
830
|
+
!["not_called", "accounted", "unknown"].includes(command.accounting))
|
|
831
|
+
throw new TypeError("Invalid learning accounting");
|
|
832
|
+
const hash = await fingerprint(command);
|
|
833
|
+
for (let attempt = 0; attempt < deps.maxConflictAttempts; attempt += 1) {
|
|
834
|
+
const loaded = await load(command.ticketId);
|
|
835
|
+
const stored = await workAt(command);
|
|
836
|
+
const work = stored.value;
|
|
837
|
+
if (work.lastFinish?.fingerprint === hash)
|
|
838
|
+
return outcome(work);
|
|
839
|
+
if (!sameClaim(work, command) ||
|
|
840
|
+
work.leaseUntil === undefined ||
|
|
841
|
+
timestamp(work.leaseUntil, "leaseUntil") <=
|
|
842
|
+
timestamp(clock.now(), "now"))
|
|
843
|
+
throw new SupportDeskConflictError("Learning claim expired");
|
|
844
|
+
if ((command.stepId !== undefined && command.stepId !== work.stepId) ||
|
|
845
|
+
(command.requestHash !== undefined &&
|
|
846
|
+
command.requestHash !== work.requestHash))
|
|
847
|
+
throw new SupportDeskConflictError("Learning finish step changed");
|
|
848
|
+
if (command.stepId === undefined &&
|
|
849
|
+
(work.exactRequest !== undefined ||
|
|
850
|
+
work.modelAttempts.some((entry) => entry.preparedClaimGeneration === command.claimGeneration)))
|
|
851
|
+
throw new SupportDeskConflictError("Prepared learning work requires its exact step fence");
|
|
852
|
+
const status = current(loaded, work) ? command.disposition : "held";
|
|
853
|
+
if (command.retryAt !== undefined &&
|
|
854
|
+
(timestamp(command.retryAt, "retryAt") >=
|
|
855
|
+
timestamp(work.deadline, "deadline") ||
|
|
856
|
+
timestamp(command.retryAt, "retryAt") <
|
|
857
|
+
timestamp(clock.now(), "now")))
|
|
858
|
+
throw new SupportDeskConflictError("Retry exceeds work deadline");
|
|
859
|
+
const next = {
|
|
860
|
+
...withoutLease(work),
|
|
861
|
+
status,
|
|
862
|
+
workGeneration: work.workGeneration + 1,
|
|
863
|
+
lastFinish: { fingerprint: hash },
|
|
864
|
+
...(command.retryAt === undefined
|
|
865
|
+
? {}
|
|
866
|
+
: { retryAt: command.retryAt }),
|
|
867
|
+
...(command.reason === undefined
|
|
868
|
+
? {}
|
|
869
|
+
: { heldReason: command.reason }),
|
|
870
|
+
modelAttempts: work.modelAttempts.map((entry) => {
|
|
871
|
+
// A finish before preparation belongs to no provider request. A new
|
|
872
|
+
// claim cannot relabel an earlier invocation's known accounting.
|
|
873
|
+
if (command.stepId === undefined ||
|
|
874
|
+
command.requestHash === undefined ||
|
|
875
|
+
entry.stepId !== command.stepId ||
|
|
876
|
+
entry.requestHash !== command.requestHash)
|
|
877
|
+
return entry;
|
|
878
|
+
if (entry.preparedClaimGeneration !== command.claimGeneration) {
|
|
879
|
+
if (command.accounting !== undefined &&
|
|
880
|
+
command.accounting !== "unknown" &&
|
|
881
|
+
command.accounting !== entry.accounting)
|
|
882
|
+
throw new SupportDeskConflictError("Cannot rewrite another invocation's accounting");
|
|
883
|
+
return entry;
|
|
884
|
+
}
|
|
885
|
+
if (entry.accounting !== "unknown" &&
|
|
886
|
+
command.accounting !== undefined &&
|
|
887
|
+
command.accounting !== entry.accounting)
|
|
888
|
+
throw new SupportDeskConflictError("Known accounting cannot be replaced");
|
|
889
|
+
return {
|
|
890
|
+
...entry,
|
|
891
|
+
...(command.accounting === undefined
|
|
892
|
+
? {}
|
|
893
|
+
: { accounting: command.accounting }),
|
|
894
|
+
...(command.processing === undefined
|
|
895
|
+
? {}
|
|
896
|
+
: { processing: command.processing }),
|
|
897
|
+
};
|
|
898
|
+
}),
|
|
899
|
+
};
|
|
900
|
+
if (await save(loaded, stored, next))
|
|
901
|
+
return outcome(next);
|
|
902
|
+
}
|
|
903
|
+
throw new SupportDeskConflictError();
|
|
904
|
+
},
|
|
905
|
+
async reviewResolutionLearningProposal(access, input, guard) {
|
|
906
|
+
permission(access, true);
|
|
907
|
+
if (typeof guard !== "function")
|
|
908
|
+
throw new TypeError("KB target review requires a read-only current-target guard");
|
|
909
|
+
const command = snapshot(fields(input, "review learning", [
|
|
910
|
+
"ticketId",
|
|
911
|
+
"sourceGeneration",
|
|
912
|
+
"commandId",
|
|
913
|
+
"correlationId",
|
|
914
|
+
"expectedWorkGeneration",
|
|
915
|
+
"target",
|
|
916
|
+
"decision",
|
|
917
|
+
"policyVersion",
|
|
918
|
+
], ["reason"]));
|
|
919
|
+
validateResolutionTarget(command.target);
|
|
920
|
+
integer(command.expectedWorkGeneration, "expectedWorkGeneration", 1);
|
|
921
|
+
text(command.commandId, "commandId");
|
|
922
|
+
text(command.correlationId, "correlationId");
|
|
923
|
+
text(command.policyVersion, "policyVersion");
|
|
924
|
+
if (command.reason !== undefined)
|
|
925
|
+
text(command.reason, "reason");
|
|
926
|
+
if (command.decision !== "approve" && command.decision !== "reject")
|
|
927
|
+
throw new TypeError("invalid review decision");
|
|
928
|
+
const hash = await fingerprint({ command, actorId: access.principalId });
|
|
929
|
+
const key = {
|
|
930
|
+
partition: command.ticketId,
|
|
931
|
+
id: `learning-command:${command.commandId}`,
|
|
932
|
+
};
|
|
933
|
+
for (let attempt = 0; attempt < deps.maxConflictAttempts; attempt += 1) {
|
|
934
|
+
const loaded = await load(command.ticketId);
|
|
935
|
+
const work = await workAt(command);
|
|
936
|
+
const prior = await records.get(key);
|
|
937
|
+
if (prior !== null) {
|
|
938
|
+
if (prior.kind !== "resolution_learning_receipt" ||
|
|
939
|
+
prior.fingerprint !== hash)
|
|
940
|
+
throw new SupportDeskConflictError("Review command reused");
|
|
941
|
+
return snapshot(prior.result);
|
|
942
|
+
}
|
|
943
|
+
if (!current(loaded, work.value) ||
|
|
944
|
+
work.value.workGeneration !== command.expectedWorkGeneration ||
|
|
945
|
+
!loaded.source.value.destinations.some((entry) => entry.articleId === command.target.articleId))
|
|
946
|
+
throw new SupportDeskConflictError("Review source/work/target changed");
|
|
947
|
+
const reviewKey = await resolutionLearningReviewKey(loaded.source.value, command.target, command.policyVersion);
|
|
948
|
+
const previousReview = await records.getVersioned(reviewKey);
|
|
949
|
+
if (previousReview !== null &&
|
|
950
|
+
previousReview.value.kind !== "resolution_learning_review")
|
|
951
|
+
throw new SupportDeskConflictError("Review binding key collision");
|
|
952
|
+
if (previousReview === null &&
|
|
953
|
+
loaded.source.value.reviewBindingCount >= options.maxReviewBindings)
|
|
954
|
+
throw new SupportDeskLimitError("ticket_partition", deps.limits.maxRecordsPerTicket);
|
|
955
|
+
deps.admission(loaded.ticket.value.ticket, loaded.rows, previousReview === null ? 3 : 2);
|
|
956
|
+
const now = clock.now();
|
|
957
|
+
const review = {
|
|
958
|
+
kind: "resolution_learning_review",
|
|
959
|
+
partition: command.ticketId,
|
|
960
|
+
id: reviewKey.id,
|
|
961
|
+
ticketId: command.ticketId,
|
|
962
|
+
sourceGeneration: loaded.source.value.sourceGeneration,
|
|
963
|
+
sourceBindingHash: loaded.source.value.sourceBindingHash,
|
|
964
|
+
target: command.target,
|
|
965
|
+
decision: command.decision,
|
|
966
|
+
policyVersion: command.policyVersion,
|
|
967
|
+
reviewerId: access.principalId,
|
|
968
|
+
reviewedAt: now,
|
|
969
|
+
...(command.reason === undefined ? {} : { reason: command.reason }),
|
|
970
|
+
};
|
|
971
|
+
assertResolutionLearningRecord(review, reviewKey);
|
|
972
|
+
const source = {
|
|
973
|
+
...loaded.source.value,
|
|
974
|
+
workGeneration: loaded.source.value.workGeneration + 1,
|
|
975
|
+
reviewBindingCount: loaded.source.value.reviewBindingCount +
|
|
976
|
+
(previousReview === null ? 1 : 0),
|
|
977
|
+
};
|
|
978
|
+
checkRow(source);
|
|
979
|
+
const outcome = {
|
|
980
|
+
ticketId: source.ticketId,
|
|
981
|
+
sourceGeneration: source.sourceGeneration,
|
|
982
|
+
resolutionCycle: source.resolutionCycle,
|
|
983
|
+
state: source.state,
|
|
984
|
+
};
|
|
985
|
+
await guard();
|
|
986
|
+
if ((await records.transact(command.ticketId, [
|
|
987
|
+
{
|
|
988
|
+
action: "putIfUnchanged",
|
|
989
|
+
version: loaded.ticket.version,
|
|
990
|
+
value: loaded.ticket.value,
|
|
991
|
+
},
|
|
992
|
+
{
|
|
993
|
+
action: "putIfUnchanged",
|
|
994
|
+
version: loaded.source.version,
|
|
995
|
+
value: source,
|
|
996
|
+
},
|
|
997
|
+
{
|
|
998
|
+
action: "putIfUnchanged",
|
|
999
|
+
version: work.version,
|
|
1000
|
+
value: work.value,
|
|
1001
|
+
},
|
|
1002
|
+
previousReview === null
|
|
1003
|
+
? { action: "insert", value: review }
|
|
1004
|
+
: {
|
|
1005
|
+
action: "putIfUnchanged",
|
|
1006
|
+
version: previousReview.version,
|
|
1007
|
+
value: review,
|
|
1008
|
+
},
|
|
1009
|
+
deps.audit(command, loaded.ticket.value.ticket, access.principalId, now, "support.learning.reviewed"),
|
|
1010
|
+
{
|
|
1011
|
+
action: "insert",
|
|
1012
|
+
value: {
|
|
1013
|
+
kind: "resolution_learning_receipt",
|
|
1014
|
+
partition: command.ticketId,
|
|
1015
|
+
id: key.id,
|
|
1016
|
+
fingerprint: hash,
|
|
1017
|
+
result: outcome,
|
|
1018
|
+
},
|
|
1019
|
+
},
|
|
1020
|
+
])).committed)
|
|
1021
|
+
return outcome;
|
|
1022
|
+
}
|
|
1023
|
+
throw new SupportDeskConflictError();
|
|
1024
|
+
},
|
|
1025
|
+
};
|
|
1026
|
+
}
|
|
1027
|
+
//# sourceMappingURL=resolution-learning-worker.js.map
|