File size: 3,094 Bytes
e29c7ce
 
 
 
5eb1b3d
 
 
 
 
 
 
 
 
 
e29c7ce
 
5eb1b3d
e29c7ce
5eb1b3d
e29c7ce
 
 
832430f
e29c7ce
 
 
832430f
 
5eb1b3d
 
 
 
 
 
 
 
 
 
e29c7ce
 
 
5eb1b3d
 
e29c7ce
 
 
 
 
5eb1b3d
 
 
 
 
 
 
 
e29c7ce
5eb1b3d
e29c7ce
 
 
 
5eb1b3d
e29c7ce
 
 
5eb1b3d
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
e29c7ce
5eb1b3d
 
 
 
e29c7ce
832430f
 
e29c7ce
 
 
 
 
5eb1b3d
e29c7ce
5eb1b3d
 
 
e29c7ce
 
5eb1b3d
e29c7ce
 
 
 
 
5eb1b3d
e29c7ce
 
5eb1b3d
e29c7ce
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
"""
Response parser β€” splits a raw LLM response into typed segments.

Segment types:
    'dialogue' β€” plain spoken text
    'action'   β€” content inside <action>...</action>
    'thought'  β€” content inside <thought>...</thought>

Example:
    segments = parse_response(
        "<action>Looks around.</action>"
        "Hello."
        "<thought>This is getting strange...</thought>"
    )

    # [
    #   {'type': 'action',   'content': 'Looks around.'},
    #   {'type': 'dialogue', 'content': 'Hello.'},
    #   {'type': 'thought',  'content': 'This is getting strange...'},
    # ]
"""

import logging
import re
from typing import TypedDict

logger = logging.getLogger(__name__)


# Matches:
#   <action>...</action>
#   <thought>...</thought>
#
# re.DOTALL allows the content to span multiple lines.
_PATTERN = re.compile(
    r"(<action>.*?</action>|<thought>.*?</thought>)",
    re.DOTALL | re.IGNORECASE,
)


class Segment(TypedDict):
    type: str      # 'dialogue' | 'action' | 'thought'
    content: str   # text without surrounding tags


def parse_response(text: str) -> list[Segment]:
    """
    Parse a raw character response into a list of typed segments.

    Supported formats:
        <action>...</action>
        <thought>...</thought>
        plain dialogue text

    Preserves reading order.
    Skips empty parts.
    """

    segments: list[Segment] = []

    for part in _PATTERN.split(text):
        part = part.strip()

        if not part:
            continue

        lower_part = part.lower()

        if lower_part.startswith("<action>") and lower_part.endswith("</action>"):
            content = part[len("<action>"):-len("</action>")].strip()

            if content:
                segments.append({
                    "type": "action",
                    "content": content,
                })

        elif lower_part.startswith("<thought>") and lower_part.endswith("</thought>"):
            content = part[len("<thought>"):-len("</thought>")].strip()

            if content:
                segments.append({
                    "type": "thought",
                    "content": content,
                })

        else:
            segments.append({
                "type": "dialogue",
                "content": part,
            })

    type_counts = {t: sum(1 for s in segments if s["type"] == t) for t in ("dialogue", "action", "thought")}
    logger.debug(f"[Parser] {len(text)} chars β†’ {len(segments)} segments {type_counts}")
    return segments


def segments_to_markdown(segments: list[Segment]) -> str:
    """
    Convert segments into a Gradio-friendly markdown string.

    Conventions:
        action   β†’ italic text
        thought  β†’ italic text with an em dash
        dialogue β†’ plain text
    """

    lines: list[str] = []

    for seg in segments:
        if seg["type"] == "action":
            lines.append(f"*({seg['content']})*")

        elif seg["type"] == "thought":
            lines.append(f"*β€” {seg['content']}*")

        else:
            lines.append(seg["content"])

    return "\n".join(lines)