@open-mercato/ai-assistant 0.10.1-develop.7688.1.8bbd70dcdf → 0.10.1-develop.7690.1.9d41ec4115
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/.turbo/turbo-build.log +1 -1
- package/dist/modules/ai_assistant/di.js +4 -0
- package/dist/modules/ai_assistant/di.js.map +2 -2
- package/dist/modules/ai_assistant/lib/decision-adapters/fixture.js +79 -0
- package/dist/modules/ai_assistant/lib/decision-adapters/fixture.js.map +7 -0
- package/dist/modules/ai_assistant/lib/decision-adapters/openrouter.js +226 -0
- package/dist/modules/ai_assistant/lib/decision-adapters/openrouter.js.map +7 -0
- package/dist/modules/ai_assistant/lib/decision-model-registry.js +66 -0
- package/dist/modules/ai_assistant/lib/decision-model-registry.js.map +7 -0
- package/dist/modules/ai_assistant/lib/decision-model-schemas.js +159 -0
- package/dist/modules/ai_assistant/lib/decision-model-schemas.js.map +7 -0
- package/dist/modules/ai_assistant/lib/decision-model-service.js +208 -0
- package/dist/modules/ai_assistant/lib/decision-model-service.js.map +7 -0
- package/package.json +6 -6
- package/src/modules/ai_assistant/di.ts +4 -0
- package/src/modules/ai_assistant/lib/__tests__/decision-fixture-adapter.test.ts +119 -0
- package/src/modules/ai_assistant/lib/__tests__/decision-model-registry.test.ts +203 -0
- package/src/modules/ai_assistant/lib/__tests__/decision-model-schemas-logging.test.ts +43 -0
- package/src/modules/ai_assistant/lib/__tests__/decision-model-schemas.test.ts +195 -0
- package/src/modules/ai_assistant/lib/__tests__/decision-model-service.test.ts +466 -0
- package/src/modules/ai_assistant/lib/__tests__/decision-openrouter-adapter.test.ts +321 -0
- package/src/modules/ai_assistant/lib/decision-adapters/fixture.ts +95 -0
- package/src/modules/ai_assistant/lib/decision-adapters/openrouter.ts +267 -0
- package/src/modules/ai_assistant/lib/decision-model-registry.ts +108 -0
- package/src/modules/ai_assistant/lib/decision-model-schemas.ts +224 -0
- package/src/modules/ai_assistant/lib/decision-model-service.ts +297 -0
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
import type { DecisionRequest } from '@open-mercato/shared/lib/ai/decision-model'
|
|
2
|
+
|
|
3
|
+
const debugMock = jest.fn()
|
|
4
|
+
|
|
5
|
+
jest.mock('@open-mercato/shared/lib/logger', () => {
|
|
6
|
+
const childLogger = { debug: (...args: unknown[]) => debugMock(...args), info: jest.fn(), warn: jest.fn(), error: jest.fn() }
|
|
7
|
+
return { createLogger: () => ({ ...childLogger, child: () => childLogger }) }
|
|
8
|
+
})
|
|
9
|
+
|
|
10
|
+
import { normalizeDecisionAnswers } from '../decision-model-schemas'
|
|
11
|
+
|
|
12
|
+
const request: DecisionRequest = {
|
|
13
|
+
state: { secret: 'customer-email@example.test' },
|
|
14
|
+
questions: {
|
|
15
|
+
route: {
|
|
16
|
+
type: 'choice',
|
|
17
|
+
instructions: 'Pick the queue',
|
|
18
|
+
options: [{ key: 'billing', criteria: 'Billing' }, { key: 'support', criteria: 'Support' }],
|
|
19
|
+
},
|
|
20
|
+
},
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
describe('normalizeDecisionAnswers logging', () => {
|
|
24
|
+
beforeEach(() => debugMock.mockClear())
|
|
25
|
+
|
|
26
|
+
it('logs at debug when a near-1 distribution is normalized, without state or instructions', () => {
|
|
27
|
+
normalizeDecisionAnswers(request, {
|
|
28
|
+
route: { type: 'choice', value: 'billing', probabilities: [{ key: 'billing', probability: 0.6 }, { key: 'support', probability: 0.41 }], confidence: 0.6 },
|
|
29
|
+
})
|
|
30
|
+
expect(debugMock).toHaveBeenCalledTimes(1)
|
|
31
|
+
const serialized = JSON.stringify(debugMock.mock.calls)
|
|
32
|
+
expect(serialized).toContain('route')
|
|
33
|
+
expect(serialized).not.toContain('customer-email@example.test')
|
|
34
|
+
expect(serialized).not.toContain('Pick the queue')
|
|
35
|
+
})
|
|
36
|
+
|
|
37
|
+
it('does not log when the distribution already sums to 1', () => {
|
|
38
|
+
normalizeDecisionAnswers(request, {
|
|
39
|
+
route: { type: 'choice', value: 'billing', probabilities: [{ key: 'billing', probability: 0.6 }, { key: 'support', probability: 0.4 }], confidence: 0.6 },
|
|
40
|
+
})
|
|
41
|
+
expect(debugMock).not.toHaveBeenCalled()
|
|
42
|
+
})
|
|
43
|
+
})
|
|
@@ -0,0 +1,195 @@
|
|
|
1
|
+
import type { z } from 'zod'
|
|
2
|
+
import type { DecisionRequest } from '@open-mercato/shared/lib/ai/decision-model'
|
|
3
|
+
import {
|
|
4
|
+
DecisionModelError,
|
|
5
|
+
decisionRequestSchema,
|
|
6
|
+
normalizeDecisionAnswers,
|
|
7
|
+
} from '../decision-model-schemas'
|
|
8
|
+
|
|
9
|
+
type MutuallyAssignable<A, B> = [A] extends [B] ? ([B] extends [A] ? true : false) : false
|
|
10
|
+
const requestTypesMatch: MutuallyAssignable<z.infer<typeof decisionRequestSchema>, DecisionRequest> = true
|
|
11
|
+
|
|
12
|
+
function choiceRequest(keys: string[]): DecisionRequest {
|
|
13
|
+
return {
|
|
14
|
+
state: { ticket: 'refund please' },
|
|
15
|
+
questions: {
|
|
16
|
+
route: {
|
|
17
|
+
type: 'choice',
|
|
18
|
+
instructions: 'Pick the queue',
|
|
19
|
+
options: keys.map((key) => ({ key, criteria: `Queue ${key}` })),
|
|
20
|
+
},
|
|
21
|
+
},
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
function expectInvalidResponse(run: () => unknown) {
|
|
26
|
+
try {
|
|
27
|
+
run()
|
|
28
|
+
} catch (error) {
|
|
29
|
+
expect(error).toBeInstanceOf(DecisionModelError)
|
|
30
|
+
expect((error as DecisionModelError).code).toBe('invalid_response')
|
|
31
|
+
return
|
|
32
|
+
}
|
|
33
|
+
throw new Error('expected invalid_response')
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
describe('decisionRequestSchema', () => {
|
|
37
|
+
it('keeps the shared DecisionRequest type in sync with the schema', () => {
|
|
38
|
+
expect(requestTypesMatch).toBe(true)
|
|
39
|
+
})
|
|
40
|
+
|
|
41
|
+
it('accepts all three question types', () => {
|
|
42
|
+
const parsed = decisionRequestSchema.safeParse({
|
|
43
|
+
state: { any: ['thing'] },
|
|
44
|
+
questions: {
|
|
45
|
+
route: { type: 'choice', instructions: 'Pick', options: [{ key: 'a', criteria: 'A' }, { key: 'b', criteria: 'B' }] },
|
|
46
|
+
urgency: { type: 'score', instructions: 'Rate', levels: ['low', 'high'] },
|
|
47
|
+
spam: { type: 'binary', instructions: 'Spam?', criteria: { true: 'spam', false: 'not spam' } },
|
|
48
|
+
},
|
|
49
|
+
sessionId: 'workflow-instance-1',
|
|
50
|
+
})
|
|
51
|
+
expect(parsed.success).toBe(true)
|
|
52
|
+
})
|
|
53
|
+
|
|
54
|
+
it('rejects __proto__ as a question name or option key instead of dropping it', () => {
|
|
55
|
+
const questions = JSON.parse('{"__proto__":{"type":"score","instructions":"Rate","levels":["a","b"]},"other":{"type":"score","instructions":"Rate","levels":["a","b"]}}')
|
|
56
|
+
expect(decisionRequestSchema.safeParse({ state: null, questions }).success).toBe(false)
|
|
57
|
+
expect(decisionRequestSchema.safeParse(choiceRequest(['__proto__', 'b'])).success).toBe(false)
|
|
58
|
+
})
|
|
59
|
+
|
|
60
|
+
it('rejects an unknown question type', () => {
|
|
61
|
+
const parsed = decisionRequestSchema.safeParse({
|
|
62
|
+
state: null,
|
|
63
|
+
questions: { route: { type: 'noul', instructions: 'Pick', criteria: { true: 'a', false: 'b' } } },
|
|
64
|
+
})
|
|
65
|
+
expect(parsed.success).toBe(false)
|
|
66
|
+
})
|
|
67
|
+
|
|
68
|
+
it('enforces question count, option count, key format and duplicate keys', () => {
|
|
69
|
+
const tooMany = Object.fromEntries(
|
|
70
|
+
Array.from({ length: 9 }, (_, index) => [`q${index}`, { type: 'score', instructions: 'Rate', levels: ['a', 'b'] }]),
|
|
71
|
+
)
|
|
72
|
+
expect(decisionRequestSchema.safeParse({ state: null, questions: {} }).success).toBe(false)
|
|
73
|
+
expect(decisionRequestSchema.safeParse({ state: null, questions: tooMany }).success).toBe(false)
|
|
74
|
+
expect(decisionRequestSchema.safeParse(choiceRequest(['only'])).success).toBe(false)
|
|
75
|
+
expect(decisionRequestSchema.safeParse(choiceRequest(['bad key', 'ok'])).success).toBe(false)
|
|
76
|
+
expect(decisionRequestSchema.safeParse(choiceRequest(['same', 'same'])).success).toBe(false)
|
|
77
|
+
expect(decisionRequestSchema.safeParse({
|
|
78
|
+
state: null,
|
|
79
|
+
questions: { 'bad name': { type: 'score', instructions: 'Rate', levels: ['a', 'b'] } },
|
|
80
|
+
}).success).toBe(false)
|
|
81
|
+
})
|
|
82
|
+
|
|
83
|
+
it('enforces text and sessionId limits', () => {
|
|
84
|
+
const longInstructions = choiceRequest(['a', 'b'])
|
|
85
|
+
const question = longInstructions.questions.route
|
|
86
|
+
if (question.type === 'choice') question.instructions = 'x'.repeat(4001)
|
|
87
|
+
expect(decisionRequestSchema.safeParse(longInstructions).success).toBe(false)
|
|
88
|
+
expect(decisionRequestSchema.safeParse({ ...choiceRequest(['a', 'b']), sessionId: 'x'.repeat(257) }).success).toBe(false)
|
|
89
|
+
})
|
|
90
|
+
|
|
91
|
+
it('preserves option order, including integer-like keys', () => {
|
|
92
|
+
const parsed = decisionRequestSchema.parse(choiceRequest(['10', '2', 'b', '1']))
|
|
93
|
+
const question = parsed.questions.route
|
|
94
|
+
expect(question.type === 'choice' && question.options.map((option) => option.key)).toEqual(['10', '2', 'b', '1'])
|
|
95
|
+
})
|
|
96
|
+
})
|
|
97
|
+
|
|
98
|
+
describe('normalizeDecisionAnswers', () => {
|
|
99
|
+
it('returns choice probabilities in request order and normalizes a near-1 sum', () => {
|
|
100
|
+
const answers = normalizeDecisionAnswers(choiceRequest(['10', '2', 'b']), {
|
|
101
|
+
route: {
|
|
102
|
+
type: 'choice',
|
|
103
|
+
value: '2',
|
|
104
|
+
probabilities: [
|
|
105
|
+
{ key: 'b', probability: 0.1 },
|
|
106
|
+
{ key: '10', probability: 0.2 },
|
|
107
|
+
{ key: '2', probability: 0.71 },
|
|
108
|
+
],
|
|
109
|
+
confidence: 0.71,
|
|
110
|
+
},
|
|
111
|
+
})
|
|
112
|
+
const answer = answers.route
|
|
113
|
+
expect(answer.type).toBe('choice')
|
|
114
|
+
if (answer.type !== 'choice') return
|
|
115
|
+
expect(answer.value).toBe('2')
|
|
116
|
+
expect(answer.probabilities.map((entry) => entry.key)).toEqual(['10', '2', 'b'])
|
|
117
|
+
expect(answer.probabilities.reduce((total, entry) => total + entry.probability, 0)).toBeCloseTo(1, 10)
|
|
118
|
+
expect(answer.probabilities[1].probability).toBeCloseTo(0.71 / 1.01, 10)
|
|
119
|
+
})
|
|
120
|
+
|
|
121
|
+
it('rejects a sum outside 1 ± 0.02', () => {
|
|
122
|
+
expectInvalidResponse(() => normalizeDecisionAnswers(choiceRequest(['a', 'b']), {
|
|
123
|
+
route: { type: 'choice', value: 'a', probabilities: [{ key: 'a', probability: 0.5 }, { key: 'b', probability: 0.4 }], confidence: 0.5 },
|
|
124
|
+
}))
|
|
125
|
+
})
|
|
126
|
+
|
|
127
|
+
it('rejects a missing key, an extra key, a duplicate key and an unknown selection', () => {
|
|
128
|
+
const request = choiceRequest(['a', 'b'])
|
|
129
|
+
expectInvalidResponse(() => normalizeDecisionAnswers(request, {
|
|
130
|
+
route: { type: 'choice', value: 'a', probabilities: [{ key: 'a', probability: 1 }], confidence: 1 },
|
|
131
|
+
}))
|
|
132
|
+
expectInvalidResponse(() => normalizeDecisionAnswers(request, {
|
|
133
|
+
route: { type: 'choice', value: 'a', probabilities: [{ key: 'a', probability: 0.5 }, { key: 'b', probability: 0.25 }, { key: 'c', probability: 0.25 }], confidence: 0.5 },
|
|
134
|
+
}))
|
|
135
|
+
expectInvalidResponse(() => normalizeDecisionAnswers(request, {
|
|
136
|
+
route: { type: 'choice', value: 'a', probabilities: [{ key: 'a', probability: 0.5 }, { key: 'a', probability: 0.5 }], confidence: 0.5 },
|
|
137
|
+
}))
|
|
138
|
+
expectInvalidResponse(() => normalizeDecisionAnswers(request, {
|
|
139
|
+
route: { type: 'choice', value: 'z', probabilities: [{ key: 'a', probability: 0.5 }, { key: 'b', probability: 0.5 }], confidence: 0.5 },
|
|
140
|
+
}))
|
|
141
|
+
})
|
|
142
|
+
|
|
143
|
+
it('rejects probabilities outside [0, 1], a missing answer and a type mismatch', () => {
|
|
144
|
+
const request = choiceRequest(['a', 'b'])
|
|
145
|
+
expectInvalidResponse(() => normalizeDecisionAnswers(request, {
|
|
146
|
+
route: { type: 'choice', value: 'a', probabilities: [{ key: 'a', probability: 1.5 }, { key: 'b', probability: -0.5 }], confidence: 1 },
|
|
147
|
+
}))
|
|
148
|
+
expectInvalidResponse(() => normalizeDecisionAnswers(request, {}))
|
|
149
|
+
expectInvalidResponse(() => normalizeDecisionAnswers(request, { route: { type: 'binary', probability: 0.5 } }))
|
|
150
|
+
})
|
|
151
|
+
|
|
152
|
+
it('derives score value as the weighted position and level as the argmax', () => {
|
|
153
|
+
const request: DecisionRequest = {
|
|
154
|
+
state: null,
|
|
155
|
+
questions: { urgency: { type: 'score', instructions: 'Rate', levels: ['low', 'medium', 'high'] } },
|
|
156
|
+
}
|
|
157
|
+
const answers = normalizeDecisionAnswers(request, {
|
|
158
|
+
urgency: {
|
|
159
|
+
type: 'score',
|
|
160
|
+
probabilities: [
|
|
161
|
+
{ level: 2, probability: 0.6 },
|
|
162
|
+
{ level: 0, probability: 0.1 },
|
|
163
|
+
{ level: 1, probability: 0.3 },
|
|
164
|
+
],
|
|
165
|
+
confidence: 0.6,
|
|
166
|
+
},
|
|
167
|
+
})
|
|
168
|
+
const answer = answers.urgency
|
|
169
|
+
expect(answer.type).toBe('score')
|
|
170
|
+
if (answer.type !== 'score') return
|
|
171
|
+
expect(answer.level).toBe(2)
|
|
172
|
+
expect(answer.value).toBeCloseTo(0.3 + 1.2, 10)
|
|
173
|
+
expect(answer.probabilities.map((entry) => entry.level)).toEqual([0, 1, 2])
|
|
174
|
+
})
|
|
175
|
+
|
|
176
|
+
it('rejects a score distribution that does not match the requested levels', () => {
|
|
177
|
+
const request: DecisionRequest = {
|
|
178
|
+
state: null,
|
|
179
|
+
questions: { urgency: { type: 'score', instructions: 'Rate', levels: ['low', 'high'] } },
|
|
180
|
+
}
|
|
181
|
+
expectInvalidResponse(() => normalizeDecisionAnswers(request, {
|
|
182
|
+
urgency: { type: 'score', probabilities: [{ level: 0, probability: 0.5 }, { level: 2, probability: 0.5 }], confidence: 0.5 },
|
|
183
|
+
}))
|
|
184
|
+
})
|
|
185
|
+
|
|
186
|
+
it('passes binary P(true) through', () => {
|
|
187
|
+
const request: DecisionRequest = {
|
|
188
|
+
state: null,
|
|
189
|
+
questions: { spam: { type: 'binary', instructions: 'Spam?', criteria: { true: 'spam', false: 'not spam' } } },
|
|
190
|
+
}
|
|
191
|
+
expect(normalizeDecisionAnswers(request, { spam: { type: 'binary', probability: 0.83 } })).toEqual({
|
|
192
|
+
spam: { type: 'binary', probability: 0.83 },
|
|
193
|
+
})
|
|
194
|
+
})
|
|
195
|
+
})
|