Skip to content

Commit 46260ba

Browse files
committed
feat(render): Python nested-object + array prompt expansion (FR-012); count guard 12
1 parent 8d7a97d commit 46260ba

3 files changed

Lines changed: 374 additions & 61 deletions

File tree

server/python/src/metaobjects/render/prompt/output_format_renderer.py

Lines changed: 205 additions & 60 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,13 @@
2424
{FieldKind.INT, FieldKind.LONG, FieldKind.DOUBLE, FieldKind.BOOLEAN}
2525
)
2626

27+
_INDENT = " "
28+
_MAX_NEST_DEPTH = 8
29+
30+
# Render mode shared by the example/inline skeleton recursion.
31+
_MODE_EXAMPLE = "example"
32+
_MODE_INLINE = "inline"
33+
2734
# The render engine OWNS format-keyed escaping; the Format enum's UPPER values
2835
# ("JSON"/"XML") map to the lowercase escaper-registry keys.
2936
def _escape_xml(s: str) -> str:
@@ -52,27 +59,8 @@ def render_output_format(spec: OutputFormatSpec, overrides: PromptOverrides) ->
5259

5360
def _render_inline(spec: OutputFormatSpec, overrides: PromptOverrides) -> str:
5461
if spec.format is Format.XML:
55-
return _render_xml_inline(spec, overrides)
56-
return _render_json_inline(spec, overrides)
57-
58-
59-
def _render_xml_inline(spec: OutputFormatSpec, overrides: PromptOverrides) -> str:
60-
lines = [
61-
f" <{f.name}>{_escape_xml(_inline_content(f, overrides))}</{f.name}>\n"
62-
for f in spec.fields
63-
]
64-
return f"<{spec.root_name}>\n{''.join(lines)}</{spec.root_name}>"
65-
66-
67-
def _render_json_inline(spec: OutputFormatSpec, overrides: PromptOverrides) -> str:
68-
lines = [
69-
f' "{f.name}": "{_escape_json(_inline_content(f, overrides))}"'
70-
for f in spec.fields
71-
]
72-
# Empty object is `{\n}` (cross-port parity), not `{\n\n}`.
73-
if not lines:
74-
return "{\n}"
75-
return "{\n" + ",\n".join(lines) + "\n}"
62+
return _render_xml_skeleton(spec, overrides, _MODE_INLINE)
63+
return _render_json_skeleton(spec, overrides, _MODE_INLINE)
7664

7765

7866
def _inline_content(field: PromptField, overrides: PromptOverrides) -> str:
@@ -99,67 +87,224 @@ def _resolve_instruction(field: PromptField, overrides: PromptOverrides) -> str
9987

10088
def _render_guide(spec: OutputFormatSpec, overrides: PromptOverrides) -> str:
10189
sb = "Fill in each field as described below:\n"
102-
for field in spec.fields:
103-
req = "required" if field.required else "optional"
104-
sb += f"- {field.name} ({req})"
105-
instruction = _resolve_instruction(field, overrides)
106-
if instruction is not None:
107-
sb += f": {instruction}"
108-
sb += "\n"
109-
if field.kind is FieldKind.ENUM and field.enum_values:
110-
sb += f" one of {', '.join(field.enum_values)}\n"
111-
enum_doc = field.enum_doc
112-
if enum_doc is not None:
113-
for val in field.enum_values:
114-
doc = enum_doc.get(val)
115-
if doc is not None:
116-
sb += f" {val} = {doc}\n"
117-
eg = _example_value_if_declared(field, overrides)
118-
if eg is not None:
119-
sb += f" e.g. {eg}\n"
90+
sb += _guide_fields(spec, overrides, "", {id(spec)}, 0)
12091
sb += "\nRespond exactly like this:\n"
12192
sb += _render_example_only(spec, overrides)
12293
return sb
12394

12495

