NeonClary Cursor commited on
Commit
e661e91
·
1 Parent(s): cdef1a0

feat(conversation): pluggable structures + decision methods (Robert's Rules, ranked-choice, majority)

Browse files

The orchestrator is now structured as ConversationStructure → DecisionMethod
plugins joined by a standardized DecisionInput contract. The existing
six-phase flow is preserved as CollaborativeDiscussion + ConsensusDecision
(unchanged behavior); three new decision methods (MajorityRulesDecision,
RankedChoiceDecision, RobertsRulesVote) and one new structure
(RobertsRulesDiscussion) are wired into the registry. Adding a new
structure or decision method now means dropping a class into
backend/app/services/conversation/{structures,decisions}/ and adding
one line to the registry.

Backend
- New package backend/app/services/conversation/ with base classes,
DecisionInput contract, and a get_structure/get_decision registry
resolver (defaults: collaborative + consensus, so older /chat/start
payloads keep working).
- CollaborativeDiscussion + ConsensusDecision are thin wrappers over
the original _phase_* functions in orchestrator.py — zero behavior
change for the default path.
- RobertsRulesDiscussion implements opening → initial remarks → main
motion synthesis (+ seconder) → debate rounds → call the question;
hands off a DecisionInput populated with main_motion and per-member
finalized positions.
- MajorityRulesDecision: clusters finalized positions into <=6 option
labels via the orchestrator LLM, polls each AI participant for one
vote, tallies plurality. With a main_motion, switches to aye/nay/
abstain.
- RankedChoiceDecision: each participant fully ranks the options;
IRV picks a winner (with deterministic bottom-tie elimination).
- RobertsRulesVote: aye/nay/abstain on the main motion with
parliamentary phrasing; synthesizes a motion from finalized
positions if paired with a non-RR structure.
- Voting helpers (extract_candidate_options, cast_vote_*, tally_*,
run_irv) live in conversation/voting.py and are decision-method
agnostic.
- run_conversation in orchestrator.py becomes a thin dispatcher that
resolves structure + decision IDs from the session.
- StartChatRequest accepts conversation_structure_id and
decision_method_id; Session carries the choices plus main_motion /
proposed_motions for RR state.
- New GET /api/chat/conversation-formats endpoint exposes the catalog
so the frontend picker is driven by the server (no code change
needed to add plugins).
- New Phase enum values: RR_OPENING, RR_INITIAL_REMARKS, RR_MOTION,
RR_DEBATE, RR_MOVE_THE_QUESTION, VOTING.

Frontend
- New "Conversation format" accordion in Settings → DevMenu with
Structure + Decision picker lists; catalog hydrated on mount,
selections persisted to localStorage and sent in the /chat/start
payload.
- New SSE event mappings: vote_cast (per-ballot system note) and
vote_tally (one-line summary).
- OrchestratorMessage.js renders new kinds: vote_result,
ranked_choice_result, motion, ballot_options, rr_opening,
rr_call_the_question (with kind-aware label suffixes; vote_result
and ranked_choice_result get the report visual treatment).

Verified
- All 60 backend tests pass.
- Frontend production build compiles clean.
- Module-level smoke test: default + non-default IDs resolve, unknown
IDs fall back to defaults, all phases load.
- IRV / plurality / yes-no tally helpers smoke-tested with mixed
ballot shapes (clear winners, runoffs, ties, abstentions).

Co-authored-by: Cursor <cursoragent@cursor.com>

backend/app/api/chat.py CHANGED
@@ -166,6 +166,13 @@ class StartChatRequest(BaseModel):
166
  # that has kind == "human". Capped at one human per session.
167
  human_credential: HumanCredentialPayload | None = None
168
 
 
 
 
 
 
 
 
169
 
170
  # ---------------------------------------------------------------------------
171
  # Settings endpoints (orchestrator default + speed priority)
@@ -187,6 +194,29 @@ async def api_get_speed_priority():
187
  return {"enabled": settings.speed_priority}
188
 
189
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
190
  @router.put("/chat/speed-priority")
191
  async def api_set_speed_priority(req: SetSpeedPriorityRequest):
192
  settings.speed_priority = req.enabled
@@ -565,6 +595,20 @@ async def api_start_chat(req: StartChatRequest, request: Request):
565
  ))
566
  session.candidate_pool = candidate_pool
567
  session.substitution_chain = build_substitution_chain(neon_hana_model_ids)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
568
  if humans and req.human_credential is not None:
569
  session.human_credential = normalize_one_credential({
570
  "participant_id": req.human_credential.participant_id,
 
166
  # that has kind == "human". Capped at one human per session.
167
  human_credential: HumanCredentialPayload | None = None
168
 
169
+ # Conversation-format plugin selection. IDs come from
170
+ # app.services.conversation.STRUCTURE_REGISTRY /
171
+ # DECISION_REGISTRY. Missing or unknown IDs are silently coerced
172
+ # to the defaults (collaborative + consensus) at start time.
173
+ conversation_structure_id: str | None = None
174
+ decision_method_id: str | None = None
175
+
176
 
177
  # ---------------------------------------------------------------------------
178
  # Settings endpoints (orchestrator default + speed priority)
 
194
  return {"enabled": settings.speed_priority}
195
 
196
 
197
+ @router.get("/chat/conversation-formats")
198
+ async def api_conversation_formats():
199
+ """Catalog the available conversation structures + decision methods.
200
+
201
+ The frontend reads this once to populate the "Conversation format"
202
+ accordion in Settings. IDs returned here are the same values
203
+ accepted by /chat/start under `conversation_structure_id` and
204
+ `decision_method_id`.
205
+ """
206
+ from app.services.conversation import (
207
+ list_structures,
208
+ list_decisions,
209
+ DEFAULT_STRUCTURE_ID,
210
+ DEFAULT_DECISION_ID,
211
+ )
212
+ return {
213
+ "structures": list_structures(),
214
+ "decisions": list_decisions(),
215
+ "default_structure_id": DEFAULT_STRUCTURE_ID,
216
+ "default_decision_id": DEFAULT_DECISION_ID,
217
+ }
218
+
219
+
220
  @router.put("/chat/speed-priority")
221
  async def api_set_speed_priority(req: SetSpeedPriorityRequest):
222
  settings.speed_priority = req.enabled
 
595
  ))
596
  session.candidate_pool = candidate_pool
597
  session.substitution_chain = build_substitution_chain(neon_hana_model_ids)
598
+
599
+ # Conversation-format plugin selection. Coerce unknown IDs to the
600
+ # defaults via the get_structure/get_decision resolvers.
601
+ from app.services.conversation import (
602
+ get_structure as _get_structure_cls,
603
+ get_decision as _get_decision_cls,
604
+ STRUCTURE_REGISTRY as _STRUCT_REG,
605
+ DECISION_REGISTRY as _DEC_REG,
606
+ )
607
+ if req.conversation_structure_id and req.conversation_structure_id in _STRUCT_REG:
608
+ session.conversation_structure_id = req.conversation_structure_id
609
+ if req.decision_method_id and req.decision_method_id in _DEC_REG:
610
+ session.decision_method_id = req.decision_method_id
611
+
612
  if humans and req.human_credential is not None:
