@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.
Files changed (26) hide show
  1. package/.turbo/turbo-build.log +1 -1
  2. package/dist/modules/ai_assistant/di.js +4 -0
  3. package/dist/modules/ai_assistant/di.js.map +2 -2
  4. package/dist/modules/ai_assistant/lib/decision-adapters/fixture.js +79 -0
  5. package/dist/modules/ai_assistant/lib/decision-adapters/fixture.js.map +7 -0
  6. package/dist/modules/ai_assistant/lib/decision-adapters/openrouter.js +226 -0
  7. package/dist/modules/ai_assistant/lib/decision-adapters/openrouter.js.map +7 -0
  8. package/dist/modules/ai_assistant/lib/decision-model-registry.js +66 -0
  9. package/dist/modules/ai_assistant/lib/decision-model-registry.js.map +7 -0
  10. package/dist/modules/ai_assistant/lib/decision-model-schemas.js +159 -0
  11. package/dist/modules/ai_assistant/lib/decision-model-schemas.js.map +7 -0
  12. package/dist/modules/ai_assistant/lib/decision-model-service.js +208 -0
  13. package/dist/modules/ai_assistant/lib/decision-model-service.js.map +7 -0
  14. package/package.json +6 -6
  15. package/src/modules/ai_assistant/di.ts +4 -0
  16. package/src/modules/ai_assistant/lib/__tests__/decision-fixture-adapter.test.ts +119 -0
  17. package/src/modules/ai_assistant/lib/__tests__/decision-model-registry.test.ts +203 -0
  18. package/src/modules/ai_assistant/lib/__tests__/decision-model-schemas-logging.test.ts +43 -0
  19. package/src/modules/ai_assistant/lib/__tests__/decision-model-schemas.test.ts +195 -0
  20. package/src/modules/ai_assistant/lib/__tests__/decision-model-service.test.ts +466 -0
  21. package/src/modules/ai_assistant/lib/__tests__/decision-openrouter-adapter.test.ts +321 -0
  22. package/src/modules/ai_assistant/lib/decision-adapters/fixture.ts +95 -0
  23. package/src/modules/ai_assistant/lib/decision-adapters/openrouter.ts +267 -0
  24. package/src/modules/ai_assistant/lib/decision-model-registry.ts +108 -0
  25. package/src/modules/ai_assistant/lib/decision-model-schemas.ts +224 -0
  26. 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
+ })