96+
def _guide_fields(
97+
spec: OutputFormatSpec,
98+
overrides: PromptOverrides,
99+
prefix: str,
100+
path: set[int],
101+
depth: int,
102+
) -> str:
103+
sb = ""
104+
for field in spec.fields:
105+
display_name = prefix + field.name
106+
sb += _guide_entry(field, overrides, display_name)
107+
if _can_expand(field, path, depth):
108+
nested = field.nested
109+
assert nested is not None
110+
child_prefix = (
111+
f"{display_name}[]." if field.array else f"{display_name}."
112+
)
113+
path.add(id(nested))
114+
sb += _guide_fields(nested, overrides, child_prefix, path, depth + 1)
115+
path.discard(id(nested))
116+
return sb
117+
118+
119+
def _guide_entry(
120+
field: PromptField, overrides: PromptOverrides, display_name: str
121+
) -> str:
122+
req = "required" if field.required else "optional"
123+
sb = f"- {display_name} ({req})"
124+
instruction = _resolve_instruction(field, overrides)
125+
if instruction is not None:
126+
sb += f": {instruction}"
127+
sb += "\n"
128+
if field.kind is FieldKind.ENUM and field.enum_values:
129+
sb += f" one of {', '.join(field.enum_values)}\n"
130+
enum_doc = field.enum_doc
131+
if enum_doc is not None:
132+
for val in field.enum_values:
133+
doc = enum_doc.get(val)
134+
if doc is not None:
135+
sb += f" {val} = {doc}\n"
136+
eg = _example_value_if_declared(field, overrides)
137+
if eg is not None:
138+
sb += f" e.g. {eg}\n"
139+
return sb
140+
141+
125142
# ---- EXAMPLE-ONLY (also the skeleton appended by GUIDE) ---------------------
126143

127144

128145
def _render_example_only(spec: OutputFormatSpec, overrides: PromptOverrides) -> str:
129146
if spec.format is Format.XML:
130-
return _render_xml_skeleton(spec, overrides)
131-
return _render_json_skeleton(spec, overrides)
147+
return _render_xml_skeleton(spec, overrides, _MODE_EXAMPLE)
148+
return _render_json_skeleton(spec, overrides, _MODE_EXAMPLE)
132149

133150

134-
def _render_xml_skeleton(spec: OutputFormatSpec, overrides: PromptOverrides) -> str:
135-
lines = [
136-
f" <{f.name}>{_escape_xml(_example_value(f, overrides))}</{f.name}>\n"
137-
for f in spec.fields
138-
]
139-
return f"<{spec.root_name}>\n{''.join(lines)}</{spec.root_name}>"
151+
# ---- JSON skeleton (recursive) ---------------------------------------------
140152

141153

142-
def _render_json_skeleton(spec: OutputFormatSpec, overrides: PromptOverrides) -> str:
143-
# NOTE: FieldKind.OBJECT / nested fields are not expanded here — they render as a
144-
# "{fieldName}" placeholder. Nested-object expansion is a bounded deferral
145-
# (mirrors Java/C#/TS).
146-
lines = [
147-
f' "{f.name}": {_json_skeleton_value(f, overrides)}' for f in spec.fields
148-
]
149-
# Empty object is `{\n}` (cross-port parity), not `{\n\n}`.
150-
if not lines:
151-
return "{\n}"
152-
return "{\n" + ",\n".join(lines) + "\n}"
154+
def _render_json_skeleton(
155+
spec: OutputFormatSpec, overrides: PromptOverrides, mode: str
156+
) -> str:
157+
return _json_object(spec, overrides, "", mode, {id(spec)}, 0)
153158

154159

155-
def _json_skeleton_value(field: PromptField, overrides: PromptOverrides) -> str:
156-
"""The example value as a JSON literal: bare for numeric/boolean, else quoted."""
160+
def _json_object(
161+
spec: OutputFormatSpec,
162+
overrides: PromptOverrides,
163+
brace_indent: str,
164+
mode: str,
165+
path: set[int],
166+
depth: int,
167+
) -> str:
168+
# Empty object is `{\n<brace_indent>}` (cross-port parity), not `{\n\n}`.
169+
if not spec.fields:
170+
return f"{{\n{brace_indent}}}"
171+
field_indent = brace_indent + _INDENT
172+
lines = [
173+
f'{field_indent}"{field.name}": '
174+
f"{_json_value(field, overrides, field_indent, mode, path, depth)}"
175+
for field in spec.fields
176+
]
177+
return "{\n" + ",\n".join(lines) + f"\n{brace_indent}}}"
178+
179+
180+
def _json_value(
181+
field: PromptField,
182+
overrides: PromptOverrides,
183+
indent: str,
184+
mode: str,
185+
path: set[int],
186+
depth: int,
187+
) -> str:
188+
if field.array:
189+
return _json_array(field, overrides, indent, mode, path, depth)
190+
if field.kind is FieldKind.OBJECT:
191+
return _json_object_field(field, overrides, indent, mode, path, depth)
192+
return _json_leaf(field, overrides, mode)
193+
194+
195+
def _json_leaf(field: PromptField, overrides: PromptOverrides, mode: str) -> str:
196+
if mode == _MODE_INLINE:
197+
return '"' + _escape_json(_inline_content(field, overrides)) + '"'
157198
value = _example_value(field, overrides)
158199
if _is_numeric_or_boolean(field.kind, value):
159200
return value
160201
return '"' + _escape_json(value) + '"'
161202

