A
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,
    )