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
611from 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+ )
819from dspy .signatures .signature import Signature
20+ from dspy .utils .exceptions import AdapterParseError
921
1022
1123class 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 ("&" , "&" ).replace ("<" , "<" )
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 ('&' , '&' ).replace ('<' , '<' )} </{ 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