jsfe 0.9.86 → 0.9.87

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 CHANGED
@@ -310,6 +310,28 @@ async function aiCallback(systemInstruction, userMessage, jsonSchema, request) {
310
310
  run its text model alongside the classifier (for example, for free-text parameters). A callback that
311
311
  ignores the 4th argument behaves exactly as before; every other call still receives three arguments.
312
312
 
313
+ **Host helpers — `jsfe/host`:**
314
+ Pure functions for the host side of `aiCallback`, so a production host and a local test harness
315
+ share one implementation. Loading `jsfe/host` never loads the engine.
316
+
317
+ ```javascript
318
+ import { buildAiRequest, isDetectFlowRequest, toFlowRequest, fromFlowAnswers } from 'jsfe/host';
319
+
320
+ // An OpenAI-compatible chat-completions body from a model description. It picks the right
321
+ // response_format for each kind of jsonSchema argument, and never sends `response_format: null`.
322
+ const body = buildAiRequest(
323
+ { name: 'gpt-4o-mini', reservedReplySize: 1024, supportsSchema: true },
324
+ systemInstruction, userMessage, jsonSchema);
325
+ ```
326
+
327
+ - `buildAiRequest(model, systemInstruction, userMessage, jsonSchema)`, `parseSchemaArg`, `schemaName`.
328
+ `model` fields: `name`, `reservedReplySize`, and the optional `tokenParam`, `supportsSchema`,
329
+ `fixedSampling` and `extraParams`.
330
+ - `isDetectFlowRequest`, `toFlowRequest` / `fromFlowAnswers` (which flow), `toParameterRequest` /
331
+ `fromParameterAnswers` (its enum and boolean parameters), `freeTextParameters`,
332
+ `mergeTextParameters`, `INTENT_YES_THRESHOLD`: these map the structured intent-detection
333
+ request to TypeSafe System One and back. The host sends the requests.
334
+
313
335
  **Alternative AI Services:**
314
336
  You can integrate any AI service (Claude, Gemini, local LLMs, etc.) by implementing this same interface. The engine only requires a function that takes system instructions and user input, then returns an AI response.
315
337
 
@@ -0,0 +1,10 @@
1
+ /**
2
+ * jsfe/host — pure helpers for the HOST side of aiCallback, so a production host and a local
3
+ * test harness share one implementation instead of copying it. Zero runtime imports: loading
4
+ * `jsfe/host` never loads the engine.
5
+ *
6
+ * import { buildAiRequest, isDetectFlowRequest, toFlowRequest } from 'jsfe/host';
7
+ */
8
+ export * from './request.js';
9
+ export * from './systemone-intent.js';
10
+ //# sourceMappingURL=index.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../src/host/index.ts"],"names":[],"mappings":"AAAA;;;;;;GAMG;AACH,cAAc,cAAc,CAAC;AAC7B,cAAc,uBAAuB,CAAC"}
@@ -0,0 +1,9 @@
1
+ /**
2
+ * jsfe/host — pure helpers for the HOST side of aiCallback, so a production host and a local
3
+ * test harness share one implementation instead of copying it. Zero runtime imports: loading
4
+ * `jsfe/host` never loads the engine.
5
+ *
6
+ * import { buildAiRequest, isDetectFlowRequest, toFlowRequest } from 'jsfe/host';
7
+ */
8
+ export * from './request.js';
9
+ export * from './systemone-intent.js';
@@ -0,0 +1,57 @@
1
+ /**
2
+ * aiCallback request shaping for an OpenAI-compatible host — pure, zero imports.
3
+ *
4
+ * jsfe calls the host's aiCallback(systemInstruction, userMessage, jsonSchema, request?). These
5
+ * helpers turn that call into a chat-completions request body, so a production host and a local
6
+ * test harness build byte-identical requests from one implementation.
7
+ */
8
+ /**
9
+ * The request-relevant description of one model. Every field but `name` is optional:
10
+ * tokenParam — 'max_completion_tokens' for models that reject `max_tokens` (GPT-5 family).
11
+ * reservedReplySize — the reply's token cap.
12
+ * supportsSchema — the model honours a json_schema `response_format`.
13
+ * fixedSampling — the model rejects any non-default temperature/top_p/penalties.
14
+ * extraParams — raw body pass-through (e.g. `reasoning_effort`, `thinking`).
15
+ */
16
+ export interface HostModel {
17
+ name: string;
18
+ tokenParam?: string;
19
+ reservedReplySize?: number;
20
+ supportsSchema?: boolean;
21
+ fixedSampling?: boolean;
22
+ extraParams?: Record<string, unknown>;
23
+ }
24
+ export interface ResponseFormatWrapper {
25
+ type?: string;
26
+ json_schema?: {
27
+ name?: string;
28
+ strict?: boolean;
29
+ schema?: unknown;
30
+ };
31
+ [key: string]: unknown;
32
+ }
33
+ /**
34
+ * Parse aiCallback's `jsonSchema` argument, which is NOT always JSON. jsfe passes:
35
+ * - a JSON `response_format` wrapper, e.g. {type:'json_schema', json_schema:{name:'detect_flow',…}}
36
+ * - a PLAIN-TEXT schema description ("city: string (required) - …") on the tool-argument path
37
+ * - nothing at all (voice cleanup, language detection)
38
+ * Returns null for the last two.
39
+ */
40
+ export declare function parseSchemaArg(jsonSchema: unknown): ResponseFormatWrapper | null;
41
+ /** The schema's name, or null. `detect_flow` and `intent_analysis` are the two jsfe uses. */
42
+ export declare function schemaName(jsonSchema: unknown): string | null;
43
+ /**
44
+ * Build the chat-completions request body for one model.
45
+ *
46
+ * `response_format` rules:
47
+ * - detect_flow: omitted. Its schema carries dynamic per-flow parameters that json mode
48
+ * rejects; the host still has the schema to validate the answer.
49
+ * - a response_format wrapper + a model that supports schemas: sent as-is.
50
+ * - any other JSON schema, or a model without schema support: `{type:'json_object'}`.
51
+ * - NOT parseable as JSON (the plain-text description): omitted entirely — never
52
+ * `response_format: null`, whose handling is undefined across vendors.
53
+ *
54
+ * Sampling is deterministic (temperature 0, a fixed seed) unless the model is `fixedSampling`.
55
+ */
56
+ export declare function buildAiRequest(model: HostModel, systemInstruction: string, userMessage: string, jsonSchema?: unknown): Record<string, unknown>;
57
+ //# sourceMappingURL=request.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"request.d.ts","sourceRoot":"","sources":["../../src/host/request.ts"],"names":[],"mappings":"AAAA;;;;;;GAMG;AAEH;;;;;;;GAOG;AACH,MAAM,WAAW,SAAS;IACxB,IAAI,EAAE,MAAM,CAAC;IACb,UAAU,CAAC,EAAE,MAAM,CAAC;IACpB,iBAAiB,CAAC,EAAE,MAAM,CAAC;IAC3B,cAAc,CAAC,EAAE,OAAO,CAAC;IACzB,aAAa,CAAC,EAAE,OAAO,CAAC;IACxB,WAAW,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAC;CACvC;AAED,MAAM,WAAW,qBAAqB;IACpC,IAAI,CAAC,EAAE,MAAM,CAAC;IACd,WAAW,CAAC,EAAE;QAAE,IAAI,CAAC,EAAE,MAAM,CAAC;QAAC,MAAM,CAAC,EAAE,OAAO,CAAC;QAAC,MAAM,CAAC,EAAE,OAAO,CAAA;KAAE,CAAC;IACpE,CAAC,GAAG,EAAE,MAAM,GAAG,OAAO,CAAC;CACxB;AAED;;;;;;GAMG;AACH,wBAAgB,cAAc,CAAC,UAAU,EAAE,OAAO,GAAG,qBAAqB,GAAG,IAAI,CAYhF;AAED,6FAA6F;AAC7F,wBAAgB,UAAU,CAAC,UAAU,EAAE,OAAO,GAAG,MAAM,GAAG,IAAI,CAE7D;AAED;;;;;;;;;;;;GAYG;AACH,wBAAgB,cAAc,CAC5B,KAAK,EAAE,SAAS,EAChB,iBAAiB,EAAE,MAAM,EACzB,WAAW,EAAE,MAAM,EACnB,UAAU,CAAC,EAAE,OAAO,GACnB,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CA6BzB"}
@@ -0,0 +1,75 @@
1
+ /**
2
+ * aiCallback request shaping for an OpenAI-compatible host — pure, zero imports.
3
+ *
4
+ * jsfe calls the host's aiCallback(systemInstruction, userMessage, jsonSchema, request?). These
5
+ * helpers turn that call into a chat-completions request body, so a production host and a local
6
+ * test harness build byte-identical requests from one implementation.
7
+ */
8
+ /**
9
+ * Parse aiCallback's `jsonSchema` argument, which is NOT always JSON. jsfe passes:
10
+ * - a JSON `response_format` wrapper, e.g. {type:'json_schema', json_schema:{name:'detect_flow',…}}
11
+ * - a PLAIN-TEXT schema description ("city: string (required) - …") on the tool-argument path
12
+ * - nothing at all (voice cleanup, language detection)
13
+ * Returns null for the last two.
14
+ */
15
+ export function parseSchemaArg(jsonSchema) {
16
+ if (!jsonSchema) {
17
+ return null;
18
+ }
19
+ else {
20
+ // a schema argument was supplied — it may still not be JSON
21
+ }
22
+ try {
23
+ const parsed = JSON.parse(String(jsonSchema));
24
+ return parsed && typeof parsed === 'object' ? parsed : null;
25
+ }
26
+ catch {
27
+ return null; // plain-text schema description — an instruction for the prompt, not a response_format
28
+ }
29
+ }
30
+ /** The schema's name, or null. `detect_flow` and `intent_analysis` are the two jsfe uses. */
31
+ export function schemaName(jsonSchema) {
32
+ return parseSchemaArg(jsonSchema)?.json_schema?.name ?? null;
33
+ }
34
+ /**
35
+ * Build the chat-completions request body for one model.
36
+ *
37
+ * `response_format` rules:
38
+ * - detect_flow: omitted. Its schema carries dynamic per-flow parameters that json mode
39
+ * rejects; the host still has the schema to validate the answer.
40
+ * - a response_format wrapper + a model that supports schemas: sent as-is.
41
+ * - any other JSON schema, or a model without schema support: `{type:'json_object'}`.
42
+ * - NOT parseable as JSON (the plain-text description): omitted entirely — never
43
+ * `response_format: null`, whose handling is undefined across vendors.
44
+ *
45
+ * Sampling is deterministic (temperature 0, a fixed seed) unless the model is `fixedSampling`.
46
+ */
47
+ export function buildAiRequest(model, systemInstruction, userMessage, jsonSchema) {
48
+ const parsedSchema = parseSchemaArg(jsonSchema);
49
+ const useResponseFormat = parsedSchema !== null && parsedSchema.json_schema?.name !== 'detect_flow';
50
+ const tokenParam = model.tokenParam || 'max_tokens';
51
+ return {
52
+ messages: [
53
+ { role: 'system', content: systemInstruction },
54
+ { role: 'user', content: userMessage },
55
+ ],
56
+ model: model.name,
57
+ [tokenParam]: model.reservedReplySize,
58
+ stream: false,
59
+ ...(model.extraParams || {}),
60
+ ...(model.fixedSampling ? {} : {
61
+ top_p: 0.000000001,
62
+ seed: 1024,
63
+ temperature: 0,
64
+ n: 1,
65
+ presence_penalty: 0,
66
+ frequency_penalty: 0,
67
+ logit_bias: {},
68
+ }),
69
+ ...(useResponseFormat
70
+ ? (model.supportsSchema && parsedSchema.json_schema
71
+ ? { response_format: parsedSchema }
72
+ : { response_format: { type: 'json_object' } })
73
+ : {}),
74
+ };
75
+ }
@@ -0,0 +1,108 @@
1
+ /**
2
+ * Intent detection (`detect_flow`) ⇄ TypeSafe System One — mapping only, zero imports.
3
+ *
4
+ * jsfe hands aiCallback a 4th argument on intent detection: the call in structured form,
5
+ * `{ task: 'detect_flow', input, conversation, flows }`, each flow with its parameters and any
6
+ * declared `enum`. That is what a classifier needs, so this module maps it to System One questions
7
+ * and the answers back to the reply jsfe expects: `{ flowName, parameters }`. Two stages:
8
+ *
9
+ * 1. one noul per flow: "should `latest_message` start this workflow?" — argmax, if it clears
10
+ * INTENT_YES_THRESHOLD, else "None".
11
+ * 2. only for the chosen flow, and only if it declares one: a noul per ENUM value and per
12
+ * BOOLEAN parameter.
13
+ * Every other parameter (free text: a city, an account number…) is not the classifier's; the
14
+ * host decides whether to take it from a text model — see freeTextParameters().
15
+ *
16
+ * The host sends the bodies (POST …/v1/systemone) and owns the HTTP, the key and the timeouts.
17
+ *
18
+ * A question that comes back without a usable answer FAILS the call, so the host can fall back
19
+ * to a text model. A missing flow answer read as "no" would silently send the turn nowhere.
20
+ */
21
+ import type { DetectFlowRequest } from '../index.js';
22
+ /** One flow entry of the structured intent-detection request. */
23
+ export type DetectFlowRequestFlow = DetectFlowRequest['flows'][number];
24
+ /** A noul at or above this is a yes. */
25
+ export declare const INTENT_YES_THRESHOLD = 0.5;
26
+ /** One System One request body plus the index that maps each question id back to what it asks. */
27
+ export interface SystemOneCall<I> {
28
+ body: {
29
+ model: string;
30
+ state: Record<string, unknown>;
31
+ questions: Record<string, unknown>;
32
+ };
33
+ index: I[];
34
+ }
35
+ export interface FlowQuestion {
36
+ id: string;
37
+ flow: number;
38
+ }
39
+ export type ParameterQuestion = {
40
+ id: string;
41
+ kind: 'enum';
42
+ param: string;
43
+ value: string;
44
+ } | {
45
+ id: string;
46
+ kind: 'boolean';
47
+ param: string;
48
+ };
49
+ /** System One's answers, keyed by question id. */
50
+ export type SystemOneAnswers = Record<string, {
51
+ noul?: unknown;
52
+ } | undefined> | undefined;
53
+ export interface IntentReply {
54
+ flowName: string;
55
+ parameters: Record<string, unknown>;
56
+ }
57
+ export interface MapOptions {
58
+ model: string;
59
+ assistantInstructions: string;
60
+ }
61
+ /** Is this a jsfe intent-detection request this module can map? */
62
+ export declare function isDetectFlowRequest(request: unknown): request is DetectFlowRequest;
63
+ /** Parameters of `flow` the classifier does not answer: neither an enum nor a boolean. */
64
+ export declare function freeTextParameters(flow: DetectFlowRequestFlow | null | undefined): string[];
65
+ /**
66
+ * STAGE 1 — which flow. One noul per flow. `assistantInstructions` is jsfe's own task + rules (the
67
+ * text prompt's system instruction without its JSON-schema tail), so a classifier and a text model
68
+ * judge by one rule set.
69
+ */
70
+ export declare function toFlowRequest(request: DetectFlowRequest, { model, assistantInstructions }: MapOptions): SystemOneCall<FlowQuestion>;
71
+ /**
72
+ * Decodes stage 1. Throws if any question is unanswered. Returns `{ flow, scores }` — `flow` is the
73
+ * chosen request entry, or null for "None"; `scores` the top three, for logging.
74
+ */
75
+ export declare function fromFlowAnswers(answers: SystemOneAnswers, index: FlowQuestion[], request: DetectFlowRequest, threshold?: number): {
76
+ flow: DetectFlowRequestFlow | null;
77
+ scores: {
78
+ name: string;
79
+ p: number;
80
+ }[];
81
+ };
82
+ /** Does `flow` declare anything stage 2 answers (an enum or a boolean)? */
83
+ export declare function hasJevParameters(flow: DetectFlowRequestFlow | null | undefined): boolean;
84
+ /**
85
+ * STAGE 2 — the chosen flow's enum and boolean parameters: one noul per enum value, one per
86
+ * boolean. Each parameter's description is its rule. Free text is not asked.
87
+ *
88
+ * The flow and the rules are stated ONCE, in `assistant_instructions`; each question names its
89
+ * parameter only. Repeating a long rule in every question of its enum multiplies the body size
90
+ * and the latency for identical answers.
91
+ */
92
+ export declare function toParameterRequest(request: DetectFlowRequest, flow: DetectFlowRequestFlow, { model, assistantInstructions }: MapOptions): SystemOneCall<ParameterQuestion>;
93
+ /**
94
+ * Decodes stage 2 into parameters: an enum takes its argmax value if it clears the cut, a boolean is
95
+ * true if it clears it; otherwise the parameter is OMITTED (the flow asks / its default applies),
96
+ * never set false. Throws if any question is unanswered.
97
+ */
98
+ export declare function fromParameterAnswers(answers: SystemOneAnswers, index: ParameterQuestion[], threshold?: number): Record<string, unknown>;
99
+ /**
100
+ * Merges a text model's free-text parameters into the classifier's reply — only when the text model
101
+ * chose the SAME flow (its parameters were extracted for its own choice), and only the parameters
102
+ * the classifier does not answer. The classifier's enum/boolean values always win.
103
+ */
104
+ export declare function mergeTextParameters(jevReply: IntentReply, jevFlow: DetectFlowRequestFlow | null | undefined, textReply: {
105
+ flowName?: unknown;
106
+ parameters?: Record<string, unknown>;
107
+ } | null | undefined): IntentReply;
108
+ //# sourceMappingURL=systemone-intent.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"systemone-intent.d.ts","sourceRoot":"","sources":["../../src/host/systemone-intent.ts"],"names":[],"mappings":"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"}
@@ -0,0 +1,188 @@
1
+ /**
2
+ * Intent detection (`detect_flow`) ⇄ TypeSafe System One — mapping only, zero imports.
3
+ *
4
+ * jsfe hands aiCallback a 4th argument on intent detection: the call in structured form,
5
+ * `{ task: 'detect_flow', input, conversation, flows }`, each flow with its parameters and any
6
+ * declared `enum`. That is what a classifier needs, so this module maps it to System One questions
7
+ * and the answers back to the reply jsfe expects: `{ flowName, parameters }`. Two stages:
8
+ *
9
+ * 1. one noul per flow: "should `latest_message` start this workflow?" — argmax, if it clears
10
+ * INTENT_YES_THRESHOLD, else "None".
11
+ * 2. only for the chosen flow, and only if it declares one: a noul per ENUM value and per
12
+ * BOOLEAN parameter.
13
+ * Every other parameter (free text: a city, an account number…) is not the classifier's; the
14
+ * host decides whether to take it from a text model — see freeTextParameters().
15
+ *
16
+ * The host sends the bodies (POST …/v1/systemone) and owns the HTTP, the key and the timeouts.
17
+ *
18
+ * A question that comes back without a usable answer FAILS the call, so the host can fall back
19
+ * to a text model. A missing flow answer read as "no" would silently send the turn nowhere.
20
+ */
21
+ /** A noul at or above this is a yes. */
22
+ export const INTENT_YES_THRESHOLD = 0.5;
23
+ const FLOW_QUESTION = 'Should `latest_message` start the workflow described in `flow`?';
24
+ const FLOW_CRITERIA = {
25
+ true: "`latest_message`, read with `conversation`, is what `flow`'s description says should trigger it: the user asks for its action or information, or raises the problem it exists to handle.",
26
+ false: "It only shares a topic with `flow`, is something `flow`'s description does not cover, or is a greeting, acknowledgement or fragment that asks for nothing. A question about an available balance or credit is not a request to pay, and a complaint about a charge is not a request to pay.",
27
+ };
28
+ const ENUM_CRITERIA = (value) => ({
29
+ true: `The rules for \`parameter\` make \`${value}\` the correct value for what the customer said.`,
30
+ false: 'Another value, or none, is correct; or the customer has not said enough to tell.',
31
+ });
32
+ const BOOLEAN_CRITERIA = {
33
+ true: 'The rules for `parameter` make it true for what the customer said.',
34
+ false: 'The rules do not make it true, or the customer has not said enough to tell.',
35
+ };
36
+ const isEnum = (p) => Array.isArray(p.enum) && p.enum.length > 0;
37
+ const isBoolean = (p) => p.type === 'boolean';
38
+ /** Is this a jsfe intent-detection request this module can map? */
39
+ export function isDetectFlowRequest(request) {
40
+ const r = request;
41
+ return !!r && r.task === 'detect_flow' && Array.isArray(r.flows) && r.flows.length > 0;
42
+ }
43
+ /** Parameters of `flow` the classifier does not answer: neither an enum nor a boolean. */
44
+ export function freeTextParameters(flow) {
45
+ return (flow?.parameters || []).filter(p => !isEnum(p) && !isBoolean(p)).map(p => p.name);
46
+ }
47
+ const stateOf = (request, assistantInstructions) => ({
48
+ assistant_instructions: assistantInstructions,
49
+ conversation: (request.conversation || []).map(t => ({ role: t.role, content: t.content })),
50
+ latest_message: request.input,
51
+ });
52
+ function answersOf(answers, index) {
53
+ const p = new Map();
54
+ for (const q of index) {
55
+ const noul = Number(answers?.[q.id]?.noul);
56
+ if (!Number.isFinite(noul)) {
57
+ throw new Error(`System One intent: question ${q.id} unanswered`);
58
+ }
59
+ else {
60
+ p.set(q.id, noul);
61
+ }
62
+ }
63
+ return p;
64
+ }
65
+ /**
66
+ * STAGE 1 — which flow. One noul per flow. `assistantInstructions` is jsfe's own task + rules (the
67
+ * text prompt's system instruction without its JSON-schema tail), so a classifier and a text model
68
+ * judge by one rule set.
69
+ */
70
+ export function toFlowRequest(request, { model, assistantInstructions }) {
71
+ const questions = {};
72
+ const index = [];
73
+ request.flows.forEach((flow, f) => {
74
+ const id = `f${f}`;
75
+ questions[id] = {
76
+ type: 'noul', criteria: FLOW_CRITERIA,
77
+ instructions: { flow: { name: flow.name, description: String(flow.description ?? '') }, question: FLOW_QUESTION },
78
+ };
79
+ index.push({ id, flow: f });
80
+ });
81
+ return { body: { model, state: stateOf(request, assistantInstructions), questions }, index };
82
+ }
83
+ /**
84
+ * Decodes stage 1. Throws if any question is unanswered. Returns `{ flow, scores }` — `flow` is the
85
+ * chosen request entry, or null for "None"; `scores` the top three, for logging.
86
+ */
87
+ export function fromFlowAnswers(answers, index, request, threshold = INTENT_YES_THRESHOLD) {
88
+ const p = answersOf(answers, index);
89
+ const ranked = index
90
+ .map(q => ({ name: request.flows[q.flow].name, f: q.flow, p: p.get(q.id) }))
91
+ .sort((a, b) => b.p - a.p);
92
+ const scores = ranked.slice(0, 3).map(({ name, p: prob }) => ({ name, p: prob }));
93
+ return { flow: ranked[0] && ranked[0].p >= threshold ? request.flows[ranked[0].f] : null, scores };
94
+ }
95
+ /** Does `flow` declare anything stage 2 answers (an enum or a boolean)? */
96
+ export function hasJevParameters(flow) {
97
+ return (flow?.parameters || []).some(p => isEnum(p) || isBoolean(p));
98
+ }
99
+ /**
100
+ * STAGE 2 — the chosen flow's enum and boolean parameters: one noul per enum value, one per
101
+ * boolean. Each parameter's description is its rule. Free text is not asked.
102
+ *
103
+ * The flow and the rules are stated ONCE, in `assistant_instructions`; each question names its
104
+ * parameter only. Repeating a long rule in every question of its enum multiplies the body size
105
+ * and the latency for identical answers.
106
+ */
107
+ export function toParameterRequest(request, flow, { model, assistantInstructions }) {
108
+ const questions = {};
109
+ const index = [];
110
+ const flowRef = { name: flow.name };
111
+ const rules = {};
112
+ (flow.parameters || []).forEach((p, k) => {
113
+ const parameter = { name: p.name };
114
+ if (isEnum(p)) {
115
+ p.enum.forEach((value, v) => {
116
+ const id = `p${k}v${v}`;
117
+ questions[id] = {
118
+ type: 'noul', criteria: ENUM_CRITERIA(value), instructions: {
119
+ flow: flowRef, parameter, candidate_value: value,
120
+ question: `Is \`${value}\` the correct value of \`${p.name}\` for \`latest_message\`?`,
121
+ },
122
+ };
123
+ index.push({ id, kind: 'enum', param: p.name, value });
124
+ });
125
+ }
126
+ else if (isBoolean(p)) {
127
+ const id = `p${k}`;
128
+ questions[id] = {
129
+ type: 'noul', criteria: BOOLEAN_CRITERIA, instructions: {
130
+ flow: flowRef, parameter, question: `Should \`${p.name}\` be true for \`latest_message\`?`,
131
+ },
132
+ };
133
+ index.push({ id, kind: 'boolean', param: p.name });
134
+ }
135
+ else {
136
+ return; // free text — not the classifier's; see freeTextParameters()
137
+ }
138
+ rules[p.name] = String(p.description ?? '');
139
+ });
140
+ const instructions = `${assistantInstructions}\n\nThe workflow \`${flow.name}\` starts for \`latest_message\`: ${String(flow.description ?? '')}\n\n` +
141
+ `Each question asks for one of its parameters. The rules for each parameter, by name:\n${JSON.stringify(rules, null, 1)}`;
142
+ return { body: { model, state: stateOf(request, instructions), questions }, index };
143
+ }
144
+ /**
145
+ * Decodes stage 2 into parameters: an enum takes its argmax value if it clears the cut, a boolean is
146
+ * true if it clears it; otherwise the parameter is OMITTED (the flow asks / its default applies),
147
+ * never set false. Throws if any question is unanswered.
148
+ */
149
+ export function fromParameterAnswers(answers, index, threshold = INTENT_YES_THRESHOLD) {
150
+ const p = answersOf(answers, index);
151
+ const parameters = {};
152
+ const best = new Map();
153
+ for (const q of index) {
154
+ const prob = p.get(q.id);
155
+ if (q.kind === 'boolean') {
156
+ if (prob >= threshold)
157
+ parameters[q.param] = true;
158
+ else { /* omitted */ }
159
+ }
160
+ else {
161
+ const cur = best.get(q.param);
162
+ if (!cur || prob > cur.p)
163
+ best.set(q.param, { value: q.value, p: prob });
164
+ else { /* lower */ }
165
+ }
166
+ }
167
+ for (const [param, { value, p: prob }] of best) {
168
+ if (prob >= threshold)
169
+ parameters[param] = value;
170
+ else { /* omitted: the flow asks */ }
171
+ }
172
+ return parameters;
173
+ }
174
+ /**
175
+ * Merges a text model's free-text parameters into the classifier's reply — only when the text model
176
+ * chose the SAME flow (its parameters were extracted for its own choice), and only the parameters
177
+ * the classifier does not answer. The classifier's enum/boolean values always win.
178
+ */
179
+ export function mergeTextParameters(jevReply, jevFlow, textReply) {
180
+ if (!jevFlow || !textReply || String(textReply.flowName).toLowerCase() !== jevFlow.name.toLowerCase()) {
181
+ return jevReply;
182
+ }
183
+ else {
184
+ const free = new Set(freeTextParameters(jevFlow));
185
+ const extra = Object.fromEntries(Object.entries(textReply.parameters || {}).filter(([k]) => free.has(k)));
186
+ return { flowName: jevReply.flowName, parameters: { ...extra, ...jevReply.parameters } };
187
+ }
188
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "jsfe",
3
- "version": "0.9.86",
3
+ "version": "0.9.87",
4
4
  "description": "TypeScript workflow engine for conversational/automation apps: multi-flow orchestration, interrupt/resume, stack-based control, robust errors, and flexible tool/REST integration.",
5
5
  "license": "MIT",
6
6
  "author": "Ron Pinkas",
@@ -14,6 +14,10 @@
14
14
  ".": {
15
15
  "types": "./dist/index.d.ts",
16
16
  "import": "./dist/index.js"
17
+ },
18
+ "./host": {
19
+ "types": "./dist/host/index.d.ts",
20
+ "import": "./dist/host/index.js"
17
21
  }
18
22
  },
19
23
  "main": "./dist/index.js",