File size: 14,547 Bytes
e661e91
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
"""Robert's Rules of Order — chair-mediated parliamentary discussion.

The classical RR flow, condensed for an LLM forum:

  RR-1 OPENING: the chair (orchestrator) calls the meeting to order
       and reads the question.

  RR-2 INITIAL REMARKS: each member is recognized in roster order
       and briefly states their position (a few sentences). This
       mirrors Phase 1 of the Collaborative structure but with chair
       framing. We also build the credential summary here so the
       same downstream tooling (View Credential Summary, etc.) works.

  RR-3 MOTION: the chair synthesizes a main motion from the initial
       remarks and asks for a second. Members who aren't the
       motion-maker are polled briefly for a "second" / "no second";
       the first agreement carries.

  RR-4 DEBATE: configurable number of rounds (defaults to
       `limits.critique_rounds`). Each member speaks in turn, must
       address the motion (support / oppose / amend / propose
       alternative), and may name other members. Each member's last
       remark in the debate is captured as their finalized position
       so the decision phase has the standardized hand-off it needs.

  RR-5 MOVE THE QUESTION: the chair declares debate closed and
       hands off to the decision method (typically RobertsRulesVote
       but any decision method is compatible).

The structure is deliberately self-contained — none of the
Collaborative phase functions are reused — so future RR features
(amendments, points of order, etc.) can be added without
entangling the original CCAI flow.
"""
from __future__ import annotations

from typing import Any, AsyncIterator

from app.services.conversation.structures.base import ConversationStructure
from app.services.conversation.types import DecisionInput
from app.services.credential import build_credential_summary
from app.services.json_calls import orchestrator_call
from app.services.models import Participant, Phase
from app.services.resilience import run_resilient_turn


RR_OPENING_TEMPLATE = (
    "The meeting is called to order. The chair reads the question "
    "before the assembly:\n\n"
    "  {question}\n\n"
    "The chair will recognize each member in turn for opening "
    "remarks, after which the floor will be open for motions."
)


RR_INITIAL_REMARKS_PROMPT = """The chair has called the meeting to order and read \
the following question:

  {question}

You are {speaker_name}. The chair recognizes you for opening remarks. \
Briefly state your position on the question in two or three sentences. \
Speak as a member of a deliberative assembly — formal but not stiff. \
Do not yet make a formal motion; that will come later.
"""


RR_MOTION_SYNTHESIS_PROMPT = """You are the chair of a meeting governed by \
Robert's Rules of Order. The following members have just given their \
opening remarks on this question:

  Question: {question}

Opening remarks:
{remarks_block}

Synthesize ONE concrete motion that reflects the most strongly-supported \
position. Also identify which member is most likely to formally make \
this motion (the "mover") based on their remarks, and which other \
member is most likely to second it.

Return ONLY this JSON (no prose, no fences):
{{
  "motion": "Resolved, that ...",
  "mover_id": "<participant_id>",
  "seconder_id": "<participant_id>"
}}
"""


RR_DEBATE_PROMPT = """You are {speaker_name}, a member of an assembly \
governed by Robert's Rules of Order. The main motion on the floor is:

  Motion: {motion}

This is debate round {round_n} of {round_total}. The chair has \
recognized you. Speak to the motion: support it, oppose it, propose \
an amendment, or offer a substitute. Be specific. You may address \
other members by name.

Question under consideration: {question}

Debate so far:
{transcript}
"""


