A
File size: 7,499 Bytes
068faf9
 
 
 
 
 
 
 
 
 
 
 
 
 
289ec6e
068faf9
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
289ec6e
068faf9
 
 
 
 
 
 
 
 
 
 
289ec6e
068faf9
 
289ec6e
068faf9
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
"""Main Socratic bot agent.

Receives the manager's verdict + discussion plan + chat history. Streams a
visible response to the participant, with a trailing
`<scratch>...</scratch>` block parsed by the host and logged.

Prompt lives in `prompts/main_prompt.md` and is loaded via
`core.config_loader.MAIN_PROMPT` at module import time.
"""

import re
import time
from dataclasses import dataclass, field
from core.config_loader import (
    client as _default_client, MODEL, REASONING_EFFORT, VERBOSITY, MAIN_PROMPT,
)
from core import plan_parsing


SCRATCH_RE = re.compile(r"<scratch>(.*?)</scratch>\s*", re.DOTALL | re.IGNORECASE)
SCRATCH_OPEN_RE = re.compile(r"<scratch>", re.IGNORECASE)


@dataclass
class BotResponse:
    """One main-bot reply. `scratch` is a parsed dict from the trailing
    <scratch>...</scratch> block; missing fields become None.

    `visible` is the text shown to the participant (scratch stripped).
    `raw` is the full bot output including the <scratch> block."""
    visible: str = ""
    scratch: dict = field(default_factory=dict)
    raw: str = ""
    latency_ms: int = 0
    ttft_ms: int | None = None


def parse_scratch(raw):
    """Extract the scratch fields from a <scratch>...</scratch> block.
    Returns {} if the block is missing or unparseable. Expects YAML-style
    `key: value` lines inside the tags."""
    if not raw:
        return {}
    m = SCRATCH_RE.search(raw)
    if not m:
        return {}
    body = m.group(1).strip()
    out = {}
    for line in body.splitlines():
        line = line.strip()
        if not line or ":" not in line:
            continue
        k, _, v = line.partition(":")
        out[k.strip().lower()] = v.strip()
    # Coerce known integer fields.
    if "chosen_target" in out:
        try:
            out["chosen_target"] = int(out["chosen_target"])
        except (ValueError, TypeError):
            pass
    return out


def split_visible_and_scratch(raw):
    """Split the raw bot output into (visible_text, scratch_dict). The
    scratch block is stripped from visible regardless of whether parsing
    succeeded."""
    scratch = parse_scratch(raw)
    visible = SCRATCH_RE.sub("", raw or "", count=1).strip()
    # If a stray opening tag remains (unmatched), still strip from that point.
    m = SCRATCH_OPEN_RE.search(visible)
    if m:
        visible = visible[: m.start()].rstrip()
    return visible, scratch


def build_plan_control(verdict, discussion_plan_subprobes):
    """Render the per-turn PLAN CONTROL developer message from the manager's
    verdict and the parsed discussion plan."""
    live = plan_parsing.by_id(discussion_plan_subprobes, verdict.live_subprobe_id)
    if live is None:
        live_block = f"(sub-probe id={verdict.live_subprobe_id} not found in plan)"
    else:
        live_block = (
            f"id: {live['id']}\n"
            f"component: {live['component']}\n"
            f"gap: {live['gap']}\n"
            f"rationale: {live['rationale']}\n"
            f"hook: {live.get('hook','')}"
        )

    qset = verdict.question_set or []
    if qset:
        qset_lines = []
        # Display indices as 1-based ([1], [2], ...) so the bot reads them
        # in the same numbering the prompt examples and the manager's
        # `reason` text use. Internal storage stays a 0-based list.
        for i, item in enumerate(qset):
            qset_lines.append(
                f"  [{i + 1}] ({item.get('status','?')}) {item.get('text','')}"
            )
        qset_block = "\n".join(qset_lines)
        # Highlight the first-pending — that's the question the bot must ask.
        first_pending = next(
            (i for i, item in enumerate(qset)
             if item.get("status") == "pending"),
            None,
        )
        if first_pending is not None:
            qset_block += (
                f"\n  ----\n"
                f"  >>> ASK THIS TURN: index [{first_pending + 1}] = "
                f"{qset[first_pending].get('text','')}"
            )
        else:
            qset_block += "\n  (no pending items — should not happen on a question-asking turn)"
    else:
        qset_block = "  (empty — no questions for this turn; expected for plan-complete)"

    return (
        "=== PLAN CONTROL (host-managed, authoritative) ===\n"
        f"turn_type: {verdict.turn_type}\n"
        f"live_subprobe_id: {verdict.live_subprobe_id}\n"
        f"advanced_this_turn: {verdict.advanced_this_turn}\n"
        "\n"
        "LIVE SUB-PROBE (work this one this turn):\n"
        f"{live_block}\n"
        "\n"
        "QUESTION_SET (ordered list; ask the first item with status=pending):\n"
        f"{qset_block}\n"
        "\n"
        "DIRECTIVE for this turn (framing / anchor / recap instructions):\n"
        f"{verdict.directive}\n"
        "=== END PLAN CONTROL ==="
    )


def build_messages(history, case_text, discussion_plan_text, plan_control_text):
    """Assemble the developer + chat messages for the main bot call.

    Layout (oldest to newest):
      developer: MAIN_PROMPT (the bot's system prompt)
      developer: case text
      developer: full discussion plan (for reference; PLAN CONTROL names the live one)
      developer: PLAN CONTROL block (this turn's directive)
      user/assistant: chat history (first 'assistant' = case text, skipped)
    """
    msgs = [
        {"role": "developer", "content": MAIN_PROMPT},
        {"role": "developer", "content": f"The case being discussed:\n\n{case_text}"},
        {
            "role": "developer",
            "content": (
                "=== DISCUSSION PLAN ===\n"
                f"{discussion_plan_text}\n"
                "=== END DISCUSSION PLAN ==="
            ),
        },
        {"role": "developer", "content": plan_control_text},
    ]

    skip_first_assistant = True
    for m in history:
        role = m.get("role", "user")
        content = m.get("content", "")
        if isinstance(content, list):
            content = " ".join(
                b.get("text", "") if isinstance(b, dict) else str(b)
                for b in content
            )
        if skip_first_assistant and role == "assistant":
            skip_first_assistant = False
            continue
        msgs.append({"role": role, "content": str(content)})
    return msgs


def stream(messages, client=None):
    """Single-pass main bot reply. The bot now does both reasoning AND
    rendering (short-question pattern, sentence shape) in one call, so
    no separate paraphrase pass is needed.

    Non-streaming for simplicity. Emits one delta with the full visible
    text, then a done event.

    Generator contract preserved:
      ("delta", str)       — the visible text (yielded once)
      ("done", BotResponse) — final summary; ALWAYS the last yield
    """
    api = client if client is not None else _default_client
    t0 = time.perf_counter()

    bot_resp = api.responses.create(
        model=MODEL,
        reasoning={"effort": REASONING_EFFORT},
        text={"verbosity": VERBOSITY},
        input=messages,
    )
    raw = bot_resp.output_text or ""
    visible, scratch = split_visible_and_scratch(raw)
    done_t = time.perf_counter()

    ttft_ms = int((done_t - t0) * 1000)
    if visible:
        yield ("delta", visible)

    yield (
        "done",
        BotResponse(
            visible=visible,
            scratch=scratch,
            raw=raw,
            latency_ms=int((done_t - t0) * 1000),
            ttft_ms=ttft_ms,
        ),
    )