@rikaidev/yomi 0.5.0 → 0.6.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 +28 -0
- package/dist/ambient/ambient-event-graph.js +172 -0
- package/dist/ambient/ambient-observer.js +71 -0
- package/dist/ambient/event-graph-reducer.js +300 -0
- package/dist/ambient/ingestion-adapter.js +203 -0
- package/dist/ambient/journal/crypto.js +58 -0
- package/dist/ambient/journal/journal-store.js +284 -0
- package/dist/ambient/journal/types.js +7 -0
- package/dist/ambient/summaries/retriever.js +55 -0
- package/dist/ambient/summaries/summary-engine.js +128 -0
- package/dist/ambient/summaries/types.js +4 -0
- package/dist/attention/policy.js +22 -16
- package/dist/auth/credential-store.js +25 -12
- package/dist/cli/query.js +276 -69
- package/dist/cli/work-item.js +24 -0
- package/dist/line/client/auth-service/client.js +7 -8
- package/dist/line/client/buddy-service/client.js +73 -0
- package/dist/line/client/index.js +28 -1
- package/dist/line/client/obs-media-client.js +2 -1
- package/dist/line/client/square-service/client.js +298 -0
- package/dist/line/client/talk-service/command-client.js +9 -1
- package/dist/line/client/talk-service/message-id-query.js +27 -3
- package/dist/line/client/talk-service/requests.js +9 -0
- package/dist/line/core/auth-session-runtime.js +1 -1
- package/dist/line/core/chat-event.js +2 -0
- package/dist/line/core/chat-runtime-service.js +18 -3
- package/dist/line/core/config.js +1 -0
- package/dist/line/core/flex-message.js +323 -0
- package/dist/line/core/service.js +0 -1
- package/dist/mcp/handlers/contacts.js +12 -6
- package/dist/mcp/handlers/conversations.js +54 -0
- package/dist/mcp/handlers/send.js +8 -11
- package/dist/mcp/integrator.js +21 -7
- package/dist/mcp/media.js +101 -2
- package/dist/mcp/names.js +78 -6
- package/dist/search/default-embedder.js +12 -3
- package/dist/version.js +1 -1
- package/dist/work-item/adapter.js +1 -0
- package/dist/work-item/destination-contracts.js +1 -0
- package/dist/work-item/event-context.js +73 -0
- package/dist/work-item/gold-contract.js +101 -0
- package/dist/work-item/gold-coverage.js +96 -0
- package/dist/work-item/gold-derivation.js +67 -0
- package/dist/work-item/gold-split.js +143 -0
- package/dist/work-item/index.js +23 -0
- package/dist/work-item/line-adapter.js +111 -0
- package/dist/work-item/llama-cpp-work-event-adapter.js +239 -0
- package/dist/work-item/local-provider.js +38 -0
- package/dist/work-item/local-work-event-provider.js +56 -0
- package/dist/work-item/onnx-work-event-adapter.js +223 -0
- package/dist/work-item/provider.js +21 -0
- package/dist/work-item/registry.js +19 -0
- package/dist/work-item/service.js +85 -0
- package/dist/work-item/store.js +97 -0
- package/dist/work-item/types.js +79 -0
- package/dist/work-item/work-event-graph-engine.js +63 -0
- package/dist/work-item/work-event-inference.js +267 -0
- package/dist/work-item/work-event-matcher.js +105 -0
- package/dist/work-item/work-event-model-manifest.js +39 -0
- package/dist/work-item/work-event-store.js +125 -0
- package/dist/work-item/work-event.js +123 -0
- package/package.json +25 -4
- package/run.mjs +186 -9
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
export class WorkItemProviderRegistry {
|
|
2
|
+
providers = new Map();
|
|
3
|
+
register(provider) {
|
|
4
|
+
if (this.providers.has(provider.id)) {
|
|
5
|
+
throw new Error(`Work item provider already registered: ${provider.id}`);
|
|
6
|
+
}
|
|
7
|
+
this.providers.set(provider.id, provider);
|
|
8
|
+
return this;
|
|
9
|
+
}
|
|
10
|
+
resolve(id) {
|
|
11
|
+
const provider = this.providers.get(id);
|
|
12
|
+
if (!provider)
|
|
13
|
+
throw new Error(`Unknown work item provider: ${id}`);
|
|
14
|
+
return provider;
|
|
15
|
+
}
|
|
16
|
+
list() {
|
|
17
|
+
return [...this.providers.values()];
|
|
18
|
+
}
|
|
19
|
+
}
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
import { randomUUID } from 'node:crypto';
|
|
2
|
+
import { validateProviderCapabilities, } from './provider.js';
|
|
3
|
+
import { createWorkItemDraft, transitionWorkItem, } from './types.js';
|
|
4
|
+
export class WorkItemService {
|
|
5
|
+
store;
|
|
6
|
+
registry;
|
|
7
|
+
constructor(store, registry) {
|
|
8
|
+
this.store = store;
|
|
9
|
+
this.registry = registry;
|
|
10
|
+
}
|
|
11
|
+
async create(adapter, input, options = {}) {
|
|
12
|
+
const normalized = await adapter.normalize(input);
|
|
13
|
+
const now = options.now ?? Date.now();
|
|
14
|
+
const draft = createWorkItemDraft({
|
|
15
|
+
id: options.id ?? `work-${now}-${randomUUID()}`,
|
|
16
|
+
createdAt: now,
|
|
17
|
+
source: normalized.source,
|
|
18
|
+
sources: normalized.sources,
|
|
19
|
+
evidence: normalized.evidence,
|
|
20
|
+
summary: options.summary ?? null,
|
|
21
|
+
action: options.action ?? 'review',
|
|
22
|
+
destination: options.destination ?? null,
|
|
23
|
+
assignee: options.assignee ?? null,
|
|
24
|
+
labels: options.labels ?? [],
|
|
25
|
+
});
|
|
26
|
+
await this.store.save(draft);
|
|
27
|
+
return draft;
|
|
28
|
+
}
|
|
29
|
+
async transition(id, event) {
|
|
30
|
+
const current = await this.store.get(id);
|
|
31
|
+
if (!current || !('evidence' in current))
|
|
32
|
+
throw new Error(`Unknown work item: ${id}`);
|
|
33
|
+
const next = transitionWorkItem(current, event);
|
|
34
|
+
await this.store.save(next);
|
|
35
|
+
return next;
|
|
36
|
+
}
|
|
37
|
+
async deliver(id, provider) {
|
|
38
|
+
const current = await this.store.get(id);
|
|
39
|
+
if (!current || !('evidence' in current))
|
|
40
|
+
throw new Error(`Unknown work item: ${id}`);
|
|
41
|
+
if (current.destination && current.destination !== provider.id) {
|
|
42
|
+
throw new Error(`Work item destination ${current.destination} does not match provider ${provider.id}`);
|
|
43
|
+
}
|
|
44
|
+
const candidate = current.destination
|
|
45
|
+
? current
|
|
46
|
+
: { ...current, destination: provider.id };
|
|
47
|
+
const capabilityValidation = validateProviderCapabilities(provider, candidate);
|
|
48
|
+
const providerValidation = provider.validate(candidate);
|
|
49
|
+
const errors = [
|
|
50
|
+
...capabilityValidation.errors,
|
|
51
|
+
...providerValidation.errors,
|
|
52
|
+
];
|
|
53
|
+
if (errors.length > 0)
|
|
54
|
+
throw new Error(errors.join('; '));
|
|
55
|
+
const sending = transitionWorkItem(candidate, { type: 'send' });
|
|
56
|
+
await this.store.save(sending);
|
|
57
|
+
try {
|
|
58
|
+
const result = await provider.deliver(sending);
|
|
59
|
+
const delivered = transitionWorkItem(sending, {
|
|
60
|
+
type: 'deliver',
|
|
61
|
+
delivery: result.delivery,
|
|
62
|
+
});
|
|
63
|
+
await this.store.save(delivered);
|
|
64
|
+
return delivered;
|
|
65
|
+
}
|
|
66
|
+
catch (error) {
|
|
67
|
+
const failed = transitionWorkItem(sending, {
|
|
68
|
+
type: 'fail',
|
|
69
|
+
failure: {
|
|
70
|
+
code: 'provider_delivery_failed',
|
|
71
|
+
message: error instanceof Error ? error.message : String(error),
|
|
72
|
+
at: Date.now(),
|
|
73
|
+
retryable: true,
|
|
74
|
+
},
|
|
75
|
+
});
|
|
76
|
+
await this.store.save(failed);
|
|
77
|
+
throw error;
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
async deliverByProviderId(id, providerId) {
|
|
81
|
+
if (!this.registry)
|
|
82
|
+
throw new Error('Work item provider registry is not configured');
|
|
83
|
+
return this.deliver(id, this.registry.resolve(providerId));
|
|
84
|
+
}
|
|
85
|
+
}
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
import { createHash } from 'node:crypto';
|
|
2
|
+
import { mkdir, readFile, rename, writeFile } from 'node:fs/promises';
|
|
3
|
+
import { dirname } from 'node:path';
|
|
4
|
+
import { yomiDataPath } from '../util/data-dir.js';
|
|
5
|
+
function digest(value) {
|
|
6
|
+
if (value === null)
|
|
7
|
+
return null;
|
|
8
|
+
return createHash('sha256').update(value).digest('hex');
|
|
9
|
+
}
|
|
10
|
+
export function toPersistedWorkItem(draft) {
|
|
11
|
+
const primarySource = draft.source ?? draft.sources[0];
|
|
12
|
+
if (!primarySource)
|
|
13
|
+
throw new Error('Work item has no source to persist');
|
|
14
|
+
return {
|
|
15
|
+
id: draft.id,
|
|
16
|
+
createdAt: draft.createdAt,
|
|
17
|
+
updatedAt: draft.updatedAt,
|
|
18
|
+
source: {
|
|
19
|
+
adapter: primarySource.adapter,
|
|
20
|
+
platform: primarySource.platform,
|
|
21
|
+
conversationIdHash: digest(primarySource.conversationId),
|
|
22
|
+
messageIdHashes: primarySource.messageIds.map((messageId) => digest(messageId)),
|
|
23
|
+
latestMessageAt: primarySource.latestMessageAt,
|
|
24
|
+
},
|
|
25
|
+
evidence: draft.evidence.map((evidence) => ({
|
|
26
|
+
sourceMessageIdHash: digest(evidence.sourceMessageId),
|
|
27
|
+
senderIdHash: evidence.senderId === null ? null : digest(evidence.senderId),
|
|
28
|
+
senderNameHash: evidence.senderName === null ? null : digest(evidence.senderName),
|
|
29
|
+
sentAt: evidence.sentAt,
|
|
30
|
+
textSha256: digest(evidence.text),
|
|
31
|
+
attachments: evidence.attachments.map((attachment) => ({
|
|
32
|
+
idHash: digest(attachment.id),
|
|
33
|
+
fileNameSha256: digest(attachment.fileName),
|
|
34
|
+
mimeType: attachment.mimeType,
|
|
35
|
+
byteLength: attachment.byteLength,
|
|
36
|
+
sha256: attachment.sha256,
|
|
37
|
+
})),
|
|
38
|
+
})),
|
|
39
|
+
summarySha256: digest(draft.summary),
|
|
40
|
+
action: draft.action,
|
|
41
|
+
destination: draft.destination,
|
|
42
|
+
assignee: draft.assignee,
|
|
43
|
+
labels: [...draft.labels],
|
|
44
|
+
status: draft.status,
|
|
45
|
+
failure: draft.failure,
|
|
46
|
+
delivery: draft.delivery,
|
|
47
|
+
};
|
|
48
|
+
}
|
|
49
|
+
export class InMemoryWorkItemStore {
|
|
50
|
+
drafts = new Map();
|
|
51
|
+
async save(draft) {
|
|
52
|
+
this.drafts.set(draft.id, structuredClone(draft));
|
|
53
|
+
}
|
|
54
|
+
async get(id) {
|
|
55
|
+
const draft = this.drafts.get(id);
|
|
56
|
+
return draft ? structuredClone(draft) : null;
|
|
57
|
+
}
|
|
58
|
+
async list() {
|
|
59
|
+
return [...this.drafts.values()].map((draft) => structuredClone(draft));
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
/** Metadata-only persistence. It intentionally cannot be used to deliver a draft. */
|
|
63
|
+
export class FileWorkItemStore {
|
|
64
|
+
path;
|
|
65
|
+
maxItems;
|
|
66
|
+
constructor(path = yomiDataPath('work-items.json'), maxItems = 200) {
|
|
67
|
+
this.path = path;
|
|
68
|
+
this.maxItems = Math.max(1, maxItems);
|
|
69
|
+
}
|
|
70
|
+
async save(draft) {
|
|
71
|
+
const entries = await this.read();
|
|
72
|
+
const persisted = toPersistedWorkItem(draft);
|
|
73
|
+
const next = [
|
|
74
|
+
persisted,
|
|
75
|
+
...entries.filter((entry) => entry.id !== draft.id),
|
|
76
|
+
].slice(0, this.maxItems);
|
|
77
|
+
await mkdir(dirname(this.path), { recursive: true });
|
|
78
|
+
const temporary = `${this.path}.tmp-${process.pid}`;
|
|
79
|
+
await writeFile(temporary, JSON.stringify(next), { mode: 0o600 });
|
|
80
|
+
await rename(temporary, this.path);
|
|
81
|
+
}
|
|
82
|
+
async get(id) {
|
|
83
|
+
return (await this.read()).find((entry) => entry.id === id) ?? null;
|
|
84
|
+
}
|
|
85
|
+
async list() {
|
|
86
|
+
return this.read();
|
|
87
|
+
}
|
|
88
|
+
async read() {
|
|
89
|
+
try {
|
|
90
|
+
const value = JSON.parse(await readFile(this.path, 'utf8'));
|
|
91
|
+
return Array.isArray(value) ? value : [];
|
|
92
|
+
}
|
|
93
|
+
catch {
|
|
94
|
+
return [];
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
}
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Destination-agnostic work item domain.
|
|
3
|
+
*
|
|
4
|
+
* Message text and attachment bytes are intentionally kept in memory only.
|
|
5
|
+
* The persistence projection (see store.ts) contains metadata and digests,
|
|
6
|
+
* never decrypted LINE content.
|
|
7
|
+
*/
|
|
8
|
+
const TRANSITIONS = {
|
|
9
|
+
draft: ['preview'],
|
|
10
|
+
preview: ['approved', 'draft'],
|
|
11
|
+
approved: ['sending', 'draft'],
|
|
12
|
+
sending: ['delivered', 'failed'],
|
|
13
|
+
delivered: [],
|
|
14
|
+
failed: ['approved', 'draft'],
|
|
15
|
+
};
|
|
16
|
+
export function canTransition(from, to) {
|
|
17
|
+
return TRANSITIONS[from].includes(to);
|
|
18
|
+
}
|
|
19
|
+
export function transitionWorkItem(draft, event, now = Date.now()) {
|
|
20
|
+
const target = event.type === 'preview'
|
|
21
|
+
? 'preview'
|
|
22
|
+
: event.type === 'approve'
|
|
23
|
+
? 'approved'
|
|
24
|
+
: event.type === 'send'
|
|
25
|
+
? 'sending'
|
|
26
|
+
: event.type === 'deliver'
|
|
27
|
+
? 'delivered'
|
|
28
|
+
: event.type === 'fail'
|
|
29
|
+
? 'failed'
|
|
30
|
+
: 'approved';
|
|
31
|
+
if (!canTransition(draft.status, target)) {
|
|
32
|
+
throw new Error(`Invalid work item transition: ${draft.status} → ${target}`);
|
|
33
|
+
}
|
|
34
|
+
if (draft.status === 'failed' && event.type !== 'retry') {
|
|
35
|
+
throw new Error('Failed work items must use retry');
|
|
36
|
+
}
|
|
37
|
+
if (event.type === 'retry' && !draft.failure?.retryable) {
|
|
38
|
+
throw new Error('Work item failure is not retryable');
|
|
39
|
+
}
|
|
40
|
+
const updatedAt = event.at ?? now;
|
|
41
|
+
return {
|
|
42
|
+
...draft,
|
|
43
|
+
status: target,
|
|
44
|
+
updatedAt,
|
|
45
|
+
failure: event.type === 'fail' || event.type === 'retry'
|
|
46
|
+
? event.type === 'fail'
|
|
47
|
+
? event.failure
|
|
48
|
+
: null
|
|
49
|
+
: null,
|
|
50
|
+
delivery: event.type === 'deliver' ? event.delivery : draft.delivery,
|
|
51
|
+
};
|
|
52
|
+
}
|
|
53
|
+
export function createWorkItemDraft(input) {
|
|
54
|
+
const sources = input.sources?.length
|
|
55
|
+
? input.sources
|
|
56
|
+
: input.evidence
|
|
57
|
+
.map((evidence) => evidence.source ?? input.source)
|
|
58
|
+
.filter((source) => Boolean(source));
|
|
59
|
+
return {
|
|
60
|
+
...input,
|
|
61
|
+
sources,
|
|
62
|
+
source: input.source ?? sources[0],
|
|
63
|
+
updatedAt: input.updatedAt ?? input.createdAt,
|
|
64
|
+
status: 'draft',
|
|
65
|
+
failure: null,
|
|
66
|
+
delivery: null,
|
|
67
|
+
};
|
|
68
|
+
}
|
|
69
|
+
/** Upgrade a draft received from a pre-collection bridge. */
|
|
70
|
+
export function normalizeWorkItemDraft(draft) {
|
|
71
|
+
const sources = draft.sources?.length
|
|
72
|
+
? draft.sources
|
|
73
|
+
: draft.evidence
|
|
74
|
+
.map((evidence) => evidence.source ?? draft.source)
|
|
75
|
+
.filter((source) => Boolean(source));
|
|
76
|
+
if (sources.length === 0)
|
|
77
|
+
throw new Error('Work item has no valid evidence source');
|
|
78
|
+
return { ...draft, sources, source: draft.source ?? sources[0] };
|
|
79
|
+
}
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
import { reviseWorkEvent, } from './work-event.js';
|
|
2
|
+
import { projectFactorizedGraphInference, } from './work-event-inference.js';
|
|
3
|
+
import { matchWorkEventRevisions } from './work-event-matcher.js';
|
|
4
|
+
export class WorkEventGraphEngine {
|
|
5
|
+
provider;
|
|
6
|
+
sink;
|
|
7
|
+
latest = new Map();
|
|
8
|
+
constructor(provider, sink, initial = []) {
|
|
9
|
+
this.provider = provider;
|
|
10
|
+
this.sink = sink;
|
|
11
|
+
for (const revision of initial)
|
|
12
|
+
this.latest.set(revision.eventId, revision);
|
|
13
|
+
}
|
|
14
|
+
async observe(context, thresholds, inferredAt = Date.now()) {
|
|
15
|
+
let output;
|
|
16
|
+
try {
|
|
17
|
+
output = await this.provider.infer(context);
|
|
18
|
+
}
|
|
19
|
+
catch {
|
|
20
|
+
return {
|
|
21
|
+
projection: {
|
|
22
|
+
dialogue: [],
|
|
23
|
+
candidates: [
|
|
24
|
+
{
|
|
25
|
+
candidateKey: '',
|
|
26
|
+
result: {
|
|
27
|
+
kind: 'abstain',
|
|
28
|
+
reasons: ['provider_inference_failed'],
|
|
29
|
+
modelId: this.provider.id,
|
|
30
|
+
},
|
|
31
|
+
},
|
|
32
|
+
],
|
|
33
|
+
modelId: this.provider.id,
|
|
34
|
+
},
|
|
35
|
+
revisions: [],
|
|
36
|
+
};
|
|
37
|
+
}
|
|
38
|
+
const projection = projectFactorizedGraphInference(context, output, thresholds, inferredAt);
|
|
39
|
+
const incoming = projection.candidates.flatMap((candidate) => candidate.result.kind === 'inference' ? [candidate.result.inference] : []);
|
|
40
|
+
const previous = [...this.latest.values()].filter((revision) => revision.chatId === context.chatId);
|
|
41
|
+
const matches = matchWorkEventRevisions(previous, incoming);
|
|
42
|
+
const previousByIncoming = new Map(matches.map((match) => [
|
|
43
|
+
match.incomingIndex,
|
|
44
|
+
previous[match.previousIndex],
|
|
45
|
+
]));
|
|
46
|
+
const revisions = incoming.map((inference, index) => {
|
|
47
|
+
const prior = previousByIncoming.get(index) ?? null;
|
|
48
|
+
const stableInference = prior
|
|
49
|
+
? { ...inference, eventId: prior.eventId }
|
|
50
|
+
: inference;
|
|
51
|
+
return reviseWorkEvent(prior, stableInference);
|
|
52
|
+
});
|
|
53
|
+
if (revisions.length > 0) {
|
|
54
|
+
await this.sink.appendMany(revisions);
|
|
55
|
+
for (const revision of revisions)
|
|
56
|
+
this.latest.set(revision.eventId, revision);
|
|
57
|
+
}
|
|
58
|
+
return { projection, revisions };
|
|
59
|
+
}
|
|
60
|
+
current() {
|
|
61
|
+
return [...this.latest.values()].sort((left, right) => right.inferredAt - left.inferredAt);
|
|
62
|
+
}
|
|
63
|
+
}
|
|
@@ -0,0 +1,267 @@
|
|
|
1
|
+
const DIALOGUE_ACTS = new Set([
|
|
2
|
+
'inform',
|
|
3
|
+
'ask',
|
|
4
|
+
'answer',
|
|
5
|
+
'directive',
|
|
6
|
+
'commit',
|
|
7
|
+
'delegate',
|
|
8
|
+
'acknowledge',
|
|
9
|
+
'deliver',
|
|
10
|
+
'accept',
|
|
11
|
+
'reject',
|
|
12
|
+
'cancel',
|
|
13
|
+
]);
|
|
14
|
+
const OWNERSHIP_STATES = new Set([
|
|
15
|
+
'unassigned',
|
|
16
|
+
'owned',
|
|
17
|
+
'delegated',
|
|
18
|
+
'unknown',
|
|
19
|
+
]);
|
|
20
|
+
const PARTICIPANT_KINDS = new Set([
|
|
21
|
+
'self',
|
|
22
|
+
'conversation_member',
|
|
23
|
+
'external_party',
|
|
24
|
+
'unknown',
|
|
25
|
+
]);
|
|
26
|
+
const DEPENDENCY_STATES = new Set([
|
|
27
|
+
'none',
|
|
28
|
+
'waiting_person',
|
|
29
|
+
'waiting_external_system',
|
|
30
|
+
'waiting_decision',
|
|
31
|
+
'unknown',
|
|
32
|
+
]);
|
|
33
|
+
const LIFECYCLES = new Set([
|
|
34
|
+
'proposed',
|
|
35
|
+
'committed',
|
|
36
|
+
'in_progress',
|
|
37
|
+
'blocked',
|
|
38
|
+
'delivered',
|
|
39
|
+
'accepted',
|
|
40
|
+
'cancelled',
|
|
41
|
+
'resolved',
|
|
42
|
+
'uncertain',
|
|
43
|
+
]);
|
|
44
|
+
const DEFAULT_THRESHOLDS = {
|
|
45
|
+
createsWork: 0.82,
|
|
46
|
+
action: 0.78,
|
|
47
|
+
ownership: 0.8,
|
|
48
|
+
dependency: 0.76,
|
|
49
|
+
lifecycle: 0.82,
|
|
50
|
+
completion: 0.86,
|
|
51
|
+
};
|
|
52
|
+
function isRecord(value) {
|
|
53
|
+
return Boolean(value) && typeof value === 'object' && !Array.isArray(value);
|
|
54
|
+
}
|
|
55
|
+
function validConfidence(value) {
|
|
56
|
+
return (typeof value === 'number' &&
|
|
57
|
+
Number.isFinite(value) &&
|
|
58
|
+
value >= 0 &&
|
|
59
|
+
value <= 1);
|
|
60
|
+
}
|
|
61
|
+
function validEvidenceArray(value) {
|
|
62
|
+
return (Array.isArray(value) &&
|
|
63
|
+
value.every((item) => typeof item === 'string' && item.length > 0));
|
|
64
|
+
}
|
|
65
|
+
/** Runtime boundary for untrusted JSON emitted by a local model. */
|
|
66
|
+
export function validateFactorizedGraphModelOutput(value) {
|
|
67
|
+
const errors = [];
|
|
68
|
+
if (!isRecord(value))
|
|
69
|
+
return ['model_output_not_object'];
|
|
70
|
+
if (typeof value.modelId !== 'string' || !value.modelId)
|
|
71
|
+
errors.push('model_id_invalid');
|
|
72
|
+
if (!Array.isArray(value.dialogue))
|
|
73
|
+
errors.push('dialogue_invalid');
|
|
74
|
+
else
|
|
75
|
+
for (const item of value.dialogue) {
|
|
76
|
+
if (!isRecord(item) ||
|
|
77
|
+
typeof item.messageId !== 'string' ||
|
|
78
|
+
!Array.isArray(item.acts) ||
|
|
79
|
+
item.acts.some((act) => !DIALOGUE_ACTS.has(act)) ||
|
|
80
|
+
!validConfidence(item.confidence))
|
|
81
|
+
errors.push('dialogue_item_invalid');
|
|
82
|
+
}
|
|
83
|
+
if (!Array.isArray(value.candidates))
|
|
84
|
+
errors.push('candidates_invalid');
|
|
85
|
+
else
|
|
86
|
+
for (const candidate of value.candidates) {
|
|
87
|
+
if (!isRecord(candidate) ||
|
|
88
|
+
typeof candidate.candidateKey !== 'string' ||
|
|
89
|
+
!candidate.candidateKey) {
|
|
90
|
+
errors.push('candidate_key_invalid');
|
|
91
|
+
continue;
|
|
92
|
+
}
|
|
93
|
+
for (const name of [
|
|
94
|
+
'createsWork',
|
|
95
|
+
'ownership',
|
|
96
|
+
'dependency',
|
|
97
|
+
'lifecycle',
|
|
98
|
+
'completion',
|
|
99
|
+
]) {
|
|
100
|
+
const field = candidate[name];
|
|
101
|
+
if (!isRecord(field) ||
|
|
102
|
+
!validConfidence(field.confidence) ||
|
|
103
|
+
!validEvidenceArray(field.evidenceMessageIds))
|
|
104
|
+
errors.push(`${name}_field_invalid`);
|
|
105
|
+
}
|
|
106
|
+
if (!isRecord(candidate.createsWork) ||
|
|
107
|
+
typeof candidate.createsWork.value !== 'boolean')
|
|
108
|
+
errors.push('creates_work_value_invalid');
|
|
109
|
+
if (candidate.action !== null) {
|
|
110
|
+
if (!isRecord(candidate.action) ||
|
|
111
|
+
typeof candidate.action.predicate !== 'string' ||
|
|
112
|
+
!candidate.action.predicate ||
|
|
113
|
+
!validEvidenceArray(candidate.action.sourceMessageIds) ||
|
|
114
|
+
candidate.action.sourceMessageIds.length === 0 ||
|
|
115
|
+
!validConfidence(candidate.action.confidence))
|
|
116
|
+
errors.push('action_invalid');
|
|
117
|
+
}
|
|
118
|
+
const ownership = candidate.ownership?.value;
|
|
119
|
+
if (!isRecord(ownership) ||
|
|
120
|
+
!OWNERSHIP_STATES.has(ownership.state) ||
|
|
121
|
+
!Array.isArray(ownership.owners) ||
|
|
122
|
+
ownership.owners.some((owner) => !isRecord(owner) || !PARTICIPANT_KINDS.has(owner.kind)))
|
|
123
|
+
errors.push('ownership_value_invalid');
|
|
124
|
+
const dependency = candidate.dependency?.value;
|
|
125
|
+
if (!isRecord(dependency) || !DEPENDENCY_STATES.has(dependency.state))
|
|
126
|
+
errors.push('dependency_value_invalid');
|
|
127
|
+
if (!isRecord(candidate.lifecycle) ||
|
|
128
|
+
!LIFECYCLES.has(candidate.lifecycle.value))
|
|
129
|
+
errors.push('lifecycle_value_invalid');
|
|
130
|
+
const completion = candidate.completion?.value;
|
|
131
|
+
if (!isRecord(completion) ||
|
|
132
|
+
['delivered', 'accepted', 'cancelled'].some((key) => typeof completion[key] !== 'boolean'))
|
|
133
|
+
errors.push('completion_value_invalid');
|
|
134
|
+
}
|
|
135
|
+
return [...new Set(errors)];
|
|
136
|
+
}
|
|
137
|
+
function validEvidence(ids, available) {
|
|
138
|
+
return ids.length === 0 || ids.every((id) => available.has(id));
|
|
139
|
+
}
|
|
140
|
+
/** Validate, calibrate and project model output. It never converts uncertainty into no-work. */
|
|
141
|
+
export function projectFactorizedInference(context, output, thresholds = DEFAULT_THRESHOLDS, inferredAt = Date.now()) {
|
|
142
|
+
const reasons = [];
|
|
143
|
+
const available = new Set(context.messages.map((message) => message.messageId));
|
|
144
|
+
if (!context.complete)
|
|
145
|
+
reasons.push(`context_${context.boundary}`);
|
|
146
|
+
if (output.dialogue.some((item) => !available.has(item.messageId)))
|
|
147
|
+
reasons.push('dialogue_evidence_outside_context');
|
|
148
|
+
if (!validEvidence(output.createsWork.evidenceMessageIds, available))
|
|
149
|
+
reasons.push('work_evidence_outside_context');
|
|
150
|
+
if (output.createsWork.confidence < thresholds.createsWork)
|
|
151
|
+
reasons.push('work_confidence_low');
|
|
152
|
+
if (output.createsWork.value && !output.action)
|
|
153
|
+
reasons.push('work_action_missing');
|
|
154
|
+
if (output.action) {
|
|
155
|
+
if (output.action.sourceMessageIds.length === 0)
|
|
156
|
+
reasons.push('action_source_missing');
|
|
157
|
+
if (!validEvidence(output.action.sourceMessageIds, available))
|
|
158
|
+
reasons.push('action_source_outside_context');
|
|
159
|
+
if (output.action.confidence < thresholds.action)
|
|
160
|
+
reasons.push('action_confidence_low');
|
|
161
|
+
}
|
|
162
|
+
for (const [name, field, threshold] of [
|
|
163
|
+
['ownership', output.ownership, thresholds.ownership],
|
|
164
|
+
['dependency', output.dependency, thresholds.dependency],
|
|
165
|
+
['lifecycle', output.lifecycle, thresholds.lifecycle],
|
|
166
|
+
['completion', output.completion, thresholds.completion],
|
|
167
|
+
]) {
|
|
168
|
+
if (field.confidence < threshold)
|
|
169
|
+
reasons.push(`${name}_confidence_low`);
|
|
170
|
+
if (!validEvidence(field.evidenceMessageIds, available))
|
|
171
|
+
reasons.push(`${name}_evidence_outside_context`);
|
|
172
|
+
}
|
|
173
|
+
if (output.completion.value.accepted && !output.completion.value.delivered)
|
|
174
|
+
reasons.push('acceptance_without_delivery');
|
|
175
|
+
if (output.lifecycle.value === 'accepted' &&
|
|
176
|
+
!output.completion.value.accepted)
|
|
177
|
+
reasons.push('accepted_lifecycle_without_evidence');
|
|
178
|
+
if (output.lifecycle.value === 'cancelled' &&
|
|
179
|
+
!output.completion.value.cancelled)
|
|
180
|
+
reasons.push('cancelled_lifecycle_without_evidence');
|
|
181
|
+
if (reasons.length > 0)
|
|
182
|
+
return { kind: 'abstain', reasons, modelId: output.modelId };
|
|
183
|
+
return {
|
|
184
|
+
kind: 'inference',
|
|
185
|
+
inference: {
|
|
186
|
+
eventId: context.anchorMessageIds.slice().sort().join(':'),
|
|
187
|
+
chatId: context.chatId,
|
|
188
|
+
evidence: context.messages.map((message) => ({
|
|
189
|
+
messageId: message.messageId,
|
|
190
|
+
chatId: message.chatId,
|
|
191
|
+
sentAt: message.sentAt,
|
|
192
|
+
speaker: message.isSelf ? 'self' : 'other',
|
|
193
|
+
})),
|
|
194
|
+
dialogue: output.dialogue,
|
|
195
|
+
createsWork: output.createsWork.value,
|
|
196
|
+
workConfidence: output.createsWork.confidence,
|
|
197
|
+
action: output.action
|
|
198
|
+
? {
|
|
199
|
+
predicate: output.action.predicate,
|
|
200
|
+
object: output.action.object,
|
|
201
|
+
expectedOutcome: output.action.expectedOutcome,
|
|
202
|
+
dueAt: output.action.dueAt,
|
|
203
|
+
confidence: output.action.confidence,
|
|
204
|
+
evidenceMessageIds: output.action.sourceMessageIds,
|
|
205
|
+
}
|
|
206
|
+
: null,
|
|
207
|
+
ownership: {
|
|
208
|
+
...output.ownership.value,
|
|
209
|
+
confidence: output.ownership.confidence,
|
|
210
|
+
evidenceMessageIds: output.ownership.evidenceMessageIds,
|
|
211
|
+
},
|
|
212
|
+
dependency: {
|
|
213
|
+
...output.dependency.value,
|
|
214
|
+
confidence: output.dependency.confidence,
|
|
215
|
+
evidenceMessageIds: output.dependency.evidenceMessageIds,
|
|
216
|
+
},
|
|
217
|
+
lifecycle: output.lifecycle.value,
|
|
218
|
+
completion: {
|
|
219
|
+
...output.completion.value,
|
|
220
|
+
confidence: output.completion.confidence,
|
|
221
|
+
evidenceMessageIds: output.completion.evidenceMessageIds,
|
|
222
|
+
},
|
|
223
|
+
sourceModel: output.modelId,
|
|
224
|
+
inferredAt,
|
|
225
|
+
},
|
|
226
|
+
};
|
|
227
|
+
}
|
|
228
|
+
/** Project every hypothesis independently; one utterance may support several events. */
|
|
229
|
+
export function projectFactorizedGraphInference(context, output, thresholds = DEFAULT_THRESHOLDS, inferredAt = Date.now()) {
|
|
230
|
+
const validationErrors = validateFactorizedGraphModelOutput(output);
|
|
231
|
+
if (validationErrors.length > 0) {
|
|
232
|
+
return {
|
|
233
|
+
dialogue: [],
|
|
234
|
+
candidates: [
|
|
235
|
+
{
|
|
236
|
+
candidateKey: '',
|
|
237
|
+
result: {
|
|
238
|
+
kind: 'abstain',
|
|
239
|
+
reasons: validationErrors.map((error) => `model_output_${error}`),
|
|
240
|
+
modelId: typeof output?.modelId === 'string' ? output.modelId : 'unknown',
|
|
241
|
+
},
|
|
242
|
+
},
|
|
243
|
+
],
|
|
244
|
+
modelId: typeof output?.modelId === 'string' ? output.modelId : 'unknown',
|
|
245
|
+
};
|
|
246
|
+
}
|
|
247
|
+
const keys = new Set();
|
|
248
|
+
const candidates = output.candidates.map((candidate) => {
|
|
249
|
+
if (!candidate.candidateKey || keys.has(candidate.candidateKey)) {
|
|
250
|
+
return {
|
|
251
|
+
candidateKey: candidate.candidateKey,
|
|
252
|
+
result: {
|
|
253
|
+
kind: 'abstain',
|
|
254
|
+
reasons: ['candidate_key_invalid'],
|
|
255
|
+
modelId: output.modelId,
|
|
256
|
+
},
|
|
257
|
+
};
|
|
258
|
+
}
|
|
259
|
+
keys.add(candidate.candidateKey);
|
|
260
|
+
const result = projectFactorizedInference(context, { ...candidate, dialogue: output.dialogue, modelId: output.modelId }, thresholds, inferredAt);
|
|
261
|
+
if (result.kind === 'inference') {
|
|
262
|
+
result.inference.eventId = `${context.anchorMessageIds.slice().sort().join(':')}:${candidate.candidateKey}`;
|
|
263
|
+
}
|
|
264
|
+
return { candidateKey: candidate.candidateKey, result };
|
|
265
|
+
});
|
|
266
|
+
return { dialogue: output.dialogue, candidates, modelId: output.modelId };
|
|
267
|
+
}
|