@open-mercato/ai-assistant 0.10.1-develop.7689.1.453d3db612 → 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
package/.turbo/turbo-build.log
CHANGED
|
@@ -1,2 +1,2 @@
|
|
|
1
|
-
[build:ai-assistant] found
|
|
1
|
+
[build:ai-assistant] found 244 entry points
|
|
2
2
|
[build:ai-assistant] built successfully
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { asFunction, asValue } from "awilix";
|
|
2
2
|
import { toolRegistry } from "./lib/tool-registry.js";
|
|
3
3
|
import { createModerationService } from "./lib/moderation.js";
|
|
4
|
+
import { createDecisionModelService } from "./lib/decision-model-service.js";
|
|
4
5
|
import { createOpenCodeClient } from "./lib/opencode-client.js";
|
|
5
6
|
function register(container) {
|
|
6
7
|
container.register({
|
|
@@ -8,6 +9,9 @@ function register(container) {
|
|
|
8
9
|
// Input pre-moderation service (OpenAI /v1/moderations). Singleton +
|
|
9
10
|
// overridable by downstream apps via the module overrides DI seam.
|
|
10
11
|
moderationService: asFunction(() => createModerationService()).singleton(),
|
|
12
|
+
// Decision model port (typed answers with probabilities). Singleton that
|
|
13
|
+
// owns the only decision model registry; consumers resolve it soft-optionally.
|
|
14
|
+
decisionModelService: asFunction(() => createDecisionModelService()).singleton(),
|
|
11
15
|
// Injectable OpenCode client so the file-agent runner (and tests, via a
|
|
12
16
|
// fake client) resolve it from DI instead of constructing it inline.
|
|
13
17
|
// Production wiring uses the env-configured factory.
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"version": 3,
|
|
3
3
|
"sources": ["../../../src/modules/ai_assistant/di.ts"],
|
|
4
|
-
"sourcesContent": ["import { asFunction, asValue } from 'awilix'\nimport type { AwilixContainer } from 'awilix'\nimport { toolRegistry } from './lib/tool-registry'\nimport { createModerationService } from './lib/moderation'\nimport { createOpenCodeClient } from './lib/opencode-client'\n\nexport function register(container: AwilixContainer): void {\n container.register({\n mcpToolRegistry: asValue(toolRegistry),\n // Input pre-moderation service (OpenAI /v1/moderations). Singleton +\n // overridable by downstream apps via the module overrides DI seam.\n moderationService: asFunction(() => createModerationService()).singleton(),\n // Injectable OpenCode client so the file-agent runner (and tests, via a\n // fake client) resolve it from DI instead of constructing it inline.\n // Production wiring uses the env-configured factory.\n openCodeClient: asFunction(() => createOpenCodeClient()).singleton(),\n })\n}\n"],
|
|
5
|
-
"mappings": "AAAA,SAAS,YAAY,eAAe;AAEpC,SAAS,oBAAoB;AAC7B,SAAS,+BAA+B;AACxC,SAAS,4BAA4B;AAE9B,SAAS,SAAS,WAAkC;AACzD,YAAU,SAAS;AAAA,IACjB,iBAAiB,QAAQ,YAAY;AAAA;AAAA;AAAA,IAGrC,mBAAmB,WAAW,MAAM,wBAAwB,CAAC,EAAE,UAAU;AAAA;AAAA;AAAA;AAAA,
|
|
4
|
+
"sourcesContent": ["import { asFunction, asValue } from 'awilix'\nimport type { AwilixContainer } from 'awilix'\nimport { toolRegistry } from './lib/tool-registry'\nimport { createModerationService } from './lib/moderation'\nimport { createDecisionModelService } from './lib/decision-model-service'\nimport { createOpenCodeClient } from './lib/opencode-client'\n\nexport function register(container: AwilixContainer): void {\n container.register({\n mcpToolRegistry: asValue(toolRegistry),\n // Input pre-moderation service (OpenAI /v1/moderations). Singleton +\n // overridable by downstream apps via the module overrides DI seam.\n moderationService: asFunction(() => createModerationService()).singleton(),\n // Decision model port (typed answers with probabilities). Singleton that\n // owns the only decision model registry; consumers resolve it soft-optionally.\n decisionModelService: asFunction(() => createDecisionModelService()).singleton(),\n // Injectable OpenCode client so the file-agent runner (and tests, via a\n // fake client) resolve it from DI instead of constructing it inline.\n // Production wiring uses the env-configured factory.\n openCodeClient: asFunction(() => createOpenCodeClient()).singleton(),\n })\n}\n"],
|
|
5
|
+
"mappings": "AAAA,SAAS,YAAY,eAAe;AAEpC,SAAS,oBAAoB;AAC7B,SAAS,+BAA+B;AACxC,SAAS,kCAAkC;AAC3C,SAAS,4BAA4B;AAE9B,SAAS,SAAS,WAAkC;AACzD,YAAU,SAAS;AAAA,IACjB,iBAAiB,QAAQ,YAAY;AAAA;AAAA;AAAA,IAGrC,mBAAmB,WAAW,MAAM,wBAAwB,CAAC,EAAE,UAAU;AAAA;AAAA;AAAA,IAGzE,sBAAsB,WAAW,MAAM,2BAA2B,CAAC,EAAE,UAAU;AAAA;AAAA;AAAA;AAAA,IAI/E,gBAAgB,WAAW,MAAM,qBAAqB,CAAC,EAAE,UAAU;AAAA,EACrE,CAAC;AACH;",
|
|
6
6
|
"names": []
|
|
7
7
|
}
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
import { normalizeDecisionAnswers } from "../decision-model-schemas.js";
|
|
2
|
+
function escapeRegExp(value) {
|
|
3
|
+
return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
4
|
+
}
|
|
5
|
+
function containsWholeWord(haystack, word) {
|
|
6
|
+
const pattern = new RegExp(`(^|[^A-Za-z0-9_-])${escapeRegExp(word)}($|[^A-Za-z0-9_-])`, "i");
|
|
7
|
+
return pattern.test(haystack);
|
|
8
|
+
}
|
|
9
|
+
function serializeState(state) {
|
|
10
|
+
return JSON.stringify(state) ?? "";
|
|
11
|
+
}
|
|
12
|
+
function answerChoice(question, serializedState) {
|
|
13
|
+
const matched = question.options.find((option) => containsWholeWord(serializedState, option.key));
|
|
14
|
+
if (matched) {
|
|
15
|
+
return {
|
|
16
|
+
type: "choice",
|
|
17
|
+
value: matched.key,
|
|
18
|
+
probabilities: question.options.map((option) => ({
|
|
19
|
+
key: option.key,
|
|
20
|
+
probability: option.key === matched.key ? 1 : 0
|
|
21
|
+
})),
|
|
22
|
+
confidence: 1
|
|
23
|
+
};
|
|
24
|
+
}
|
|
25
|
+
const uniform = 1 / question.options.length;
|
|
26
|
+
return {
|
|
27
|
+
type: "choice",
|
|
28
|
+
value: question.options[0].key,
|
|
29
|
+
probabilities: question.options.map((option) => ({ key: option.key, probability: uniform })),
|
|
30
|
+
confidence: uniform
|
|
31
|
+
};
|
|
32
|
+
}
|
|
33
|
+
function answerScore(question) {
|
|
34
|
+
const uniform = 1 / question.levels.length;
|
|
35
|
+
return {
|
|
36
|
+
type: "score",
|
|
37
|
+
probabilities: question.levels.map((_, level) => ({ level, probability: uniform })),
|
|
38
|
+
confidence: uniform
|
|
39
|
+
};
|
|
40
|
+
}
|
|
41
|
+
function answerQuestion(question, serializedState) {
|
|
42
|
+
switch (question.type) {
|
|
43
|
+
case "choice":
|
|
44
|
+
return answerChoice(question, serializedState);
|
|
45
|
+
case "score":
|
|
46
|
+
return answerScore(question);
|
|
47
|
+
case "binary":
|
|
48
|
+
return { type: "binary", probability: 0.5 };
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
function createFixtureDecisionAdapter() {
|
|
52
|
+
return {
|
|
53
|
+
id: "fixture",
|
|
54
|
+
isConfigured: () => true,
|
|
55
|
+
async decide(request, options) {
|
|
56
|
+
if (options.signal.aborted) {
|
|
57
|
+
throw options.signal.reason;
|
|
58
|
+
}
|
|
59
|
+
const startedAt = Date.now();
|
|
60
|
+
const serializedState = serializeState(request.state);
|
|
61
|
+
const raw = {};
|
|
62
|
+
for (const [questionName, question] of Object.entries(request.questions)) {
|
|
63
|
+
raw[questionName] = answerQuestion(question, serializedState);
|
|
64
|
+
}
|
|
65
|
+
const answers = normalizeDecisionAnswers(request, raw);
|
|
66
|
+
return {
|
|
67
|
+
answers,
|
|
68
|
+
provider: "fixture",
|
|
69
|
+
model: options.modelId,
|
|
70
|
+
usage: { inputTokens: null, outputTokens: null, costUsd: null },
|
|
71
|
+
durationMs: Date.now() - startedAt
|
|
72
|
+
};
|
|
73
|
+
}
|
|
74
|
+
};
|
|
75
|
+
}
|
|
76
|
+
export {
|
|
77
|
+
createFixtureDecisionAdapter
|
|
78
|
+
};
|
|
79
|
+
//# sourceMappingURL=fixture.js.map
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
{
|
|
2
|
+
"version": 3,
|
|
3
|
+
"sources": ["../../../../../src/modules/ai_assistant/lib/decision-adapters/fixture.ts"],
|
|
4
|
+
"sourcesContent": ["import type {\n DecisionModelAdapter,\n DecisionQuestion,\n DecisionRequest,\n DecisionResult,\n} from '@open-mercato/shared/lib/ai/decision-model'\nimport { normalizeDecisionAnswers, type RawDecisionAnswer } from '../decision-model-schemas'\n\nfunction escapeRegExp(value: string): string {\n return value.replace(/[.*+?^${}()|[\\]\\\\]/g, '\\\\$&')\n}\n\nfunction containsWholeWord(haystack: string, word: string): boolean {\n const pattern = new RegExp(`(^|[^A-Za-z0-9_-])${escapeRegExp(word)}($|[^A-Za-z0-9_-])`, 'i')\n return pattern.test(haystack)\n}\n\nfunction serializeState(state: unknown): string {\n return JSON.stringify(state) ?? ''\n}\n\nfunction answerChoice(\n question: Extract<DecisionQuestion, { type: 'choice' }>,\n serializedState: string,\n): RawDecisionAnswer {\n const matched = question.options.find((option) => containsWholeWord(serializedState, option.key))\n if (matched) {\n return {\n type: 'choice',\n value: matched.key,\n probabilities: question.options.map((option) => ({\n key: option.key,\n probability: option.key === matched.key ? 1 : 0,\n })),\n confidence: 1,\n }\n }\n const uniform = 1 / question.options.length\n return {\n type: 'choice',\n value: question.options[0].key,\n probabilities: question.options.map((option) => ({ key: option.key, probability: uniform })),\n confidence: uniform,\n }\n}\n\nfunction answerScore(question: Extract<DecisionQuestion, { type: 'score' }>): RawDecisionAnswer {\n const uniform = 1 / question.levels.length\n return {\n type: 'score',\n probabilities: question.levels.map((_, level) => ({ level, probability: uniform })),\n confidence: uniform,\n }\n}\n\nfunction answerQuestion(question: DecisionQuestion, serializedState: string): RawDecisionAnswer {\n switch (question.type) {\n case 'choice':\n return answerChoice(question, serializedState)\n case 'score':\n return answerScore(question)\n case 'binary':\n return { type: 'binary', probability: 0.5 }\n }\n}\n\n/**\n * Deterministic, vendor-free decision adapter for tests and local\n * development (spec D9). Registration is gated by the registry, not here.\n */\nexport function createFixtureDecisionAdapter(): DecisionModelAdapter {\n return {\n id: 'fixture',\n isConfigured: () => true,\n async decide(request: DecisionRequest, options): Promise<DecisionResult> {\n if (options.signal.aborted) {\n throw options.signal.reason\n }\n const startedAt = Date.now()\n const serializedState = serializeState(request.state)\n const raw: Record<string, RawDecisionAnswer> = {}\n for (const [questionName, question] of Object.entries(request.questions)) {\n raw[questionName] = answerQuestion(question, serializedState)\n }\n const answers = normalizeDecisionAnswers(request, raw)\n return {\n answers,\n provider: 'fixture',\n model: options.modelId,\n usage: { inputTokens: null, outputTokens: null, costUsd: null },\n durationMs: Date.now() - startedAt,\n }\n },\n }\n}\n"],
|
|
5
|
+
"mappings": "AAMA,SAAS,gCAAwD;AAEjE,SAAS,aAAa,OAAuB;AAC3C,SAAO,MAAM,QAAQ,uBAAuB,MAAM;AACpD;AAEA,SAAS,kBAAkB,UAAkB,MAAuB;AAClE,QAAM,UAAU,IAAI,OAAO,qBAAqB,aAAa,IAAI,CAAC,sBAAsB,GAAG;AAC3F,SAAO,QAAQ,KAAK,QAAQ;AAC9B;AAEA,SAAS,eAAe,OAAwB;AAC9C,SAAO,KAAK,UAAU,KAAK,KAAK;AAClC;AAEA,SAAS,aACP,UACA,iBACmB;AACnB,QAAM,UAAU,SAAS,QAAQ,KAAK,CAAC,WAAW,kBAAkB,iBAAiB,OAAO,GAAG,CAAC;AAChG,MAAI,SAAS;AACX,WAAO;AAAA,MACL,MAAM;AAAA,MACN,OAAO,QAAQ;AAAA,MACf,eAAe,SAAS,QAAQ,IAAI,CAAC,YAAY;AAAA,QAC/C,KAAK,OAAO;AAAA,QACZ,aAAa,OAAO,QAAQ,QAAQ,MAAM,IAAI;AAAA,MAChD,EAAE;AAAA,MACF,YAAY;AAAA,IACd;AAAA,EACF;AACA,QAAM,UAAU,IAAI,SAAS,QAAQ;AACrC,SAAO;AAAA,IACL,MAAM;AAAA,IACN,OAAO,SAAS,QAAQ,CAAC,EAAE;AAAA,IAC3B,eAAe,SAAS,QAAQ,IAAI,CAAC,YAAY,EAAE,KAAK,OAAO,KAAK,aAAa,QAAQ,EAAE;AAAA,IAC3F,YAAY;AAAA,EACd;AACF;AAEA,SAAS,YAAY,UAA2E;AAC9F,QAAM,UAAU,IAAI,SAAS,OAAO;AACpC,SAAO;AAAA,IACL,MAAM;AAAA,IACN,eAAe,SAAS,OAAO,IAAI,CAAC,GAAG,WAAW,EAAE,OAAO,aAAa,QAAQ,EAAE;AAAA,IAClF,YAAY;AAAA,EACd;AACF;AAEA,SAAS,eAAe,UAA4B,iBAA4C;AAC9F,UAAQ,SAAS,MAAM;AAAA,IACrB,KAAK;AACH,aAAO,aAAa,UAAU,eAAe;AAAA,IAC/C,KAAK;AACH,aAAO,YAAY,QAAQ;AAAA,IAC7B,KAAK;AACH,aAAO,EAAE,MAAM,UAAU,aAAa,IAAI;AAAA,EAC9C;AACF;AAMO,SAAS,+BAAqD;AACnE,SAAO;AAAA,IACL,IAAI;AAAA,IACJ,cAAc,MAAM;AAAA,IACpB,MAAM,OAAO,SAA0B,SAAkC;AACvE,UAAI,QAAQ,OAAO,SAAS;AAC1B,cAAM,QAAQ,OAAO;AAAA,MACvB;AACA,YAAM,YAAY,KAAK,IAAI;AAC3B,YAAM,kBAAkB,eAAe,QAAQ,KAAK;AACpD,YAAM,MAAyC,CAAC;AAChD,iBAAW,CAAC,cAAc,QAAQ,KAAK,OAAO,QAAQ,QAAQ,SAAS,GAAG;AACxE,YAAI,YAAY,IAAI,eAAe,UAAU,eAAe;AAAA,MAC9D;AACA,YAAM,UAAU,yBAAyB,SAAS,GAAG;AACrD,aAAO;AAAA,QACL;AAAA,QACA,UAAU;AAAA,QACV,OAAO,QAAQ;AAAA,QACf,OAAO,EAAE,aAAa,MAAM,cAAc,MAAM,SAAS,KAAK;AAAA,QAC9D,YAAY,KAAK,IAAI,IAAI;AAAA,MAC3B;AAAA,IACF;AAAA,EACF;AACF;",
|
|
6
|
+
"names": []
|
|
7
|
+
}
|
|
@@ -0,0 +1,226 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
import { DecisionModelError, normalizeDecisionAnswers } from "../decision-model-schemas.js";
|
|
3
|
+
const DEFAULT_BASE_URL = "https://openrouter.ai/api";
|
|
4
|
+
const API_KEY_ENV = "OPENROUTER_API_KEY";
|
|
5
|
+
const BASE_URL_ENV = "OM_DECISION_OPENROUTER_BASE_URL";
|
|
6
|
+
const VENDOR_ERROR_CODE_PATTERN = /^[A-Za-z0-9_.-]{1,64}$/;
|
|
7
|
+
const wireConfidenceSchema = z.number().nullish();
|
|
8
|
+
const wireChoiceAnswerSchema = z.object({
|
|
9
|
+
choice: z.string(),
|
|
10
|
+
confidence: wireConfidenceSchema,
|
|
11
|
+
probabilities: z.record(z.string(), z.number())
|
|
12
|
+
});
|
|
13
|
+
const wireScoreAnswerSchema = z.object({
|
|
14
|
+
confidence: wireConfidenceSchema,
|
|
15
|
+
probabilities: z.union([z.array(z.number()), z.record(z.string(), z.number())])
|
|
16
|
+
});
|
|
17
|
+
const wireNoulAnswerSchema = z.object({
|
|
18
|
+
noul: z.number()
|
|
19
|
+
});
|
|
20
|
+
const wireResponseSchema = z.object({
|
|
21
|
+
model: z.string().nullish(),
|
|
22
|
+
answers: z.record(z.string(), z.unknown()),
|
|
23
|
+
usage: z.record(z.string(), z.unknown()).nullish()
|
|
24
|
+
});
|
|
25
|
+
function readEnv(env, key) {
|
|
26
|
+
const value = (env ?? process.env)[key];
|
|
27
|
+
if (typeof value !== "string") return void 0;
|
|
28
|
+
const trimmed = value.trim();
|
|
29
|
+
return trimmed.length > 0 ? trimmed : void 0;
|
|
30
|
+
}
|
|
31
|
+
function resolveBaseUrl(env) {
|
|
32
|
+
const configured = readEnv(env, BASE_URL_ENV) ?? DEFAULT_BASE_URL;
|
|
33
|
+
return configured.replace(/\/+$/, "");
|
|
34
|
+
}
|
|
35
|
+
function serializeOrderedObject(entries) {
|
|
36
|
+
return `{${entries.map(([key, serializedValue]) => `${JSON.stringify(key)}:${serializedValue}`).join(",")}}`;
|
|
37
|
+
}
|
|
38
|
+
function serializeCriteria(question) {
|
|
39
|
+
switch (question.type) {
|
|
40
|
+
case "choice":
|
|
41
|
+
return serializeOrderedObject(question.options.map((option) => [option.key, JSON.stringify(option.criteria)]));
|
|
42
|
+
case "score":
|
|
43
|
+
return JSON.stringify(question.levels);
|
|
44
|
+
case "binary":
|
|
45
|
+
return JSON.stringify({ true: question.criteria.true, false: question.criteria.false });
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
function wireQuestionType(question) {
|
|
49
|
+
return question.type === "binary" ? "noul" : question.type;
|
|
50
|
+
}
|
|
51
|
+
function serializeQuestion(question) {
|
|
52
|
+
return serializeOrderedObject([
|
|
53
|
+
["type", JSON.stringify(wireQuestionType(question))],
|
|
54
|
+
["instructions", JSON.stringify(question.instructions)],
|
|
55
|
+
["criteria", serializeCriteria(question)]
|
|
56
|
+
]);
|
|
57
|
+
}
|
|
58
|
+
function serializeRequestBody(request, modelId) {
|
|
59
|
+
const entries = [
|
|
60
|
+
["model", JSON.stringify(modelId)],
|
|
61
|
+
["state", JSON.stringify(request.state) ?? "null"],
|
|
62
|
+
["questions", serializeOrderedObject(
|
|
63
|
+
Object.entries(request.questions).map(([questionName, question]) => [questionName, serializeQuestion(question)])
|
|
64
|
+
)]
|
|
65
|
+
];
|
|
66
|
+
if (request.sessionId !== void 0) {
|
|
67
|
+
entries.push(["session_id", JSON.stringify(request.sessionId)]);
|
|
68
|
+
}
|
|
69
|
+
return serializeOrderedObject(entries);
|
|
70
|
+
}
|
|
71
|
+
function mapHttpStatus(status) {
|
|
72
|
+
switch (status) {
|
|
73
|
+
case 400:
|
|
74
|
+
case 422:
|
|
75
|
+
return "invalid_request";
|
|
76
|
+
case 401:
|
|
77
|
+
case 403:
|
|
78
|
+
return "auth";
|
|
79
|
+
case 402:
|
|
80
|
+
return "insufficient_credits";
|
|
81
|
+
case 404:
|
|
82
|
+
return "unavailable";
|
|
83
|
+
case 413:
|
|
84
|
+
return "payload_too_large";
|
|
85
|
+
case 429:
|
|
86
|
+
return "rate_limited";
|
|
87
|
+
case 524:
|
|
88
|
+
return "timeout";
|
|
89
|
+
default:
|
|
90
|
+
return "provider_error";
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
function parseJson(text) {
|
|
94
|
+
try {
|
|
95
|
+
return JSON.parse(text);
|
|
96
|
+
} catch {
|
|
97
|
+
return void 0;
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
function extractVendorErrorCode(body) {
|
|
101
|
+
if (!body || typeof body !== "object") return void 0;
|
|
102
|
+
const error = body.error;
|
|
103
|
+
if (!error || typeof error !== "object") return void 0;
|
|
104
|
+
const code = error.code;
|
|
105
|
+
const asString = typeof code === "number" ? String(code) : code;
|
|
106
|
+
return typeof asString === "string" && VENDOR_ERROR_CODE_PATTERN.test(asString) ? asString : void 0;
|
|
107
|
+
}
|
|
108
|
+
async function readErrorBodySafely(response) {
|
|
109
|
+
try {
|
|
110
|
+
return parseJson(await response.text());
|
|
111
|
+
} catch {
|
|
112
|
+
return void 0;
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
async function throwHttpError(response) {
|
|
116
|
+
const vendorCode = extractVendorErrorCode(await readErrorBodySafely(response));
|
|
117
|
+
const suffix = vendorCode ? ` (vendor code ${vendorCode})` : "";
|
|
118
|
+
throw new DecisionModelError(
|
|
119
|
+
mapHttpStatus(response.status),
|
|
120
|
+
`OpenRouter decision request failed with HTTP ${response.status}${suffix}`,
|
|
121
|
+
response.status
|
|
122
|
+
);
|
|
123
|
+
}
|
|
124
|
+
function unmappableAnswer(questionName) {
|
|
125
|
+
return new DecisionModelError("invalid_response", `OpenRouter answer "${questionName}" has an unexpected shape`);
|
|
126
|
+
}
|
|
127
|
+
function mapChoiceAnswer(questionName, question, wireAnswer) {
|
|
128
|
+
const parsed = wireChoiceAnswerSchema.safeParse(wireAnswer);
|
|
129
|
+
if (!parsed.success) throw unmappableAnswer(questionName);
|
|
130
|
+
const wireProbabilities = new Map(Object.entries(parsed.data.probabilities));
|
|
131
|
+
const requestKeys = question.options.map((option) => option.key);
|
|
132
|
+
const requestKeySet = new Set(requestKeys);
|
|
133
|
+
const orderedKeys = [
|
|
134
|
+
...requestKeys.filter((key) => wireProbabilities.has(key)),
|
|
135
|
+
...[...wireProbabilities.keys()].filter((key) => !requestKeySet.has(key))
|
|
136
|
+
];
|
|
137
|
+
return {
|
|
138
|
+
type: "choice",
|
|
139
|
+
value: parsed.data.choice,
|
|
140
|
+
probabilities: orderedKeys.map((key) => ({ key, probability: wireProbabilities.get(key) ?? 0 })),
|
|
141
|
+
confidence: parsed.data.confidence ?? null
|
|
142
|
+
};
|
|
143
|
+
}
|
|
144
|
+
function mapScoreAnswer(questionName, wireAnswer) {
|
|
145
|
+
const parsed = wireScoreAnswerSchema.safeParse(wireAnswer);
|
|
146
|
+
if (!parsed.success) throw unmappableAnswer(questionName);
|
|
147
|
+
const wireProbabilities = parsed.data.probabilities;
|
|
148
|
+
const probabilities = Array.isArray(wireProbabilities) ? wireProbabilities.map((probability, level) => ({ level, probability })) : Object.entries(wireProbabilities).map(([levelKey, probability]) => {
|
|
149
|
+
if (!/^\d+$/.test(levelKey)) throw unmappableAnswer(questionName);
|
|
150
|
+
return { level: Number(levelKey), probability };
|
|
151
|
+
});
|
|
152
|
+
return { type: "score", probabilities, confidence: parsed.data.confidence ?? null };
|
|
153
|
+
}
|
|
154
|
+
function mapBinaryAnswer(questionName, wireAnswer) {
|
|
155
|
+
const parsed = wireNoulAnswerSchema.safeParse(wireAnswer);
|
|
156
|
+
if (!parsed.success) throw unmappableAnswer(questionName);
|
|
157
|
+
return { type: "binary", probability: parsed.data.noul };
|
|
158
|
+
}
|
|
159
|
+
function mapWireAnswers(request, wireAnswers) {
|
|
160
|
+
const raw = {};
|
|
161
|
+
for (const [questionName, question] of Object.entries(request.questions)) {
|
|
162
|
+
if (!Object.prototype.hasOwnProperty.call(wireAnswers, questionName)) continue;
|
|
163
|
+
const wireAnswer = wireAnswers[questionName];
|
|
164
|
+
switch (question.type) {
|
|
165
|
+
case "choice":
|
|
166
|
+
raw[questionName] = mapChoiceAnswer(questionName, question, wireAnswer);
|
|
167
|
+
break;
|
|
168
|
+
case "score":
|
|
169
|
+
raw[questionName] = mapScoreAnswer(questionName, wireAnswer);
|
|
170
|
+
break;
|
|
171
|
+
case "binary":
|
|
172
|
+
raw[questionName] = mapBinaryAnswer(questionName, wireAnswer);
|
|
173
|
+
break;
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
return raw;
|
|
177
|
+
}
|
|
178
|
+
function readUsageNumber(usage, key) {
|
|
179
|
+
const value = usage?.[key];
|
|
180
|
+
return typeof value === "number" && Number.isFinite(value) ? value : null;
|
|
181
|
+
}
|
|
182
|
+
function createOpenRouterDecisionAdapter() {
|
|
183
|
+
return {
|
|
184
|
+
id: "openrouter",
|
|
185
|
+
isConfigured: (env) => readEnv(env, API_KEY_ENV) !== void 0,
|
|
186
|
+
async decide(request, options) {
|
|
187
|
+
const apiKey = readEnv(options.env, API_KEY_ENV);
|
|
188
|
+
if (!apiKey) {
|
|
189
|
+
throw new DecisionModelError("unavailable", "OpenRouter decision adapter is not configured");
|
|
190
|
+
}
|
|
191
|
+
const startedAt = Date.now();
|
|
192
|
+
const response = await fetch(`${resolveBaseUrl(options.env)}/alpha/decisions`, {
|
|
193
|
+
method: "POST",
|
|
194
|
+
headers: {
|
|
195
|
+
"Content-Type": "application/json",
|
|
196
|
+
Authorization: `Bearer ${apiKey}`
|
|
197
|
+
},
|
|
198
|
+
body: serializeRequestBody(request, options.modelId),
|
|
199
|
+
signal: options.signal
|
|
200
|
+
});
|
|
201
|
+
if (!response.ok) {
|
|
202
|
+
await throwHttpError(response);
|
|
203
|
+
}
|
|
204
|
+
const parsed = wireResponseSchema.safeParse(parseJson(await response.text()));
|
|
205
|
+
if (!parsed.success) {
|
|
206
|
+
throw new DecisionModelError("provider_error", "OpenRouter decision response body is not valid JSON of the expected shape");
|
|
207
|
+
}
|
|
208
|
+
const answers = normalizeDecisionAnswers(request, mapWireAnswers(request, parsed.data.answers));
|
|
209
|
+
return {
|
|
210
|
+
answers,
|
|
211
|
+
provider: "openrouter",
|
|
212
|
+
model: parsed.data.model ?? options.modelId,
|
|
213
|
+
usage: {
|
|
214
|
+
inputTokens: readUsageNumber(parsed.data.usage, "input_tokens"),
|
|
215
|
+
outputTokens: readUsageNumber(parsed.data.usage, "output_tokens"),
|
|
216
|
+
costUsd: readUsageNumber(parsed.data.usage, "cost")
|
|
217
|
+
},
|
|
218
|
+
durationMs: Date.now() - startedAt
|
|
219
|
+
};
|
|
220
|
+
}
|
|
221
|
+
};
|
|
222
|
+
}
|
|
223
|
+
export {
|
|
224
|
+
createOpenRouterDecisionAdapter
|
|
225
|
+
};
|
|
226
|
+
//# sourceMappingURL=openrouter.js.map
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
{
|
|
2
|
+
"version": 3,
|
|
3
|
+
"sources": ["../../../../../src/modules/ai_assistant/lib/decision-adapters/openrouter.ts"],
|
|
4
|
+
"sourcesContent": ["import { z } from 'zod'\nimport type { EnvLookup } from '@open-mercato/shared/lib/ai/llm-provider'\nimport type {\n DecisionErrorCode,\n DecisionModelAdapter,\n DecisionQuestion,\n DecisionRequest,\n DecisionResult,\n} from '@open-mercato/shared/lib/ai/decision-model'\nimport { DecisionModelError, normalizeDecisionAnswers, type RawDecisionAnswer } from '../decision-model-schemas'\n\nconst DEFAULT_BASE_URL = 'https://openrouter.ai/api'\nconst API_KEY_ENV = 'OPENROUTER_API_KEY'\nconst BASE_URL_ENV = 'OM_DECISION_OPENROUTER_BASE_URL'\nconst VENDOR_ERROR_CODE_PATTERN = /^[A-Za-z0-9_.-]{1,64}$/\n\nconst wireConfidenceSchema = z.number().nullish()\n\nconst wireChoiceAnswerSchema = z.object({\n choice: z.string(),\n confidence: wireConfidenceSchema,\n probabilities: z.record(z.string(), z.number()),\n})\n\nconst wireScoreAnswerSchema = z.object({\n confidence: wireConfidenceSchema,\n probabilities: z.union([z.array(z.number()), z.record(z.string(), z.number())]),\n})\n\nconst wireNoulAnswerSchema = z.object({\n noul: z.number(),\n})\n\nconst wireResponseSchema = z.object({\n model: z.string().nullish(),\n answers: z.record(z.string(), z.unknown()),\n usage: z.record(z.string(), z.unknown()).nullish(),\n})\n\nfunction readEnv(env: EnvLookup | undefined, key: string): string | undefined {\n const value = (env ?? process.env)[key]\n if (typeof value !== 'string') return undefined\n const trimmed = value.trim()\n return trimmed.length > 0 ? trimmed : undefined\n}\n\nfunction resolveBaseUrl(env: EnvLookup | undefined): string {\n const configured = readEnv(env, BASE_URL_ENV) ?? DEFAULT_BASE_URL\n return configured.replace(/\\/+$/, '')\n}\n\nfunction serializeOrderedObject(entries: Array<[string, string]>): string {\n return `{${entries.map(([key, serializedValue]) => `${JSON.stringify(key)}:${serializedValue}`).join(',')}}`\n}\n\nfunction serializeCriteria(question: DecisionQuestion): string {\n switch (question.type) {\n case 'choice':\n return serializeOrderedObject(question.options.map((option) => [option.key, JSON.stringify(option.criteria)]))\n case 'score':\n return JSON.stringify(question.levels)\n case 'binary':\n return JSON.stringify({ true: question.criteria.true, false: question.criteria.false })\n }\n}\n\nfunction wireQuestionType(question: DecisionQuestion): string {\n return question.type === 'binary' ? 'noul' : question.type\n}\n\nfunction serializeQuestion(question: DecisionQuestion): string {\n return serializeOrderedObject([\n ['type', JSON.stringify(wireQuestionType(question))],\n ['instructions', JSON.stringify(question.instructions)],\n ['criteria', serializeCriteria(question)],\n ])\n}\n\nfunction serializeRequestBody(request: DecisionRequest, modelId: string): string {\n const entries: Array<[string, string]> = [\n ['model', JSON.stringify(modelId)],\n ['state', JSON.stringify(request.state) ?? 'null'],\n ['questions', serializeOrderedObject(\n Object.entries(request.questions).map(([questionName, question]) => [questionName, serializeQuestion(question)]),\n )],\n ]\n if (request.sessionId !== undefined) {\n entries.push(['session_id', JSON.stringify(request.sessionId)])\n }\n return serializeOrderedObject(entries)\n}\n\nfunction mapHttpStatus(status: number): DecisionErrorCode {\n switch (status) {\n case 400:\n case 422:\n return 'invalid_request'\n case 401:\n case 403:\n return 'auth'\n case 402:\n return 'insufficient_credits'\n case 404:\n return 'unavailable'\n case 413:\n return 'payload_too_large'\n case 429:\n return 'rate_limited'\n case 524:\n return 'timeout'\n default:\n return 'provider_error'\n }\n}\n\nfunction parseJson(text: string): unknown {\n try {\n return JSON.parse(text)\n } catch {\n return undefined\n }\n}\n\nfunction extractVendorErrorCode(body: unknown): string | undefined {\n if (!body || typeof body !== 'object') return undefined\n const error = (body as { error?: unknown }).error\n if (!error || typeof error !== 'object') return undefined\n const code = (error as { code?: unknown }).code\n const asString = typeof code === 'number' ? String(code) : code\n return typeof asString === 'string' && VENDOR_ERROR_CODE_PATTERN.test(asString) ? asString : undefined\n}\n\nasync function readErrorBodySafely(response: Response): Promise<unknown> {\n try {\n return parseJson(await response.text())\n } catch {\n return undefined\n }\n}\n\nasync function throwHttpError(response: Response): Promise<never> {\n const vendorCode = extractVendorErrorCode(await readErrorBodySafely(response))\n const suffix = vendorCode ? ` (vendor code ${vendorCode})` : ''\n throw new DecisionModelError(\n mapHttpStatus(response.status),\n `OpenRouter decision request failed with HTTP ${response.status}${suffix}`,\n response.status,\n )\n}\n\nfunction unmappableAnswer(questionName: string): DecisionModelError {\n return new DecisionModelError('invalid_response', `OpenRouter answer \"${questionName}\" has an unexpected shape`)\n}\n\nfunction mapChoiceAnswer(\n questionName: string,\n question: Extract<DecisionQuestion, { type: 'choice' }>,\n wireAnswer: unknown,\n): RawDecisionAnswer {\n const parsed = wireChoiceAnswerSchema.safeParse(wireAnswer)\n if (!parsed.success) throw unmappableAnswer(questionName)\n const wireProbabilities = new Map(Object.entries(parsed.data.probabilities))\n const requestKeys = question.options.map((option) => option.key)\n const requestKeySet = new Set(requestKeys)\n const orderedKeys = [\n ...requestKeys.filter((key) => wireProbabilities.has(key)),\n ...[...wireProbabilities.keys()].filter((key) => !requestKeySet.has(key)),\n ]\n return {\n type: 'choice',\n value: parsed.data.choice,\n probabilities: orderedKeys.map((key) => ({ key, probability: wireProbabilities.get(key) ?? 0 })),\n confidence: parsed.data.confidence ?? null,\n }\n}\n\nfunction mapScoreAnswer(questionName: string, wireAnswer: unknown): RawDecisionAnswer {\n const parsed = wireScoreAnswerSchema.safeParse(wireAnswer)\n if (!parsed.success) throw unmappableAnswer(questionName)\n const wireProbabilities = parsed.data.probabilities\n const probabilities = Array.isArray(wireProbabilities)\n ? wireProbabilities.map((probability, level) => ({ level, probability }))\n : Object.entries(wireProbabilities).map(([levelKey, probability]) => {\n if (!/^\\d+$/.test(levelKey)) throw unmappableAnswer(questionName)\n return { level: Number(levelKey), probability }\n })\n return { type: 'score', probabilities, confidence: parsed.data.confidence ?? null }\n}\n\nfunction mapBinaryAnswer(questionName: string, wireAnswer: unknown): RawDecisionAnswer {\n const parsed = wireNoulAnswerSchema.safeParse(wireAnswer)\n if (!parsed.success) throw unmappableAnswer(questionName)\n return { type: 'binary', probability: parsed.data.noul }\n}\n\nfunction mapWireAnswers(request: DecisionRequest, wireAnswers: Record<string, unknown>): Record<string, RawDecisionAnswer> {\n const raw: Record<string, RawDecisionAnswer> = {}\n for (const [questionName, question] of Object.entries(request.questions)) {\n if (!Object.prototype.hasOwnProperty.call(wireAnswers, questionName)) continue\n const wireAnswer = wireAnswers[questionName]\n switch (question.type) {\n case 'choice':\n raw[questionName] = mapChoiceAnswer(questionName, question, wireAnswer)\n break\n case 'score':\n raw[questionName] = mapScoreAnswer(questionName, wireAnswer)\n break\n case 'binary':\n raw[questionName] = mapBinaryAnswer(questionName, wireAnswer)\n break\n }\n }\n return raw\n}\n\nfunction readUsageNumber(usage: Record<string, unknown> | null | undefined, key: string): number | null {\n const value = usage?.[key]\n return typeof value === 'number' && Number.isFinite(value) ? value : null\n}\n\n/**\n * Decision model adapter for the OpenRouter Decisions API\n * (`POST /api/alpha/decisions`). Maps the neutral port to the vendor wire\n * shape and back; validation of the answers is delegated to\n * `normalizeDecisionAnswers`.\n */\nexport function createOpenRouterDecisionAdapter(): DecisionModelAdapter {\n return {\n id: 'openrouter',\n isConfigured: (env?: EnvLookup) => readEnv(env, API_KEY_ENV) !== undefined,\n async decide(request: DecisionRequest, options): Promise<DecisionResult> {\n const apiKey = readEnv(options.env, API_KEY_ENV)\n if (!apiKey) {\n throw new DecisionModelError('unavailable', 'OpenRouter decision adapter is not configured')\n }\n const startedAt = Date.now()\n const response = await fetch(`${resolveBaseUrl(options.env)}/alpha/decisions`, {\n method: 'POST',\n headers: {\n 'Content-Type': 'application/json',\n Authorization: `Bearer ${apiKey}`,\n },\n body: serializeRequestBody(request, options.modelId),\n signal: options.signal,\n })\n if (!response.ok) {\n await throwHttpError(response)\n }\n const parsed = wireResponseSchema.safeParse(parseJson(await response.text()))\n if (!parsed.success) {\n throw new DecisionModelError('provider_error', 'OpenRouter decision response body is not valid JSON of the expected shape')\n }\n const answers = normalizeDecisionAnswers(request, mapWireAnswers(request, parsed.data.answers))\n return {\n answers,\n provider: 'openrouter',\n model: parsed.data.model ?? options.modelId,\n usage: {\n inputTokens: readUsageNumber(parsed.data.usage, 'input_tokens'),\n outputTokens: readUsageNumber(parsed.data.usage, 'output_tokens'),\n costUsd: readUsageNumber(parsed.data.usage, 'cost'),\n },\n durationMs: Date.now() - startedAt,\n }\n },\n }\n}\n"],
|
|
5
|
+
"mappings": "AAAA,SAAS,SAAS;AASlB,SAAS,oBAAoB,gCAAwD;AAErF,MAAM,mBAAmB;AACzB,MAAM,cAAc;AACpB,MAAM,eAAe;AACrB,MAAM,4BAA4B;AAElC,MAAM,uBAAuB,EAAE,OAAO,EAAE,QAAQ;AAEhD,MAAM,yBAAyB,EAAE,OAAO;AAAA,EACtC,QAAQ,EAAE,OAAO;AAAA,EACjB,YAAY;AAAA,EACZ,eAAe,EAAE,OAAO,EAAE,OAAO,GAAG,EAAE,OAAO,CAAC;AAChD,CAAC;AAED,MAAM,wBAAwB,EAAE,OAAO;AAAA,EACrC,YAAY;AAAA,EACZ,eAAe,EAAE,MAAM,CAAC,EAAE,MAAM,EAAE,OAAO,CAAC,GAAG,EAAE,OAAO,EAAE,OAAO,GAAG,EAAE,OAAO,CAAC,CAAC,CAAC;AAChF,CAAC;AAED,MAAM,uBAAuB,EAAE,OAAO;AAAA,EACpC,MAAM,EAAE,OAAO;AACjB,CAAC;AAED,MAAM,qBAAqB,EAAE,OAAO;AAAA,EAClC,OAAO,EAAE,OAAO,EAAE,QAAQ;AAAA,EAC1B,SAAS,EAAE,OAAO,EAAE,OAAO,GAAG,EAAE,QAAQ,CAAC;AAAA,EACzC,OAAO,EAAE,OAAO,EAAE,OAAO,GAAG,EAAE,QAAQ,CAAC,EAAE,QAAQ;AACnD,CAAC;AAED,SAAS,QAAQ,KAA4B,KAAiC;AAC5E,QAAM,SAAS,OAAO,QAAQ,KAAK,GAAG;AACtC,MAAI,OAAO,UAAU,SAAU,QAAO;AACtC,QAAM,UAAU,MAAM,KAAK;AAC3B,SAAO,QAAQ,SAAS,IAAI,UAAU;AACxC;AAEA,SAAS,eAAe,KAAoC;AAC1D,QAAM,aAAa,QAAQ,KAAK,YAAY,KAAK;AACjD,SAAO,WAAW,QAAQ,QAAQ,EAAE;AACtC;AAEA,SAAS,uBAAuB,SAA0C;AACxE,SAAO,IAAI,QAAQ,IAAI,CAAC,CAAC,KAAK,eAAe,MAAM,GAAG,KAAK,UAAU,GAAG,CAAC,IAAI,eAAe,EAAE,EAAE,KAAK,GAAG,CAAC;AAC3G;AAEA,SAAS,kBAAkB,UAAoC;AAC7D,UAAQ,SAAS,MAAM;AAAA,IACrB,KAAK;AACH,aAAO,uBAAuB,SAAS,QAAQ,IAAI,CAAC,WAAW,CAAC,OAAO,KAAK,KAAK,UAAU,OAAO,QAAQ,CAAC,CAAC,CAAC;AAAA,IAC/G,KAAK;AACH,aAAO,KAAK,UAAU,SAAS,MAAM;AAAA,IACvC,KAAK;AACH,aAAO,KAAK,UAAU,EAAE,MAAM,SAAS,SAAS,MAAM,OAAO,SAAS,SAAS,MAAM,CAAC;AAAA,EAC1F;AACF;AAEA,SAAS,iBAAiB,UAAoC;AAC5D,SAAO,SAAS,SAAS,WAAW,SAAS,SAAS;AACxD;AAEA,SAAS,kBAAkB,UAAoC;AAC7D,SAAO,uBAAuB;AAAA,IAC5B,CAAC,QAAQ,KAAK,UAAU,iBAAiB,QAAQ,CAAC,CAAC;AAAA,IACnD,CAAC,gBAAgB,KAAK,UAAU,SAAS,YAAY,CAAC;AAAA,IACtD,CAAC,YAAY,kBAAkB,QAAQ,CAAC;AAAA,EAC1C,CAAC;AACH;AAEA,SAAS,qBAAqB,SAA0B,SAAyB;AAC/E,QAAM,UAAmC;AAAA,IACvC,CAAC,SAAS,KAAK,UAAU,OAAO,CAAC;AAAA,IACjC,CAAC,SAAS,KAAK,UAAU,QAAQ,KAAK,KAAK,MAAM;AAAA,IACjD,CAAC,aAAa;AAAA,MACZ,OAAO,QAAQ,QAAQ,SAAS,EAAE,IAAI,CAAC,CAAC,cAAc,QAAQ,MAAM,CAAC,cAAc,kBAAkB,QAAQ,CAAC,CAAC;AAAA,IACjH,CAAC;AAAA,EACH;AACA,MAAI,QAAQ,cAAc,QAAW;AACnC,YAAQ,KAAK,CAAC,cAAc,KAAK,UAAU,QAAQ,SAAS,CAAC,CAAC;AAAA,EAChE;AACA,SAAO,uBAAuB,OAAO;AACvC;AAEA,SAAS,cAAc,QAAmC;AACxD,UAAQ,QAAQ;AAAA,IACd,KAAK;AAAA,IACL,KAAK;AACH,aAAO;AAAA,IACT,KAAK;AAAA,IACL,KAAK;AACH,aAAO;AAAA,IACT,KAAK;AACH,aAAO;AAAA,IACT,KAAK;AACH,aAAO;AAAA,IACT,KAAK;AACH,aAAO;AAAA,IACT,KAAK;AACH,aAAO;AAAA,IACT,KAAK;AACH,aAAO;AAAA,IACT;AACE,aAAO;AAAA,EACX;AACF;AAEA,SAAS,UAAU,MAAuB;AACxC,MAAI;AACF,WAAO,KAAK,MAAM,IAAI;AAAA,EACxB,QAAQ;AACN,WAAO;AAAA,EACT;AACF;AAEA,SAAS,uBAAuB,MAAmC;AACjE,MAAI,CAAC,QAAQ,OAAO,SAAS,SAAU,QAAO;AAC9C,QAAM,QAAS,KAA6B;AAC5C,MAAI,CAAC,SAAS,OAAO,UAAU,SAAU,QAAO;AAChD,QAAM,OAAQ,MAA6B;AAC3C,QAAM,WAAW,OAAO,SAAS,WAAW,OAAO,IAAI,IAAI;AAC3D,SAAO,OAAO,aAAa,YAAY,0BAA0B,KAAK,QAAQ,IAAI,WAAW;AAC/F;AAEA,eAAe,oBAAoB,UAAsC;AACvE,MAAI;AACF,WAAO,UAAU,MAAM,SAAS,KAAK,CAAC;AAAA,EACxC,QAAQ;AACN,WAAO;AAAA,EACT;AACF;AAEA,eAAe,eAAe,UAAoC;AAChE,QAAM,aAAa,uBAAuB,MAAM,oBAAoB,QAAQ,CAAC;AAC7E,QAAM,SAAS,aAAa,iBAAiB,UAAU,MAAM;AAC7D,QAAM,IAAI;AAAA,IACR,cAAc,SAAS,MAAM;AAAA,IAC7B,gDAAgD,SAAS,MAAM,GAAG,MAAM;AAAA,IACxE,SAAS;AAAA,EACX;AACF;AAEA,SAAS,iBAAiB,cAA0C;AAClE,SAAO,IAAI,mBAAmB,oBAAoB,sBAAsB,YAAY,2BAA2B;AACjH;AAEA,SAAS,gBACP,cACA,UACA,YACmB;AACnB,QAAM,SAAS,uBAAuB,UAAU,UAAU;AAC1D,MAAI,CAAC,OAAO,QAAS,OAAM,iBAAiB,YAAY;AACxD,QAAM,oBAAoB,IAAI,IAAI,OAAO,QAAQ,OAAO,KAAK,aAAa,CAAC;AAC3E,QAAM,cAAc,SAAS,QAAQ,IAAI,CAAC,WAAW,OAAO,GAAG;AAC/D,QAAM,gBAAgB,IAAI,IAAI,WAAW;AACzC,QAAM,cAAc;AAAA,IAClB,GAAG,YAAY,OAAO,CAAC,QAAQ,kBAAkB,IAAI,GAAG,CAAC;AAAA,IACzD,GAAG,CAAC,GAAG,kBAAkB,KAAK,CAAC,EAAE,OAAO,CAAC,QAAQ,CAAC,cAAc,IAAI,GAAG,CAAC;AAAA,EAC1E;AACA,SAAO;AAAA,IACL,MAAM;AAAA,IACN,OAAO,OAAO,KAAK;AAAA,IACnB,eAAe,YAAY,IAAI,CAAC,SAAS,EAAE,KAAK,aAAa,kBAAkB,IAAI,GAAG,KAAK,EAAE,EAAE;AAAA,IAC/F,YAAY,OAAO,KAAK,cAAc;AAAA,EACxC;AACF;AAEA,SAAS,eAAe,cAAsB,YAAwC;AACpF,QAAM,SAAS,sBAAsB,UAAU,UAAU;AACzD,MAAI,CAAC,OAAO,QAAS,OAAM,iBAAiB,YAAY;AACxD,QAAM,oBAAoB,OAAO,KAAK;AACtC,QAAM,gBAAgB,MAAM,QAAQ,iBAAiB,IACjD,kBAAkB,IAAI,CAAC,aAAa,WAAW,EAAE,OAAO,YAAY,EAAE,IACtE,OAAO,QAAQ,iBAAiB,EAAE,IAAI,CAAC,CAAC,UAAU,WAAW,MAAM;AACnE,QAAI,CAAC,QAAQ,KAAK,QAAQ,EAAG,OAAM,iBAAiB,YAAY;AAChE,WAAO,EAAE,OAAO,OAAO,QAAQ,GAAG,YAAY;AAAA,EAChD,CAAC;AACH,SAAO,EAAE,MAAM,SAAS,eAAe,YAAY,OAAO,KAAK,cAAc,KAAK;AACpF;AAEA,SAAS,gBAAgB,cAAsB,YAAwC;AACrF,QAAM,SAAS,qBAAqB,UAAU,UAAU;AACxD,MAAI,CAAC,OAAO,QAAS,OAAM,iBAAiB,YAAY;AACxD,SAAO,EAAE,MAAM,UAAU,aAAa,OAAO,KAAK,KAAK;AACzD;AAEA,SAAS,eAAe,SAA0B,aAAyE;AACzH,QAAM,MAAyC,CAAC;AAChD,aAAW,CAAC,cAAc,QAAQ,KAAK,OAAO,QAAQ,QAAQ,SAAS,GAAG;AACxE,QAAI,CAAC,OAAO,UAAU,eAAe,KAAK,aAAa,YAAY,EAAG;AACtE,UAAM,aAAa,YAAY,YAAY;AAC3C,YAAQ,SAAS,MAAM;AAAA,MACrB,KAAK;AACH,YAAI,YAAY,IAAI,gBAAgB,cAAc,UAAU,UAAU;AACtE;AAAA,MACF,KAAK;AACH,YAAI,YAAY,IAAI,eAAe,cAAc,UAAU;AAC3D;AAAA,MACF,KAAK;AACH,YAAI,YAAY,IAAI,gBAAgB,cAAc,UAAU;AAC5D;AAAA,IACJ;AAAA,EACF;AACA,SAAO;AACT;AAEA,SAAS,gBAAgB,OAAmD,KAA4B;AACtG,QAAM,QAAQ,QAAQ,GAAG;AACzB,SAAO,OAAO,UAAU,YAAY,OAAO,SAAS,KAAK,IAAI,QAAQ;AACvE;AAQO,SAAS,kCAAwD;AACtE,SAAO;AAAA,IACL,IAAI;AAAA,IACJ,cAAc,CAAC,QAAoB,QAAQ,KAAK,WAAW,MAAM;AAAA,IACjE,MAAM,OAAO,SAA0B,SAAkC;AACvE,YAAM,SAAS,QAAQ,QAAQ,KAAK,WAAW;AAC/C,UAAI,CAAC,QAAQ;AACX,cAAM,IAAI,mBAAmB,eAAe,+CAA+C;AAAA,MAC7F;AACA,YAAM,YAAY,KAAK,IAAI;AAC3B,YAAM,WAAW,MAAM,MAAM,GAAG,eAAe,QAAQ,GAAG,CAAC,oBAAoB;AAAA,QAC7E,QAAQ;AAAA,QACR,SAAS;AAAA,UACP,gBAAgB;AAAA,UAChB,eAAe,UAAU,MAAM;AAAA,QACjC;AAAA,QACA,MAAM,qBAAqB,SAAS,QAAQ,OAAO;AAAA,QACnD,QAAQ,QAAQ;AAAA,MAClB,CAAC;AACD,UAAI,CAAC,SAAS,IAAI;AAChB,cAAM,eAAe,QAAQ;AAAA,MAC/B;AACA,YAAM,SAAS,mBAAmB,UAAU,UAAU,MAAM,SAAS,KAAK,CAAC,CAAC;AAC5E,UAAI,CAAC,OAAO,SAAS;AACnB,cAAM,IAAI,mBAAmB,kBAAkB,2EAA2E;AAAA,MAC5H;AACA,YAAM,UAAU,yBAAyB,SAAS,eAAe,SAAS,OAAO,KAAK,OAAO,CAAC;AAC9F,aAAO;AAAA,QACL;AAAA,QACA,UAAU;AAAA,QACV,OAAO,OAAO,KAAK,SAAS,QAAQ;AAAA,QACpC,OAAO;AAAA,UACL,aAAa,gBAAgB,OAAO,KAAK,OAAO,cAAc;AAAA,UAC9D,cAAc,gBAAgB,OAAO,KAAK,OAAO,eAAe;AAAA,UAChE,SAAS,gBAAgB,OAAO,KAAK,OAAO,MAAM;AAAA,QACpD;AAAA,QACA,YAAY,KAAK,IAAI,IAAI;AAAA,MAC3B;AAAA,IACF;AAAA,EACF;AACF;",
|
|
6
|
+
"names": []
|
|
7
|
+
}
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
import { parseBooleanToken } from "@open-mercato/shared/lib/boolean";
|
|
2
|
+
import { createFixtureDecisionAdapter } from "./decision-adapters/fixture.js";
|
|
3
|
+
import { createOpenRouterDecisionAdapter } from "./decision-adapters/openrouter.js";
|
|
4
|
+
const PROVIDER_ENV = "OM_DECISION_PROVIDER";
|
|
5
|
+
const MODEL_ENV = "OM_DECISION_MODEL";
|
|
6
|
+
const FIXTURE_ENABLED_ENV = "OM_DECISION_FIXTURE_ENABLED";
|
|
7
|
+
const FIXTURE_ADAPTER_ID = "fixture";
|
|
8
|
+
function decisionModuleModelEnvKey(moduleId) {
|
|
9
|
+
return `OM_DECISION_${moduleId.toUpperCase().replace(/[^A-Z0-9]/g, "_")}_MODEL`;
|
|
10
|
+
}
|
|
11
|
+
function isDecisionFixtureEnabled(env) {
|
|
12
|
+
return parseBooleanToken(env[FIXTURE_ENABLED_ENV]) === true && env.NODE_ENV !== "production";
|
|
13
|
+
}
|
|
14
|
+
function readTrimmed(env, key) {
|
|
15
|
+
const value = env[key];
|
|
16
|
+
if (typeof value !== "string") return void 0;
|
|
17
|
+
const trimmed = value.trim();
|
|
18
|
+
return trimmed ? trimmed : void 0;
|
|
19
|
+
}
|
|
20
|
+
function normalizeId(id) {
|
|
21
|
+
return id.trim().toLowerCase();
|
|
22
|
+
}
|
|
23
|
+
function createDecisionModelRegistry(input) {
|
|
24
|
+
const creationEnv = input?.env;
|
|
25
|
+
const adapters = /* @__PURE__ */ new Map();
|
|
26
|
+
const registry = {
|
|
27
|
+
register(adapter) {
|
|
28
|
+
adapters.set(normalizeId(adapter.id), adapter);
|
|
29
|
+
},
|
|
30
|
+
get(id) {
|
|
31
|
+
return adapters.get(normalizeId(id));
|
|
32
|
+
},
|
|
33
|
+
list() {
|
|
34
|
+
return Array.from(adapters.values());
|
|
35
|
+
},
|
|
36
|
+
resolve(options) {
|
|
37
|
+
const lookup = options?.env ?? creationEnv ?? process.env;
|
|
38
|
+
const providerId = readTrimmed(lookup, PROVIDER_ENV);
|
|
39
|
+
if (!providerId) return { ok: false, reason: "not_configured" };
|
|
40
|
+
const normalizedProvider = normalizeId(providerId);
|
|
41
|
+
const adapter = adapters.get(normalizedProvider);
|
|
42
|
+
if (!adapter) {
|
|
43
|
+
return {
|
|
44
|
+
ok: false,
|
|
45
|
+
reason: normalizedProvider === FIXTURE_ADAPTER_ID ? "fixture_disabled" : "unknown_provider"
|
|
46
|
+
};
|
|
47
|
+
}
|
|
48
|
+
if (!adapter.isConfigured(lookup)) return { ok: false, reason: "credentials_missing" };
|
|
49
|
+
const moduleModel = options?.moduleId ? readTrimmed(lookup, decisionModuleModelEnvKey(options.moduleId)) : void 0;
|
|
50
|
+
const modelId = moduleModel ?? readTrimmed(lookup, MODEL_ENV);
|
|
51
|
+
if (!modelId) return { ok: false, reason: "model_missing" };
|
|
52
|
+
return { ok: true, adapter, modelId };
|
|
53
|
+
}
|
|
54
|
+
};
|
|
55
|
+
registry.register(createOpenRouterDecisionAdapter());
|
|
56
|
+
if (isDecisionFixtureEnabled(creationEnv ?? process.env)) {
|
|
57
|
+
registry.register(createFixtureDecisionAdapter());
|
|
58
|
+
}
|
|
59
|
+
return registry;
|
|
60
|
+
}
|
|
61
|
+
export {
|
|
62
|
+
createDecisionModelRegistry,
|
|
63
|
+
decisionModuleModelEnvKey,
|
|
64
|
+
isDecisionFixtureEnabled
|
|
65
|
+
};
|
|
66
|
+
//# sourceMappingURL=decision-model-registry.js.map
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
{
|
|
2
|
+
"version": 3,
|
|
3
|
+
"sources": ["../../../../src/modules/ai_assistant/lib/decision-model-registry.ts"],
|
|
4
|
+
"sourcesContent": ["import type { DecisionModelAdapter } from '@open-mercato/shared/lib/ai/decision-model'\nimport type { EnvLookup } from '@open-mercato/shared/lib/ai/llm-provider'\nimport { parseBooleanToken } from '@open-mercato/shared/lib/boolean'\nimport { createFixtureDecisionAdapter } from './decision-adapters/fixture'\nimport { createOpenRouterDecisionAdapter } from './decision-adapters/openrouter'\n\nconst PROVIDER_ENV = 'OM_DECISION_PROVIDER'\nconst MODEL_ENV = 'OM_DECISION_MODEL'\nconst FIXTURE_ENABLED_ENV = 'OM_DECISION_FIXTURE_ENABLED'\nconst FIXTURE_ADAPTER_ID = 'fixture'\n\n/** @experimental Why no decision model adapter could be resolved. */\nexport type DecisionModelUnavailableReason =\n | 'not_configured'\n | 'unknown_provider'\n | 'credentials_missing'\n | 'model_missing'\n | 'fixture_disabled'\n\n/** @experimental Outcome of resolving the active decision model adapter from env. */\nexport type DecisionModelResolution =\n | { ok: true; adapter: DecisionModelAdapter; modelId: string }\n | { ok: false; reason: DecisionModelUnavailableReason }\n\n/** @experimental Pure, instance-scoped registry of decision model adapters. */\nexport interface DecisionModelRegistry {\n /** Registers an adapter. Idempotent by id: re-registering an id replaces the previous entry in place. */\n register(adapter: DecisionModelAdapter): void\n get(id: string): DecisionModelAdapter | undefined\n /** Registered adapters in registration order. */\n list(): DecisionModelAdapter[]\n /**\n * Resolves the adapter and model from `OM_DECISION_PROVIDER`,\n * `OM_DECISION_<MODULE>_MODEL` and `OM_DECISION_MODEL`.\n */\n resolve(input?: { moduleId?: string; env?: EnvLookup }): DecisionModelResolution\n}\n\n/** Env key of the per-module model override, e.g. `workflows` \u2192 `OM_DECISION_WORKFLOWS_MODEL`. */\nexport function decisionModuleModelEnvKey(moduleId: string): string {\n return `OM_DECISION_${moduleId.toUpperCase().replace(/[^A-Z0-9]/g, '_')}_MODEL`\n}\n\n/** True when the fixture adapter is opted in and the process is not running in production. */\nexport function isDecisionFixtureEnabled(env: EnvLookup): boolean {\n return parseBooleanToken(env[FIXTURE_ENABLED_ENV]) === true && env.NODE_ENV !== 'production'\n}\n\nfunction readTrimmed(env: EnvLookup, key: string): string | undefined {\n const value = env[key]\n if (typeof value !== 'string') return undefined\n const trimmed = value.trim()\n return trimmed ? trimmed : undefined\n}\n\nfunction normalizeId(id: string): string {\n return id.trim().toLowerCase()\n}\n\n/**\n * Creates a decision model registry with its built-in adapters registered:\n * `openrouter` always, `fixture` only when `isDecisionFixtureEnabled` holds\n * for the creation env.\n *\n * @experimental\n */\nexport function createDecisionModelRegistry(input?: { env?: EnvLookup }): DecisionModelRegistry {\n const creationEnv = input?.env\n const adapters = new Map<string, DecisionModelAdapter>()\n\n const registry: DecisionModelRegistry = {\n register(adapter) {\n adapters.set(normalizeId(adapter.id), adapter)\n },\n get(id) {\n return adapters.get(normalizeId(id))\n },\n list() {\n return Array.from(adapters.values())\n },\n resolve(options) {\n const lookup = options?.env ?? creationEnv ?? process.env\n const providerId = readTrimmed(lookup, PROVIDER_ENV)\n if (!providerId) return { ok: false, reason: 'not_configured' }\n const normalizedProvider = normalizeId(providerId)\n const adapter = adapters.get(normalizedProvider)\n if (!adapter) {\n return {\n ok: false,\n reason: normalizedProvider === FIXTURE_ADAPTER_ID ? 'fixture_disabled' : 'unknown_provider',\n }\n }\n if (!adapter.isConfigured(lookup)) return { ok: false, reason: 'credentials_missing' }\n const moduleModel = options?.moduleId\n ? readTrimmed(lookup, decisionModuleModelEnvKey(options.moduleId))\n : undefined\n const modelId = moduleModel ?? readTrimmed(lookup, MODEL_ENV)\n if (!modelId) return { ok: false, reason: 'model_missing' }\n return { ok: true, adapter, modelId }\n },\n }\n\n registry.register(createOpenRouterDecisionAdapter())\n if (isDecisionFixtureEnabled(creationEnv ?? process.env)) {\n registry.register(createFixtureDecisionAdapter())\n }\n return registry\n}\n"],
|
|
5
|
+
"mappings": "AAEA,SAAS,yBAAyB;AAClC,SAAS,oCAAoC;AAC7C,SAAS,uCAAuC;AAEhD,MAAM,eAAe;AACrB,MAAM,YAAY;AAClB,MAAM,sBAAsB;AAC5B,MAAM,qBAAqB;AA8BpB,SAAS,0BAA0B,UAA0B;AAClE,SAAO,eAAe,SAAS,YAAY,EAAE,QAAQ,cAAc,GAAG,CAAC;AACzE;AAGO,SAAS,yBAAyB,KAAyB;AAChE,SAAO,kBAAkB,IAAI,mBAAmB,CAAC,MAAM,QAAQ,IAAI,aAAa;AAClF;AAEA,SAAS,YAAY,KAAgB,KAAiC;AACpE,QAAM,QAAQ,IAAI,GAAG;AACrB,MAAI,OAAO,UAAU,SAAU,QAAO;AACtC,QAAM,UAAU,MAAM,KAAK;AAC3B,SAAO,UAAU,UAAU;AAC7B;AAEA,SAAS,YAAY,IAAoB;AACvC,SAAO,GAAG,KAAK,EAAE,YAAY;AAC/B;AASO,SAAS,4BAA4B,OAAoD;AAC9F,QAAM,cAAc,OAAO;AAC3B,QAAM,WAAW,oBAAI,IAAkC;AAEvD,QAAM,WAAkC;AAAA,IACtC,SAAS,SAAS;AAChB,eAAS,IAAI,YAAY,QAAQ,EAAE,GAAG,OAAO;AAAA,IAC/C;AAAA,IACA,IAAI,IAAI;AACN,aAAO,SAAS,IAAI,YAAY,EAAE,CAAC;AAAA,IACrC;AAAA,IACA,OAAO;AACL,aAAO,MAAM,KAAK,SAAS,OAAO,CAAC;AAAA,IACrC;AAAA,IACA,QAAQ,SAAS;AACf,YAAM,SAAS,SAAS,OAAO,eAAe,QAAQ;AACtD,YAAM,aAAa,YAAY,QAAQ,YAAY;AACnD,UAAI,CAAC,WAAY,QAAO,EAAE,IAAI,OAAO,QAAQ,iBAAiB;AAC9D,YAAM,qBAAqB,YAAY,UAAU;AACjD,YAAM,UAAU,SAAS,IAAI,kBAAkB;AAC/C,UAAI,CAAC,SAAS;AACZ,eAAO;AAAA,UACL,IAAI;AAAA,UACJ,QAAQ,uBAAuB,qBAAqB,qBAAqB;AAAA,QAC3E;AAAA,MACF;AACA,UAAI,CAAC,QAAQ,aAAa,MAAM,EAAG,QAAO,EAAE,IAAI,OAAO,QAAQ,sBAAsB;AACrF,YAAM,cAAc,SAAS,WACzB,YAAY,QAAQ,0BAA0B,QAAQ,QAAQ,CAAC,IAC/D;AACJ,YAAM,UAAU,eAAe,YAAY,QAAQ,SAAS;AAC5D,UAAI,CAAC,QAAS,QAAO,EAAE,IAAI,OAAO,QAAQ,gBAAgB;AAC1D,aAAO,EAAE,IAAI,MAAM,SAAS,QAAQ;AAAA,IACtC;AAAA,EACF;AAEA,WAAS,SAAS,gCAAgC,CAAC;AACnD,MAAI,yBAAyB,eAAe,QAAQ,GAAG,GAAG;AACxD,aAAS,SAAS,6BAA6B,CAAC;AAAA,EAClD;AACA,SAAO;AACT;",
|
|
6
|
+
"names": []
|
|
7
|
+
}
|
|
@@ -0,0 +1,159 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
import { createLogger } from "@open-mercato/shared/lib/logger";
|
|
3
|
+
const decisionKeySchema = z.string().regex(/^[A-Za-z0-9_-]{1,64}$/).refine((key) => key !== "__proto__", {
|
|
4
|
+
message: "Reserved key"
|
|
5
|
+
});
|
|
6
|
+
const decisionChoiceQuestionSchema = z.object({
|
|
7
|
+
type: z.literal("choice"),
|
|
8
|
+
instructions: z.string().min(1).max(4e3),
|
|
9
|
+
options: z.array(z.object({ key: decisionKeySchema, criteria: z.string().min(1).max(500) })).min(2).max(64).refine((options) => new Set(options.map((option) => option.key)).size === options.length, {
|
|
10
|
+
message: "Option keys must be unique"
|
|
11
|
+
})
|
|
12
|
+
});
|
|
13
|
+
const decisionScoreQuestionSchema = z.object({
|
|
14
|
+
type: z.literal("score"),
|
|
15
|
+
instructions: z.string().min(1).max(4e3),
|
|
16
|
+
levels: z.array(z.string().min(1).max(500)).min(2).max(16)
|
|
17
|
+
});
|
|
18
|
+
const decisionBinaryQuestionSchema = z.object({
|
|
19
|
+
type: z.literal("binary"),
|
|
20
|
+
instructions: z.string().min(1).max(4e3),
|
|
21
|
+
criteria: z.object({ true: z.string().min(1).max(500), false: z.string().min(1).max(500) })
|
|
22
|
+
});
|
|
23
|
+
const decisionQuestionSchema = z.discriminatedUnion("type", [
|
|
24
|
+
decisionChoiceQuestionSchema,
|
|
25
|
+
decisionScoreQuestionSchema,
|
|
26
|
+
decisionBinaryQuestionSchema
|
|
27
|
+
]);
|
|
28
|
+
const decisionRequestSchema = z.object({
|
|
29
|
+
state: z.unknown(),
|
|
30
|
+
questions: z.unknown().refine((raw) => !(typeof raw === "object" && raw !== null && Object.prototype.hasOwnProperty.call(raw, "__proto__")), {
|
|
31
|
+
message: "Reserved question name"
|
|
32
|
+
}).pipe(z.record(decisionKeySchema, decisionQuestionSchema)).refine((questions) => Object.keys(questions).length >= 1 && Object.keys(questions).length <= 8, {
|
|
33
|
+
message: "Between 1 and 8 questions are required"
|
|
34
|
+
}),
|
|
35
|
+
sessionId: z.string().max(256).optional()
|
|
36
|
+
});
|
|
37
|
+
class DecisionModelError extends Error {
|
|
38
|
+
constructor(code, message, httpStatus) {
|
|
39
|
+
super(`[internal] ${message}`);
|
|
40
|
+
this.name = "DecisionModelError";
|
|
41
|
+
this.code = code;
|
|
42
|
+
this.httpStatus = httpStatus;
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
const PROBABILITY_SUM_TOLERANCE = 0.02;
|
|
46
|
+
const EXACT_SUM_EPSILON = 1e-9;
|
|
47
|
+
const logger = createLogger("ai_assistant").child({ component: "decision-model" });
|
|
48
|
+
function logNormalized(questionName, sum) {
|
|
49
|
+
try {
|
|
50
|
+
logger.debug("Normalized decision probabilities", { questionName, sum });
|
|
51
|
+
} catch {
|
|
52
|
+
return;
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
const probabilitySchema = z.number().finite().min(0).max(1);
|
|
56
|
+
const rawChoiceAnswerSchema = z.object({
|
|
57
|
+
type: z.literal("choice"),
|
|
58
|
+
value: z.string(),
|
|
59
|
+
probabilities: z.array(z.object({ key: z.string(), probability: probabilitySchema })),
|
|
60
|
+
confidence: probabilitySchema.nullable()
|
|
61
|
+
});
|
|
62
|
+
const rawScoreAnswerSchema = z.object({
|
|
63
|
+
type: z.literal("score"),
|
|
64
|
+
probabilities: z.array(z.object({ level: z.number().int(), probability: probabilitySchema })),
|
|
65
|
+
confidence: probabilitySchema.nullable()
|
|
66
|
+
});
|
|
67
|
+
const rawBinaryAnswerSchema = z.object({
|
|
68
|
+
type: z.literal("binary"),
|
|
69
|
+
probability: probabilitySchema
|
|
70
|
+
});
|
|
71
|
+
const rawDecisionAnswerSchema = z.discriminatedUnion("type", [
|
|
72
|
+
rawChoiceAnswerSchema,
|
|
73
|
+
rawScoreAnswerSchema,
|
|
74
|
+
rawBinaryAnswerSchema
|
|
75
|
+
]);
|
|
76
|
+
function invalidResponse(questionName, reason) {
|
|
77
|
+
return new DecisionModelError("invalid_response", `Decision answer "${questionName}" ${reason}`);
|
|
78
|
+
}
|
|
79
|
+
function normalizeDistribution(questionName, probabilities) {
|
|
80
|
+
const sum = probabilities.reduce((total, probability) => total + probability, 0);
|
|
81
|
+
if (Math.abs(sum - 1) > PROBABILITY_SUM_TOLERANCE) {
|
|
82
|
+
throw invalidResponse(questionName, "has probabilities that do not sum to 1");
|
|
83
|
+
}
|
|
84
|
+
if (Math.abs(sum - 1) > EXACT_SUM_EPSILON) logNormalized(questionName, sum);
|
|
85
|
+
return probabilities.map((probability) => probability / sum);
|
|
86
|
+
}
|
|
87
|
+
function argmax(values) {
|
|
88
|
+
return values.reduce((bestIndex, value, index) => value > values[bestIndex] ? index : bestIndex, 0);
|
|
89
|
+
}
|
|
90
|
+
function normalizeChoice(questionName, question, answer) {
|
|
91
|
+
const byKey = new Map(answer.probabilities.map((entry) => [entry.key, entry.probability]));
|
|
92
|
+
const requestKeys = question.options.map((option) => option.key);
|
|
93
|
+
if (byKey.size !== answer.probabilities.length || byKey.size !== requestKeys.length || requestKeys.some((key) => !byKey.has(key))) {
|
|
94
|
+
throw invalidResponse(questionName, "does not cover exactly the requested options");
|
|
95
|
+
}
|
|
96
|
+
if (!byKey.has(answer.value)) {
|
|
97
|
+
throw invalidResponse(questionName, "selected an option that was not requested");
|
|
98
|
+
}
|
|
99
|
+
const normalized = normalizeDistribution(questionName, requestKeys.map((key) => byKey.get(key) ?? 0));
|
|
100
|
+
return {
|
|
101
|
+
type: "choice",
|
|
102
|
+
value: answer.value,
|
|
103
|
+
probabilities: requestKeys.map((key, index) => ({ key, probability: normalized[index] })),
|
|
104
|
+
confidence: answer.confidence
|
|
105
|
+
};
|
|
106
|
+
}
|
|
107
|
+
function normalizeScore(questionName, question, answer) {
|
|
108
|
+
const byLevel = new Map(answer.probabilities.map((entry) => [entry.level, entry.probability]));
|
|
109
|
+
const levelCount = question.levels.length;
|
|
110
|
+
const levelIndexes = question.levels.map((_, index) => index);
|
|
111
|
+
if (byLevel.size !== answer.probabilities.length || byLevel.size !== levelCount || levelIndexes.some((level) => !byLevel.has(level))) {
|
|
112
|
+
throw invalidResponse(questionName, "does not cover exactly the requested levels");
|
|
113
|
+
}
|
|
114
|
+
const normalized = normalizeDistribution(questionName, levelIndexes.map((level) => byLevel.get(level) ?? 0));
|
|
115
|
+
const value = normalized.reduce((total, probability, level) => total + probability * level, 0);
|
|
116
|
+
return {
|
|
117
|
+
type: "score",
|
|
118
|
+
value,
|
|
119
|
+
level: argmax(normalized),
|
|
120
|
+
probabilities: levelIndexes.map((level) => ({ level, probability: normalized[level] })),
|
|
121
|
+
confidence: answer.confidence
|
|
122
|
+
};
|
|
123
|
+
}
|
|
124
|
+
function normalizeDecisionAnswers(request, rawAnswers) {
|
|
125
|
+
const answers = {};
|
|
126
|
+
for (const [questionName, question] of Object.entries(request.questions)) {
|
|
127
|
+
if (!Object.prototype.hasOwnProperty.call(rawAnswers, questionName)) {
|
|
128
|
+
throw invalidResponse(questionName, "is missing");
|
|
129
|
+
}
|
|
130
|
+
const parsed = rawDecisionAnswerSchema.safeParse(rawAnswers[questionName]);
|
|
131
|
+
if (!parsed.success) {
|
|
132
|
+
throw invalidResponse(questionName, "has an invalid shape");
|
|
133
|
+
}
|
|
134
|
+
const answer = parsed.data;
|
|
135
|
+
if (answer.type !== question.type) {
|
|
136
|
+
throw invalidResponse(questionName, "has a type that does not match the question");
|
|
137
|
+
}
|
|
138
|
+
if (answer.type === "choice" && question.type === "choice") {
|
|
139
|
+
answers[questionName] = normalizeChoice(questionName, question, answer);
|
|
140
|
+
} else if (answer.type === "score" && question.type === "score") {
|
|
141
|
+
answers[questionName] = normalizeScore(questionName, question, answer);
|
|
142
|
+
} else if (answer.type === "binary") {
|
|
143
|
+
answers[questionName] = { type: "binary", probability: answer.probability };
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
return answers;
|
|
147
|
+
}
|
|
148
|
+
export {
|
|
149
|
+
DecisionModelError,
|
|
150
|
+
PROBABILITY_SUM_TOLERANCE,
|
|
151
|
+
decisionBinaryQuestionSchema,
|
|
152
|
+
decisionChoiceQuestionSchema,
|
|
153
|
+
decisionQuestionSchema,
|
|
154
|
+
decisionRequestSchema,
|
|
155
|
+
decisionScoreQuestionSchema,
|
|
156
|
+
normalizeDecisionAnswers,
|
|
157
|
+
rawDecisionAnswerSchema
|
|
158
|
+
};
|
|
159
|
+
//# sourceMappingURL=decision-model-schemas.js.map
|