613
  session.human_credential = normalize_one_credential({
614
  "participant_id": req.human_credential.participant_id,
backend/app/services/conversation/__init__.py ADDED
@@ -0,0 +1,86 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Pluggable conversation structures and decision-making methods.
2
+
3
+ The orchestrator drives one ConversationStructure (how the discussion
4
+ is organized) followed by one DecisionMethod (how a verdict is
5
+ reached). The two are decoupled by the `DecisionInput` contract in
6
+ `types.py`: every structure produces one, every method consumes one,
7
+ so in principle any combination is valid.
8
+
9
+ Adding a new structure or decision method means:
10
+
11
+ 1. Subclass `ConversationStructure` or `DecisionMethod` in
12
+ `structures/` or `decisions/`.
13
+ 2. Register the class in the appropriate registry below.
14
+
15
+ Built-in choices:
16
+
17
+ Structures:
18
+ - "collaborative" → CollaborativeDiscussion (default)
19
+ - "roberts_rules" → RobertsRulesDiscussion
20
+
21
+ Decision methods:
22
+ - "consensus" → ConsensusDecision (default)
23
+ - "majority" → MajorityRulesDecision
24
+ - "ranked_choice" → RankedChoiceDecision
25
+ - "roberts_rules_vote" → RobertsRulesVote
26
+ """
27
+ from __future__ import annotations
28
+
29
+ from typing import Type
30
+
31
+ from app.services.conversation.decisions.base import DecisionMethod
32
+ from app.services.conversation.decisions.consensus import ConsensusDecision
33
+ from app.services.conversation.decisions.majority import MajorityRulesDecision
34
+ from app.services.conversation.decisions.ranked_choice import RankedChoiceDecision
35
+ from app.services.conversation.decisions.roberts_rules_vote import RobertsRulesVote
36
+ from app.services.conversation.structures.base import ConversationStructure
37
+ from app.services.conversation.structures.collaborative import CollaborativeDiscussion
38
+ from app.services.conversation.structures.roberts_rules import RobertsRulesDiscussion
39
+ from app.services.conversation.types import DecisionInput # noqa: F401 re-export
40
+
41
+ DEFAULT_STRUCTURE_ID = "collaborative"
42
+ DEFAULT_DECISION_ID = "consensus"
43
+
44
+
45
+ STRUCTURE_REGISTRY: dict[str, Type[ConversationStructure]] = {
46
+ "collaborative": CollaborativeDiscussion,
47
+ "roberts_rules": RobertsRulesDiscussion,
48
+ }
49
+
50
+
51
+ DECISION_REGISTRY: dict[str, Type[DecisionMethod]] = {
52
+ "consensus": ConsensusDecision,
53
+ "majority": MajorityRulesDecision,
54
+ "ranked_choice": RankedChoiceDecision,
55
+ "roberts_rules_vote": RobertsRulesVote,
56
+ }
57
+
58
+
59
+ def get_structure(structure_id: str | None) -> Type[ConversationStructure]:
60
+ """Resolve a structure id to its class, defaulting on miss."""
61
+ if not structure_id or structure_id not in STRUCTURE_REGISTRY:
62
+ return STRUCTURE_REGISTRY[DEFAULT_STRUCTURE_ID]
63
+ return STRUCTURE_REGISTRY[structure_id]
64
+
65
+
66
+ def get_decision(decision_id: str | None) -> Type[DecisionMethod]:
67
+ """Resolve a decision-method id to its class, defaulting on miss."""
68
+ if not decision_id or decision_id not in DECISION_REGISTRY:
69
+ return DECISION_REGISTRY[DEFAULT_DECISION_ID]
70
+ return DECISION_REGISTRY[decision_id]
71
+
72
+
73
+ def list_structures() -> list[dict[str, str]]:
74
+ """Catalog data for the frontend picker."""
75
+ return [
76
+ {"id": sid, "name": cls.NAME, "description": cls.DESCRIPTION}
77
+ for sid, cls in STRUCTURE_REGISTRY.items()
78
+ ]
79
+
80
+
81
+ def list_decisions() -> list[dict[str, str]]:
82
+ """Catalog data for the frontend picker."""
83
+ return [
84
+ {"id": did, "name": cls.NAME, "description": cls.DESCRIPTION}
85
+ for did, cls in DECISION_REGISTRY.items()
86
+ ]
backend/app/services/conversation/decisions/__init__.py ADDED
@@ -0,0 +1 @@
 
 
1
+ """DecisionMethod plugins. See `..__init__.py` for the registry."""
backend/app/services/conversation/decisions/base.py ADDED
@@ -0,0 +1,45 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Base class every DecisionMethod plugin inherits.
2
+
3
+ A decision method takes the `DecisionInput` produced by the
4
+ ConversationStructure and runs whatever process yields a verdict —
5
+ collaborative consensus, ranked-choice vote, simple majority, etc.
6
+
7
+ Subclasses should:
8
+
9
+ 1. Override `NAME` and `DESCRIPTION` for the frontend picker.
10
+ 2. Implement `run()` as an async generator that yields SSE chunks.
11
+ 3. Populate `session.final_report` with a dict the frontend can
12
+ render. Conventionally:
13
+ { "kind": "<decision-flavored kind>",
14
+ "text": "<human-readable summary>",
15
+ ... any per-method extras ... }
16
+ The `kind` flows into `orchestrator` SSE messages so
17
+ `OrchestratorMessage.js` can render distinct headers.
18
+
19
+ Plugins live in this directory and are registered in
20
+ `app/services/conversation/__init__.py`.
21
+ """
22
+ from __future__ import annotations
23
+
24
+ from typing import AsyncIterator, TYPE_CHECKING
25
+
26
+ if TYPE_CHECKING:
27
+ from app.services.conversation.types import DecisionInput
28
+ from app.services.models import Session
29
+
30
+
31
+ class DecisionMethod:
32
+ """Abstract base; subclass and override `NAME`, `DESCRIPTION`,
33
+ and `run()`."""
34
+
35
+ NAME: str = "Decision"
36
+ DESCRIPTION: str = ""
37
+
38
+ def __init__(self, session: "Session", decision_input: "DecisionInput") -> None:
39
+ self.session = session
40
+ self.decision_input = decision_input
41
+
42
+ async def run(self) -> AsyncIterator[str]: # noqa: D401
43
+ """Drive the decision phase. Yields SSE chunks."""
44
+ raise NotImplementedError
45
+ yield # pragma: no cover
backend/app/services/conversation/decisions/consensus.py ADDED
@@ -0,0 +1,43 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Consensus — the original CCAI decision-making mode.
2
+
3
+ This is a thin wrapper over the existing Phase 5/6 implementation in
4
+ `app.services.orchestrator`. The structure produces a DecisionInput
5
+ with finalized positions; we discard the input fields here because
6
+ the consensus phase reads `session.final_opinions`,
7
+ `session.alliance_groups`, etc. directly. The wrapper exists so the
8
+ dispatcher in orchestrator.py can treat Consensus uniformly with the
9
+ new decision methods.
10
+
11
+ Behavior:
12
+ Phase 5: Consensus deliberation (turn-based, alliance-aware,
13
+ with dyad caps and addressed-to routing).
14
+ Phase 6: Closure — if a majority emerges, emit a majority report;
15
+ otherwise either re-run consensus with a surfaced
16
+ "unaddressed factor" or emit a no-consensus report.
17
+ """
18
+ from __future__ import annotations
19
+
20
+ from typing import AsyncIterator
21
+
22
+ from app.services.conversation.decisions.base import DecisionMethod
23
+
24
+
25
+ class ConsensusDecision(DecisionMethod):
26
+ NAME = "Consensus"
27
+ DESCRIPTION = (
28
+ "Participants deliberate until either an alliance forms with "
29
+ "majority support, or the orchestrator reports no consensus "
30
+ "with the remaining points of disagreement."
31
+ )
32
+
33
+ async def run(self) -> AsyncIterator[str]:
34
+ from app.services.orchestrator import (
35
+ _phase_consensus,
36
+ _phase_closure,
37
+ )
38
+
39
+ async for chunk in _phase_consensus(self.session):
40
+ yield chunk
41
+
42
+ async for chunk in _phase_closure(self.session):
43
+ yield chunk
backend/app/services/conversation/decisions/majority.py ADDED
@@ -0,0 +1,224 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Majority-rules voting decision method.
2
+
3
+ Two voting modes, chosen automatically:
4
+
5
+ * If the structure handed us a `main_motion` (Robert's Rules
6
+ typically does), each participant votes aye/nay/abstain on it.
7
+ Motion passes if ayes > nays.
8
+
9
+ * Otherwise, the orchestrator clusters finalized positions into a
10
+ small set of option labels (or uses `proposed_candidates` from
11
+ the structure if present), and each participant votes for ONE
12
+ option. Highest vote count wins.
13
+
14
+ A tie at the top is reported as a "no winner" outcome — we don't
15
+ auto-break ties because the user's design intent for majority rules
16
+ is "most votes wins, period".
17
+
18
+ Emits SSE events:
19
+ * `status` — phase announcement and progress.
20
+ * `vote_cast` — one per ballot.
21
+ * `vote_tally` — final counts (so the frontend can render a chart).
22
+ * `orchestrator` — the final report with `kind == "vote_result"`.
23
+ """
24
+ from __future__ import annotations
25
+
26
+ from typing import Any, AsyncIterator
27
+
28
+ from app.services.conversation.decisions.base import DecisionMethod
29
+ from app.services.conversation.voting import (
30
+ cast_vote_single,
31
+ cast_vote_yesno,
32
+ extract_candidate_options,
33
+ tally_single_votes,
34
+ tally_yesno_votes,
35
+ )
36
+ from app.services.models import Phase
37
+
38
+
39
+ class MajorityRulesDecision(DecisionMethod):
40
+ NAME = "Majority Rules Voting"
41
+ DESCRIPTION = (
42
+ "Each participant votes for one option (or aye/nay on a "
43
+ "motion). The choice with the most votes wins; ties are "
44
+ "reported as no-winner."
45
+ )
46
+
47
+ async def run(self) -> AsyncIterator[str]:
48
+ from app.services.orchestrator import (
49
+ _sse,
50
+ _add_orchestrator_message,
51
+ _msg_payload,
52
+ )
53
+
54
+ session = self.session
55
+ di = self.decision_input
56
+ session.phase = Phase.VOTING
57
+ yield _sse("status", {"message": "Phase 5: majority vote..."})
58
+
59
+ # AI participants only — humans abstain in the automated path.
60
+ voters = [p for p in di.participants if p.kind != "human"]
61
+ if not voters:
62
+ yield _sse("error", {"message": "No AI participants left to vote."})
63
+ return
64
+
65
+ if di.main_motion:
66
+ async for chunk in self._run_motion_vote(voters, di.main_motion):
67
+ yield chunk
68
+ return
69
+
70
+ # Plurality vote on derived candidates
71
+ async for chunk in self._run_plurality_vote(voters):
72
+ yield chunk
73
+
74
+ async def _run_motion_vote(self, voters, motion: str) -> AsyncIterator[str]:
75
+ from app.services.orchestrator import (
76
+ _sse,
77
+ _add_orchestrator_message,
78
+ _msg_payload,
79
+ )
80
+
81
+ session = self.session
82
+ announce_text = f"The motion on the floor is: \"{motion}\""
83
+ msg = _add_orchestrator_message(
84
+ session, announce_text, kind="motion", extra={"motion": motion},
85
+ )
86
+ yield _sse("orchestrator", _msg_payload(msg))
87
+
88
+ ballots: list[dict[str, Any]] = []
89
+ for p in voters:
90
+ result = await cast_vote_yesno(
91
+ session=session, participant=p, motion=motion,
92
+ )
93
+ ballot = {
94
+ "voter_id": p.participant_id,
95
+ "voter_name": p.name,
96
+ "vote": result.get("vote", ""),
97
+ "reason": result.get("reason", ""),
98
+ "ok": result.get("ok", False),
99
+ }
100
+ ballots.append(ballot)
101
+ yield _sse("vote_cast", ballot)
102
+
103
+ tally = tally_yesno_votes(ballots)
104
+ yield _sse("vote_tally", {"kind": "yesno", "tally": tally})
105
+
106
+ verdict = "PASSES" if tally["passes"] else (
107
+ "FAILS" if tally["majority"] != "tie" else "is TIED (no winner)"
108
+ )
109
+ text = (
110
+ f"The motion {verdict}.\n\n"
111
+ f"Aye: {tally['aye']} Nay: {tally['nay']} "
112
+ f"Abstain: {tally['abstain']}\n\n"
113
+ f"Motion: {motion}"
114
+ )
115
+ session.final_report = {
116
+ "kind": "vote_result",
117
+ "text": text,
118
+ "vote_kind": "yesno",
119
+ "motion": motion,
120
+ "tally": tally,
121
+ "ballots": ballots,
122
+ }
123
+ msg = _add_orchestrator_message(
124
+ session, text, kind="vote_result",
125
+ extra={"vote_kind": "yesno", "tally": tally, "motion": motion},
126
+ )
127
+ yield _sse("orchestrator", _msg_payload(msg))
128
+
129
+ async def _run_plurality_vote(self, voters) -> AsyncIterator[str]:
130
+ from app.services.orchestrator import (
131
+ _sse,
132
+ _add_orchestrator_message,
133
+ _msg_payload,
134
+ )
135
+
136
+ session = self.session
137
+ di = self.decision_input
138
+
139
+ # Use structure-proposed candidates if available; else derive.
140
+ options = list(di.proposed_candidates or [])
141
+ if not options:
142
+ yield _sse("status", {"message": "Identifying voting options..."})
143
+ options = await extract_candidate_options(
144
+ session=session,
145
+ question=di.question,
146
+ positions=di.finalized_positions,
147
+ participants=di.participants,
148
+ )
149
+
150
+ if len(options) < 2:
151
+ yield _sse("error", {
152
+ "message": "Not enough distinct positions to hold a vote.",
153
+ })
154
+ return
155
+
156
+ options_label = "\n".join(f" {i + 1}. {o}" for i, o in enumerate(options))
157
+ announce = f"The following options are on the ballot:\n{options_label}"
158
+ msg = _add_orchestrator_message(
159
+ session, announce, kind="ballot_options",
160
+ extra={"options": options},
161
+ )
162
+ yield _sse("orchestrator", _msg_payload(msg))
163
+
164
+ ballots: list[dict[str, Any]] = []
165
+ for p in voters:
166
+ result = await cast_vote_single(
167
+ session=session, participant=p, question=di.question,
168
+ options=options,
169
+ )
170
+ ballot = {
171
+ "voter_id": p.participant_id,
172
+ "voter_name": p.name,
173
+ "choice": result.get("choice", 0),
174
+ "reason": result.get("reason", ""),
175
+ "ok": result.get("ok", False),
176
+ }
177
+ ballots.append(ballot)
178
+ yield _sse("vote_cast", ballot)
179
+
180
+ tally = tally_single_votes(ballots, len(options))
181
+ yield _sse("vote_tally", {
182
+ "kind": "plurality", "tally": tally, "options": options,
183
+ })
184
+
185
+ if tally["winner"] > 0:
186
+ winner_text = options[tally["winner"] - 1]
187
+ summary = (
188
+ f"Winning option: \"{winner_text}\" "
189
+ f"({tally['counts'][tally['winner'] - 1]} of "
190
+ f"{tally['total_cast']} votes)"
191
+ )
192
+ elif tally["tied_for_first"]:
193
+ tied_names = ", ".join(
194
+ f"\"{options[i - 1]}\"" for i in tally["tied_for_first"]
195
+ )
196
+ summary = (
197
+ f"Tied for first ({tally['counts'][tally['tied_for_first'][0] - 1]} votes each): "
198
+ f"{tied_names}. No winner under majority-rules."
199
+ )
200
+ else:
201
+ summary = "No votes were cast successfully — no winner."
202
+
203
+ details = "\n".join(
204
+ f" - \"{opt}\": {tally['counts'][i]} vote(s)"
205
+ for i, opt in enumerate(options)
206
+ )
207
+ text = f"{summary}\n\nFull tally:\n{details}"
208
+ session.final_report = {
209
+ "kind": "vote_result",
210
+ "text": text,
211
+ "vote_kind": "plurality",
212
+ "options": options,
213
+ "tally": tally,
214
+ "ballots": ballots,
215
+ }
216
+ msg = _add_orchestrator_message(
217
+ session, text, kind="vote_result",
218
+ extra={
219
+ "vote_kind": "plurality",
220
+ "tally": tally,
221
+ "options": options,
222
+ },
223
+ )
224
+ yield _sse("orchestrator", _msg_payload(msg))
backend/app/services/conversation/decisions/ranked_choice.py ADDED
@@ -0,0 +1,170 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Ranked-choice (instant-runoff) voting decision method.
2
+
3
+ Each participant ranks ALL options. If an option holds >50% of
4
+ first-choice votes it wins immediately. Otherwise the option with
5
+ the fewest first-choice votes is eliminated and its voters'
6
+ next-still-eligible choices are redistributed; the process repeats
7
+ until a winner emerges or all remaining options tie at the bottom.
8
+
9
+ When the structure handed us a `main_motion` we still hold a ranked
10
+ vote, treating the motion text as Option 1 and the structure's
11
+ `proposed_candidates` (or derived clusters of finalized positions)
12
+ as the additional options. This is the user's "RR + RCV" combo: the
13
+ chair's motion is on the ballot but doesn't get a privileged spot.
14
+
15
+ Emits SSE events:
16
+ * `status` for progress.
17
+ * `vote_cast` per ballot (with the full ranking).
18
+ * `vote_tally` with the per-round IRV breakdown.
19
+ * `orchestrator` for the final report (`kind == "ranked_choice_result"`).
20
+ """
21
+ from __future__ import annotations
22
+
23
+ from typing import Any, AsyncIterator
24
+
25
+ from app.services.conversation.decisions.base import DecisionMethod
26
+ from app.services.conversation.voting import (
27
+ cast_vote_ranking,
28
+ extract_candidate_options,
29
+ run_irv,
30
+ )
31
+ from app.services.models import Phase
32
+
33
+
34
+ class RankedChoiceDecision(DecisionMethod):
35
+ NAME = "Ranked Choice Voting"
36
+ DESCRIPTION = (
37
+ "Each participant ranks all options from most to least "
38
+ "preferred. The winner is found by instant runoff: lowest "
39
+ "first-choice option is dropped each round, votes "
40
+ "redistribute, repeat until a majority emerges."
41
+ )
42
+
43
+ async def run(self) -> AsyncIterator[str]:
44
+ from app.services.orchestrator import (
45
+ _sse,
46
+ _add_orchestrator_message,
47
+ _msg_payload,
48
+ )
49
+
50
+ session = self.session
51
+ di = self.decision_input
52
+ session.phase = Phase.VOTING
53
+ yield _sse("status", {"message": "Phase 5: ranked-choice vote..."})
54
+
55
+ voters = [p for p in di.participants if p.kind != "human"]
56
+ if not voters:
57
+ yield _sse("error", {"message": "No AI participants left to vote."})
58
+ return
59
+
60
+ options: list[str] = []
61
+ if di.main_motion:
62
+ options.append(di.main_motion)
63
+ if di.proposed_candidates:
64
+ for o in di.proposed_candidates:
65
+ if o and o not in options:
66
+ options.append(o)
67
+ if len(options) < 2:
68
+ yield _sse("status", {"message": "Identifying voting options..."})
69
+ derived = await extract_candidate_options(
70
+ session=session, question=di.question,
71
+ positions=di.finalized_positions,
72
+ participants=di.participants,
73
+ )
74
+ for o in derived:
75
+ if o and o not in options:
76
+ options.append(o)
77
+
78
+ if len(options) < 2:
79
+ yield _sse("error", {
80
+ "message": "Not enough distinct positions for a ranked vote.",
81
+ })
82
+ return
83
+
84
+ options_label = "\n".join(f" {i + 1}. {o}" for i, o in enumerate(options))
85
+ announce = (
86
+ f"The following options will be ranked by each participant:\n"
87
+ f"{options_label}"
88
+ )
89
+ msg = _add_orchestrator_message(
90
+ session, announce, kind="ballot_options",
91
+ extra={"options": options},
92
+ )
93
+ yield _sse("orchestrator", _msg_payload(msg))
94
+
95
+ ballots: list[dict[str, Any]] = []
96
+ rankings_only: list[list[int]] = []
97
+ for p in voters:
98
+ result = await cast_vote_ranking(
99
+ session=session, participant=p,
100
+ question=di.question, options=options,
101
+ )
102
+ ranking = result.get("ranking") or []
103
+ ballots.append({
104
+ "voter_id": p.participant_id,
105
+ "voter_name": p.name,
106
+ "ranking": ranking,
107
+ "reason": result.get("reason", ""),
108
+ "ok": result.get("ok", False),
109
+ })
110
+ rankings_only.append(list(ranking))
111
+ yield _sse("vote_cast", {
112
+ "voter_id": p.participant_id,
113
+ "voter_name": p.name,
114
+ "ranking": ranking,
115
+ "reason": result.get("reason", ""),
116
+ "ok": result.get("ok", False),
117
+ })
118
+
119
+ irv = run_irv(rankings_only, len(options))
120
+ yield _sse("vote_tally", {
121
+ "kind": "ranked_choice",
122
+ "options": options,
123
+ "irv": irv,
124
+ })
125
+
126
+ if irv["winner"] > 0:
127
+ winner_text = options[irv["winner"] - 1]
128
+ summary = (
129
+ f"Winning option: \"{winner_text}\" "
130
+ f"({len(irv['rounds'])} round(s) of instant runoff)."
131
+ )
132
+ elif irv.get("tied"):
133
+ summary = (
134
+ "All remaining options tied at the bottom of the final "
135
+ "round — no winner under ranked-choice."
136
+ )
137
+ else:
138
+ summary = (
139
+ "No valid ballots — no winner. (LLM voters may have "
140
+ "returned malformed rankings.)"
141
+ )
142
+
143
+ rounds_block = []
144
+ for r in irv["rounds"]:
145
+ line_counts = ", ".join(
146
+ f"\"{options[opt - 1]}\": {c}"
147
+ for opt, c in sorted(r["counts"].items())
148
+ )
149
+ line = f" Round {r['round']}: {line_counts}"
150
+ if r.get("eliminated"):
151
+ line += f" → eliminated \"{options[r['eliminated'] - 1]}\""
152
+ if r.get("winner"):
153
+ line += f" → WINNER \"{options[r['winner'] - 1]}\""
154
+ rounds_block.append(line)
155
+
156
+ text = (
157
+ f"{summary}\n\nInstant-runoff rounds:\n" + "\n".join(rounds_block)
158
+ )
159
+ session.final_report = {
160
+ "kind": "ranked_choice_result",
161
+ "text": text,
162
+ "options": options,
163
+ "irv": irv,
164
+ "ballots": ballots,
165
+ }
166
+ msg = _add_orchestrator_message(
167
+ session, text, kind="ranked_choice_result",
168
+ extra={"options": options, "irv": irv},
169
+ )
170
+ yield _sse("orchestrator", _msg_payload(msg))
backend/app/services/conversation/decisions/roberts_rules_vote.py ADDED
@@ -0,0 +1,163 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Robert's Rules vote — aye/nay/abstain on the main motion.
2
+
3
+ Functionally identical to MajorityRulesDecision's motion-vote path,
4
+ but presented with Robert's Rules phrasing ("The chair calls for the
5
+ vote on the main motion...") so the conversation reads cleanly when
6
+ the user pairs it with RobertsRulesDiscussion. It's still a separate
7
+ plugin so the registry can list it as a first-class choice and the
8
+ frontend picker can label it accordingly.
9
+
10
+ If invoked WITHOUT a `main_motion` (e.g. the user paired this
11
+ decision method with the Collaborative structure), we synthesize a
12
+ motion from the finalized positions and put that on the floor —
13
+ giving sensible behavior across all structure / decision pairings.
14
+ """
15
+ from __future__ import annotations
16
+
17
+ from typing import Any, AsyncIterator
18
+
19
+ from app.services.conversation.decisions.base import DecisionMethod
20
+ from app.services.conversation.voting import (
21
+ cast_vote_yesno,
22
+ tally_yesno_votes,
23
+ )
24
+ from app.services.json_calls import orchestrator_call
25
+ from app.services.models import Phase
26
+
27
+
28
+ SYNTHESIZE_MOTION_PROMPT = """You are acting as the chair of a meeting.
29
+
30
+ The body has been discussing the following question:
31
+ {question}
32
+
33
+ The participants' final positions are:
34
+ {positions_block}
35
+
36
+ As chair, propose a single concrete motion that captures the most \
37
+ widely-supported position. Phrase it as a complete sentence starting \
38
+ with "Resolved, that" or "I move that".
39
+
40
+ Return ONLY this JSON (no prose, no fences):
41
+ {{
42
+ "motion": "..."
43
+ }}
44
+ """
45
+
46
+
47
+ class RobertsRulesVote(DecisionMethod):
48
+ NAME = "Robert's Rules Vote"
49
+ DESCRIPTION = (
50
+ "The chair puts the main motion to the assembly. Each "
51
+ "member casts aye, nay, or abstain. Motion passes on simple "
52
+ "majority of those voting."
53
+ )
54
+
55
+ async def run(self) -> AsyncIterator[str]:
56
+ from app.services.orchestrator import (
57
+ _sse,
58
+ _add_orchestrator_message,
59
+ _msg_payload,
60
+ _orchestrator_model_id,
61
+ _bump_orchestrator_count,
62
+ _format_history,
63
+ )
64
+
65
+ session = self.session
66
+ di = self.decision_input
67
+ session.phase = Phase.VOTING
68
+ yield _sse("status", {"message": "Phase 5: Robert's Rules vote..."})
69
+
70
+ voters = [p for p in di.participants if p.kind != "human"]
71
+ if not voters:
72
+ yield _sse("error", {"message": "No AI members present to vote."})
73
+ return
74
+
75
+ motion = (di.main_motion or "").strip()
76
+ if not motion:
77
+ # Synthesize a motion from finalized positions.
78
+ from app.services.conversation.voting import _format_positions_block
79
+ yield _sse("status", {"message": "Chair: drafting a motion..."})
80
+ prompt = SYNTHESIZE_MOTION_PROMPT.format(
81
+ question=di.question,
82
+ positions_block=_format_positions_block(
83
+ di.finalized_positions, di.participants,
84
+ ),
85
+ )
86
+ _, parsed = await orchestrator_call(
87
+ orchestrator_model_id=_orchestrator_model_id(session),
88
+ user_prompt=prompt,
89
+ label="rr_synthesize_motion",
90
+ api_log=session.api_log,
91
+ expect_json=True,
92
+ max_tokens=400,
93
+ temperature=0.3,
94
+ )
95
+ _bump_orchestrator_count(session)
96
+ if isinstance(parsed, dict):
97
+ motion = str(parsed.get("motion") or "").strip()
98
+ if not motion:
99
+ motion = (
100
+ "Resolved, that the assembly accepts the consensus "
101
+ "view reflected by the majority of stated positions."
102
+ )
103
+ session.main_motion = motion
104
+
105
+ announce = (
106
+ "The chair puts the following motion before the assembly:\n\n"
107
+ f"\"{motion}\"\n\n"
108
+ "The chair calls for the vote. Members will say aye, nay, "
109
+ "or abstain."
110
+ )
111
+ msg = _add_orchestrator_message(
112
+ session, announce, kind="motion", extra={"motion": motion},
113
+ )
114
+ yield _sse("orchestrator", _msg_payload(msg))
115
+
116
+ ballots: list[dict[str, Any]] = []
117
+ for p in voters:
118
+ result = await cast_vote_yesno(
119
+ session=session, participant=p, motion=motion,
120
+ )
121
+ ballot = {
122
+ "voter_id": p.participant_id,
123
+ "voter_name": p.name,
124
+ "vote": result.get("vote", ""),
125
+ "reason": result.get("reason", ""),
126
+ "ok": result.get("ok", False),
127
+ }
128
+ ballots.append(ballot)
129
+ yield _sse("vote_cast", ballot)
130
+
131
+ tally = tally_yesno_votes(ballots)
132
+ yield _sse("vote_tally", {"kind": "yesno", "tally": tally})
133
+
134
+ if tally["passes"]:
135
+ verdict = "The motion is carried."
136
+ elif tally["majority"] == "nay":
137
+ verdict = "The motion is lost."
138
+ else:
139
+ verdict = "The motion is tied; the chair declares it lost."
140
+
141
+ text = (
142
+ f"{verdict}\n\n"
143
+ f"Aye: {tally['aye']} Nay: {tally['nay']} "
144
+ f"Abstain: {tally['abstain']}\n\n"
145
+ f"Motion: \"{motion}\""
146
+ )
147
+ session.final_report = {
148
+ "kind": "vote_result",
149
+ "text": text,
150
+ "vote_kind": "yesno",
151
+ "motion": motion,
152
+ "tally": tally,
153
+ "ballots": ballots,
154
+ "flavor": "roberts_rules",
155
+ }
156
+ msg = _add_orchestrator_message(
157
+ session, text, kind="vote_result",
158
+ extra={
159
+ "vote_kind": "yesno", "tally": tally, "motion": motion,
160
+ "flavor": "roberts_rules",
161
+ },
162
+ )
163
+ yield _sse("orchestrator", _msg_payload(msg))
backend/app/services/conversation/structures/__init__.py ADDED
@@ -0,0 +1 @@
 
 
1
+ """ConversationStructure plugins. See `..__init__.py` for the registry."""
backend/app/services/conversation/structures/base.py ADDED
@@ -0,0 +1,55 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Base class every ConversationStructure plugin inherits.
2
+
3
+ A structure drives the *discussion* phase of a chat: how participants
4
+ present their views, debate, refine, and finalize positions. It does
5
+ NOT decide the verdict — that's the DecisionMethod's job. The two are
6
+ joined by `DecisionInput` (see `..types`).
7
+
8
+ Subclasses should:
9
+
10
+ 1. Override `NAME` and `DESCRIPTION` so the frontend picker can
11
+ present a sensible label.
12
+ 2. Implement `run()` as an async generator that yields SSE chunks.
13
+ Use the orchestrator's `_sse(...)` helper (imported lazily so
14
+ this module doesn't pull in the whole orchestrator) — see
15
+ existing structures for the pattern.
16
+ 3. Implement `build_decision_input()` to return a `DecisionInput`
17
+ populated from the session state your `run()` produced.
18
+
19
+ Plugins live in this directory and are registered in
20
+ `app/services/conversation/__init__.py`.
21
+ """
22
+ from __future__ import annotations
23
+
24
+ from typing import AsyncIterator, TYPE_CHECKING
25
+
26
+ if TYPE_CHECKING:
27
+ from app.services.conversation.types import DecisionInput
28
+ from app.services.models import Session
29
+
30
+
31
+ class ConversationStructure:
32
+ """Abstract base; subclass and override `NAME`, `DESCRIPTION`,
33
+ `run()`, and `build_decision_input()`."""
34
+
35
+ #: Short label shown in the Settings menu.
36
+ NAME: str = "Conversation"
37
+
38
+ #: One-line description for the picker tooltip.
39
+ DESCRIPTION: str = ""
40
+
41
+ def __init__(self, session: "Session") -> None:
42
+ self.session = session
43
+
44
+ async def run(self) -> AsyncIterator[str]: # noqa: D401
45
+ """Drive the discussion. Yields SSE chunks."""
46
+ raise NotImplementedError
47
+ yield # pragma: no cover # keeps mypy happy that this is a generator
48
+
49
+ def build_decision_input(self) -> "DecisionInput":
50
+ """Produce the standardized hand-off for the decision phase.
51
+
52
+ Called once `run()` completes. The result is passed verbatim
53
+ to the chosen DecisionMethod's constructor.
54
+ """
55
+ raise NotImplementedError
backend/app/services/conversation/structures/collaborative.py ADDED
@@ -0,0 +1,75 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Collaborative Discussion — the original CCAI structure.
2
+
3
+ This is a thin wrapper over the existing Phase 1-4 implementation in
4
+ `app.services.orchestrator`. We keep the function bodies in
5
+ orchestrator.py for now (less code churn during the plugin refactor)
6
+ and the plugin just dispatches to them in the historical order:
7
+
8
+ Phase 1: Initial opinions (each participant speaks once with no
9
+ transcript, so first opinions are independent).
10
+ Phase 2: Critique rounds (1-4, configurable). Each participant
11
+ critiques / agrees with / builds on what others said.
12
+ Phase 3: Status assessment + targeted follow-ups (orchestrator
13
+ surfaces open questions; max iterations from limits).
14
+ Phase 4: Finalization (each participant states a final opinion,
15
+ which is what we hand off to the decision phase).
16
+
17
+ When this structure finishes, `session.final_opinions[pid]` holds
18
+ each AI participant's last word. We marshal that into a
19
+ DecisionInput so any DecisionMethod can take over.
20
+ """
21
+ from __future__ import annotations
22
+
23
+ from typing import AsyncIterator, TYPE_CHECKING
24
+
25
+ from app.services.conversation.structures.base import ConversationStructure
26
+ from app.services.conversation.types import DecisionInput
27
+
28
+ if TYPE_CHECKING:
29
+ pass
30
+
31
+
32
+ class CollaborativeDiscussion(ConversationStructure):
33
+ NAME = "Collaborative Discussion"
34
+ DESCRIPTION = (
35
+ "Initial opinions, then critique rounds, then a status check "
36
+ "for unanswered questions, then a final opinion from each "
37
+ "participant."
38
+ )
39
+
40
+ async def run(self) -> AsyncIterator[str]:
41
+ # Lazy import so this module can be loaded without dragging
42
+ # in the whole orchestrator (and so orchestrator.py can in
43
+ # turn import the structures registry without a cycle).
44
+ from app.services.orchestrator import (
45
+ _phase_initial_opinions,
46
+ _phase_critique,
47
+ _phase_status_assessment,
48
+ _phase_finalization,
49
+ )
50
+
51
+ async for chunk in _phase_initial_opinions(self.session):
52
+ yield chunk
53
+
54
+ for round_n in range(1, self.session.limits.critique_rounds + 1):
55
+ async for chunk in _phase_critique(self.session, round_n):
56
+ yield chunk
57
+
58
+ async for chunk in _phase_status_assessment(self.session):
59
+ yield chunk
60
+
61
+ async for chunk in _phase_finalization(self.session):
62
+ yield chunk
63
+
64
+ def build_decision_input(self) -> DecisionInput:
65
+ # Filter out disabled participants here — the decision phase
66
+ # always speaks about the *active* roster, not whoever started
67
+ # the chat. (Auto-disable in Phase 1/2 means some original
68
+ # participants may have been removed.)
69
+ actives = [p for p in self.session.participants if p.enabled]
70
+ return DecisionInput(
71
+ question=self.session.question,
72
+ participants=actives,
73
+ transcript_messages=list(self.session.messages),
74
+ finalized_positions=dict(self.session.final_opinions),
75
+ )
backend/app/services/conversation/structures/roberts_rules.py ADDED
@@ -0,0 +1,359 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Robert's Rules of Order — chair-mediated parliamentary discussion.
2
+
3
+ The classical RR flow, condensed for an LLM forum:
4
+
5
+ RR-1 OPENING: the chair (orchestrator) calls the meeting to order
6
+ and reads the question.
7
+
8
+ RR-2 INITIAL REMARKS: each member is recognized in roster order
9
+ and briefly states their position (a few sentences). This
10
+ mirrors Phase 1 of the Collaborative structure but with chair
11
+ framing. We also build the credential summary here so the
12
+ same downstream tooling (View Credential Summary, etc.) works.
13
+
14
+ RR-3 MOTION: the chair synthesizes a main motion from the initial
15
+ remarks and asks for a second. Members who aren't the
16
+ motion-maker are polled briefly for a "second" / "no second";
17
+ the first agreement carries.
18
+
19
+ RR-4 DEBATE: configurable number of rounds (defaults to
20
+ `limits.critique_rounds`). Each member speaks in turn, must
21
+ address the motion (support / oppose / amend / propose
22
+ alternative), and may name other members. Each member's last
23
+ remark in the debate is captured as their finalized position
24
+ so the decision phase has the standardized hand-off it needs.
25
+
26
+ RR-5 MOVE THE QUESTION: the chair declares debate closed and
27
+ hands off to the decision method (typically RobertsRulesVote
28
+ but any decision method is compatible).
29
+
30
+ The structure is deliberately self-contained — none of the
31
+ Collaborative phase functions are reused — so future RR features
32
+ (amendments, points of order, etc.) can be added without
33
+ entangling the original CCAI flow.
34
+ """
35
+ from __future__ import annotations
36
+
37
+ from typing import Any, AsyncIterator
38
+
39
+ from app.services.conversation.structures.base import ConversationStructure
40
+ from app.services.conversation.types import DecisionInput
41
+ from app.services.credential import build_credential_summary
42
+ from app.services.json_calls import orchestrator_call
43
+ from app.services.models import Participant, Phase
44
+ from app.services.resilience import run_resilient_turn
45
+
46
+
47
+ RR_OPENING_TEMPLATE = (
48
+ "The meeting is called to order. The chair reads the question "
49
+ "before the assembly:\n\n"
50
+ " {question}\n\n"
51
+ "The chair will recognize each member in turn for opening "
52
+ "remarks, after which the floor will be open for motions."
53
+ )
54
+
55
+
56
+ RR_INITIAL_REMARKS_PROMPT = """The chair has called the meeting to order and read \
57
+ the following question:
58
+
59
+ {question}
60
+
61
+ You are {speaker_name}. The chair recognizes you for opening remarks. \
62
+ Briefly state your position on the question in two or three sentences. \
63
+ Speak as a member of a deliberative assembly — formal but not stiff. \
64
+ Do not yet make a formal motion; that will come later.
65
+ """
66
+
67
+
68
+ RR_MOTION_SYNTHESIS_PROMPT = """You are the chair of a meeting governed by \
69
+ Robert's Rules of Order. The following members have just given their \
70
+ opening remarks on this question:
71
+
72
+ Question: {question}
73
+
74
+ Opening remarks:
75
+ {remarks_block}
76
+
77
+ Synthesize ONE concrete motion that reflects the most strongly-supported \
78
+ position. Also identify which member is most likely to formally make \
79
+ this motion (the "mover") based on their remarks, and which other \
80
+ member is most likely to second it.
81
+
82
+ Return ONLY this JSON (no prose, no fences):
83
+ {{
84
+ "motion": "Resolved, that ...",
85
+ "mover_id": "<participant_id>",
86
+ "seconder_id": "<participant_id>"
87
+ }}
88
+ """
89
+
90
+
91
+ RR_DEBATE_PROMPT = """You are {speaker_name}, a member of an assembly \
92
+ governed by Robert's Rules of Order. The main motion on the floor is:
93
+
94
+ Motion: {motion}
95
+
96
+ This is debate round {round_n} of {round_total}. The chair has \
97
+ recognized you. Speak to the motion: support it, oppose it, propose \
98
+ an amendment, or offer a substitute. Be specific. You may address \
99
+ other members by name.
100
+
101
+ Question under consideration: {question}
102
+
103
+ Debate so far:
104
+ {transcript}
105
+ """
106
+
107
+
108
+ class RobertsRulesDiscussion(ConversationStructure):
109
+ NAME = "Robert's Rules of Order"
110
+ DESCRIPTION = (
111
+ "Chair-mediated parliamentary debate: opening remarks, the "
112
+ "chair synthesizes a main motion and finds a seconder, "
113
+ "members debate the motion in turn, then the chair calls "
114
+ "for the vote."
115
+ )
116
+
117
+ async def run(self) -> AsyncIterator[str]:
118
+ # Imports are local to avoid pulling the orchestrator module
119
+ # at package-import time (orchestrator.py itself imports the
120
+ # conversation package via the dispatcher path).
121
+ from app.services.orchestrator import (
122
+ _sse,
123
+ _active_participants,
124
+ _add_orchestrator_message,
125
+ _add_participant_message,
126
+ _bump_orchestrator_count,
127
+ _call_participant,
128
+ _format_history,
129
+ _msg_payload,
130
+ _orchestrator_model_id,
131
+ _participant_msg_cap_hit,
132
+ _wait_for_continue,
133
+ )
134
+
135
+ session = self.session
136
+ actives = _active_participants(session)
137
+ if len(actives) < 2:
138
+ yield _sse("error", {"message": "Robert's Rules needs at least 2 members."})
139
+ return
140
+
141
+ # ---- RR-1: Opening -------------------------------------------------
142
+ session.phase = Phase.RR_OPENING
143
+ yield _sse("status", {"message": "RR-1: chair calls the meeting to order..."})
144
+ opening_text = RR_OPENING_TEMPLATE.format(question=session.question)
145
+ msg = _add_orchestrator_message(
146
+ session, opening_text, kind="rr_opening",
147
+ extra={"question": session.question},
148
+ )
149
+ yield _sse("orchestrator", _msg_payload(msg))
150
+
151
+ # ---- RR-2: Initial Remarks ----------------------------------------
152
+ session.phase = Phase.RR_INITIAL_REMARKS
153
+ yield _sse("status", {"message": "RR-2: opening remarks..."})
154
+ remarks: dict[str, str] = {}
155
+ for p in _active_participants(session):
156
+ if p.kind == "human":
157
+ # For now humans skip remarks under RR; a future pass
158
+ # can pause for input via human_io.
159
+ continue
160
+ prompt = RR_INITIAL_REMARKS_PROMPT.format(
161
+ question=session.question,
162
+ speaker_name=p.name,
163
+ )
164
+ turn = await run_resilient_turn(
165
+ session=session, participant=p,
166
+ user_prompt=prompt,
167
+ label="rr_initial_remarks",
168
+ max_tokens=400,
169
+ call_participant=_call_participant,
170
+ )
171
+ for ev in turn.sse_events:
172
+ yield ev
173
+ if not turn.ok:
174
+ yield _sse("participant_error", {
175
+ "participant_id": p.participant_id,
176
+ "name": p.name,
177
+ "phase": session.phase.value,
178
+ })
179
+ if p.consecutive_failures >= session.limits.auto_disable_failures:
180
+ p.enabled = False
181
+ continue
182
+ speaker = turn.speaker
183
+ msg = _add_participant_message(
184
+ session, speaker, turn.text,
185
+ phase=session.phase, elapsed=turn.elapsed,
186
+ )
187
+ remarks[speaker.participant_id] = turn.text
188
+ session.initial_opinions[speaker.participant_id] = turn.text
189
+ yield _sse("message", _msg_payload(msg))
190
+ if _participant_msg_cap_hit(session):
191
+ async for chunk in _wait_for_continue(session, "messages"):
192
+ yield chunk
193
+
194
+ # Credential summary — mirrors Phase 1 of Collaborative so the
195
+ # rest of the app (View Credential Summary, etc.) stays happy.
196
+ yield _sse("status", {"message": "Building Credential Summary..."})
197
+ creds = await build_credential_summary(
198
+ orchestrator_model_id=_orchestrator_model_id(session),
199
+ question=session.question,
200
+ participants=_active_participants(session),
201
+ initial_opinions=session.initial_opinions,
202
+ api_log=session.api_log,
203
+ human_credential=session.human_credential,
204
+ )
205
+ _bump_orchestrator_count(session)
206
+ session.credential_summary = creds
207
+ yield _sse("credentials_updated", {
208
+ "stage": "built",
209
+ "credentials": session.credential_summary,
210
+ })
211
+
212
+ # ---- RR-3: Motion --------------------------------------------------
213
+ session.phase = Phase.RR_MOTION
214
+ yield _sse("status", {"message": "RR-3: chair invites a motion..."})
215
+
216
+ remarks_block = "\n".join(
217
+ f"- {p.name}: {remarks.get(p.participant_id, '(no remarks)')}"
218
+ for p in _active_participants(session)
219
+ )
220
+ _, parsed = await orchestrator_call(
221
+ orchestrator_model_id=_orchestrator_model_id(session),
222
+ user_prompt=RR_MOTION_SYNTHESIS_PROMPT.format(
223
+ question=session.question, remarks_block=remarks_block,
224
+ ),
225
+ label="rr_motion_synthesis",
226
+ api_log=session.api_log,
227
+ expect_json=True,
228
+ max_tokens=500,
229
+ temperature=0.3,
230
+ )
231
+ _bump_orchestrator_count(session)
232
+
233
+ motion = ""
234
+ mover_id = ""
235
+ seconder_id = ""
236
+ if isinstance(parsed, dict):
237
+ motion = str(parsed.get("motion") or "").strip()
238
+ mover_id = str(parsed.get("mover_id") or "").strip()
239
+ seconder_id = str(parsed.get("seconder_id") or "").strip()
240
+ if not motion:
241
+ motion = (
242
+ "Resolved, that the assembly endorses the position most "
243
+ "broadly reflected in opening remarks."
244
+ )
245
+ session.main_motion = motion
246
+ session.proposed_motions.append({
247
+ "motion": motion,
248
+ "mover_id": mover_id,
249
+ "seconder_id": seconder_id,
250
+ })
251
+
252
+ by_id = {p.participant_id: p for p in _active_participants(session)}
253
+ mover = by_id.get(mover_id)
254
+ seconder = by_id.get(seconder_id)
255
+ # Fallbacks if the chair's id picks don't match the roster
256
+ if not mover:
257
+ ai_members = [p for p in _active_participants(session) if p.kind != "human"]
258
+ mover = ai_members[0] if ai_members else None
259
+ if not seconder:
260
+ ai_members = [
261
+ p for p in _active_participants(session)
262
+ if p.kind != "human" and mover and p.participant_id != mover.participant_id
263
+ ]
264
+ seconder = ai_members[0] if ai_members else None
265
+
266
+ motion_announce = (
267
+ f"The chair recognizes {mover.name if mover else 'the floor'} "
268
+ f"to make a motion:\n\n"
269
+ f" \"{motion}\"\n\n"
270
+ + (f"{seconder.name} seconds the motion. " if seconder else "")
271
+ + "The motion is on the floor. Debate is open."
272
+ )
273
+ msg = _add_orchestrator_message(
274
+ session, motion_announce, kind="motion",
275
+ extra={
276
+ "motion": motion,
277
+ "mover_id": mover.participant_id if mover else "",
278
+ "seconder_id": seconder.participant_id if seconder else "",
279
+ },
280
+ )
281
+ yield _sse("orchestrator", _msg_payload(msg))
282
+
283
+ # ---- RR-4: Debate --------------------------------------------------
284
+ session.phase = Phase.RR_DEBATE
285
+ debate_rounds = max(1, session.limits.critique_rounds)
286
+ last_remark_per_member: dict[str, str] = dict(remarks)
287
+
288
+ for round_n in range(1, debate_rounds + 1):
289
+ yield _sse("status", {
290
+ "message": f"RR-4: debate round {round_n} of {debate_rounds}...",
291
+ })
292
+ for p in _active_participants(session):
293
+ if p.kind == "human":
294
+ continue
295
+ prompt = RR_DEBATE_PROMPT.format(
296
+ speaker_name=p.name,
297
+ motion=motion,
298
+ round_n=round_n,
299
+ round_total=debate_rounds,
300
+ question=session.question,
301
+ transcript=_format_history(session.messages),
302
+ )
303
+ turn = await run_resilient_turn(
304
+ session=session, participant=p,
305
+ user_prompt=prompt,
306
+ label=f"rr_debate_round_{round_n}",
307
+ max_tokens=600,
308
+ call_participant=_call_participant,
309
+ )
310
+ for ev in turn.sse_events:
311
+ yield ev
312
+ if not turn.ok:
313
+ yield _sse("participant_error", {
314
+ "participant_id": p.participant_id,
315
+ "name": p.name,
316
+ "phase": session.phase.value,
317
+ })
318
+ if p.consecutive_failures >= session.limits.auto_disable_failures:
319
+ p.enabled = False
320
+ continue
321
+ speaker = turn.speaker
322
+ msg = _add_participant_message(
323
+ session, speaker, turn.text,
324
+ phase=session.phase, elapsed=turn.elapsed,
325
+ )
326
+ last_remark_per_member[speaker.participant_id] = turn.text
327
+ yield _sse("message", _msg_payload(msg))
328
+ if _participant_msg_cap_hit(session):
329
+ async for chunk in _wait_for_continue(session, "messages"):
330
+ yield chunk
331
+
332
+ # Final positions = last thing each member said in debate. This
333
+ # is what we hand off to the decision phase via DecisionInput.
334
+ session.final_opinions = dict(last_remark_per_member)
335
+
336
+ # ---- RR-5: Move the Question --------------------------------------
337
+ session.phase = Phase.RR_MOVE_THE_QUESTION
338
+ closing = (
339
+ "The chair calls the question. Debate is closed. The "
340
+ "assembly will now proceed to the vote on the motion: "
341
+ f"\"{motion}\""
342
+ )
343
+ msg = _add_orchestrator_message(
344
+ session, closing, kind="rr_call_the_question",
345
+ extra={"motion": motion},
346
+ )
347
+ yield _sse("orchestrator", _msg_payload(msg))
348
+
349
+ def build_decision_input(self) -> DecisionInput:
350
+ session = self.session
351
+ actives = [p for p in session.participants if p.enabled]
352
+ return DecisionInput(
353
+ question=session.question,
354
+ participants=actives,
355
+ transcript_messages=list(session.messages),
356
+ finalized_positions=dict(session.final_opinions),
357
+ main_motion=session.main_motion,
358
+ extras={"proposed_motions": list(session.proposed_motions)},
359
+ )
backend/app/services/conversation/types.py ADDED
@@ -0,0 +1,60 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Shared dataclasses for the conversation plugin system.
2
+
3
+ `DecisionInput` is the contract between a ConversationStructure and a
4
+ DecisionMethod: whatever shape the discussion took, by the time the
5
+ structure hands off, we should have a question, the active
6
+ participants, the transcript, and each participant's finalized
7
+ position. Structures that surface explicit options or motions
8
+ (notably Robert's Rules) populate the optional fields.
9
+
10
+ Keeping this in its own module avoids structures and decisions having
11
+ to import from each other.
12
+ """
13
+ from __future__ import annotations
14
+
15
+ from dataclasses import dataclass, field
16
+ from typing import TYPE_CHECKING, Any
17
+
18
+ if TYPE_CHECKING:
19
+ from app.services.models import Participant
20
+
21
+
22
+ @dataclass
23
+ class DecisionInput:
24
+ """Standardized hand-off from a ConversationStructure to a
25
+ DecisionMethod.
26
+
27
+ Fields:
28
+ * question: the conversation's prompt.
29
+ * participants: snapshot of active LLM + human participants in
30
+ roster order. (Disabled participants are filtered out by the
31
+ structure before handoff.)
32
+ * transcript_messages: full ordered message log; decision
33
+ methods may quote or summarize.
34
+ * finalized_positions: participant_id -> short text summary of
35
+ that participant's final stance. Always populated for
36
+ non-human AI participants by the end of discussion. Human
37
+ participants may also have entries when they spoke a final
38
+ opinion.
39
+ * proposed_candidates: explicit list of options the structure
40
+ surfaced (e.g. Robert's Rules motions, or an LLM-extracted
41
+ cluster of positions from a collaborative discussion). When
42
+ None, the decision method derives candidates from
43
+ `finalized_positions`.
44
+ * main_motion: for Robert's Rules, the text of the main motion
45
+ on the floor. When set, decision methods that support a
46
+ binary vote (RobertsRulesVote, MajorityRulesDecision) will
47
+ vote yes/no/abstain on this motion instead of choosing among
48
+ candidates.
49
+ * extras: free-form bag for structure-specific metadata a
50
+ decision method might want (motion seconder, debate
51
+ speaker order, etc.). Treat as opaque unless you know the
52
+ structure produced something specific.
53
+ """
54
+ question: str
55
+ participants: list["Participant"]
56
+ transcript_messages: list[dict[str, Any]]
57
+ finalized_positions: dict[str, str]
58
+ proposed_candidates: list[str] | None = None
59
+ main_motion: str | None = None
60
+ extras: dict[str, Any] = field(default_factory=dict)
backend/app/services/conversation/voting.py ADDED
@@ -0,0 +1,550 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Voting helpers shared by Majority, Ranked-Choice, and Robert's
2
+ Rules decision methods.
3
+
4
+ Three pieces live here:
5
+
6
+ * `extract_candidate_options(...)`: takes a list of finalized
7
+ position texts, asks the orchestrator LLM to cluster them into a
8
+ small set of distinct option labels, returns them as a list of
9
+ short strings.
10
+
11
+ * `cast_vote(...)`: asks one participant to vote on a list of
12
+ options (or to rank them, depending on `mode`), returning a
13
+ structured dict.
14
+
15
+ * `run_irv(...)`: instant-runoff tally for ranked-choice ballots.
16
+
17
+ All three are pure helpers (no Session mutation) so the decision
18
+ method classes can compose them however they want.
19
+ """
20
+ from __future__ import annotations
21
+
22
+ import json
23
+ import logging
24
+ import time
25
+ from typing import Any
26
+
27
+ from app.clients.llm_router import chat_completion
28
+ from app.services.json_calls import (
29
+ orchestrator_call,
30
+ parse_json_response,
31
+ )
32
+ from app.services.models import Participant, Session
33
+ from app.services.prompts import NO_REASONING_DIRECTIVE
34
+ from app.utils.sanitize import strip_thinking
35
+
36
+ LOG = logging.getLogger(__name__)
37
+
38
+
39
+ # How many distinct options the candidate-extraction LLM call may
40
+ # return. Cap is intentional: ranked-choice with > 6 options gets
41
+ # unwieldy for both LLM voters and human readers of the report.
42
+ MAX_CANDIDATES = 6
43
+
44
+
45
+ CANDIDATE_EXTRACTION_PROMPT = """The following are participants' final \
46
+ positions on this question:
47
+
48
+ Question: {question}
49
+
50
+ Participant positions:
51
+ {positions_block}
52
+
53
+ Cluster these into between 2 and {max_candidates} distinct option \
54
+ labels that capture the main answers being supported. Each label \
55
+ should be short (one sentence) and self-contained — a voter who \
56
+ hadn't read the transcript should still understand what they're \
57
+ voting for.
58
+
59
+ Return ONLY this JSON shape (no prose, no fences):
60
+ {{
61
+ "options": ["short option 1", "short option 2", ...]
62
+ }}
63
+ """
64
+
65
+
66
+ VOTE_SINGLE_PROMPT = """You are {participant_name}.
67
+
68
+ The group has been discussing this question:
69
+ {question}
70
+
71
+ After deliberation, the choices on the table are:
72
+ {options_block}
73
+
74
+ Cast your vote for the ONE option you support. Return ONLY this JSON \
75
+ (no prose, no fences):
76
+ {{
77
+ "choice": <integer 1..N of your top choice>,
78
+ "reason": "one sentence on why"
79
+ }}
80
+ """
81
+
82
+
83
+ VOTE_RANK_PROMPT = """You are {participant_name}.
84
+
85
+ The group has been discussing this question:
86
+ {question}
87
+
88
+ After deliberation, the choices on the table are:
89
+ {options_block}
90
+
91
+ Rank ALL of the options from most preferred (rank 1) to least \
92
+ preferred. You must include every option exactly once. Return ONLY \
93
+ this JSON (no prose, no fences):
94
+ {{
95
+ "ranking": [<rank-1 option number>, <rank-2 option number>, ...],
96
+ "reason": "one sentence on your top choice"
97
+ }}
98
+ """
99
+
100
+
101
+ VOTE_YESNO_PROMPT = """You are {participant_name}.
102
+
103
+ The chair has put the following motion before the assembly:
104
+
105
+ Motion: {motion}
106
+
107
+ Cast your vote. Return ONLY this JSON (no prose, no fences):
108
+ {{
109
+ "vote": "aye" | "nay" | "abstain",
110
+ "reason": "one sentence on why"
111
+ }}
112
+ """
113
+
114
+
115
+ def _format_positions_block(positions: dict[str, str], participants: list[Participant]) -> str:
116
+ by_id = {p.participant_id: p for p in participants}
117
+ lines: list[str] = []
118
+ for pid, text in positions.items():
119
+ name = by_id[pid].name if pid in by_id else pid
120
+ lines.append(f"- {name}: {text}".strip())
121
+ return "\n".join(lines) if lines else "(no positions recorded)"
122
+
123
+
124
+ def _format_options_block(options: list[str]) -> str:
125
+ return "\n".join(f" {i + 1}. {opt}" for i, opt in enumerate(options))
126
+
127
+
128
+ async def extract_candidate_options(
129
+ *,
130
+ session: Session,
131
+ question: str,
132
+ positions: dict[str, str],
133
+ participants: list[Participant],
134
+ max_candidates: int = MAX_CANDIDATES,
135
+ ) -> list[str]:
136
+ """Cluster finalized positions into a short list of distinct
137
+ option labels for a vote.
138
+
139
+ Falls back to using each participant's position verbatim (up to
140
+ `max_candidates`) if the LLM call fails or returns nothing
141
+ parseable.
142
+ """
143
+ if not positions:
144
+ return []
145
+
146
+ positions_block = _format_positions_block(positions, participants)
147
+ prompt = CANDIDATE_EXTRACTION_PROMPT.format(
148
+ question=question,
149
+ positions_block=positions_block,
150
+ max_candidates=max_candidates,
151
+ )
152
+
153
+ from app.services.orchestrator import _orchestrator_model_id, _bump_orchestrator_count
154
+
155
+ raw, parsed = await orchestrator_call(
156
+ orchestrator_model_id=_orchestrator_model_id(session),
157
+ user_prompt=prompt,
158
+ label="vote_candidate_extraction",
159
+ api_log=session.api_log,
160
+ expect_json=True,
161
+ max_tokens=500,
162
+ temperature=0.2,
163
+ )
164
+ _bump_orchestrator_count(session)
165
+
166
+ options: list[str] = []
167
+ if isinstance(parsed, dict):
168
+ raw_opts = parsed.get("options") or []
169
+ if isinstance(raw_opts, list):
170
+ options = [str(o).strip() for o in raw_opts if str(o).strip()]
171
+
172
+ # Truncate to cap.
173
+ options = options[:max_candidates]
174
+
175
+ if not options:
176
+ # Fallback: take each unique position verbatim, truncated.
177
+ seen: set[str] = set()
178
+ for txt in positions.values():
179
+ cleaned = (txt or "").strip()
180
+ if not cleaned:
181
+ continue
182
+ key = cleaned[:80].lower()
183
+ if key in seen:
184
+ continue
185
+ seen.add(key)
186
+ # Single-line short version
187
+ single = " ".join(cleaned.split())
188
+ if len(single) > 160:
189
+ single = single[:157] + "..."
190
+ options.append(single)
191
+ if len(options) >= max_candidates:
192
+ break
193
+
194
+ return options
195
+
196
+
197
+ async def cast_vote_single(
198
+ *,
199
+ session: Session,
200
+ participant: Participant,
201
+ question: str,
202
+ options: list[str],
203
+ ) -> dict[str, Any]:
204
+ """Ask one participant to pick exactly one option.
205
+
206
+ Returns {"choice": int (1-based, 0 if invalid), "reason": str,
207
+ "ok": bool}. Always non-fatal — a malformed reply just yields
208
+ {"choice": 0, ...} so the tally can skip it.
209
+ """
210
+ return await _cast_vote(
211
+ session=session,
212
+ participant=participant,
213
+ prompt=VOTE_SINGLE_PROMPT.format(
214
+ participant_name=participant.name,
215
+ question=question,
216
+ options_block=_format_options_block(options),
217
+ ),
218
+ mode="single",
219
+ n_options=len(options),
220
+ )
221
+
222
+
223
+ async def cast_vote_ranking(
224
+ *,
225
+ session: Session,
226
+ participant: Participant,
227
+ question: str,
228
+ options: list[str],
229
+ ) -> dict[str, Any]:
230
+ """Ask one participant to fully rank all options.
231
+
232
+ Returns {"ranking": [int, ...] (1-based, may be partial if the
233
+ model misbehaved), "reason": str, "ok": bool}.
234
+ """
235
+ return await _cast_vote(
236
+ session=session,
237
+ participant=participant,
238
+ prompt=VOTE_RANK_PROMPT.format(
239
+ participant_name=participant.name,
240
+ question=question,
241
+ options_block=_format_options_block(options),
242
+ ),
243
+ mode="rank",
244
+ n_options=len(options),
245
+ )
246
+
247
+
248
+ async def cast_vote_yesno(
249
+ *,
250
+ session: Session,
251
+ participant: Participant,
252
+ motion: str,
253
+ ) -> dict[str, Any]:
254
+ """Ask one participant to vote aye/nay/abstain on a motion.
255
+
256
+ Returns {"vote": "aye"|"nay"|"abstain"|"", "reason": str,
257
+ "ok": bool}.
258
+ """
259
+ return await _cast_vote(
260
+ session=session,
261
+ participant=participant,
262
+ prompt=VOTE_YESNO_PROMPT.format(
263
+ participant_name=participant.name,
264
+ motion=motion,
265
+ ),
266
+ mode="yesno",
267
+ n_options=0,
268
+ )
269
+
270
+
271
+ async def _cast_vote(
272
+ *,
273
+ session: Session,
274
+ participant: Participant,
275
+ prompt: str,
276
+ mode: str,
277
+ n_options: int,
278
+ ) -> dict[str, Any]:
279
+ if participant.kind == "human":
280
+ # For now, treat human participants as abstaining in the
281
+ # automated vote path. Future work: pause the orchestrator
282
+ # and route through human_io so the user can cast a real
283
+ # ballot. The decision method can detect this and surface a
284
+ # note in the report.
285
+ if mode == "yesno":
286
+ return {"vote": "abstain", "reason": "(human participant)", "ok": False}
287
+ if mode == "rank":
288
+ return {"ranking": [], "reason": "(human participant)", "ok": False}
289
+ return {"choice": 0, "reason": "(human participant)", "ok": False}
290
+
291
+ system_text = (
292
+ f"{participant.role_prompt}\n\n{NO_REASONING_DIRECTIVE}\n\n"
293
+ "When asked to cast a vote, reply with ONLY the requested JSON "
294
+ "object and no other text."
295
+ )
296
+ messages = [
297
+ {"role": "system", "content": system_text},
298
+ {"role": "user", "content": prompt},
299
+ ]
300
+ resolved = {
301
+ "model_id": participant.model_id,
302
+ "base_url": participant.base_url,
303
+ "api_key": participant.api_key,
304
+ "is_neon": participant.is_neon,
305
+ "hana_model_id": participant.hana_model_id,
306
+ "persona_name": participant.persona_name,
307
+ "neon_direct_vllm": participant.neon_direct_vllm,
308
+ "vllm_base_url": participant.vllm_base_url,
309
+ "vllm_api_key": participant.vllm_api_key,
310
+ }
311
+ log_entry: dict[str, Any] = {
312
+ "timestamp": time.time(),
313
+ "label": f"vote:{mode}:{participant.participant_id}",
314
+ "model": participant.model_id,
315
+ "request": {"messages": messages, "max_tokens": 300},
316
+ }
317
+ try:
318
+ result = await chat_completion(
319
+ resolved=resolved,
320
+ messages=messages,
321
+ max_tokens=300,
322
+ temperature=0.2,
323
+ timeout=45.0,
324
+ )
325
+ except Exception as exc: # noqa: BLE001
326
+ LOG.warning("vote %s for %s failed: %s", mode, participant.participant_id, exc)
327
+ log_entry["response"] = {"error": str(exc)}
328
+ session.api_log.append(log_entry)
329
+ return _vote_default(mode)
330
+
331
+ log_entry["response"] = result
332
+ session.api_log.append(log_entry)
333
+
334
+ if result.get("error"):
335
+ return _vote_default(mode)
336
+
337
+ raw = strip_thinking(result.get("response", ""))
338
+ return _parse_vote(raw, mode=mode, n_options=n_options)
339
+
340
+
341
+ def _vote_default(mode: str) -> dict[str, Any]:
342
+ if mode == "yesno":
343
+ return {"vote": "", "reason": "", "ok": False}
344
+ if mode == "rank":
345
+ return {"ranking": [], "reason": "", "ok": False}
346
+ return {"choice": 0, "reason": "", "ok": False}
347
+
348
+
349
+ def _parse_vote(raw: str, *, mode: str, n_options: int) -> dict[str, Any]:
350
+ parsed = parse_json_response(raw)
351
+ if not isinstance(parsed, dict):
352
+ return _vote_default(mode)
353
+
354
+ reason = str(parsed.get("reason") or "").strip()
355
+
356
+ if mode == "yesno":
357
+ vote = str(parsed.get("vote") or "").strip().lower()
358
+ if vote not in ("aye", "nay", "abstain"):
359
+ return {"vote": "", "reason": reason, "ok": False}
360
+ return {"vote": vote, "reason": reason, "ok": True}
361
+
362
+ if mode == "rank":
363
+ raw_rank = parsed.get("ranking") or parsed.get("rank") or []
364
+ if not isinstance(raw_rank, list):
365
+ return {"ranking": [], "reason": reason, "ok": False}
366
+ ranking: list[int] = []
367
+ seen: set[int] = set()
368
+ for item in raw_rank:
369
+ try:
370
+ idx = int(item)
371
+ except (TypeError, ValueError):
372
+ continue
373
+ if 1 <= idx <= n_options and idx not in seen:
374
+ seen.add(idx)
375
+ ranking.append(idx)
376
+ ok = len(ranking) == n_options
377
+ return {"ranking": ranking, "reason": reason, "ok": ok}
378
+
379
+ # single-choice
380
+ try:
381
+ choice = int(parsed.get("choice") or 0)
382
+ except (TypeError, ValueError):
383
+ choice = 0
384
+ if not (1 <= choice <= n_options):
385
+ choice = 0
386
+ return {"choice": choice, "reason": reason, "ok": choice > 0}
387
+
388
+
389
+ # ---------------------------------------------------------------------------
390
+ # Tallying
391
+ # ---------------------------------------------------------------------------
392
+
393
+ def tally_single_votes(
394
+ ballots: list[dict[str, Any]],
395
+ n_options: int,
396
+ ) -> dict[str, Any]:
397
+ """Tally one-shot plurality votes.
398
+
399
+ `ballots` items shape: {"choice": int 1..N or 0, ...}.
400
+
401
+ Returns:
402
+ {
403
+ "counts": [vote_count_for_option_1, ..._for_option_N],
404
+ "winner": int (1-based; 0 if no votes),
405
+ "tied_for_first": [int, ...],
406
+ "total_cast": int,
407
+ "abstentions": int,
408
+ }
409
+ """
410
+ counts = [0] * n_options
411
+ cast = 0
412
+ abst = 0
413
+ for b in ballots:
414
+ choice = b.get("choice", 0)
415
+ if isinstance(choice, int) and 1 <= choice <= n_options:
416
+ counts[choice - 1] += 1
417
+ cast += 1
418
+ else:
419
+ abst += 1
420
+ if cast == 0:
421
+ return {
422
+ "counts": counts, "winner": 0, "tied_for_first": [],
423
+ "total_cast": 0, "abstentions": abst,
424
+ }
425
+ top = max(counts)
426
+ leaders = [i + 1 for i, c in enumerate(counts) if c == top]
427
+ return {
428
+ "counts": counts,
429
+ "winner": leaders[0] if len(leaders) == 1 else 0,
430
+ "tied_for_first": leaders if len(leaders) > 1 else [],
431
+ "total_cast": cast,
432
+ "abstentions": abst,
433
+ }
434
+
435
+
436
+ def tally_yesno_votes(ballots: list[dict[str, Any]]) -> dict[str, Any]:
437
+ """Tally aye/nay/abstain motion votes.
438
+
439
+ Returns: {"aye": int, "nay": int, "abstain": int,
440
+ "passes": bool, "majority": "aye"|"nay"|"tie",
441
+ "ratio_aye": float}.
442
+ """
443
+ aye = sum(1 for b in ballots if b.get("vote") == "aye")
444
+ nay = sum(1 for b in ballots if b.get("vote") == "nay")
445
+ abst = sum(
446
+ 1 for b in ballots
447
+ if b.get("vote") == "abstain" or not b.get("vote")
448
+ )
449
+ cast = aye + nay
450
+ if cast == 0:
451
+ return {
452
+ "aye": aye, "nay": nay, "abstain": abst,
453
+ "passes": False, "majority": "tie", "ratio_aye": 0.0,
454
+ }
455
+ if aye > nay:
456
+ majority = "aye"
457
+ elif nay > aye:
458
+ majority = "nay"
459
+ else:
460
+ majority = "tie"
461
+ return {
462
+ "aye": aye, "nay": nay, "abstain": abst,
463
+ "passes": aye > nay,
464
+ "majority": majority,
465
+ "ratio_aye": aye / cast,
466
+ }
467
+
468
+
469
+ def run_irv(
470
+ ballots: list[list[int]],
471
+ n_options: int,
472
+ ) -> dict[str, Any]:
473
+ """Instant-runoff tally on 1-based ranking ballots.
474
+
475
+ A ballot is a list of 1-based option indices in order of
476
+ preference; partial ballots are tolerated. Eliminate the
477
+ lowest-first-choice option each round, redistribute its top-rank
478
+ votes to the next still-eligible choice on each ballot, until one
479
+ option has >50% or all but one are eliminated.
480
+
481
+ Returns:
482
+ {
483
+ "rounds": [ {round: int, counts: {option_idx: count},
484
+ eliminated: int or None,
485
+ winner: int or None}, ... ],
486
+ "winner": int (1-based; 0 if no ballots),
487
+ "tied": bool,
488
+ }
489
+ """
490
+ if not ballots or n_options <= 0:
491
+ return {"rounds": [], "winner": 0, "tied": False}
492
+
493
+ eligible: set[int] = set(range(1, n_options + 1))
494
+ # Per-ballot cursor (skips eliminated options on the fly)
495
+ rounds: list[dict[str, Any]] = []
496
+ round_n = 0
497
+
498
+ while True:
499
+ round_n += 1
500
+ counts: dict[int, int] = {opt: 0 for opt in eligible}
501
+ for ballot in ballots:
502
+ top: int | None = None
503
+ for opt in ballot:
504
+ if opt in eligible:
505
+ top = opt
506
+ break
507
+ if top is not None:
508
+ counts[top] = counts.get(top, 0) + 1
509
+
510
+ total = sum(counts.values())
511
+ if total == 0:
512
+ rounds.append({"round": round_n, "counts": counts,
513
+ "eliminated": None, "winner": None})
514
+ return {"rounds": rounds, "winner": 0, "tied": False}
515
+
516
+ # Check for majority winner
517
+ for opt, c in counts.items():
518
+ if c * 2 > total:
519
+ rounds.append({"round": round_n, "counts": counts,
520
+ "eliminated": None, "winner": opt})
521
+ return {"rounds": rounds, "winner": opt, "tied": False}
522
+
523
+ # Only one option left → it wins by default
524
+ if len(eligible) == 1:
525
+ sole = next(iter(eligible))
526
+ rounds.append({"round": round_n, "counts": counts,
527
+ "eliminated": None, "winner": sole})
528
+ return {"rounds": rounds, "winner": sole, "tied": False}
529
+
530
+ # Eliminate the option with the fewest first-rank votes; on a
531
+ # tie at the bottom, eliminate the one with the lowest 1-based
532
+ # index (deterministic tiebreak).
533
+ lowest = min(counts.values())
534
+ candidates_to_drop = sorted(
535
+ opt for opt, c in counts.items() if c == lowest
536
+ )
537
+ # If ALL remaining options tie at the bottom, we have a
538
+ # degenerate tie — no winner.
539
+ if len(candidates_to_drop) == len(eligible):
540
+ rounds.append({"round": round_n, "counts": counts,
541
+ "eliminated": None, "winner": None})
542
+ return {"rounds": rounds, "winner": 0, "tied": True}
543
+ drop = candidates_to_drop[0]
544
+ eligible.discard(drop)
545
+ rounds.append({"round": round_n, "counts": counts,
546
+ "eliminated": drop, "winner": None})
547
+
548
+ # Safety: cap iterations at n_options + 1.
549
+ if round_n > n_options + 1: # pragma: no cover
550
+ return {"rounds": rounds, "winner": 0, "tied": True}
backend/app/services/models.py CHANGED
@@ -29,6 +29,18 @@ class Phase(str, Enum):
29
  CLOSURE = "closure"
30
  FAILSAFE_PAUSED = "failsafe_paused"
31
  FINISHED = "finished"
 
 
 
 
 
 
 
 
 
 
 
 
32
 
33
 
34
  # How many participants a session may include (overridable by the user
@@ -379,3 +391,19 @@ class Session:
379
  # as ordered queues (pop from front).
380
  candidate_pool: list[Participant] = field(default_factory=list)
381
  substitution_chain: list[dict[str, Any]] = field(default_factory=list)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
29
  CLOSURE = "closure"
30
  FAILSAFE_PAUSED = "failsafe_paused"
31
  FINISHED = "finished"
32
+ # Robert's Rules of Order conversation-structure phases. These run
33
+ # in place of (or alongside) the Collaborative Discussion phases
34
+ # when the user picks the "Robert's Rules" conversation structure.
35
+ RR_OPENING = "rr_opening"
36
+ RR_INITIAL_REMARKS = "rr_initial_remarks"
37
+ RR_MOTION = "rr_motion"
38
+ RR_DEBATE = "rr_debate"
39
+ RR_MOVE_THE_QUESTION = "rr_move_the_question"
40
+ # Vote-based decision-method phases. Used by MajorityRulesDecision,
41
+ # RankedChoiceDecision, and RobertsRulesVote so the frontend can
42
+ # render an appropriate phase label.
43
+ VOTING = "voting"
44
 
45
 
46
  # How many participants a session may include (overridable by the user
 
391
  # as ordered queues (pop from front).
392
  candidate_pool: list[Participant] = field(default_factory=list)
393
  substitution_chain: list[dict[str, Any]] = field(default_factory=list)
394
+
395
+ # Conversation-format plugin selection. Resolved via
396
+ # `app.services.conversation.get_structure(...)` /
397
+ # `get_decision(...)`. Default to the original CCAI behavior
398
+ # (collaborative discussion + consensus decision) so older
399
+ # /chat/start payloads keep working without changes.
400
+ conversation_structure_id: str = "collaborative"
401
+ decision_method_id: str = "consensus"
402
+
403
+ # Robert's Rules state. Only populated when
404
+ # `conversation_structure_id == "roberts_rules"`. The decision
405
+ # method reads `main_motion` and (optionally) `proposed_motions`
406
+ # so a non-RR decision method like RankedChoice can still operate
407
+ # on RR output.
408
+ main_motion: str | None = None
409
+ proposed_motions: list[dict[str, Any]] = field(default_factory=list)
backend/app/services/orchestrator.py CHANGED
@@ -1505,7 +1505,19 @@ async def _phase_closure(session: Session) -> AsyncIterator[str]:
1505
  # ---------------------------------------------------------------------------
1506
 
1507
  async def run_conversation(session: Session) -> AsyncIterator[str]:
1508
- """Drive the full six-phase conversation, yielding SSE chunks."""
 
 
 
 
 
 
 
 
 
 
 
 
1509
  actives = _active_participants(session)
1510
  if len(actives) < 2:
1511
  yield _sse("error", {
@@ -1518,24 +1530,17 @@ async def run_conversation(session: Session) -> AsyncIterator[str]:
1518
  for extra in actives[session.max_participants:]:
1519
  extra.enabled = False
1520
 
1521
- try:
1522
- async for chunk in _phase_initial_opinions(session):
1523
- yield chunk
1524
-
1525
- for round_n in range(1, session.limits.critique_rounds + 1):
1526
- async for chunk in _phase_critique(session, round_n):
1527
- yield chunk
1528
 
1529
- async for chunk in _phase_status_assessment(session):
1530
- yield chunk
1531
-
1532
- async for chunk in _phase_finalization(session):
1533
- yield chunk
1534
-
1535
- async for chunk in _phase_consensus(session):
1536
  yield chunk
1537
 
1538
- async for chunk in _phase_closure(session):
 
 
1539
  yield chunk
1540
  except Exception as exc:
1541
  LOG.exception("Conversation crashed: %s", exc)
 
1505
  # ---------------------------------------------------------------------------
1506
 
1507
  async def run_conversation(session: Session) -> AsyncIterator[str]:
1508
+ """Drive the full conversation, yielding SSE chunks.
1509
+
1510
+ The flow is structure → decision: the chosen ConversationStructure
1511
+ runs its phases, then hands a DecisionInput to the chosen
1512
+ DecisionMethod which runs the decision phase(s). Both are
1513
+ resolved from `session.conversation_structure_id` /
1514
+ `session.decision_method_id` (defaults: collaborative + consensus,
1515
+ which preserves the original CCAI behavior).
1516
+ """
1517
+ # Lazy import so the conversation package can import orchestrator
1518
+ # helpers without a circular module load.
1519
+ from app.services.conversation import get_structure, get_decision
1520
+
1521
  actives = _active_participants(session)
1522
  if len(actives) < 2:
1523
  yield _sse("error", {
 
1530
  for extra in actives[session.max_participants:]:
1531
  extra.enabled = False
1532
 
1533
+ structure_cls = get_structure(session.conversation_structure_id)
1534
+ decision_cls = get_decision(session.decision_method_id)
1535
+ structure = structure_cls(session)
 
 
 
 
1536
 
1537
+ try:
1538
+ async for chunk in structure.run():
 
 
 
 
 
1539
  yield chunk
1540
 
1541
+ decision_input = structure.build_decision_input()
1542
+ decision = decision_cls(session, decision_input)
1543
+ async for chunk in decision.run():
1544
  yield chunk
1545
  except Exception as exc:
1546
  LOG.exception("Conversation crashed: %s", exc)
frontend/src/App.js CHANGED
@@ -16,6 +16,7 @@ import {
16
  getAuthStatus,
17
  exportChat, exportApiLog, fetchTableView, fetchCredentials,
18
  fetchConversationLimitsDefaults,
 
19
  autoSelectParticipants,
20
  fetchPromptCatalog,
21
  getRateLimitStatus,
@@ -51,6 +52,31 @@ export default function App() {
51
  // Default false matches the backend default ("Prioritize model choice").
52
  const [speedPriority, setSpeedPriorityState] = useState(false);
53
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
54
  // Backend catalog
55
  const [providers, setProviders] = useState([]);
56
  const [neonModels, setNeonModels] = useState([]);
@@ -150,6 +176,10 @@ export default function App() {
150
  getSpeedPriority().then(d => {
151
  if (typeof d?.enabled === 'boolean') setSpeedPriorityState(d.enabled);
152
  }).catch(() => {});
 
 
 
 
153
  getAuthStatus().then(setAuth).catch(() => {});
154
  getRateLimitStatus().then(d => {
155
  if (d?.daily_limit) setDailyLimit(d.daily_limit);
@@ -583,8 +613,17 @@ export default function App() {
583
  // Sparse override map; backend clamps and falls back per-field.
584
  limits: limitsOverrides,
585
  human_credential,
 
 
 
 
586
  };
587
- }, [selectedParticipants, enabledMap, modelAssignments, orchestratorModel, summarizerModel, maxParticipants, limitsOverrides, humanParticipant]);
 
 
 
 
 
588
 
589
  // ─── Stop / continue ────────────────────────────────────────────
590
  const handleStop = useCallback(() => {
@@ -760,6 +799,33 @@ export default function App() {
760
  text: `${origName} couldn't give an initial opinion; ${altName} is taking their place.`,
761
  }]);
762
  },
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
763
  onFailsafePause: (data) => {
764
  setPause({ reason: 'messages', ...data });
765
  },
@@ -865,6 +931,11 @@ export default function App() {
865
  onSummarizerChange={handleSummarizerChange}
866
  speedPriority={speedPriority}
867
  onSpeedPriorityChange={handleSpeedPriorityChange}
 
 
 
 
 
868
  showResponseTime={showResponseTime}
869
  onShowResponseTimeChange={setShowResponseTime}
870
  showChatStats={showChatStats}
 
16
  getAuthStatus,
17
  exportChat, exportApiLog, fetchTableView, fetchCredentials,
18
  fetchConversationLimitsDefaults,
19
+ fetchConversationFormats,
20
  autoSelectParticipants,
21
  fetchPromptCatalog,
22
  getRateLimitStatus,
 
52
  // Default false matches the backend default ("Prioritize model choice").
53
  const [speedPriority, setSpeedPriorityState] = useState(false);
54
 
55
+ // Conversation-format plugin catalog + current selection. The
56
+ // catalog is fetched once on mount from /api/chat/conversation-formats
57
+ // so adding a new structure / decision plugin server-side doesn't
58
+ // require a frontend change. Selections fall back to whatever the
59
+ // backend reports as its default until the user picks otherwise.
60
+ const [conversationFormats, setConversationFormats] = useState({
61
+ structures: [], decisions: [],
62
+ default_structure_id: 'collaborative',
63
+ default_decision_id: 'consensus',
64
+ });
65
+ const [conversationStructureId, setConversationStructureIdState] = useState(
66
+ persisted.conversation_structure_id || null,
67
+ );
68
+ const [decisionMethodId, setDecisionMethodIdState] = useState(
69
+ persisted.decision_method_id || null,
70
+ );
71
+ const handleConversationStructureChange = useCallback((id) => {
72
+ setConversationStructureIdState(id || null);
73
+ storage.setConversationStructureId(id || null);
74
+ }, []);
75
+ const handleDecisionMethodChange = useCallback((id) => {
76
+ setDecisionMethodIdState(id || null);
77
+ storage.setDecisionMethodId(id || null);
78
+ }, []);
79
+
80
  // Backend catalog
81
  const [providers, setProviders] = useState([]);
82
  const [neonModels, setNeonModels] = useState([]);
 
176
  getSpeedPriority().then(d => {
177
  if (typeof d?.enabled === 'boolean') setSpeedPriorityState(d.enabled);
178
  }).catch(() => {});
179
+ fetchConversationFormats().then(catalog => {
180
+ if (!catalog || !Array.isArray(catalog.structures)) return;
181
+ setConversationFormats(catalog);
182
+ }).catch(() => {});
183
  getAuthStatus().then(setAuth).catch(() => {});
184
  getRateLimitStatus().then(d => {
185
  if (d?.daily_limit) setDailyLimit(d.daily_limit);
 
613
  // Sparse override map; backend clamps and falls back per-field.
614
  limits: limitsOverrides,
615
  human_credential,
616
+ // Conversation format selection. null fields make the backend
617
+ // fall back to its built-in defaults (collaborative + consensus).
618
+ conversation_structure_id: conversationStructureId,
619
+ decision_method_id: decisionMethodId,
620
  };
621
+ }, [
622
+ selectedParticipants, enabledMap, modelAssignments,
623
+ orchestratorModel, summarizerModel, maxParticipants,
624
+ limitsOverrides, humanParticipant,
625
+ conversationStructureId, decisionMethodId,
626
+ ]);
627
 
628
  // ─── Stop / continue ────────────────────────────────────────────
629
  const handleStop = useCallback(() => {
 
799
  text: `${origName} couldn't give an initial opinion; ${altName} is taking their place.`,
800
  }]);
801
  },
802
+ onVoteCast: (data) => {
803
+ // One per-ballot from a vote-based decision method. We
804
+ // surface these as system notes so the user can follow
805
+ // the tally as it happens; the final report message will
806
+ // contain the canonical summary.
807
+ const voter = data?.voter_name || 'A voter';
808
+ let line;
809
+ if (data?.vote) {
810
+ line = `${voter} votes ${data.vote}.`;
811
+ } else if (Array.isArray(data?.ranking) && data.ranking.length > 0) {
812
+ line = `${voter} submitted ranking: ${data.ranking.join(' > ')}.`;
813
+ } else if (typeof data?.choice === 'number' && data.choice > 0) {
814
+ line = `${voter} votes for option ${data.choice}.`;
815
+ } else {
816
+ line = `${voter} abstained or returned an invalid ballot.`;
817
+ }
818
+ setSystemMessages(prev => [...prev, { text: line }]);
819
+ },
820
+ onVoteTally: (data) => {
821
+ // Final tally summary. The orchestrator message that
822
+ // follows contains the rendered report, so we just log
823
+ // a one-liner here for the system feed.
824
+ const kind = data?.kind || 'vote';
825
+ setSystemMessages(prev => [...prev, {
826
+ text: `Vote complete (${kind}); see report below.`,
827
+ }]);
828
+ },
829
  onFailsafePause: (data) => {
830
  setPause({ reason: 'messages', ...data });
831
  },
 
931
  onSummarizerChange={handleSummarizerChange}
932
  speedPriority={speedPriority}
933
  onSpeedPriorityChange={handleSpeedPriorityChange}
934
+ conversationFormats={conversationFormats}
935
+ conversationStructureId={conversationStructureId}
936
+ onConversationStructureChange={handleConversationStructureChange}
937
+ decisionMethodId={decisionMethodId}
938
+ onDecisionMethodChange={handleDecisionMethodChange}
939
  showResponseTime={showResponseTime}
940
  onShowResponseTimeChange={setShowResponseTime}
941
  showChatStats={showChatStats}
frontend/src/components/DevMenu.js CHANGED
@@ -44,6 +44,11 @@ export default function DevMenu({
44
  onSummarizerChange,
45
  speedPriority,
46
  onSpeedPriorityChange,
 
 
 
 
 
47
  showResponseTime,
48
  onShowResponseTimeChange,
49
  showChatStats,
@@ -68,6 +73,7 @@ export default function DevMenu({
68
  // multi-item category, just add a key here.
69
  const [openSections, setOpenSections] = useState({
70
  modelSel: false,
 
71
  responsePriority: false,
72
  display: false,
73
  transparency: false,
@@ -238,6 +244,28 @@ export default function DevMenu({
238
 
239
  <div className="dev-panel-divider" />
240
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
241
  {/* ── Response priority (accordion) ──────────────────── */}
242
  {/* Two mutually-exclusive choices. Under "Prioritize
243
  conversation speed" the backend also races the chosen
@@ -485,3 +513,70 @@ function SectionHeader({ label, open, onToggle }) {
485
  </button>
486
  );
487
  }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
44
  onSummarizerChange,
45
  speedPriority,
46
  onSpeedPriorityChange,
47
+ conversationFormats,
48
+ conversationStructureId,
49
+ onConversationStructureChange,
50
+ decisionMethodId,
51
+ onDecisionMethodChange,
52
  showResponseTime,
53
  onShowResponseTimeChange,
54
  showChatStats,
 
73
  // multi-item category, just add a key here.
74
  const [openSections, setOpenSections] = useState({
75
  modelSel: false,
76
+ conversationFormat: false,
77
  responsePriority: false,
78
  display: false,
79
  transparency: false,
 
244
 
245
  <div className="dev-panel-divider" />
246
 
247
+ {/* ── Conversation format (accordion) ───────────────── */}
248
+ {/* Two mutually-exclusive picker lists. The catalog is
249
+ served by /api/chat/conversation-formats so adding a
250
+ new structure or decision-method plugin server-side
251
+ doesn't need a frontend code change. */}
252
+ <SectionHeader
253
+ label="Conversation format"
254
+ open={openSections.conversationFormat}
255
+ onToggle={() => toggleSection('conversationFormat')}
256
+ />
257
+ {openSections.conversationFormat && (
258
+ <ConversationFormatPicker
259
+ catalog={conversationFormats}
260
+ structureId={conversationStructureId}
261
+ onStructureChange={onConversationStructureChange}
262
+ decisionId={decisionMethodId}
263
+ onDecisionChange={onDecisionMethodChange}
264
+ />
265
+ )}
266
+
267
+ <div className="dev-panel-divider" />
268
+
269
  {/* ── Response priority (accordion) ──────────────────── */}
270
  {/* Two mutually-exclusive choices. Under "Prioritize
271
  conversation speed" the backend also races the chosen
 
513
  </button>
514
  );
515
  }
516
+
517
+
518
+ /**
519
+ * Two stacked radio-style pickers for the conversation structure and
520
+ * decision-making method. Driven entirely by the server catalog so
521
+ * adding a plugin doesn't need a code change here. A null current
522
+ * selection means "follow the backend's default" — we highlight that
523
+ * default but the explicit user choice always wins when set.
524
+ */
525
+ function ConversationFormatPicker({
526
+ catalog,
527
+ structureId, onStructureChange,
528
+ decisionId, onDecisionChange,
529
+ }) {
530
+ const structures = Array.isArray(catalog?.structures) ? catalog.structures : [];
531
+ const decisions = Array.isArray(catalog?.decisions) ? catalog.decisions : [];
532
+ const defStruct = catalog?.default_structure_id || null;
533
+ const defDec = catalog?.default_decision_id || null;
534
+ const effectiveStruct = structureId || defStruct;
535
+ const effectiveDec = decisionId || defDec;
536
+
537
+ return (
538
+ <>
539
+ <div className="dev-panel-label dev-panel-sublabel">Discussion structure</div>
540
+ {structures.length === 0 && (
541
+ <div className="dev-panel-hint" style={{ padding: '4px 10px' }}>
542
+ (catalog unavailable)
543
+ </div>
544
+ )}
545
+ {structures.map(s => (
546
+ <button
547
+ key={s.id}
548
+ className={`dev-panel-choice ${effectiveStruct === s.id ? 'dev-panel-choice-active' : ''}`}
549
+ onClick={() => onStructureChange?.(s.id)}
550
+ title={s.description || ''}
551
+ >
552
+ {effectiveStruct === s.id
553
+ ? <CheckSquare size={16} className="dev-check-icon" />
554
+ : <Square size={16} className="dev-check-icon" />}
555
+ {s.name}
556
+ </button>
557
+ ))}
558
+
559
+ <div className="dev-panel-label dev-panel-sublabel" style={{ marginTop: 6 }}>
560
+ Decision method
561
+ </div>
562
+ {decisions.length === 0 && (
563
+ <div className="dev-panel-hint" style={{ padding: '4px 10px' }}>
564
+ (catalog unavailable)
565
+ </div>
566
+ )}
567
+ {decisions.map(d => (
568
+ <button
569
+ key={d.id}
570
+ className={`dev-panel-choice ${effectiveDec === d.id ? 'dev-panel-choice-active' : ''}`}
571
+ onClick={() => onDecisionChange?.(d.id)}
572
+ title={d.description || ''}
573
+ >
574
+ {effectiveDec === d.id
575
+ ? <CheckSquare size={16} className="dev-check-icon" />
576
+ : <Square size={16} className="dev-check-icon" />}
577
+ {d.name}
578
+ </button>
579
+ ))}
580
+ </>
581
+ );
582
+ }
frontend/src/components/Header.js CHANGED
@@ -44,6 +44,11 @@ export default function Header({
44
  onSummarizerChange,
45
  speedPriority,
46
  onSpeedPriorityChange,
 
 
 
 
 
47
  showResponseTime,
48
  onShowResponseTimeChange,
49
  showChatStats,
@@ -148,6 +153,11 @@ export default function Header({
148
  onSummarizerChange={onSummarizerChange}
149
  speedPriority={speedPriority}
150
  onSpeedPriorityChange={onSpeedPriorityChange}
 
 
 
 
 
151
  showResponseTime={showResponseTime}
152
  onShowResponseTimeChange={onShowResponseTimeChange}
153
  showChatStats={showChatStats}
 
44
  onSummarizerChange,
45
  speedPriority,
46
  onSpeedPriorityChange,
47
+ conversationFormats,
48
+ conversationStructureId,
49
+ onConversationStructureChange,
50
+ decisionMethodId,
51
+ onDecisionMethodChange,
52
  showResponseTime,
53
  onShowResponseTimeChange,
54
  showChatStats,
 
153
  onSummarizerChange={onSummarizerChange}
154
  speedPriority={speedPriority}
155
  onSpeedPriorityChange={onSpeedPriorityChange}
156
+ conversationFormats={conversationFormats}
157
+ conversationStructureId={conversationStructureId}
158
+ onConversationStructureChange={onConversationStructureChange}
159
+ decisionMethodId={decisionMethodId}
160
+ onDecisionMethodChange={onDecisionMethodChange}
161
  showResponseTime={showResponseTime}
162
  onShowResponseTimeChange={onShowResponseTimeChange}
163
  showChatStats={showChatStats}
frontend/src/components/OrchestratorMessage.js CHANGED
@@ -8,8 +8,37 @@ import remarkGfm from 'remark-gfm';
8
  * speaking. Used for status updates, follow-up announcements, factor
9
  * surfacing, and the final majority/no-consensus reports.
10
  */
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
11
  export default function OrchestratorMessage({ message }) {
12
- const isReport = message.kind === 'majority_report' || message.kind === 'no_consensus_report';
13
  const className = (
14
  'ccai-orchestrator-msg' +
15
  (isReport ? ' ccai-orchestrator-msg-report' : '')
@@ -17,10 +46,7 @@ export default function OrchestratorMessage({ message }) {
17
  return (
18
  <div className={className}>
19
  <div className="ccai-orchestrator-msg-label">
20
- Orchestrator{message.kind === 'majority_report' ? ' - Majority Report'
21
- : message.kind === 'no_consensus_report' ? ' - No-Consensus Report'
22
- : message.kind === 'factor' ? ' - New Consideration'
23
- : ''}
24
  </div>
25
  <div className="ccai-orchestrator-msg-body">
26
  <ReactMarkdown remarkPlugins={[remarkGfm]}>
 
8
  * speaking. Used for status updates, follow-up announcements, factor
9
  * surfacing, and the final majority/no-consensus reports.
10
  */
11
+ // Orchestrator message `kind` values that should get the "report"
12
+ // visual treatment (border, larger text). Includes the originals
13
+ // from the Consensus decision path plus the new ones emitted by the
14
+ // majority-rules, ranked-choice, and Robert's Rules vote plugins.
15
+ const REPORT_KINDS = new Set([
16
+ 'majority_report',
17
+ 'no_consensus_report',
18
+ 'vote_result',
19
+ 'ranked_choice_result',
20
+ ]);
21
+
22
+ function labelSuffixForKind(kind, message) {
23
+ switch (kind) {
24
+ case 'majority_report': return ' - Majority Report';
25
+ case 'no_consensus_report': return ' - No-Consensus Report';
26
+ case 'factor': return ' - New Consideration';
27
+ case 'motion': return ' - Motion on the Floor';
28
+ case 'ballot_options': return ' - Ballot';
29
+ case 'rr_opening': return ' - Chair';
30
+ case 'rr_call_the_question': return ' - Chair Calls the Question';
31
+ case 'vote_result':
32
+ if (message?.flavor === 'roberts_rules') return ' - Vote Result (RR)';
33
+ if (message?.vote_kind === 'yesno') return ' - Vote Result (Aye/Nay)';
34
+ return ' - Vote Result';
35
+ case 'ranked_choice_result': return ' - Ranked-Choice Result';
36
+ default: return '';
37
+ }
38
+ }
39
+
40
  export default function OrchestratorMessage({ message }) {
41
+ const isReport = REPORT_KINDS.has(message.kind);
42
  const className = (
43
  'ccai-orchestrator-msg' +
44
  (isReport ? ' ccai-orchestrator-msg-report' : '')
 
46
  return (
47
  <div className={className}>
48
  <div className="ccai-orchestrator-msg-label">
49
+ Orchestrator{labelSuffixForKind(message.kind, message)}
 
 
 
50
  </div>
51
  <div className="ccai-orchestrator-msg-body">
52
  <ReactMarkdown remarkPlugins={[remarkGfm]}>
frontend/src/utils/api.js CHANGED
@@ -127,6 +127,8 @@ function eventHandlerKey(eventType) {
127
  case 'participant_error': return 'onParticipantError';
128
  case 'participant_substituted': return 'onParticipantSubstituted';
129
  case 'participant_replaced': return 'onParticipantReplaced';
 
 
130
  case 'credentials_updated': return 'onCredentialsUpdated';
131
  case 'human_turn_needed': return 'onHumanTurnNeeded';
132
  case 'human_turn_cleared': return 'onHumanTurnCleared';
@@ -175,6 +177,22 @@ export async function setSpeedPriority(enabled) {
175
  return resp.json();
176
  }
177
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
178
  export async function exportChat(sessionId, fmt = 'txt') {
179
  const resp = await fetch(`${API_BASE}/api/chat/${sessionId}/export?fmt=${fmt}`);
180
  if (!resp.ok) throw new Error('Export failed');
 
127
  case 'participant_error': return 'onParticipantError';
128
  case 'participant_substituted': return 'onParticipantSubstituted';
129
  case 'participant_replaced': return 'onParticipantReplaced';
130
+ case 'vote_cast': return 'onVoteCast';
131
+ case 'vote_tally': return 'onVoteTally';
132
  case 'credentials_updated': return 'onCredentialsUpdated';
133
  case 'human_turn_needed': return 'onHumanTurnNeeded';
134
  case 'human_turn_cleared': return 'onHumanTurnCleared';
 
177
  return resp.json();
178
  }
179
 
180
+ /**
181
+ * Fetch the catalog of available conversation structures and
182
+ * decision-making methods. The Settings menu's "Conversation format"
183
+ * accordion populates from this so adding a new plugin server-side
184
+ * doesn't require frontend code changes.
185
+ *
186
+ * Returns: { structures: [{id, name, description}, ...],
187
+ * decisions: [{id, name, description}, ...],
188
+ * default_structure_id, default_decision_id }
189
+ */
190
+ export async function fetchConversationFormats() {
191
+ const resp = await fetch(`${API_BASE}/api/chat/conversation-formats`, { cache: 'no-store' });
192
+ if (!resp.ok) throw new Error('Failed to fetch conversation formats');
193
+ return resp.json();
194
+ }
195
+
196
  export async function exportChat(sessionId, fmt = 'txt') {
197
  const resp = await fetch(`${API_BASE}/api/chat/${sessionId}/export?fmt=${fmt}`);
198
  if (!resp.ok) throw new Error('Export failed');
frontend/src/utils/storage.js CHANGED
@@ -38,6 +38,12 @@ const DEFAULTS = {
38
  // their summary every session. Cleared from storage when the user
39
  // removes the human via the sidebar.
40
  human_participant: null,
 
 
 
 
 
 
41
  };
42
 
43
  function readAll() {
@@ -118,3 +124,11 @@ export function setAutoSelectMode(on) {
118
  export function setHumanParticipant(humanOrNull) {
119
  return patchState({ human_participant: humanOrNull || null });
120
  }
 
 
 
 
 
 
 
 
 
38
  // their summary every session. Cleared from storage when the user
39
  // removes the human via the sidebar.
40
  human_participant: null,
41
+ // Conversation-format plugin choices (see backend
42
+ // /api/chat/conversation-formats). null means "use server default"
43
+ // — the backend returns `default_structure_id` / `default_decision_id`
44
+ // alongside the catalog so the UI can highlight them.
45
+ conversation_structure_id: null,
46
+ decision_method_id: null,
47
  };
48
 
49
  function readAll() {
 
124
  export function setHumanParticipant(humanOrNull) {
125
  return patchState({ human_participant: humanOrNull || null });
126
  }
127
+
128
+ export function setConversationStructureId(id) {
129
+ return patchState({ conversation_structure_id: id || null });
130
+ }
131
+
132
+ export function setDecisionMethodId(id) {
133
+ return patchState({ decision_method_id: id || null });
134
+ }