Skip to content

Commit 5248ca3

Browse files
dmealingclaude
andcommitted
Merge FR-010 TypeScript port — Phase 2: OutputFormatRenderer
Output-format prompt fragment renderer (artifact 1) in @metaobjectsdev/render — 3 comment-free styles × {json,xml}, byte-identical to Java/Kotlin/C#, reuses ESCAPERS. Pre-merge review+simplifier caught + fixed two invalid-JSON BLOCKERs (empty-fields `{\n}` parity; radix-literal quoting), both regression-covered. 20 prompt tests + render suite 232 green; strict typecheck clean. TS Phase 3 (attrs + serializer fix + codegen) follows. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2 parents efe1180 + aa103e1 commit 5248ca3

7 files changed

Lines changed: 613 additions & 0 deletions

File tree

server/typescript/packages/render/src/index.ts

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -42,3 +42,14 @@ export {
4242
asBool,
4343
asStringList,
4444
} from "./recover/recover-map.js";
45+
46+
// FR-010 artifact 1 — output-format prompt renderer ("produce your answer like this").
47+
export { renderOutputFormat } from "./prompt/output-format-renderer.js";
48+
export { PromptStyle, promptStyleFrom } from "./prompt/prompt-style.js";
49+
export {
50+
PROMPT_OVERRIDES_NONE,
51+
noOverrides,
52+
type PromptOverrides,
53+
} from "./prompt/prompt-overrides.js";
54+
export type { OutputFormatSpec } from "./prompt/output-format-spec.js";
55+
export type { PromptField } from "./prompt/prompt-field.js";
Lines changed: 179 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,179 @@
1+
// FR-010 artifact 1 — output-format prompt renderer ("produce your answer like this").
2+
//
3+
// Renders an OutputFormatSpec into a prompt fragment that teaches an LLM how to
4+
// shape its answer. Three comment-free styles (guide / inline / exampleOnly) ×
5+
// two formats (json / xml). Guidance is carried in prose / inline placeholders /
6+
// a filled skeleton — NEVER in comments (models ignore them).
7+
//
8+
// Cross-port INVARIANT: the rendered text is byte-identical to the Java/C#/Kotlin
9+
// reference (com.metaobjects.render.prompt.OutputFormatRenderer). Do not change
10+
// the verbatim prose, skeleton shapes, or numeric-vs-quoted decision.
11+
12+
import { ESCAPERS } from "../escapers.js";
13+
import { FieldKind, Format } from "../recover/types.js";
14+
import type { OutputFormatSpec } from "./output-format-spec.js";
15+
import type { PromptField } from "./prompt-field.js";
16+
import type { PromptOverrides } from "./prompt-overrides.js";
17+
import { PromptStyle } from "./prompt-style.js";
18+
19+
const NUMERIC_KINDS: ReadonlySet<FieldKind> = new Set<FieldKind>([
20+
FieldKind.INT,
21+
FieldKind.LONG,
22+
FieldKind.DOUBLE,
23+
FieldKind.BOOLEAN,
24+
]);
25+
26+
// The render engine OWNS format-keyed escaping; Format ("JSON"/"XML") maps to the
27+
// lowercase ESCAPERS keys.
28+
const escapeXml = (s: string): string => ESCAPERS.xml(s);
29+
const escapeJson = (s: string): string => ESCAPERS.json(s);
30+
31+
/**
32+
* Render an {@link OutputFormatSpec} into an output-format prompt fragment. The
33+
* effective style is the override's style if present, otherwise the spec's.
34+
*/
35+
export function renderOutputFormat(spec: OutputFormatSpec, overrides: PromptOverrides): string {
36+
const effectiveStyle = overrides.style ?? spec.style;
37+
switch (effectiveStyle) {
38+
case PromptStyle.EXAMPLE_ONLY:
39+
return renderExampleOnly(spec, overrides);
40+
case PromptStyle.INLINE:
41+
return renderInline(spec, overrides);
42+
default:
43+
return renderGuide(spec, overrides);
44+
}
45+
}
46+
47+
// ---- INLINE ----------------------------------------------------------------
48+
49+
function renderInline(spec: OutputFormatSpec, overrides: PromptOverrides): string {
50+
return spec.format === Format.XML
51+
? renderXmlInline(spec, overrides)
52+
: renderJsonInline(spec, overrides);
53+
}
54+
55+
function renderXmlInline(spec: OutputFormatSpec, overrides: PromptOverrides): string {
56+
const lines = spec.fields.map((field) => {
57+
const escaped = escapeXml(inlineContent(field, overrides));
58+
return ` <${field.name}>${escaped}</${field.name}>\n`;
59+
});
60+
return `<${spec.rootName}>\n${lines.join("")}</${spec.rootName}>`;
61+
}
62+
63+
function renderJsonInline(spec: OutputFormatSpec, overrides: PromptOverrides): string {
64+
const lines = spec.fields.map(
65+
(field) => ` "${field.name}": "${escapeJson(inlineContent(field, overrides))}"`,
66+
);
67+
// Empty object is `{\n}` (Java/C# parity), not `{\n\n}` from join("") on no lines.
68+
return spec.fields.length === 0 ? "{\n}" : `{\n${lines.join(",\n")}\n}`;
69+
}
70+
71+
function inlineContent(field: PromptField, overrides: PromptOverrides): string {
72+
if (field.kind === FieldKind.ENUM && field.enumValues != null && field.enumValues.length > 0) {
73+
return field.enumValues.join(" | ");
74+
}
75+
if (field.kind === FieldKind.BOOLEAN) {
76+
return "true | false";
77+
}
78+
const instruction = resolveInstruction(field, overrides);
79+
return instruction != null ? `{${instruction}}` : `{${field.name}}`;
80+
}
81+
82+
/** Effective instruction: override first, then the field default, else null. */
83+
function resolveInstruction(field: PromptField, overrides: PromptOverrides): string | null {
84+
const ov = overrides.instructions?.[field.name];
85+
if (ov != null) return ov;
86+
return field.instruction;
87+
}
88+
89+
// ---- GUIDE -----------------------------------------------------------------
90+
91+
function renderGuide(spec: OutputFormatSpec, overrides: PromptOverrides): string {
92+
let sb = "Fill in each field as described below:\n";
93+
for (const field of spec.fields) {
94+
const req = field.required ? "required" : "optional";
95+
sb += `- ${field.name} (${req})`;
96+
const instruction = resolveInstruction(field, overrides);
97+
if (instruction != null) {
98+
sb += `: ${instruction}`;
99+
}
100+
sb += "\n";
101+
if (field.kind === FieldKind.ENUM && field.enumValues != null && field.enumValues.length > 0) {
102+
sb += ` one of ${field.enumValues.join(", ")}\n`;
103+
const enumDoc = field.enumDoc;
104+
if (enumDoc != null) {
105+
for (const val of field.enumValues) {
106+
const doc = enumDoc[val];
107+
if (doc != null) {
108+
sb += ` ${val} = ${doc}\n`;
109+
}
110+
}
111+
}
112+
}
113+
const eg = exampleValueIfDeclared(field, overrides);
114+
if (eg != null) {
115+
sb += ` e.g. ${eg}\n`;
116+
}
117+
}
118+
sb += "\nRespond exactly like this:\n";
119+
sb += renderExampleOnly(spec, overrides);
120+
return sb;
121+
}
122+
123+
// ---- EXAMPLE-ONLY (also the skeleton appended by GUIDE) ---------------------
124+
125+
function renderExampleOnly(spec: OutputFormatSpec, overrides: PromptOverrides): string {
126+
return spec.format === Format.XML
127+
? renderXmlSkeleton(spec, overrides)
128+
: renderJsonSkeleton(spec, overrides);
129+
}
130+
131+
function renderXmlSkeleton(spec: OutputFormatSpec, overrides: PromptOverrides): string {
132+
const lines = spec.fields.map((field) => {
133+
const escaped = escapeXml(exampleValue(field, overrides));
134+
return ` <${field.name}>${escaped}</${field.name}>\n`;
135+
});
136+
return `<${spec.rootName}>\n${lines.join("")}</${spec.rootName}>`;
137+
}
138+
139+
function renderJsonSkeleton(spec: OutputFormatSpec, overrides: PromptOverrides): string {
140+
// NOTE: FieldKind.OBJECT / nested fields are not expanded here — they render as
141+
// a "{fieldName}" placeholder. Nested-object expansion is a bounded deferral
142+
// (mirrors Java/C#).
143+
const lines = spec.fields.map((field) => {
144+
const value = exampleValue(field, overrides);
145+
const rendered = isNumericOrBoolean(field.kind, value) ? value : `"${escapeJson(value)}"`;
146+
return ` "${field.name}": ${rendered}`;
147+
});
148+
// Empty object is `{\n}` (Java/C# parity), not `{\n\n}` from join("") on no lines.
149+
return spec.fields.length === 0 ? "{\n}" : `{\n${lines.join(",\n")}\n}`;
150+
}
151+
152+
function exampleValueIfDeclared(field: PromptField, overrides: PromptOverrides): string | null {
153+
const ov = overrides.examples?.[field.name];
154+
if (ov != null) return ov;
155+
if (field.example != null) return field.example;
156+
return null;
157+
}
158+
159+
function exampleValue(field: PromptField, overrides: PromptOverrides): string {
160+
const ov = overrides.examples?.[field.name];
161+
if (ov != null) return ov;
162+
if (field.example != null) return field.example;
163+
if (field.kind === FieldKind.ENUM && field.enumValues != null && field.enumValues.length > 0) {
164+
return field.enumValues[0]!;
165+
}
166+
return `{${field.name}}`;
167+
}
168+
169+
function isNumericOrBoolean(kind: FieldKind, value: string): boolean {
170+
if (!NUMERIC_KINDS.has(kind)) return false;
171+
if (value === "true" || value === "false") return true;
172+
// Finite-only: NaN/Infinity fall through to a quoted string so the emitted JSON
173+
// stays valid. Number("") is 0, so guard the empty/blank case explicitly. Reject
174+
// JS-only radix literals (0x../0b../0o..) that Number() accepts but Java/C# don't —
175+
// same guard as the recover engine's parseFiniteNumber (keeps the JSON valid + parity).
176+
const t = value.trim();
177+
if (t === "" || /^[+-]?0[xXbBoO]/.test(t)) return false;
178+
return Number.isFinite(Number(t));
179+
}
Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
// FR-010 artifact 1 — a complete output-format descriptor.
2+
3+
import type { Format } from "../recover/types.js";
4+
import type { PromptField } from "./prompt-field.js";
5+
import type { PromptStyle } from "./prompt-style.js";
6+
7+
/**
8+
* A complete output-format descriptor: the format, the root element/object name,
9+
* the default presentation style, and the ordered fields. Drives
10+
* {@link renderOutputFormat}.
11+
*
12+
* Precondition: `rootName` must be identifier-safe (valid XML element name / JSON
13+
* key). The renderer does not escape it.
14+
*/
15+
export interface OutputFormatSpec {
16+
readonly format: Format;
17+
readonly rootName: string;
18+
readonly style: PromptStyle;
19+
readonly fields: readonly PromptField[];
20+
}
Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
// FR-010 artifact 1 — one field of an output-format fragment.
2+
3+
import type { FieldKind } from "../recover/types.js";
4+
import type { OutputFormatSpec } from "./output-format-spec.js";
5+
6+
/**
7+
* One field of an output-format fragment. `enumValues`/`enumDoc` are non-null
8+
* only for ENUM; `nested` non-null only for OBJECT; `example`/`instruction` are
9+
* nullable.
10+
*
11+
* Precondition: `name` must be identifier-safe (valid XML element name / JSON
12+
* key). The renderer does not escape field names.
13+
*/
14+
export interface PromptField {
15+
readonly name: string;
16+
readonly kind: FieldKind;
17+
readonly required: boolean;
18+
readonly array: boolean;
19+
readonly enumValues: readonly string[] | null;
20+
readonly enumDoc: Readonly<Record<string, string>> | null;
21+
readonly example: string | null;
22+
readonly instruction: string | null;
23+
readonly nested: OutputFormatSpec | null;
24+
}
Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
// FR-010 artifact 1 — render-time overrides of the metadata defaults.
2+
3+
import type { PromptStyle } from "./prompt-style.js";
4+
5+
/**
6+
* Render-time overrides of the metadata defaults. `style` undefined keeps the
7+
* spec's style; the maps override `PromptField.example`/`PromptField.instruction`
8+
* per field name.
9+
*/
10+
export interface PromptOverrides {
11+
readonly style?: PromptStyle;
12+
readonly examples?: Readonly<Record<string, string>>;
13+
readonly instructions?: Readonly<Record<string, string>>;
14+
}
15+
16+
/** No overrides — keep every metadata default. Mirrors Java `PromptOverrides.none()`. */
17+
export const PROMPT_OVERRIDES_NONE: PromptOverrides = Object.freeze({
18+
// `style` omitted (not `undefined`) under exactOptionalPropertyTypes: an absent
19+
// style keeps the spec's own style, matching Java `PromptOverrides.none()`.
20+
examples: {},
21+
instructions: {},
22+
});
23+
24+
/** No overrides — keep every metadata default. */
25+
export function noOverrides(): PromptOverrides {
26+
return PROMPT_OVERRIDES_NONE;
27+
}
Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
1+
// FR-010 artifact 1 — output-format prompt renderer.
2+
//
3+
// How the output-format fragment teaches the model. Guidance is NEVER carried in
4+
// comments (models ignore them) — it lives in prose / inline placeholders / a
5+
// filled skeleton. Default is "guide".
6+
//
7+
// Tier-2 idiomatic TS: the Java SCREAMING_SNAKE enum (GUIDE/INLINE/EXAMPLE_ONLY)
8+
// becomes a string-union whose members ARE the wire `@promptStyle` values
9+
// ("guide" | "inline" | "exampleOnly") — no name<->wire mapping table needed.
10+
11+
/**
12+
* - `"guide"`: prose field list ("Fill in each field…") followed by an example skeleton.
13+
* - `"inline"`: a single skeleton whose field values are inline placeholders / enum choices.
14+
* - `"exampleOnly"`: just a filled example skeleton, nothing else.
15+
*/
16+
export const PromptStyle = {
17+
GUIDE: "guide",
18+
INLINE: "inline",
19+
EXAMPLE_ONLY: "exampleOnly",
20+
} as const;
21+
export type PromptStyle = (typeof PromptStyle)[keyof typeof PromptStyle];
22+
23+
/**
24+
* Maps the `@promptStyle` attribute string to a {@link PromptStyle}. Null or any
25+
* unrecognized value falls back to `"guide"` (matches Java `PromptStyle.from`).
26+
*/
27+
export function promptStyleFrom(s?: string | null): PromptStyle {
28+
switch (s) {
29+
case PromptStyle.INLINE:
30+
return PromptStyle.INLINE;
31+
case PromptStyle.EXAMPLE_ONLY:
32+
return PromptStyle.EXAMPLE_ONLY;
33+
default:
34+
return PromptStyle.GUIDE;
35+
}
36+
}

0 commit comments

Comments
 (0)