Spaces:
Running
Running
NeonClary
feat(conversation): pluggable structures + decision methods (Robert's Rules, ranked-choice, majority)
e661e91 | """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)}, | |
| ) | |