162203

204+
def _json_object_field(
205+
field: PromptField,
206+
overrides: PromptOverrides,
207+
indent: str,
208+
mode: str,
209+
path: set[int],
210+
depth: int,
211+
) -> str:
212+
if not _can_expand(field, path, depth):
213+
return _json_leaf(field, overrides, mode)
214+
nested = field.nested
215+
assert nested is not None
216+
path.add(id(nested))
217+
out = _json_object(nested, overrides, indent, mode, path, depth + 1)
218+
path.discard(id(nested))
219+
return out
220+
221+
222+
def _json_array(
223+
field: PromptField,
224+
overrides: PromptOverrides,
225+
indent: str,
226+
mode: str,
227+
path: set[int],
228+
depth: int,
229+
) -> str:
230+
elem_indent = indent + _INDENT
231+
if _can_expand(field, path, depth):
232+
nested = field.nested
233+
assert nested is not None
234+
path.add(id(nested))
235+
elem = _json_object(nested, overrides, elem_indent, mode, path, depth + 1)
236+
path.discard(id(nested))
237+
else:
238+
elem = _json_leaf(field, overrides, mode)
239+
return f"[\n{elem_indent}{elem}\n{indent}]"
240+
241+
242+
# ---- XML skeleton (recursive) ----------------------------------------------
243+
244+
245+
def _render_xml_skeleton(
246+
spec: OutputFormatSpec, overrides: PromptOverrides, mode: str
247+
) -> str:
248+
body = _xml_body(spec, overrides, _INDENT, mode, {id(spec)}, 0)
249+
return f"<{spec.root_name}>\n{body}</{spec.root_name}>"
250+
251+
252+
def _xml_body(
253+
spec: OutputFormatSpec,
254+
overrides: PromptOverrides,
255+
indent: str,
256+
mode: str,
257+
path: set[int],
258+
depth: int,
259+
) -> str:
260+
return "".join(
261+
_xml_field(field, overrides, indent, mode, path, depth)
262+
for field in spec.fields
263+
)
264+
265+
266+
def _xml_field(
267+
field: PromptField,
268+
overrides: PromptOverrides,
269+
indent: str,
270+
mode: str,
271+
path: set[int],
272+
depth: int,
273+
) -> str:
274+
if _can_expand(field, path, depth):
275+
nested = field.nested
276+
assert nested is not None
277+
path.add(id(nested))
278+
body = _xml_body(nested, overrides, indent + _INDENT, mode, path, depth + 1)
279+
path.discard(id(nested))
280+
return f"{indent}<{field.name}>\n{body}{indent}</{field.name}>\n"
281+
content = (
282+
_inline_content(field, overrides)
283+
if mode == _MODE_INLINE
284+
else _example_value(field, overrides)
285+
)
286+
return f"{indent}<{field.name}>{_escape_xml(content)}</{field.name}>\n"
287+
288+
289+
# ---- nested-expansion guard ------------------------------------------------
290+
291+
292+
def _can_expand(field: PromptField, path: set[int], depth: int) -> bool:
293+
"""Expand an OBJECT field only when it has a nested spec, the depth bound is not
294+
exceeded, and it would not re-enter a spec already on the current path.
295+
296+
The cycle guard is REFERENCE IDENTITY via ``id()``: frozen dataclasses compare by
297+
value (``eq=True``), so two value-equal sibling specs must both still expand. ``id``
298+
keys distinguish them.
299+
"""
300+
return (
301+
field.kind is FieldKind.OBJECT
302+
and field.nested is not None
303+
and depth < _MAX_NEST_DEPTH
304+
and id(field.nested) not in path
305+
)
306+
307+
163308
def _example_value_if_declared(field: PromptField, overrides: PromptOverrides) -> str | None:
164309
from_override = overrides.examples.get(field.name)
165310
if from_override is not None:

0 commit comments

Comments
 (0)