class RobertsRulesDiscussion(ConversationStructure):
    NAME = "Robert's Rules of Order"
    DESCRIPTION = (
        "Chair-mediated parliamentary debate: opening remarks, the "
        "chair synthesizes a main motion and finds a seconder, "
        "members debate the motion in turn, then the chair calls "
        "for the vote."
    )

    async def run(self) -> AsyncIterator[str]:
        # Imports are local to avoid pulling the orchestrator module
        # at package-import time (orchestrator.py itself imports the
        # conversation package via the dispatcher path).
        from app.services.orchestrator import (
            _sse,
            _active_participants,
            _add_orchestrator_message,
            _add_participant_message,
            _bump_orchestrator_count,
            _call_participant,
            _format_history,
            _msg_payload,
            _orchestrator_model_id,
            _participant_msg_cap_hit,
            _wait_for_continue,
        )

        session = self.session
        actives = _active_participants(session)
        if len(actives) < 2:
            yield _sse("error", {"message": "Robert's Rules needs at least 2 members."})
            return

        # ---- RR-1: Opening -------------------------------------------------
        session.phase = Phase.RR_OPENING
        yield _sse("status", {"message": "RR-1: chair calls the meeting to order..."})
        opening_text = RR_OPENING_TEMPLATE.format(question=session.question)
        msg = _add_orchestrator_message(
            session, opening_text, kind="rr_opening",
            extra={"question": session.question},
        )
        yield _sse("orchestrator", _msg_payload(msg))

        # ---- RR-2: Initial Remarks ----------------------------------------
        session.phase = Phase.RR_INITIAL_REMARKS
        yield _sse("status", {"message": "RR-2: opening remarks..."})
        remarks: dict[str, str] = {}
        for p in _active_participants(session):
            if p.kind == "human":
                # For now humans skip remarks under RR; a future pass
                # can pause for input via human_io.
                continue
            prompt = RR_INITIAL_REMARKS_PROMPT.format(
                question=session.question,
                speaker_name=p.name,
            )
            turn = await run_resilient_turn(
                session=session, participant=p,
                user_prompt=prompt,
                label="rr_initial_remarks",
                max_tokens=400,
                call_participant=_call_participant,
            )
            for ev in turn.sse_events:
                yield ev
            if not turn.ok:
                yield _sse("participant_error", {
                    "participant_id": p.participant_id,
                    "name": p.name,
                    "phase": session.phase.value,
                })
                if p.consecutive_failures >= session.limits.auto_disable_failures:
                    p.enabled = False
                continue
            speaker = turn.speaker
            msg = _add_participant_message(
                session, speaker, turn.text,
                phase=session.phase, elapsed=turn.elapsed,
            )
            remarks[speaker.participant_id] = turn.text
            session.initial_opinions[speaker.participant_id] = turn.text
            yield _sse("message", _msg_payload(msg))
            if _participant_msg_cap_hit(session):
                async for chunk in _wait_for_continue(session, "messages"):
                    yield chunk

        # Credential summary — mirrors Phase 1 of Collaborative so the
        # rest of the app (View Credential Summary, etc.) stays happy.
        yield _sse("status", {"message": "Building Credential Summary..."})
        creds = await build_credential_summary(
            orchestrator_model_id=_orchestrator_model_id(session),
            question=session.question,
            participants=_active_participants(session),
            initial_opinions=session.initial_opinions,
            api_log=session.api_log,
            human_credential=session.human_credential,
        )
        _bump_orchestrator_count(session)
        session.credential_summary = creds
        yield _sse("credentials_updated", {
            "stage": "built",
            "credentials": session.credential_summary,
        })

        # ---- RR-3: Motion --------------------------------------------------
        session.phase = Phase.RR_MOTION
        yield _sse("status", {"message": "RR-3: chair invites a motion..."})

        remarks_block = "\n".join(
            f"- {p.name}: {remarks.get(p.participant_id, '(no remarks)')}"
            for p in _active_participants(session)
        )
        _, parsed = await orchestrator_call(
            orchestrator_model_id=_orchestrator_model_id(session),
            user_prompt=RR_MOTION_SYNTHESIS_PROMPT.format(
                question=session.question, remarks_block=remarks_block,
            ),
            label="rr_motion_synthesis",
            api_log=session.api_log,
            expect_json=True,
            max_tokens=500,
            temperature=0.3,
        )
        _bump_orchestrator_count(session)

        motion = ""
        mover_id = ""
        seconder_id = ""
        if isinstance(parsed, dict):
            motion = str(parsed.get("motion") or "").strip()
            mover_id = str(parsed.get("mover_id") or "").strip()
            seconder_id = str(parsed.get("seconder_id") or "").strip()
        if not motion:
            motion = (
                "Resolved, that the assembly endorses the position most "
                "broadly reflected in opening remarks."
            )
        session.main_motion = motion
        session.proposed_motions.append({
            "motion": motion,
            "mover_id": mover_id,
            "seconder_id": seconder_id,
        })

        by_id = {p.participant_id: p for p in _active_participants(session)}
        mover = by_id.get(mover_id)
        seconder = by_id.get(seconder_id)
        # Fallbacks if the chair's id picks don't match the roster
        if not mover:
            ai_members = [p for p in _active_participants(session) if p.kind != "human"]
            mover = ai_members[0] if ai_members else None
        if not seconder:
            ai_members = [
                p for p in _active_participants(session)
                if p.kind != "human" and mover and p.participant_id != mover.participant_id
            ]
            seconder = ai_members[0] if ai_members else None

        motion_announce = (
            f"The chair recognizes {mover.name if mover else 'the floor'} "
            f"to make a motion:\n\n"
            f"  \"{motion}\"\n\n"
            + (f"{seconder.name} seconds the motion. " if seconder else "")
            + "The motion is on the floor. Debate is open."
        )
        msg = _add_orchestrator_message(
            session, motion_announce, kind="motion",
            extra={
                "motion": motion,
                "mover_id": mover.participant_id if mover else "",
                "seconder_id": seconder.participant_id if seconder else "",
            },
        )
        yield _sse("orchestrator", _msg_payload(msg))

        # ---- RR-4: Debate --------------------------------------------------
        session.phase = Phase.RR_DEBATE
        debate_rounds = max(1, session.limits.critique_rounds)
        last_remark_per_member: dict[str, str] = dict(remarks)

        for round_n in range(1, debate_rounds + 1):
            yield _sse("status", {
                "message": f"RR-4: debate round {round_n} of {debate_rounds}...",
            })
            for p in _active_participants(session):
                if p.kind == "human":
                    continue
                prompt = RR_DEBATE_PROMPT.format(
                    speaker_name=p.name,
                    motion=motion,
                    round_n=round_n,
                    round_total=debate_rounds,
                    question=session.question,
                    transcript=_format_history(session.messages),
                )
                turn = await run_resilient_turn(
                    session=session, participant=p,
                    user_prompt=prompt,
                    label=f"rr_debate_round_{round_n}",
                    max_tokens=600,
                    call_participant=_call_participant,
                )
                for ev in turn.sse_events:
                    yield ev
                if not turn.ok:
                    yield _sse("participant_error", {
                        "participant_id": p.participant_id,
                        "name": p.name,
                        "phase": session.phase.value,
                    })
                    if p.consecutive_failures >= session.limits.auto_disable_failures:
                        p.enabled = False
                    continue
                speaker = turn.speaker
                msg = _add_participant_message(
                    session, speaker, turn.text,
                    phase=session.phase, elapsed=turn.elapsed,
                )
                last_remark_per_member[speaker.participant_id] = turn.text
                yield _sse("message", _msg_payload(msg))
                if _participant_msg_cap_hit(session):
                    async for chunk in _wait_for_continue(session, "messages"):
                        yield chunk

        # Final positions = last thing each member said in debate. This
        # is what we hand off to the decision phase via DecisionInput.
        session.final_opinions = dict(last_remark_per_member)

        # ---- RR-5: Move the Question --------------------------------------
        session.phase = Phase.RR_MOVE_THE_QUESTION
        closing = (
            "The chair calls the question. Debate is closed. The "
            "assembly will now proceed to the vote on the motion: "
            f"\"{motion}\""
        )
        msg = _add_orchestrator_message(
            session, closing, kind="rr_call_the_question",
            extra={"motion": motion},
        )
        yield _sse("orchestrator", _msg_payload(msg))

    def build_decision_input(self) -> DecisionInput:
        session = self.session
        actives = [p for p in session.participants if p.enabled]
        return DecisionInput(
            question=session.question,
            participants=actives,
            transcript_messages=list(session.messages),
            finalized_positions=dict(session.final_opinions),
            main_motion=session.main_motion,
            extras={"proposed_motions": list(session.proposed_motions)},
        )