Spaces:
Running
Running
File size: 9,476 Bytes
068faf9 8d6d054 068faf9 8d6d054 068faf9 8d6d054 068faf9 8d6d054 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 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 | """Discussion Manager agent.
Runs once before each main-bot turn. Reads the discussion plan, the
manager's own past verdicts, the recent conversation, and the participant's
current message. Outputs a JSON verdict that tells the main bot what kind
of move to make this turn.
Prompt lives in `prompts/manager_prompt.md` and is loaded via
`core.config_loader.MANAGER_PROMPT` at module import time. To change the
manager's behaviour, edit that markdown file; do not touch this module.
"""
import json
import re
import time
from dataclasses import dataclass, asdict
from core.config_loader import client as _default_client, MODEL, MANAGER_PROMPT
from core import plan_parsing
VALID_TURN_TYPES = {
"opening",
"engaging-default",
"transition-due",
"needs-clarification",
"plan-complete",
}
@dataclass
class ManagerVerdict:
"""One manager classification. Serialized into the session log so the
analyst can reproduce the bot's per-turn behaviour from the verdict
trajectory alone."""
live_subprobe_id: int
advanced_this_turn: bool
turn_type: str
directive: str
reason: str
user_msg_excerpt: str = "" # ~120-char excerpt of the participant
# message that triggered this verdict.
# Lets the manager see, on the NEXT turn,
# what the participant actually said when
# each past verdict was made (not just
# the verdict's own "reason" field).
question_set: list = None # NEW: ordered list of {text, status} items
# for the CURRENT live sub-probe. Manager
# maintains across turns. Bot reads the
# first item with status="pending" and asks it.
latency_ms: int = 0
def to_dict(self):
d = asdict(self)
if d.get("question_set") is None:
d["question_set"] = []
return d
_JSON_FENCE_RE = re.compile(r"```(?:json)?\s*(\{.*?\})\s*```", re.DOTALL | re.IGNORECASE)
_BARE_JSON_RE = re.compile(r"(\{[\s\S]*\})", re.DOTALL)
def _parse_json(raw):
"""Extract the first JSON object from the model output. Tolerant of
markdown fences and surrounding prose."""
raw = (raw or "").strip()
if not raw:
return {}
m = _JSON_FENCE_RE.search(raw)
if m:
try:
return json.loads(m.group(1))
except json.JSONDecodeError:
pass
m = _BARE_JSON_RE.search(raw)
if m:
try:
return json.loads(m.group(1))
except json.JSONDecodeError:
pass
return {}
def _format_manager_history(history):
"""Render the manager_history list as a multi-line block for the prompt.
Each row includes: classification + the participant message excerpt +
the directive issued + the reason + the question_set state at the end
of that turn (so the next manager turn can read what was pending /
asked and evolve correctly)."""
if not history:
return "(empty; this is the first manager turn of the session)"
rows = []
for i, v in enumerate(history, 1):
umsg = (v.get("user_msg_excerpt") or "").strip() or "(n/a)"
directive = (v.get("directive") or "").strip()
if len(directive) > 180:
directive = directive[:180].rstrip() + "…"
reason = (v.get("reason") or "").strip() or "(no reason)"
qset = v.get("question_set") or []
if qset:
qset_str = "\n".join(
f" [{j}] ({item.get('status','?')}) {item.get('text','')}"
for j, item in enumerate(qset)
)
else:
qset_str = " (none)"
rows.append(
f"- Turn {i}: live_subprobe_id={v.get('live_subprobe_id')} "
f"turn_type={v.get('turn_type')} "
f"advanced={v.get('advanced_this_turn')}\n"
f" participant said: {umsg}\n"
f" you instructed bot: {directive}\n"
f" reason: {reason}\n"
f" question_set at end of this turn:\n{qset_str}"
)
return "\n".join(rows)
def _format_recent_turns(turns, k=None):
"""Render participant + bot exchanges from the chat history.
The history is in Gradio chatbot format (list of {role, content} dicts).
The very first 'assistant' message (the case text) is skipped.
`k=None` (default) renders ALL turns in the session. Pass an integer to
cap to the last K turns; the prior policy was k=12 but the new manager
needs the full dialogue flow to judge path-(A) gap-satisfaction vs
path-(B) participant-stuck.
"""
if not turns:
return "(no prior turns)"
skip_first_assistant = True
cleaned = []
for m in turns:
role = m.get("role", "?")
if skip_first_assistant and role == "assistant":
skip_first_assistant = False
continue
content = m.get("content", "")
if isinstance(content, list):
content = " ".join(
b.get("text", "") if isinstance(b, dict) else str(b)
for b in content
)
cleaned.append((role, str(content).strip()))
sliced = cleaned if k is None else cleaned[-k:]
return "\n".join(f"[{role}] {content}" for role, content in sliced) or "(no prior turns)"
def classify(discussion_plan_subprobes, manager_history, recent_turns,
current_msg, initial_argument="", client=None):
"""Run the manager LLM call. Returns a ManagerVerdict.
- `discussion_plan_subprobes`: parsed sub-probe list from plan_parsing
- `manager_history`: list of prior verdict dicts (most recent last)
- `recent_turns`: Gradio chat history list (full); ALL turns are passed
to the manager so it can judge transition paths from the complete flow
- `current_msg`: the participant's message this turn
- `initial_argument`: the participant's full initial argument (the
essay they submitted at Phase 1). Always passed as a separate block
so the manager can ALWAYS see the participant's overall stance.
"""
plan_block = plan_parsing.format_for_prompt(discussion_plan_subprobes)
history_block = _format_manager_history(manager_history)
turns_block = _format_recent_turns(recent_turns, k=None)
initial_block = (initial_argument or "").strip() or "(unavailable)"
user_msg = (
"INITIAL_ARGUMENT (participant's full original essay; the discussion "
"plan was generated from this; ALWAYS visible regardless of how many "
"turns have passed):\n"
f"{initial_block}\n\n"
"DISCUSSION_PLAN:\n"
f"{plan_block}\n\n"
"MANAGER_HISTORY:\n"
f"{history_block}\n\n"
"RECENT_TURNS (all messages in this session, oldest first):\n"
f"{turns_block}\n\n"
"CURRENT_MSG:\n"
f"{current_msg}\n\n"
"Output your JSON verdict now. No prose around it, JSON only."
)
api = client if client is not None else _default_client
t0 = time.perf_counter()
response = api.responses.create(
model=MODEL,
reasoning={"effort": "low"},
text={"verbosity": "low"},
input=[
{"role": "developer", "content": MANAGER_PROMPT},
{"role": "user", "content": user_msg},
],
)
latency_ms = int((time.perf_counter() - t0) * 1000)
raw = response.output_text or ""
data = _parse_json(raw)
# Validate fields with fallbacks.
prev_id = manager_history[-1]["live_subprobe_id"] if manager_history else None
default_id = (
prev_id if prev_id is not None
else (discussion_plan_subprobes[0]["id"] if discussion_plan_subprobes else 1)
)
live_id = data.get("live_subprobe_id", default_id)
if not isinstance(live_id, int):
try:
live_id = int(live_id)
except (ValueError, TypeError):
live_id = default_id
turn_type = data.get("turn_type", "")
if turn_type not in VALID_TURN_TYPES:
turn_type = "engaging-default"
# advanced_this_turn must match the actual id change, regardless of what
# the model reported. This is the one piece of state we hard-verify.
advanced = (prev_id is not None) and (live_id != prev_id)
# Directive is a natural-language paragraph (no labelled fields).
directive = (data.get("directive") or "").strip()
reason = (data.get("reason") or "").strip() or "(no reason given)"
# question_set: ordered list of {text, status} items. Sanitize: keep
# only well-formed items with a non-empty text and a valid status.
raw_qset = data.get("question_set") or []
question_set = []
if isinstance(raw_qset, list):
for item in raw_qset:
if not isinstance(item, dict):
continue
text = (item.get("text") or "").strip()
status = (item.get("status") or "").strip().lower()
if text and status in ("asked", "pending"):
question_set.append({"text": text, "status": status})
return ManagerVerdict(
live_subprobe_id=live_id,
advanced_this_turn=advanced,
turn_type=turn_type,
directive=directive,
reason=reason,
user_msg_excerpt=(current_msg or "").strip()[:160],
question_set=question_set,
latency_ms=latency_ms,
)
|