Skip to content

Commit 33aaa19

Browse files
isaacbmillerampagentIsaac Miller
authored
feat(adapter): support nested XML data (#10239)
* feat(adapter): support nested XML data Amp-Thread-ID: https://ampcode.com/threads/T-01a0155d-044b-7195-9585-9f88207c87c6 Co-authored-by: Isaac Miller <isaac@cmpnd.ai> * docs(adapter): show nested XML prompts Amp-Thread-ID: https://ampcode.com/threads/T-01a0155d-044b-7195-9585-9f88207c87c6 Co-authored-by: Isaac Miller <isaac@cmpnd.ai> * fix(adapter): describe TypedDict XML schemas Amp-Thread-ID: https://ampcode.com/threads/T-01a0155d-044b-7195-9585-9f88207c87c6 Co-authored-by: Isaac Miller <isaac@cmpnd.ai> * refactor(adapter): consolidate XML type traversal Amp-Thread-ID: https://ampcode.com/threads/T-01a0155d-044b-7195-9585-9f88207c87c6 Co-authored-by: Isaac Miller <isaac@cmpnd.ai> * refactor(adapter): trim nested XML changes Amp-Thread-ID: https://ampcode.com/threads/T-01a0155d-044b-7195-9585-9f88207c87c6 Co-authored-by: Isaac Miller <isaac@cmpnd.ai> * refactor(adapter): unify XML schema handling Amp-Thread-ID: https://ampcode.com/threads/T-01a0155d-044b-7195-9585-9f88207c87c6 Co-authored-by: Isaac Miller <isaac@cmpnd.ai> * fix(adapter): preserve nullable and union XML values Amp-Thread-ID: https://ampcode.com/threads/T-01a0155d-044b-7195-9585-9f88207c87c6 Co-authored-by: Isaac Miller <isaac@cmpnd.ai> * fix(adapter): validate tied XML union branches Amp-Thread-ID: https://ampcode.com/threads/T-01a0155d-044b-7195-9585-9f88207c87c6 Co-authored-by: Isaac Miller <isaac@cmpnd.ai> * fix(adapter): preserve repeated nested union values Amp-Thread-ID: https://ampcode.com/threads/T-01a0155d-044b-7195-9585-9f88207c87c6 Co-authored-by: Isaac Miller <isaac@cmpnd.ai> * fix(adapter): encode invalid XML mapping keys Amp-Thread-ID: https://ampcode.com/threads/T-01a0155d-044b-7195-9585-9f88207c87c6 Co-authored-by: Isaac Miller <isaac@cmpnd.ai> * fix(adapter): reject invalid leading XML name characters Amp-Thread-ID: https://ampcode.com/threads/T-01a0155d-044b-7195-9585-9f88207c87c6 Co-authored-by: Isaac Miller <isaac@cmpnd.ai> * refactor(adapter): favor readable XML implementation Amp-Thread-ID: https://ampcode.com/threads/T-01a0155d-044b-7195-9585-9f88207c87c6 Co-authored-by: Isaac Miller <isaac@cmpnd.ai> * fix(adapter): prefer structured XML union branches Amp-Thread-ID: https://ampcode.com/threads/T-01a0155d-044b-7195-9585-9f88207c87c6 Co-authored-by: Isaac Miller <isaac@cmpnd.ai> * docs(adapter): trim nested XML guidance Amp-Thread-ID: https://ampcode.com/threads/T-01a0155d-044b-7195-9585-9f88207c87c6 Co-authored-by: Isaac Miller <isaac@cmpnd.ai> * docs(adapter): preserve concise XML overview Amp-Thread-ID: https://ampcode.com/threads/T-01a0155d-044b-7195-9585-9f88207c87c6 Co-authored-by: Isaac Miller <isaac@cmpnd.ai> * feat(adapter): use item tags for XML lists Amp-Thread-ID: https://ampcode.com/threads/T-01a0155d-044b-7195-9585-9f88207c87c6 Co-authored-by: Isaac Miller <isaac@cmpnd.ai> * fix(adapter): align XML prompts with model fields Use Pydantic field names consistently in nested XML schemas and parsing, and preserve empty object round trips. Co-authored-by: Isaac Miller <isaac@cmpnd.ai> Amp-Thread-ID: https://ampcode.com/threads/T-01a0155d-044b-7195-9585-9f88207c87c6 --------- Co-authored-by: Amp <amp@ampcode.com> Co-authored-by: Isaac Miller <isaac@cmpnd.ai>
1 parent 7500171 commit 33aaa19

3 files changed

Lines changed: 461 additions & 176 deletions

File tree

‎docs/docs/diving-deeper/adapters.md‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -65,7 +65,7 @@ The default. Builds a chat-style prompt with field markers, parses the response
6565
Outputs structured JSON. Internally extends ChatAdapter — formatting is similar, but the output instruction asks for JSON and parsing uses `json_repair`. The constructor’s `use_native_function_calling=True` default flips when tool calling is wired in.
6666

6767
**`dspy.XMLAdapter(callbacks=None)`**
68-
`<field_name>value</field_name>` tags. The parser is a regex (`r"<(\w+)>(.*?)</\1>"` with `DOTALL`); it’s robust to whitespace but doesn’t tolerate nested tags of the same name.
68+
`<field_name>value</field_name>` tags. Lists use repeated `<item>` tags.
6969

7070
**`dspy.TwoStepAdapter(extraction_model: BaseLM, **kwargs)`**
7171
Two LM calls per inference. Use it when the main LM is a reasoning model that’s bad at formatting — the extractor is usually a cheap general-purpose LM with ChatAdapter. Doesn’t support finetuning yet.

‎dspy/adapters/xml_adapter.py‎

Lines changed: 198 additions & 78 deletions
Original file line numberDiff line numberDiff line change
@@ -1,42 +1,60 @@
1-
import re
2-
from typing import Any
1+
import types
2+
import xml.etree.ElementTree as ET
3+
from collections import defaultdict
4+
from typing import Any, Union, get_args, get_origin
5+
from xml.sax.saxutils import quoteattr
36

4-
from pydantic.fields import FieldInfo
7+
import pydantic
8+
from pydantic import TypeAdapter
9+
from typing_extensions import is_typeddict
510

611
from dspy.adapters.chat_adapter import ChatAdapter, FieldInfoWithName
7-
from dspy.adapters.utils import apply_output_field_defaults, format_field_value, translate_field_type
12+
from dspy.adapters.utils import (
13+
apply_output_field_defaults,
14+
format_field_value,
15+
parse_value,
16+
serialize_for_json,
17+
translate_field_type,
18+
)
819
from dspy.signatures.signature import Signature
20+
from dspy.utils.exceptions import AdapterParseError
921

1022

1123
class XMLAdapter(ChatAdapter):
12-
field_pattern = re.compile(r"<(?P<name>\w+)>((?P<content>.*?))</\1>", re.DOTALL)
13-
1424
def format_field_with_value(self, fields_with_values: dict[FieldInfoWithName, Any]) -> str:
1525
output = []
16-
for field, field_value in fields_with_values.items():
17-
formatted = format_field_value(field_info=field.info, value=field_value)
26+
for field, value in fields_with_values.items():
27+
serialized = serialize_for_json(value)
28+
is_output = (field.info.json_schema_extra or {}).get("__dspy_field_type") == "output"
29+
if is_output and self._uses_nested_xml(field.info.annotation) and isinstance(serialized, (dict, list)):
30+
output.append(self._value_to_xml(serialized, field.name))
31+
continue
32+
formatted = format_field_value(field_info=field.info, value=value)
33+
if is_output and field.info.annotation is str:
34+
formatted = formatted.replace("&", "&amp;").replace("<", "&lt;")
1835
output.append(f"<{field.name}>\n{formatted}\n</{field.name}>")
1936
return "\n\n".join(output).strip()
2037

2138
def format_field_structure(self, signature: type[Signature]) -> str:
22-
"""
23-
XMLAdapter requires input and output fields to be wrapped in XML tags like `<field_name>`.
24-
"""
25-
26-
parts = []
27-
parts.append("All interactions will be structured in the following way, with the appropriate values filled in.")
28-
29-
def format_signature_fields_for_instructions(fields: dict[str, FieldInfo]):
39+
def format_field(name, field):
40+
if (field.json_schema_extra or {}).get("__dspy_field_type") == "output" and self._uses_nested_xml(
41+
field.annotation
42+
):
43+
return self._xml_schema(name, field.annotation)
3044
return self.format_field_with_value(
31-
fields_with_values={
32-
FieldInfoWithName(name=field_name, info=field_info): translate_field_type(field_name, field_info)
33-
for field_name, field_info in fields.items()
34-
},
45+
{FieldInfoWithName(name=name, info=field): translate_field_type(name, field)}
3546
)
3647

37-
parts.append(format_signature_fields_for_instructions(signature.input_fields))
38-
parts.append(format_signature_fields_for_instructions(signature.output_fields))
39-
return "\n\n".join(parts).strip()
48+
fields = (
49+
"\n\n".join(format_field(name, field) for name, field in group.items())
50+
for group in (signature.input_fields, signature.output_fields)
51+
)
52+
return "\n\n".join(
53+
(
54+
"All interactions will be structured in the following way, with the appropriate values filled in.",
55+
*fields,
56+
)
57+
)
4058

4159
def format_user_message_content(
4260
self,
@@ -46,75 +64,177 @@ def format_user_message_content(
4664
suffix: str = "",
4765
main_request: bool = False,
4866
) -> str:
49-
messages = [prefix]
50-
51-
messages.append(self.format_field_with_value(
52-
{
53-
FieldInfoWithName(name=k, info=v): inputs.get(k)
54-
for k, v in signature.input_fields.items() if k in inputs
55-
},
56-
))
57-
67+
fields = {
68+
FieldInfoWithName(name=k, info=v): inputs[k] for k, v in signature.input_fields.items() if k in inputs
69+
}
70+
messages = [prefix, self.format_field_with_value(fields)]
5871
if main_request:
59-
output_requirements = self.user_message_output_requirements(signature)
60-
if output_requirements is not None:
61-
messages.append(output_requirements)
62-
63-
messages.append(suffix)
64-
return "\n\n".join(messages).strip()
72+
messages.append(self.user_message_output_requirements(signature))
73+
return "\n\n".join((*messages, suffix)).strip()
6574

6675
def format_assistant_message_content(
67-
self,
68-
signature: type[Signature],
69-
outputs: dict[str, Any],
70-
missing_field_message=None,
76+
self, signature: type[Signature], outputs: dict[str, Any], missing_field_message=None
7177
) -> str:
72-
return self.format_field_with_value(
73-
{
74-
FieldInfoWithName(name=k, info=v): outputs.get(k, missing_field_message)
75-
for k, v in signature.output_fields.items()
76-
},
77-
)
78+
fields = {
79+
FieldInfoWithName(name=k, info=v): outputs.get(k, missing_field_message)
80+
for k, v in signature.output_fields.items()
81+
}
82+
return self.format_field_with_value(fields)
7883

7984
def user_message_output_requirements(self, signature: type[Signature]) -> str:
80-
message = "Respond with the corresponding output fields wrapped in XML tags "
81-
message += ", then ".join(f"`<{f}>`" for f in signature.output_fields)
82-
message += "."
83-
return message
85+
fields = ", then ".join(f"`<{name}>`" for name in signature.output_fields)
86+
schemas = [
87+
self._xml_schema(name, field.annotation)
88+
for name, field in signature.output_fields.items()
89+
if self._uses_nested_xml(field.annotation)
90+
]
91+
return f"Respond with the corresponding output fields wrapped in XML tags {fields}." + (
92+
f" Use this nested XML structure: {' '.join(schemas)}" if schemas else ""
93+
)
8494

8595
def parse(self, signature: type[Signature], completion: str) -> dict[str, Any]:
86-
fields = {}
87-
for match in self.field_pattern.finditer(completion):
88-
name = match.group("name")
89-
content = match.group("content").strip()
90-
if name in signature.output_fields and name not in fields:
91-
fields[name] = content
92-
# Cast values using base class parse_value helper
93-
for k, v in fields.items():
94-
fields[k] = self._parse_field_value(signature.output_fields[k], v, completion, signature)
95-
fields = apply_output_field_defaults(signature, fields)
96-
if fields.keys() != signature.output_fields.keys():
97-
from dspy.utils.exceptions import AdapterParseError
98-
96+
try:
97+
root = ET.fromstring(f"<dspy_root>{completion}</dspy_root>")
98+
except ET.ParseError as e:
9999
raise AdapterParseError(
100100
adapter_name="XMLAdapter",
101101
signature=signature,
102102
lm_response=completion,
103-
parsed_result=fields,
103+
message=f"Failed to parse XML: {e}",
104+
) from e
105+
elements = self._group_children(root)
106+
fields = {}
107+
for name, field in signature.output_fields.items():
108+
if name not in elements:
109+
continue
110+
adapter = TypeAdapter(field.annotation)
111+
schema = adapter.json_schema(by_alias=False)
112+
value = None
113+
for candidate in [schema, *schema.get("anyOf", [])]:
114+
try:
115+
value = self._elements_to_value(elements[name], candidate, schema.get("$defs", {}))
116+
try:
117+
fields[name] = parse_value(value, field.annotation)
118+
except pydantic.ValidationError:
119+
fields[name] = adapter.validate_python(value, by_name=True)
120+
break
121+
except Exception as e:
122+
error = e
123+
else:
124+
raise AdapterParseError(
125+
adapter_name="XMLAdapter",
126+
signature=signature,
127+
lm_response=completion,
128+
message=f"Failed to parse field {field} with value {value}: {error}",
129+
) from error
130+
fields = apply_output_field_defaults(signature, fields)
131+
if fields.keys() != signature.output_fields.keys():
132+
raise AdapterParseError(
133+
adapter_name="XMLAdapter", signature=signature, lm_response=completion, parsed_result=fields
104134
)
105135
return fields
106136

107-
def _parse_field_value(self, field_info, raw, completion, signature):
108-
from dspy.adapters.utils import parse_value
109-
110-
try:
111-
return parse_value(raw, field_info.annotation)
112-
except Exception as e:
113-
from dspy.utils.exceptions import AdapterParseError
137+
@classmethod
138+
def _value_to_xml(cls, value: Any, tag: str, key: str | None = None) -> str:
139+
attrs = f" key={quoteattr(key)}" if key is not None else ""
140+
if isinstance(value, list):
141+
children = "".join(cls._value_to_xml(item, "item") for item in value)
142+
return f"<{tag}{attrs}>{children}</{tag}>" if children else f"<{tag}{attrs} />"
143+
if isinstance(value, dict):
144+
children = []
145+
for name, child in value.items():
146+
name = str(name)
147+
valid_name = (name[:1] + name[1:].replace("-", "_").replace(".", "_")).isidentifier()
148+
children.append(
149+
cls._value_to_xml(child, name) if valid_name else cls._value_to_xml(child, "entry", name)
150+
)
151+
return f"<{tag}{attrs}>{''.join(children)}</{tag}>"
152+
return (
153+
f"<{tag}{attrs}>{str(value).replace('&', '&amp;').replace('<', '&lt;')}</{tag}>"
154+
if value is not None
155+
else f"<{tag}{attrs} />"
156+
)
114157

115-
raise AdapterParseError(
116-
adapter_name="XMLAdapter",
117-
signature=signature,
118-
lm_response=completion,
119-
message=f"Failed to parse field {field_info} with value {raw}: {e}",
158+
@classmethod
159+
def _xml_schema(cls, tag: str, annotation: Any) -> str:
160+
schema = TypeAdapter(annotation).json_schema(by_alias=False)
161+
return cls._schema_to_xml(tag, schema, schema.get("$defs", {}), frozenset())
162+
163+
@classmethod
164+
def _schema_to_xml(cls, tag: str, schema: dict, definitions: dict, seen: frozenset[str]) -> str:
165+
if ref := schema.get("$ref"):
166+
name = ref.rsplit("/", 1)[-1]
167+
return (
168+
f"<{tag}>...</{tag}>"
169+
if name in seen
170+
else cls._schema_to_xml(tag, definitions[name], definitions, seen | {name})
120171
)
172+
if choices := schema.get("anyOf"):
173+
return cls._schema_to_xml(
174+
tag, next((s for s in choices if s.get("type") != "null"), choices[0]), definitions, seen
175+
)
176+
if schema.get("type") == "array":
177+
item = cls._schema_to_xml("item", schema.get("items", {}), definitions, seen)
178+
return f"<{tag}>{item}</{tag}>"
179+
children = "".join(
180+
cls._schema_to_xml(name, child, definitions, seen) for name, child in schema.get("properties", {}).items()
181+
)
182+
return f"<{tag}>{children}</{tag}>" if children else f"<{tag}>...</{tag}>"
183+
184+
@classmethod
185+
def _elements_to_value(cls, elements: list[ET.Element], schema: dict, definitions: dict) -> Any:
186+
schema = definitions.get(schema.get("$ref", "").rsplit("/", 1)[-1], schema)
187+
if choices := schema.get("anyOf"):
188+
if {"type": "null"} in choices and not list(elements[0]) and not (elements[0].text or "").strip():
189+
return None
190+
choices = [choice for choice in choices if choice.get("type") != "null"]
191+
schema = choices[0]
192+
if list(elements[0]):
193+
schema = next((choice for choice in choices if choice.get("type") != "string"), schema)
194+
if schema.get("type") == "array":
195+
if len(elements) == 1 and not list(elements[0]):
196+
text = (elements[0].text or "").strip()
197+
if not text or text.startswith("["):
198+
return [] if not text else text
199+
if len(elements) == 1 and (items := cls._group_children(elements[0]).get("item")):
200+
elements = items
201+
return [cls._elements_to_value([element], schema.get("items", {}), definitions) for element in elements]
202+
element = elements[0]
203+
if schema.get("type") == "string" and list(element):
204+
return (element.text or "") + "".join(ET.tostring(child, encoding="unicode") for child in element)
205+
children = cls._group_children(element)
206+
if not children:
207+
if schema.get("type") == "object" and not (element.text or "").strip():
208+
return {}
209+
values = [(element.text or "").strip() for element in elements]
210+
return values[0] if len(values) == 1 else values
211+
properties = schema.get("properties", {})
212+
child_schema = schema.get("additionalProperties", {})
213+
if not isinstance(child_schema, dict):
214+
child_schema = {}
215+
return {
216+
name: cls._elements_to_value(items, properties.get(name, child_schema), definitions)
217+
for name, items in children.items()
218+
}
219+
220+
@staticmethod
221+
def _group_children(element: ET.Element) -> dict[str, list[ET.Element]]:
222+
children = defaultdict(list)
223+
for child in element:
224+
children[child.attrib.get("key", child.tag) if child.tag == "entry" else child.tag].append(child)
225+
return children
226+
227+
@staticmethod
228+
def _uses_nested_xml(annotation: Any) -> bool:
229+
args = [arg for arg in get_args(annotation) if arg is not type(None)]
230+
annotation = args[0] if len(args) == 1 and get_origin(annotation) in (Union, types.UnionType) else annotation
231+
origin = get_origin(annotation)
232+
item = get_args(annotation)[0] if origin is list and get_args(annotation) else annotation
233+
is_dspy_model = (
234+
isinstance(item, type) and issubclass(item, pydantic.BaseModel) and item.__module__.startswith("dspy.")
235+
)
236+
return not is_dspy_model and (
237+
origin in (list, dict)
238+
or is_typeddict(annotation)
239+
or (isinstance(annotation, type) and issubclass(annotation, pydantic.BaseModel))
240+
)

0 commit comments

Comments
 (0)