GVillegasPM commited on
Commit
4703065
·
1 Parent(s): dd602d1

feat: reply protocol parser for voice segments and control tags

Browse files
Files changed (2) hide show
  1. imagine_buddy/protocol.py +42 -0
  2. tests/test_protocol.py +48 -0
imagine_buddy/protocol.py ADDED
@@ -0,0 +1,42 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Parser for the brain's reply protocol: [voice:x] segments + control tags."""
2
+ import re
3
+ from dataclasses import dataclass
4
+
5
+ VOICE_TAG = re.compile(r"\[voice:([^\]]+)\]")
6
+ CONTROL_TAG = re.compile(r"\[(cast:add|beat:advance|fateroll)((?:[^\]])*)\]")
7
+ ATTR = re.compile(r'(\w+)=("[^"]*"|\S+)')
8
+
9
+
10
+ @dataclass(frozen=True)
11
+ class ReplySegment:
12
+ voice: str
13
+ text: str
14
+
15
+
16
+ @dataclass(frozen=True)
17
+ class ParsedReply:
18
+ segments: tuple[ReplySegment, ...]
19
+ controls: tuple[dict, ...]
20
+
21
+
22
+ def parse_reply(raw: str) -> ParsedReply:
23
+ controls = []
24
+
25
+ def collect(match):
26
+ attrs = {k: v.strip('"').strip() for k, v in ATTR.findall(match.group(2) or "")}
27
+ controls.append({"type": match.group(1), **attrs})
28
+ return ""
29
+
30
+ text = CONTROL_TAG.sub(collect, raw)
31
+
32
+ segments = []
33
+ parts = VOICE_TAG.split(text) # [pre, voice1, text1, voice2, text2, ...]
34
+ pre = parts[0].strip()
35
+ if pre:
36
+ segments.append(ReplySegment("narrator", pre))
37
+ for i in range(1, len(parts) - 1, 2):
38
+ voice = parts[i].strip()
39
+ seg_text = parts[i + 1].strip()
40
+ if seg_text:
41
+ segments.append(ReplySegment(voice, seg_text))
42
+ return ParsedReply(tuple(segments), tuple(controls))
tests/test_protocol.py ADDED
@@ -0,0 +1,48 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from imagine_buddy.protocol import ReplySegment, parse_reply
2
+
3
+
4
+ def test_plain_text_becomes_narrator_segment():
5
+ p = parse_reply("The wood holds its breath.")
6
+ assert p.segments == (ReplySegment("narrator", "The wood holds its breath."),)
7
+ assert p.controls == ()
8
+
9
+
10
+ def test_multiple_voice_segments():
11
+ p = parse_reply('[voice:narrator] A bridge appears. [voice:Rexy] "Who goes there?"')
12
+ assert p.segments == (
13
+ ReplySegment("narrator", "A bridge appears."),
14
+ ReplySegment("Rexy", '"Who goes there?"'),
15
+ )
16
+
17
+
18
+ def test_control_tags_extracted_and_stripped():
19
+ raw = ('[voice:narrator] A new friend! '
20
+ '[cast:add name=Rexy role="bridge guardian" temperament=gruff] '
21
+ '[beat:advance][fateroll]')
22
+ p = parse_reply(raw)
23
+ assert p.segments == (ReplySegment("narrator", "A new friend!"),)
24
+ assert {"type": "cast:add", "name": "Rexy", "role": "bridge guardian",
25
+ "temperament": "gruff"} in p.controls
26
+ assert {"type": "beat:advance"} in p.controls
27
+ assert {"type": "fateroll"} in p.controls
28
+
29
+
30
+ def test_text_before_first_voice_tag_is_narrator():
31
+ p = parse_reply('Once upon a time. [voice:Knight] Onward!')
32
+ assert p.segments[0] == ReplySegment("narrator", "Once upon a time.")
33
+ assert p.segments[1] == ReplySegment("Knight", "Onward!")
34
+
35
+
36
+ def test_empty_segments_are_dropped():
37
+ p = parse_reply("[voice:narrator] [voice:Rexy] Rawr!")
38
+ assert p.segments == (ReplySegment("Rexy", "Rawr!"),)
39
+
40
+
41
+ def test_unknown_bracket_tags_left_in_text():
42
+ p = parse_reply("[voice:narrator] He waves [enthusiastically].")
43
+ assert p.segments == (ReplySegment("narrator", "He waves [enthusiastically]."),)
44
+
45
+
46
+ def test_attr_values_are_whitespace_stripped():
47
+ p = parse_reply('[cast:add name=" Rexy " role=dino temperament=gruff]')
48
+ assert p.controls[0]["name"] == "Rexy"