Spaces:
Sleeping
Sleeping
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)},
)
|