|
12 | 12 | # See the License for the specific language governing permissions and |
13 | 13 | # limitations under the License. |
14 | 14 |
|
15 | | -import json |
16 | | -from typing import Tuple, Any |
17 | | -from ..schema.constants import A2UI_DELIMITER |
| 15 | +import re |
| 16 | +from dataclasses import dataclass |
| 17 | +from typing import List, Optional, Any |
| 18 | +from ..schema.constants import A2UI_OPEN_TAG, A2UI_CLOSE_TAG |
18 | 19 | from .payload_fixer import parse_and_fix |
19 | 20 |
|
20 | 21 |
|
21 | | -def parse_response(content: str) -> Tuple[str, Any]: |
| 22 | +_A2UI_BLOCK_PATTERN = re.compile( |
| 23 | + f"{re.escape(A2UI_OPEN_TAG)}(.*?){re.escape(A2UI_CLOSE_TAG)}", re.DOTALL |
| 24 | +) |
| 25 | + |
| 26 | + |
| 27 | +@dataclass |
| 28 | +class ResponsePart: |
| 29 | + """Represents a part of the LLM response. |
| 30 | +
|
| 31 | + Attributes: |
| 32 | + text: The conversational text part. Can be an empty string. |
| 33 | + a2ui_json: The parsed A2UI JSON data. None if this part only contains |
| 34 | + trailing text. |
| 35 | + """ |
| 36 | + |
| 37 | + text: str |
| 38 | + a2ui_json: Optional[Any] = None |
| 39 | + |
| 40 | + |
| 41 | +def has_a2ui_parts(content: str) -> bool: |
| 42 | + """Checks if the content has A2UI parts.""" |
| 43 | + return A2UI_OPEN_TAG in content and A2UI_CLOSE_TAG in content |
| 44 | + |
| 45 | + |
| 46 | +def _sanitize_json_string(json_string: str) -> str: |
| 47 | + """Sanitizes the JSON string by removing markdown code blocks.""" |
| 48 | + json_string = json_string.strip() |
| 49 | + if json_string.startswith("```json"): |
| 50 | + json_string = json_string[len("```json") :] |
| 51 | + elif json_string.startswith("```"): |
| 52 | + json_string = json_string[len("```") :] |
| 53 | + if json_string.endswith("```"): |
| 54 | + json_string = json_string[: -len("```")] |
| 55 | + json_string = json_string.strip() |
| 56 | + return json_string |
| 57 | + |
| 58 | + |
| 59 | +def parse_response(content: str) -> List[ResponsePart]: |
22 | 60 | """ |
23 | | - Parses the LLM response into a text part and a JSON object. |
| 61 | + Parses the LLM response into a list of ResponsePart objects. |
24 | 62 |
|
25 | | - Args: |
26 | | - content: The raw LLM response. |
| 63 | + Args: |
| 64 | + content: The raw LLM response. |
27 | 65 |
|
28 | | - Returns: |
29 | | - A tuple of (text_part, json_object). |
30 | | - - text_part (str): The text before the delimiter, stripped of whitespace. |
31 | | - - json_object (Any): The parsed JSON object. |
| 66 | + Returns: |
| 67 | + A list of ResponsePart objects. |
32 | 68 |
|
33 | 69 | Raises: |
34 | | - ValueError: If the delimiter is missing, the JSON part is empty, or the JSON |
35 | | - part is invalid. |
| 70 | + ValueError: If no A2UI tags are found or if the JSON part is invalid. |
36 | 71 | """ |
37 | | - if A2UI_DELIMITER not in content: |
38 | | - raise ValueError(f"Delimiter '{A2UI_DELIMITER}' not found in response.") |
| 72 | + matches = list(_A2UI_BLOCK_PATTERN.finditer(content)) |
| 73 | + |
| 74 | + if not matches: |
| 75 | + raise ValueError( |
| 76 | + f"A2UI tags '{A2UI_OPEN_TAG}' and '{A2UI_CLOSE_TAG}' not found in response." |
| 77 | + ) |
| 78 | + |
| 79 | + response_parts = [] |
| 80 | + last_end = 0 |
| 81 | + |
| 82 | + for match in matches: |
| 83 | + start, end = match.span() |
| 84 | + # Text preceding the JSON block |
| 85 | + text_part = content[last_end:start].strip() |
39 | 86 |
|
40 | | - text_part, json_string = content.split(A2UI_DELIMITER, 1) |
41 | | - text_part = text_part.strip() |
| 87 | + # The JSON content within the tags |
| 88 | + json_string = match.group(1) |
| 89 | + json_string_cleaned = _sanitize_json_string(json_string) |
| 90 | + if not json_string_cleaned: |
| 91 | + raise ValueError("A2UI JSON part is empty.") |
42 | 92 |
|
43 | | - # Clean the JSON string (strip whitespace and common markdown blocks) |
44 | | - json_string_cleaned = ( |
45 | | - json_string.strip().lstrip("```json").lstrip("```").rstrip("```").strip() |
46 | | - ) |
| 93 | + json_data = parse_and_fix(json_string_cleaned) |
| 94 | + response_parts.append(ResponsePart(text=text_part, a2ui_json=json_data)) |
| 95 | + last_end = end |
47 | 96 |
|
48 | | - if not json_string_cleaned: |
49 | | - raise ValueError("A2UI JSON part is empty.") |
| 97 | + # Trailing text after the last JSON block |
| 98 | + trailing_text = content[last_end:].strip() |
| 99 | + if trailing_text: |
| 100 | + response_parts.append(ResponsePart(text=trailing_text, a2ui_json=None)) |
50 | 101 |
|
51 | | - json_data = parse_and_fix(json_string_cleaned) |
52 | | - return text_part, json_data |
| 102 | + return response_parts |
0 commit comments