"""Main Socratic bot agent. Receives the manager's verdict + discussion plan + chat history. Streams a visible response to the participant, with a trailing `...` 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"(.*?)\s*", re.DOTALL | re.IGNORECASE) SCRATCH_OPEN_RE = re.compile(r"", re.IGNORECASE) @dataclass class BotResponse: """One main-bot reply. `scratch` is a parsed dict from the trailing ... block; missing fields become None. `visible` is the text shown to the participant (scratch stripped). `raw` is the full bot output including the 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 ... 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, ), )