@gobing-ai/ts-decision-fm 0.5.3
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/LICENSE +176 -0
- package/NOTICE +5 -0
- package/README.md +149 -0
- package/dist/driver.d.ts +48 -0
- package/dist/driver.d.ts.map +1 -0
- package/dist/driver.js +105 -0
- package/dist/estimate.d.ts +26 -0
- package/dist/estimate.d.ts.map +1 -0
- package/dist/estimate.js +90 -0
- package/dist/fm-process.d.ts +52 -0
- package/dist/fm-process.d.ts.map +1 -0
- package/dist/fm-process.js +137 -0
- package/dist/index.d.ts +11 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +10 -0
- package/dist/schema.d.ts +43 -0
- package/dist/schema.d.ts.map +1 -0
- package/dist/schema.js +104 -0
- package/package.json +63 -0
- package/src/driver.ts +206 -0
- package/src/estimate.ts +97 -0
- package/src/fm-process.ts +187 -0
- package/src/index.ts +21 -0
- package/src/schema.ts +120 -0
package/src/estimate.ts
ADDED
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Probability estimation from repeated samples (task 0084 R6, ADR-030,
|
|
3
|
+
* design § Probability estimation). Every probability is an empirical sample
|
|
4
|
+
* frequency — never a self-reported confidence — so the resolution limit is
|
|
5
|
+
* 1/k and `confidence` measures how much the k samples agreed, not calibration.
|
|
6
|
+
*/
|
|
7
|
+
import type { ChoiceAnswer, Desc, NoulAnswer, ScoreAnswer } from '@gobing-ai/ts-ai-runner';
|
|
8
|
+
/**
|
|
9
|
+
* Laya reference `confidence_from_probs` on the empirical distribution:
|
|
10
|
+
* `1 − H(p) / ln(n)` over the n declared options. One-hot ⇒ 1, uniform ⇒ 0.
|
|
11
|
+
*/
|
|
12
|
+
export function entropyConfidence(probabilities: number[]): number {
|
|
13
|
+
const n = probabilities.length;
|
|
14
|
+
if (n <= 1) return 1;
|
|
15
|
+
let entropy = 0;
|
|
16
|
+
for (const p of probabilities) {
|
|
17
|
+
if (p > 0) entropy -= p * Math.log(p);
|
|
18
|
+
}
|
|
19
|
+
return 1 - entropy / Math.log(n);
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Choice frequencies over every supplied label (unsampled ⇒ 0). The answer
|
|
24
|
+
* label is the most frequent with ties broken by label declaration order.
|
|
25
|
+
*/
|
|
26
|
+
export function estimateChoice(labels: readonly string[], samples: readonly string[]): ChoiceAnswer<string> {
|
|
27
|
+
const counts = new Map<string, number>(labels.map((label) => [label, 0]));
|
|
28
|
+
for (const sample of samples) counts.set(sample, (counts.get(sample) ?? 0) + 1);
|
|
29
|
+
const k = samples.length;
|
|
30
|
+
const probabilities: Record<string, number> = {};
|
|
31
|
+
const distribution: number[] = [];
|
|
32
|
+
let bestLabel = labels[0] ?? '';
|
|
33
|
+
let bestCount = -1;
|
|
34
|
+
for (const label of labels) {
|
|
35
|
+
const count = counts.get(label) ?? 0;
|
|
36
|
+
const probability = count / k;
|
|
37
|
+
distribution.push(probability);
|
|
38
|
+
probabilities[label] = probability;
|
|
39
|
+
// Declaration order wins ties: strict `>` keeps the earlier label.
|
|
40
|
+
if (count > bestCount) {
|
|
41
|
+
bestCount = count;
|
|
42
|
+
bestLabel = label;
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
return {
|
|
46
|
+
kind: 'choice',
|
|
47
|
+
label: bestLabel,
|
|
48
|
+
confidence: entropyConfidence(distribution),
|
|
49
|
+
probabilities,
|
|
50
|
+
};
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* Score frequencies over the level indices `0…n-1`. The answer score is the
|
|
55
|
+
* most frequent level with ties going to the lower level; the legend maps each
|
|
56
|
+
* index back to its rubric description.
|
|
57
|
+
*/
|
|
58
|
+
export function estimateScore(rubric: readonly Desc[], samples: readonly string[]): ScoreAnswer {
|
|
59
|
+
const k = samples.length;
|
|
60
|
+
const counts: number[] = [];
|
|
61
|
+
for (const sample of samples) {
|
|
62
|
+
const level = Number.parseInt(sample, 10);
|
|
63
|
+
counts[level] = (counts[level] ?? 0) + 1;
|
|
64
|
+
}
|
|
65
|
+
const probabilities: Record<number, number> = {};
|
|
66
|
+
const distribution: number[] = [];
|
|
67
|
+
let bestLevel = 0;
|
|
68
|
+
let bestCount = -1;
|
|
69
|
+
for (let level = 0; level < rubric.length; level++) {
|
|
70
|
+
const count = counts[level] ?? 0;
|
|
71
|
+
const probability = count / k;
|
|
72
|
+
distribution.push(probability);
|
|
73
|
+
probabilities[level] = probability;
|
|
74
|
+
// Lower level wins ties: strict `>` keeps the smaller index.
|
|
75
|
+
if (count > bestCount) {
|
|
76
|
+
bestCount = count;
|
|
77
|
+
bestLevel = level;
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
const legend: Record<number, Desc> = {};
|
|
81
|
+
rubric.forEach((desc, level) => {
|
|
82
|
+
legend[level] = desc;
|
|
83
|
+
});
|
|
84
|
+
return {
|
|
85
|
+
kind: 'score',
|
|
86
|
+
score: bestLevel,
|
|
87
|
+
confidence: entropyConfidence(distribution),
|
|
88
|
+
legend,
|
|
89
|
+
probabilities,
|
|
90
|
+
};
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/** Noul is a bare yes-probability — no `confidence` key, by contract. */
|
|
94
|
+
export function estimateNoul(samples: readonly string[]): NoulAnswer {
|
|
95
|
+
const yes = samples.filter((sample) => sample === 'yes').length;
|
|
96
|
+
return { kind: 'noul', probability: yes / samples.length };
|
|
97
|
+
}
|
|
@@ -0,0 +1,187 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The one-shot `fm` process bridge (task 0084 R4, R5, R7, R8; design § Errors).
|
|
3
|
+
* Argv builders plus run/error mapping. Spawned exclusively through the
|
|
4
|
+
* injected ts-runtime {@link ProcessExecutor} — never `node:child_process`.
|
|
5
|
+
* Prompt and instructions travel as positional arguments because
|
|
6
|
+
* `ProcessExecutor.run` has no stdin input; fm's argv limit is far above the
|
|
7
|
+
* token budget, and the pre-flight `count-tokens` check keeps it bounded.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import {
|
|
11
|
+
DecisionBackendError,
|
|
12
|
+
DecisionConfigError,
|
|
13
|
+
DecisionRequestError,
|
|
14
|
+
DecisionTimeoutError,
|
|
15
|
+
} from '@gobing-ai/ts-ai-runner';
|
|
16
|
+
import type { ProcessExecutor, ProcessResult } from '@gobing-ai/ts-runtime';
|
|
17
|
+
|
|
18
|
+
/** `--guardrails` levels accepted by `fm respond` (design: fm CLI facts). */
|
|
19
|
+
export type FmGuardrails = 'default' | 'permissive-content-transformations';
|
|
20
|
+
|
|
21
|
+
/** Subcommand + flag order verified against the design's live observations. */
|
|
22
|
+
export function countTokensArgv(instructions: string, prompt: string): string[] {
|
|
23
|
+
return ['count-tokens', '-q', '-i', instructions, prompt];
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/** Builds the `fm respond` argv: schema flag, `-i` instructions, positional prompt, optional `-g`/`--guardrails`. */
|
|
27
|
+
export function respondArgv(options: {
|
|
28
|
+
schemaPath: string;
|
|
29
|
+
instructions: string;
|
|
30
|
+
prompt: string;
|
|
31
|
+
/** Deterministic mode: single greedy sample. */
|
|
32
|
+
greedy?: boolean;
|
|
33
|
+
/** Omitted ⇒ the flag is omitted (fm's `default`). */
|
|
34
|
+
guardrails?: FmGuardrails;
|
|
35
|
+
}): string[] {
|
|
36
|
+
return [
|
|
37
|
+
'respond',
|
|
38
|
+
'--no-stream',
|
|
39
|
+
'--schema',
|
|
40
|
+
options.schemaPath,
|
|
41
|
+
'-i',
|
|
42
|
+
options.instructions,
|
|
43
|
+
...(options.guardrails !== undefined ? ['--guardrails', options.guardrails] : []),
|
|
44
|
+
...(options.greedy ? ['-g'] : []),
|
|
45
|
+
options.prompt,
|
|
46
|
+
];
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/** Bare `fm available` always exits 0 — only `--model system` is a real probe. */
|
|
50
|
+
export function availableArgv(): string[] {
|
|
51
|
+
return ['available', '--model', 'system'];
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/** The executor reports a failed spawn (ENOENT, …) as an `error` outcome. */
|
|
55
|
+
function isSpawnFailure(result: ProcessResult): boolean {
|
|
56
|
+
return result.outcome === 'error' && result.exitCode === null;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/** fm's own text, preferring stderr, for carrying into error messages. */
|
|
60
|
+
function fmText(result: ProcessResult): string {
|
|
61
|
+
const text = (result.stderr.trim().length > 0 ? result.stderr : result.stdout).trim();
|
|
62
|
+
return text.length > 0 ? text : '(no output)';
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* R8 prerequisite probe: `fm available --model system`. A spawn failure maps to
|
|
67
|
+
* `DecisionConfigError` "fm not found"; a non-zero exit carries fm's printed
|
|
68
|
+
* reason (e.g. `modelNotReady`). An exit 0 caches availability in the driver.
|
|
69
|
+
*/
|
|
70
|
+
export async function probeFmAvailability(executor: ProcessExecutor, fmPath: string): Promise<void> {
|
|
71
|
+
const result = await executor.run({ command: fmPath, args: availableArgv() });
|
|
72
|
+
if (isSpawnFailure(result)) {
|
|
73
|
+
throw new DecisionConfigError(`fm not found: '${fmPath}' could not be spawned`, 'fmPath');
|
|
74
|
+
}
|
|
75
|
+
if (result.exitCode !== 0) {
|
|
76
|
+
throw new DecisionConfigError(`fm system model unavailable: ${fmText(result)}`, 'fm-system-model');
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* R4 pre-flight: `fm count-tokens -q -i <instructions> <prompt>` prints a bare
|
|
82
|
+
* integer. Non-zero exit or unparseable output is a `DecisionBackendError` —
|
|
83
|
+
* the budget comparison itself happens in the driver.
|
|
84
|
+
*/
|
|
85
|
+
export async function countPromptTokens(
|
|
86
|
+
executor: ProcessExecutor,
|
|
87
|
+
fmPath: string,
|
|
88
|
+
instructions: string,
|
|
89
|
+
prompt: string,
|
|
90
|
+
): Promise<number> {
|
|
91
|
+
const result = await executor.run({ command: fmPath, args: countTokensArgv(instructions, prompt) });
|
|
92
|
+
if (isSpawnFailure(result)) {
|
|
93
|
+
throw new DecisionConfigError(`fm not found: '${fmPath}' could not be spawned`, 'fmPath');
|
|
94
|
+
}
|
|
95
|
+
if (result.exitCode !== 0) {
|
|
96
|
+
throw new DecisionBackendError(
|
|
97
|
+
`fm count-tokens failed (exit ${result.exitCode}): ${fmText(result)}`,
|
|
98
|
+
undefined,
|
|
99
|
+
);
|
|
100
|
+
}
|
|
101
|
+
const tokens = Number.parseInt(result.stdout.trim(), 10);
|
|
102
|
+
if (!Number.isFinite(tokens)) {
|
|
103
|
+
throw new DecisionBackendError(
|
|
104
|
+
`fm count-tokens did not print a token count: ${result.stdout.trim() || '(empty)'}`,
|
|
105
|
+
undefined,
|
|
106
|
+
);
|
|
107
|
+
}
|
|
108
|
+
return tokens;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/** Stable substrings of fm's failure text (design § Errors discrimination). */
|
|
112
|
+
const CONTEXT_SIZE_MARKER = "exceeded the model's context size";
|
|
113
|
+
const GUARDRAILS_MARKER = 'safety guardrails were triggered';
|
|
114
|
+
|
|
115
|
+
/**
|
|
116
|
+
* One `fm respond` sample: run with the per-sample timeout, map every failure
|
|
117
|
+
* row of the error table, return stdout on success (parsing happens in
|
|
118
|
+
* {@link parseFmRespond}).
|
|
119
|
+
*/
|
|
120
|
+
export async function runFmRespond(
|
|
121
|
+
executor: ProcessExecutor,
|
|
122
|
+
fmPath: string,
|
|
123
|
+
args: string[],
|
|
124
|
+
requestTimeoutMs: number,
|
|
125
|
+
): Promise<string> {
|
|
126
|
+
const result = await executor.run({ command: fmPath, args, timeout: requestTimeoutMs });
|
|
127
|
+
if (result.outcome === 'timeout') {
|
|
128
|
+
throw new DecisionTimeoutError(
|
|
129
|
+
`fm respond exceeded requestTimeoutMs (${requestTimeoutMs}): ${fmPath} ${args.join(' ')}`,
|
|
130
|
+
requestTimeoutMs,
|
|
131
|
+
);
|
|
132
|
+
}
|
|
133
|
+
if (isSpawnFailure(result)) {
|
|
134
|
+
throw new DecisionConfigError(`fm not found: '${fmPath}' could not be spawned`, 'fmPath');
|
|
135
|
+
}
|
|
136
|
+
if (result.exitCode !== 0) {
|
|
137
|
+
const text = fmText(result);
|
|
138
|
+
if (text.includes(CONTEXT_SIZE_MARKER)) {
|
|
139
|
+
throw new DecisionRequestError(`fm prompt exceeded the model context size: ${text}`, undefined, undefined);
|
|
140
|
+
}
|
|
141
|
+
if (text.includes(GUARDRAILS_MARKER)) {
|
|
142
|
+
// Typed failure, never an answer, and the ask is not retried.
|
|
143
|
+
throw new DecisionBackendError(`fm safety guardrails were triggered: ${text}`, undefined);
|
|
144
|
+
}
|
|
145
|
+
throw new DecisionBackendError(`fm respond failed (exit ${result.exitCode}): ${text}`, undefined);
|
|
146
|
+
}
|
|
147
|
+
return result.stdout;
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
/**
|
|
151
|
+
* Parse one sample's stdout as the schema-conformant JSON object. fm prints one
|
|
152
|
+
* JSON object per call, but the key order varies — callers read by key, never
|
|
153
|
+
* by position. Non-JSON or non-object output is a `DecisionBackendError`.
|
|
154
|
+
*/
|
|
155
|
+
export function parseFmRespond(stdout: string): Record<string, string> {
|
|
156
|
+
let parsed: unknown;
|
|
157
|
+
try {
|
|
158
|
+
parsed = JSON.parse(stdout);
|
|
159
|
+
} catch (cause) {
|
|
160
|
+
throw new DecisionBackendError(`fm respond printed non-JSON output: ${stdout.trim() || '(empty)'}`, undefined, {
|
|
161
|
+
cause,
|
|
162
|
+
});
|
|
163
|
+
}
|
|
164
|
+
if (parsed === null || typeof parsed !== 'object' || Array.isArray(parsed)) {
|
|
165
|
+
throw new DecisionBackendError(`fm respond printed non-object JSON: ${stdout.trim()}`, undefined);
|
|
166
|
+
}
|
|
167
|
+
const record: Record<string, string> = {};
|
|
168
|
+
for (const [key, value] of Object.entries(parsed)) {
|
|
169
|
+
if (typeof value !== 'string') {
|
|
170
|
+
throw new DecisionBackendError(`fm respond printed a non-string value for '${key}'`, undefined);
|
|
171
|
+
}
|
|
172
|
+
record[key] = value;
|
|
173
|
+
}
|
|
174
|
+
return record;
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
/** A parsed value outside the declared enum is a schema violation, never an answer (R7). */
|
|
178
|
+
export function requireEnumValue(parsed: Record<string, string>, key: string, allowed: readonly string[]): string {
|
|
179
|
+
const value = parsed[key];
|
|
180
|
+
if (value === undefined || !allowed.includes(value)) {
|
|
181
|
+
throw new DecisionBackendError(
|
|
182
|
+
`fm sample for '${key}' violates the schema enum: ${value === undefined ? '(missing)' : `'${value}'`}`,
|
|
183
|
+
undefined,
|
|
184
|
+
);
|
|
185
|
+
}
|
|
186
|
+
return value;
|
|
187
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @gobing-ai/ts-decision-fm — Apple on-device Foundation Model decision backend
|
|
3
|
+
* driving the macOS `fm` CLI over a one-shot process bridge (ADR-029).
|
|
4
|
+
* Implements the {@link DecisionDriver} interface for `@gobing-ai/ts-ai-runner`;
|
|
5
|
+
* probabilities are empirical sample frequencies (ADR-030).
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
export { createFmDriver, type FmDecisionDriver, type FmDriverOptions } from './driver';
|
|
9
|
+
export { entropyConfidence, estimateChoice, estimateNoul, estimateScore } from './estimate';
|
|
10
|
+
export {
|
|
11
|
+
availableArgv,
|
|
12
|
+
countPromptTokens,
|
|
13
|
+
countTokensArgv,
|
|
14
|
+
type FmGuardrails,
|
|
15
|
+
parseFmRespond,
|
|
16
|
+
probeFmAvailability,
|
|
17
|
+
requireEnumValue,
|
|
18
|
+
respondArgv,
|
|
19
|
+
runFmRespond,
|
|
20
|
+
} from './fm-process';
|
|
21
|
+
export { buildFmSchema, buildPrompt, declaredOptions, FM_INSTRUCTIONS, type FmSchema, formatDesc } from './schema';
|
package/src/schema.ts
ADDED
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* fm request construction (task 0084 R3, design § Request construction):
|
|
3
|
+
* the JSON schema passed to `fm respond --schema` and the instruction/prompt
|
|
4
|
+
* texts passed via `-i` and as the final positional argument.
|
|
5
|
+
*
|
|
6
|
+
* The schema always carries `x-order` — a hand-written object schema without it
|
|
7
|
+
* is rejected by `fm` ("The data couldn't be read because it is missing").
|
|
8
|
+
* Score levels are string enums so every question uses the same constrained
|
|
9
|
+
* string path; the driver converts them back to numbers.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
import type { DecisionState, Desc, Question } from '@gobing-ai/ts-ai-runner';
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Fixed `-i` instructions. It never asks for confidence, certainty or a
|
|
16
|
+
* probability — self-reported confidence would be fabricated calibration,
|
|
17
|
+
* which the decision contract forbids (ADR-030).
|
|
18
|
+
*/
|
|
19
|
+
export const FM_INSTRUCTIONS =
|
|
20
|
+
'You are a decision engine. Answer every question below using only the supplied state. ' +
|
|
21
|
+
'For each question choose exactly one of the listed options. ' +
|
|
22
|
+
'Reply with the JSON object the schema requires.';
|
|
23
|
+
|
|
24
|
+
/** The JSON schema object handed to `fm respond --schema` (R3). */
|
|
25
|
+
export interface FmSchema {
|
|
26
|
+
type: 'object';
|
|
27
|
+
title: string;
|
|
28
|
+
'x-order': string[];
|
|
29
|
+
required: string[];
|
|
30
|
+
additionalProperties: false;
|
|
31
|
+
properties: Record<string, { type: 'string'; enum: string[] }>;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/** Build the fm schema over the whole question map: one call answers every key. */
|
|
35
|
+
export function buildFmSchema(questions: Record<string, Question>): FmSchema {
|
|
36
|
+
const properties: Record<string, { type: 'string'; enum: string[] }> = {};
|
|
37
|
+
for (const [key, question] of Object.entries(questions)) {
|
|
38
|
+
switch (question.kind) {
|
|
39
|
+
case 'choice':
|
|
40
|
+
properties[key] = { type: 'string', enum: Object.keys(question.labels) };
|
|
41
|
+
break;
|
|
42
|
+
case 'score':
|
|
43
|
+
properties[key] = { type: 'string', enum: question.rubric.map((_, level) => String(level)) };
|
|
44
|
+
break;
|
|
45
|
+
case 'noul':
|
|
46
|
+
properties[key] = { type: 'string', enum: ['yes', 'no'] };
|
|
47
|
+
break;
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
const keys = Object.keys(questions);
|
|
51
|
+
return {
|
|
52
|
+
type: 'object',
|
|
53
|
+
title: 'Decision',
|
|
54
|
+
'x-order': keys,
|
|
55
|
+
required: keys,
|
|
56
|
+
additionalProperties: false,
|
|
57
|
+
properties,
|
|
58
|
+
};
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/** Format a {@link Desc} into prompt text: strings verbatim, structured values JSON, absence empty. */
|
|
62
|
+
export function formatDesc(desc: Desc | undefined): string {
|
|
63
|
+
if (typeof desc === 'string') return desc;
|
|
64
|
+
if (desc === null || desc === undefined) return '';
|
|
65
|
+
return JSON.stringify(desc);
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/** The declared option values of a question, in declaration order (estimation + validation share this). */
|
|
69
|
+
export function declaredOptions(question: Question): string[] {
|
|
70
|
+
switch (question.kind) {
|
|
71
|
+
case 'choice':
|
|
72
|
+
return Object.keys(question.labels);
|
|
73
|
+
case 'score':
|
|
74
|
+
return question.rubric.map((_, level) => String(level));
|
|
75
|
+
case 'noul':
|
|
76
|
+
return ['yes', 'no'];
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* Render the positional prompt: the state as text (JSON when structured), then
|
|
82
|
+
* one block per question key with the prompt `Desc` and its options with
|
|
83
|
+
* descriptions — choice label keys, score level indices with the rubric text,
|
|
84
|
+
* noul `yes`/`no` with any outcome descriptions.
|
|
85
|
+
*/
|
|
86
|
+
export function buildPrompt(state: DecisionState, questions: Record<string, Question>): string {
|
|
87
|
+
const parts: string[] = [];
|
|
88
|
+
const stateText =
|
|
89
|
+
typeof state === 'string' ? state : state === null || state === undefined ? '' : JSON.stringify(state);
|
|
90
|
+
if (stateText.length > 0) parts.push(stateText);
|
|
91
|
+
for (const [key, question] of Object.entries(questions)) {
|
|
92
|
+
const lines: string[] = [];
|
|
93
|
+
const prompt = formatDesc(question.prompt);
|
|
94
|
+
lines.push(`Question "${key}":${prompt.length > 0 ? ` ${prompt}` : ''}`);
|
|
95
|
+
lines.push('Options:');
|
|
96
|
+
switch (question.kind) {
|
|
97
|
+
case 'choice':
|
|
98
|
+
for (const [label, desc] of Object.entries(question.labels)) {
|
|
99
|
+
const d = formatDesc(desc);
|
|
100
|
+
lines.push(d.length > 0 ? `- ${label}: ${d}` : `- ${label}`);
|
|
101
|
+
}
|
|
102
|
+
break;
|
|
103
|
+
case 'score':
|
|
104
|
+
question.rubric.forEach((desc, level) => {
|
|
105
|
+
const d = formatDesc(desc);
|
|
106
|
+
lines.push(d.length > 0 ? `- ${level}: ${d}` : `- ${level}`);
|
|
107
|
+
});
|
|
108
|
+
break;
|
|
109
|
+
case 'noul': {
|
|
110
|
+
for (const outcome of ['yes', 'no'] as const) {
|
|
111
|
+
const d = formatDesc(question[outcome]);
|
|
112
|
+
lines.push(d.length > 0 ? `- ${outcome}: ${d}` : `- ${outcome}`);
|
|
113
|
+
}
|
|
114
|
+
break;
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
parts.push(lines.join('\n'));
|
|
118
|
+
}
|
|
119
|
+
return parts.join('\n\n');
|
|
120
|
+
}
|