Spaces:
Running
feat(conversation): pluggable structures + decision methods (Robert's Rules, ranked-choice, majority)
Browse filesThe 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 +44 -0
- backend/app/services/conversation/__init__.py +86 -0
- backend/app/services/conversation/decisions/__init__.py +1 -0
- backend/app/services/conversation/decisions/base.py +45 -0
- backend/app/services/conversation/decisions/consensus.py +43 -0
- backend/app/services/conversation/decisions/majority.py +224 -0
- backend/app/services/conversation/decisions/ranked_choice.py +170 -0
- backend/app/services/conversation/decisions/roberts_rules_vote.py +163 -0
- backend/app/services/conversation/structures/__init__.py +1 -0
- backend/app/services/conversation/structures/base.py +55 -0
- backend/app/services/conversation/structures/collaborative.py +75 -0
- backend/app/services/conversation/structures/roberts_rules.py +359 -0
- backend/app/services/conversation/types.py +60 -0
- backend/app/services/conversation/voting.py +550 -0
- backend/app/services/models.py +28 -0
- backend/app/services/orchestrator.py +21 -16
- frontend/src/App.js +72 -1
- frontend/src/components/DevMenu.js +95 -0
- frontend/src/components/Header.js +10 -0
- frontend/src/components/OrchestratorMessage.js +31 -5
- frontend/src/utils/api.js +18 -0
- frontend/src/utils/storage.js +14 -0
|
@@ -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,
|
|
@@ -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 |
+
]
|
|
@@ -0,0 +1 @@
|
|
|
|
|
|
|
| 1 |
+
"""DecisionMethod plugins. See `..__init__.py` for the registry."""
|
|
@@ -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
|
|
@@ -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
|
|
@@ -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))
|
|
@@ -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))
|
|
@@ -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))
|
|
@@ -0,0 +1 @@
|
|
|
|
|
|
|
| 1 |
+
"""ConversationStructure plugins. See `..__init__.py` for the registry."""
|
|
@@ -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
|
|
@@ -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 |
+
)
|
|
@@ -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 |
+
)
|
|
@@ -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)
|
|
@@ -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}
|
|
@@ -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)
|
|
@@ -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
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
| 1522 |
-
|
| 1523 |
-
|
| 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 |
-
|
| 1530 |
-
|
| 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 |
-
|
|
|
|
|
|
|
| 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)
|
|
@@ -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 |
-
}, [
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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}
|
|
@@ -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 |
+
}
|
|
@@ -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}
|
|
@@ -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 =
|
| 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
|
| 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]}>
|
|
@@ -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');
|
|
@@ -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 |
+
}
|