Spaces:
Running
perf(orchestrator): parallel phases, streaming, early table summaries
Browse files- Parallel participant turns in Phases 1/2/4 via orchestrator_speed.run_roster_ai_turns_parallel
- SSE token streaming on consensus + targeted follow-up turns (message_stream_start / message_delta)
- Parallel ballot collection in majority / ranked-choice / Robert's Rules votes
- Heuristic-first addressed_to classifier skips most LLM calls
- Compact rolling transcript for orchestrator judge prompts (cached per session)
- Fast orchestrator model (gemini-2.0-flash) for lightweight classifier calls
- Lazy contribution summaries kicked off after Phase 4 so Table View loads instantly
- Conditional Phase-3 credential refresh only when follow-ups are needed
- max_tokens 4x multiplier limited to thinking models
Co-authored-by: Cursor <cursoragent@cursor.com>
- COMMIT_MSG.txt +11 -0
- backend/app/api/chat.py +7 -0
- backend/app/clients/llm_router.py +10 -0
- backend/app/clients/openai_compat.py +73 -4
- backend/app/config.py +3 -0
- backend/app/services/consensus.py +63 -0
- backend/app/services/conversation/decisions/majority.py +16 -9
- backend/app/services/conversation/decisions/ranked_choice.py +9 -5
- backend/app/services/conversation/decisions/roberts_rules_vote.py +8 -4
- backend/app/services/conversation/voting.py +32 -1
- backend/app/services/models.py +12 -0
- backend/app/services/orchestrator.py +291 -218
- backend/app/services/orchestrator_speed.py +303 -0
- backend/app/services/resilience.py +4 -0
- frontend/src/App.js +30 -1
- frontend/src/utils/api.js +2 -0
|
@@ -0,0 +1,11 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
perf(orchestrator): parallel phases, streaming, early table summaries
|
| 2 |
+
|
| 3 |
+
- Parallel participant turns in Phases 1/2/4 via orchestrator_speed.run_roster_ai_turns_parallel
|
| 4 |
+
- SSE token streaming on consensus + targeted follow-up turns (message_stream_start / message_delta)
|
| 5 |
+
- Parallel ballot collection in majority / ranked-choice / Robert's Rules votes
|
| 6 |
+
- Heuristic-first addressed_to classifier skips most LLM calls
|
| 7 |
+
- Compact rolling transcript for orchestrator judge prompts (cached per session)
|
| 8 |
+
- Fast orchestrator model (gemini-2.0-flash) for lightweight classifier calls
|
| 9 |
+
- Lazy contribution summaries kicked off after Phase 4 so Table View loads instantly
|
| 10 |
+
- Conditional Phase-3 credential refresh only when follow-ups are needed
|
| 11 |
+
- max_tokens 4x multiplier limited to thinking models
|
|
@@ -985,6 +985,13 @@ async def api_table_view(session_id: str):
|
|
| 985 |
if not session:
|
| 986 |
raise HTTPException(404, "Session not found")
|
| 987 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 988 |
rows = []
|
| 989 |
for p in session.participants:
|
| 990 |
first = (session.initial_opinions or {}).get(p.participant_id, "")
|
|
|
|
| 985 |
if not session:
|
| 986 |
raise HTTPException(404, "Session not found")
|
| 987 |
|
| 988 |
+
from app.services.orchestrator import ensure_contribution_summaries
|
| 989 |
+
|
| 990 |
+
try:
|
| 991 |
+
await ensure_contribution_summaries(session)
|
| 992 |
+
except Exception as exc:
|
| 993 |
+
LOG.warning("Failed to build contribution summaries: %s", exc)
|
| 994 |
+
|
| 995 |
rows = []
|
| 996 |
for p in session.participants:
|
| 997 |
first = (session.initial_opinions or {}).get(p.participant_id, "")
|
|
@@ -45,11 +45,19 @@ async def chat_completion(
|
|
| 45 |
temperature: float = 0.7,
|
| 46 |
max_tokens: int = 1024,
|
| 47 |
timeout: float | None = None,
|
|
|
|
| 48 |
) -> dict[str, Any]:
|
| 49 |
"""Unified LLM call that routes Neon models through HANA and others through OpenAI-compat."""
|
| 50 |
if resolved.get("is_neon"):
|
| 51 |
return await _call_hana(resolved, messages, temperature, max_tokens)
|
| 52 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 53 |
from app.config import settings
|
| 54 |
if settings.speed_priority:
|
| 55 |
return await _racing_openai(resolved, messages, temperature, max_tokens, timeout)
|
|
@@ -63,6 +71,7 @@ async def _plain_openai(
|
|
| 63 |
temperature: float,
|
| 64 |
max_tokens: int,
|
| 65 |
timeout: float | None,
|
|
|
|
| 66 |
) -> dict[str, Any]:
|
| 67 |
return await openai_chat_completion(
|
| 68 |
base_url=resolved["base_url"],
|
|
@@ -72,6 +81,7 @@ async def _plain_openai(
|
|
| 72 |
temperature=temperature,
|
| 73 |
max_tokens=max_tokens,
|
| 74 |
timeout=timeout,
|
|
|
|
| 75 |
)
|
| 76 |
|
| 77 |
|
|
|
|
| 45 |
temperature: float = 0.7,
|
| 46 |
max_tokens: int = 1024,
|
| 47 |
timeout: float | None = None,
|
| 48 |
+
on_text_delta: Any | None = None,
|
| 49 |
) -> dict[str, Any]:
|
| 50 |
"""Unified LLM call that routes Neon models through HANA and others through OpenAI-compat."""
|
| 51 |
if resolved.get("is_neon"):
|
| 52 |
return await _call_hana(resolved, messages, temperature, max_tokens)
|
| 53 |
|
| 54 |
+
# Streaming uses a single request path (no model racing).
|
| 55 |
+
if on_text_delta is not None:
|
| 56 |
+
return await _plain_openai(
|
| 57 |
+
resolved, messages, temperature, max_tokens, timeout,
|
| 58 |
+
on_text_delta=on_text_delta,
|
| 59 |
+
)
|
| 60 |
+
|
| 61 |
from app.config import settings
|
| 62 |
if settings.speed_priority:
|
| 63 |
return await _racing_openai(resolved, messages, temperature, max_tokens, timeout)
|
|
|
|
| 71 |
temperature: float,
|
| 72 |
max_tokens: int,
|
| 73 |
timeout: float | None,
|
| 74 |
+
on_text_delta: Any | None = None,
|
| 75 |
) -> dict[str, Any]:
|
| 76 |
return await openai_chat_completion(
|
| 77 |
base_url=resolved["base_url"],
|
|
|
|
| 81 |
temperature=temperature,
|
| 82 |
max_tokens=max_tokens,
|
| 83 |
timeout=timeout,
|
| 84 |
+
on_text_delta=on_text_delta,
|
| 85 |
)
|
| 86 |
|
| 87 |
|
|
@@ -1,6 +1,7 @@
|
|
| 1 |
from __future__ import annotations
|
| 2 |
|
| 3 |
import asyncio
|
|
|
|
| 4 |
import logging
|
| 5 |
import time
|
| 6 |
from typing import Any
|
|
@@ -19,6 +20,11 @@ _MAX_COMPLETION_TOKEN_MODELS = {
|
|
| 19 |
}
|
| 20 |
_NO_TEMPERATURE_MODELS = {"o1", "o1-mini", "o1-preview", "o3", "o3-mini", "o4-mini"}
|
| 21 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 22 |
# Reserve a few tokens for chat-template framing the server tacks on (the
|
| 23 |
# vLLM server and OpenAI both add a small overhead per request that our
|
| 24 |
# input estimate doesn't account for).
|
|
@@ -68,6 +74,13 @@ def _estimate_input_tokens(messages: list[dict[str, str]]) -> int:
|
|
| 68 |
return total
|
| 69 |
|
| 70 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 71 |
def _resolve_effective_max(
|
| 72 |
model: str, requested: int, messages: list[dict[str, str]],
|
| 73 |
) -> tuple[int, int, int]:
|
|
@@ -86,7 +99,8 @@ def _resolve_effective_max(
|
|
| 86 |
window = context_window_for(model)
|
| 87 |
input_estimate = _estimate_input_tokens(messages)
|
| 88 |
headroom = max(_MIN_OUTPUT_TOKENS, window - input_estimate - _INPUT_SAFETY_MARGIN)
|
| 89 |
-
|
|
|
|
| 90 |
return effective_max, input_estimate, window
|
| 91 |
|
| 92 |
|
|
@@ -110,6 +124,7 @@ async def openai_chat_completion(
|
|
| 110 |
temperature: float = 0.7,
|
| 111 |
max_tokens: int = 1024,
|
| 112 |
timeout: float | None = None,
|
|
|
|
| 113 |
) -> dict[str, Any]:
|
| 114 |
"""Send a chat completion request to any OpenAI-compatible endpoint."""
|
| 115 |
url = f"{base_url.rstrip('/')}/chat/completions"
|
|
@@ -123,11 +138,12 @@ async def openai_chat_completion(
|
|
| 123 |
effective_max, input_estimate, window = _resolve_effective_max(
|
| 124 |
model, max_tokens, messages,
|
| 125 |
)
|
| 126 |
-
|
|
|
|
| 127 |
LOG.info(
|
| 128 |
-
"Capped max_tokens for %s: requested %d (
|
| 129 |
"window=%d, sending %d",
|
| 130 |
-
model, max_tokens, max_tokens *
|
| 131 |
effective_max,
|
| 132 |
)
|
| 133 |
effective_timeout = max(timeout * 2, 120) if timeout else timeout
|
|
@@ -146,6 +162,59 @@ async def openai_chat_completion(
|
|
| 146 |
req_timeout = httpx.Timeout(effective_timeout) if effective_timeout else None
|
| 147 |
client = _get_client()
|
| 148 |
t0 = time.time()
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 149 |
for attempt in range(2):
|
| 150 |
try:
|
| 151 |
resp = await client.post(url, json=body, headers=headers, timeout=req_timeout)
|
|
|
|
| 1 |
from __future__ import annotations
|
| 2 |
|
| 3 |
import asyncio
|
| 4 |
+
import json
|
| 5 |
import logging
|
| 6 |
import time
|
| 7 |
from typing import Any
|
|
|
|
| 20 |
}
|
| 21 |
_NO_TEMPERATURE_MODELS = {"o1", "o1-mini", "o1-preview", "o3", "o3-mini", "o4-mini"}
|
| 22 |
|
| 23 |
+
# Models that emit long internal reasoning traces; allow a higher completion
|
| 24 |
+
# cap. Flash/mini/instruct models use the requested max_tokens as-is.
|
| 25 |
+
_THINKING_MULTIPLIER_MODEL_PREFIXES = _MAX_COMPLETION_TOKEN_MODELS
|
| 26 |
+
_THINKING_NAME_HINTS = ("thinking", "reasoning", "-think", "/think")
|
| 27 |
+
|
| 28 |
# Reserve a few tokens for chat-template framing the server tacks on (the
|
| 29 |
# vLLM server and OpenAI both add a small overhead per request that our
|
| 30 |
# input estimate doesn't account for).
|
|
|
|
| 74 |
return total
|
| 75 |
|
| 76 |
|
| 77 |
+
def _model_wants_thinking_multiplier(model: str) -> bool:
|
| 78 |
+
mid = (model or "").lower()
|
| 79 |
+
if any(mid.startswith(prefix) for prefix in _THINKING_MULTIPLIER_MODEL_PREFIXES):
|
| 80 |
+
return True
|
| 81 |
+
return any(hint in mid for hint in _THINKING_NAME_HINTS)
|
| 82 |
+
|
| 83 |
+
|
| 84 |
def _resolve_effective_max(
|
| 85 |
model: str, requested: int, messages: list[dict[str, str]],
|
| 86 |
) -> tuple[int, int, int]:
|
|
|
|
| 99 |
window = context_window_for(model)
|
| 100 |
input_estimate = _estimate_input_tokens(messages)
|
| 101 |
headroom = max(_MIN_OUTPUT_TOKENS, window - input_estimate - _INPUT_SAFETY_MARGIN)
|
| 102 |
+
multiplier = 4 if _model_wants_thinking_multiplier(model) else 1
|
| 103 |
+
effective_max = max(_MIN_OUTPUT_TOKENS, min(requested * multiplier, headroom))
|
| 104 |
return effective_max, input_estimate, window
|
| 105 |
|
| 106 |
|
|
|
|
| 124 |
temperature: float = 0.7,
|
| 125 |
max_tokens: int = 1024,
|
| 126 |
timeout: float | None = None,
|
| 127 |
+
on_text_delta: Any | None = None,
|
| 128 |
) -> dict[str, Any]:
|
| 129 |
"""Send a chat completion request to any OpenAI-compatible endpoint."""
|
| 130 |
url = f"{base_url.rstrip('/')}/chat/completions"
|
|
|
|
| 138 |
effective_max, input_estimate, window = _resolve_effective_max(
|
| 139 |
model, max_tokens, messages,
|
| 140 |
)
|
| 141 |
+
mult = 4 if _model_wants_thinking_multiplier(model) else 1
|
| 142 |
+
if effective_max < max_tokens * mult:
|
| 143 |
LOG.info(
|
| 144 |
+
"Capped max_tokens for %s: requested %d (x%d=%d), input ~=%d, "
|
| 145 |
"window=%d, sending %d",
|
| 146 |
+
model, max_tokens, mult, max_tokens * mult, input_estimate, window,
|
| 147 |
effective_max,
|
| 148 |
)
|
| 149 |
effective_timeout = max(timeout * 2, 120) if timeout else timeout
|
|
|
|
| 162 |
req_timeout = httpx.Timeout(effective_timeout) if effective_timeout else None
|
| 163 |
client = _get_client()
|
| 164 |
t0 = time.time()
|
| 165 |
+
|
| 166 |
+
if on_text_delta is not None:
|
| 167 |
+
body["stream"] = True
|
| 168 |
+
try:
|
| 169 |
+
parts: list[str] = []
|
| 170 |
+
async with client.stream(
|
| 171 |
+
"POST", url, json=body, headers=headers, timeout=req_timeout,
|
| 172 |
+
) as resp:
|
| 173 |
+
if resp.status_code >= 400:
|
| 174 |
+
detail = (await resp.aread()).decode("utf-8", errors="replace")[:300]
|
| 175 |
+
return {
|
| 176 |
+
"response": f"[Error {resp.status_code}]: {detail}",
|
| 177 |
+
"elapsed_seconds": round(time.time() - t0, 2),
|
| 178 |
+
"model": model,
|
| 179 |
+
"error": True,
|
| 180 |
+
"error_kind": _classify_http_status(resp.status_code),
|
| 181 |
+
"error_status": resp.status_code,
|
| 182 |
+
}
|
| 183 |
+
async for line in resp.aiter_lines():
|
| 184 |
+
if not line or not line.startswith("data:"):
|
| 185 |
+
continue
|
| 186 |
+
payload = line[5:].strip()
|
| 187 |
+
if payload == "[DONE]":
|
| 188 |
+
break
|
| 189 |
+
try:
|
| 190 |
+
chunk = json.loads(payload)
|
| 191 |
+
except json.JSONDecodeError:
|
| 192 |
+
continue
|
| 193 |
+
choices = chunk.get("choices") or []
|
| 194 |
+
if not choices:
|
| 195 |
+
continue
|
| 196 |
+
delta = choices[0].get("delta") or {}
|
| 197 |
+
piece = delta.get("content") or ""
|
| 198 |
+
if piece:
|
| 199 |
+
parts.append(piece)
|
| 200 |
+
on_text_delta(piece)
|
| 201 |
+
text = strip_thinking("".join(parts))
|
| 202 |
+
return {
|
| 203 |
+
"response": text.strip(),
|
| 204 |
+
"elapsed_seconds": round(time.time() - t0, 2),
|
| 205 |
+
"model": model,
|
| 206 |
+
"finish_reason": "stop",
|
| 207 |
+
}
|
| 208 |
+
except Exception as exc:
|
| 209 |
+
LOG.exception("OpenAI-compat stream failed: %s", exc)
|
| 210 |
+
return {
|
| 211 |
+
"response": f"[Error]: {exc}",
|
| 212 |
+
"elapsed_seconds": round(time.time() - t0, 2),
|
| 213 |
+
"model": model,
|
| 214 |
+
"error": True,
|
| 215 |
+
"error_kind": _classify_exception(exc),
|
| 216 |
+
}
|
| 217 |
+
|
| 218 |
for attempt in range(2):
|
| 219 |
try:
|
| 220 |
resp = await client.post(url, json=body, headers=headers, timeout=req_timeout)
|
|
@@ -37,6 +37,9 @@ class Settings(BaseSettings):
|
|
| 37 |
mistral_api_key: str = ""
|
| 38 |
|
| 39 |
orchestrator_model: str = "gpt-4o-mini"
|
|
|
|
|
|
|
|
|
|
| 40 |
speed_priority: bool = False
|
| 41 |
|
| 42 |
cors_origins: str = "http://localhost:3000,http://localhost:3001,http://localhost:3002"
|
|
|
|
| 37 |
mistral_api_key: str = ""
|
| 38 |
|
| 39 |
orchestrator_model: str = "gpt-4o-mini"
|
| 40 |
+
# Lightweight model for addressed-to / status classifiers. Falls back
|
| 41 |
+
# to orchestrator_model when unset or unresolvable.
|
| 42 |
+
orchestrator_fast_model: str = "gemini-2.0-flash"
|
| 43 |
speed_priority: bool = False
|
| 44 |
|
| 45 |
cors_origins: str = "http://localhost:3000,http://localhost:3001,http://localhost:3002"
|
|
@@ -7,6 +7,7 @@ All four are short JSON-shaped orchestrator calls layered on top of
|
|
| 7 |
from __future__ import annotations
|
| 8 |
|
| 9 |
import logging
|
|
|
|
| 10 |
from typing import Any
|
| 11 |
|
| 12 |
from app.services.json_calls import orchestrator_call
|
|
@@ -99,6 +100,64 @@ def _normalize_groups(
|
|
| 99 |
return out
|
| 100 |
|
| 101 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 102 |
async def classify_addressed_to(
|
| 103 |
*,
|
| 104 |
orchestrator_model_id: str,
|
|
@@ -107,6 +166,10 @@ async def classify_addressed_to(
|
|
| 107 |
message: str,
|
| 108 |
api_log: list[dict[str, Any]] | None = None,
|
| 109 |
) -> str | None:
|
|
|
|
|
|
|
|
|
|
|
|
|
| 110 |
prompt = ADDRESSED_TO_PROMPT.format(
|
| 111 |
roster_block=_format_roster_block(participants),
|
| 112 |
speaker=speaker_name,
|
|
|
|
| 7 |
from __future__ import annotations
|
| 8 |
|
| 9 |
import logging
|
| 10 |
+
import re
|
| 11 |
from typing import Any
|
| 12 |
|
| 13 |
from app.services.json_calls import orchestrator_call
|
|
|
|
| 100 |
return out
|
| 101 |
|
| 102 |
|
| 103 |
+
def _heuristic_addressed_to(
|
| 104 |
+
participants: list[Any],
|
| 105 |
+
speaker_name: str,
|
| 106 |
+
message: str,
|
| 107 |
+
) -> str | None:
|
| 108 |
+
"""Fast path when the addressee is obvious from the text.
|
| 109 |
+
|
| 110 |
+
Returns a participant_id when confidence is high; otherwise None
|
| 111 |
+
so the orchestrator LLM classifier runs.
|
| 112 |
+
"""
|
| 113 |
+
if not message or not participants:
|
| 114 |
+
return None
|
| 115 |
+
|
| 116 |
+
others = [p for p in participants if p.name != speaker_name]
|
| 117 |
+
if len(others) == 1:
|
| 118 |
+
return others[0].participant_id
|
| 119 |
+
|
| 120 |
+
text = message.strip()
|
| 121 |
+
lower = text.lower()
|
| 122 |
+
|
| 123 |
+
# "@Name" or "Name:" at start of a sentence
|
| 124 |
+
for p in others:
|
| 125 |
+
name = p.name.strip()
|
| 126 |
+
if not name:
|
| 127 |
+
continue
|
| 128 |
+
nl = name.lower()
|
| 129 |
+
if re.search(rf"@{re.escape(nl)}\b", lower):
|
| 130 |
+
return p.participant_id
|
| 131 |
+
if re.search(
|
| 132 |
+
rf"(^|[.!?\n]\s*){re.escape(nl)}\s*[:,]",
|
| 133 |
+
lower,
|
| 134 |
+
):
|
| 135 |
+
return p.participant_id
|
| 136 |
+
|
| 137 |
+
# "I agree with Name" / "Name, I think"
|
| 138 |
+
hits: list[str] = []
|
| 139 |
+
for p in others:
|
| 140 |
+
name = p.name.strip()
|
| 141 |
+
if not name:
|
| 142 |
+
continue
|
| 143 |
+
nl = re.escape(name.lower())
|
| 144 |
+
if re.search(
|
| 145 |
+
rf"\b(?:agree with|disagree with|respond to|reply to|"
|
| 146 |
+
rf"building on|thank you,?)\s+{nl}\b",
|
| 147 |
+
lower,
|
| 148 |
+
):
|
| 149 |
+
hits.append(p.participant_id)
|
| 150 |
+
elif re.search(rf"\b{nl}\b[,:]?\s+(?:you|your|i think|i believe)\b", lower):
|
| 151 |
+
hits.append(p.participant_id)
|
| 152 |
+
elif re.search(rf"\b{nl}\b", lower) and len(name) >= 4:
|
| 153 |
+
hits.append(p.participant_id)
|
| 154 |
+
|
| 155 |
+
unique = list(dict.fromkeys(hits))
|
| 156 |
+
if len(unique) == 1:
|
| 157 |
+
return unique[0]
|
| 158 |
+
return None
|
| 159 |
+
|
| 160 |
+
|
| 161 |
async def classify_addressed_to(
|
| 162 |
*,
|
| 163 |
orchestrator_model_id: str,
|
|
|
|
| 166 |
message: str,
|
| 167 |
api_log: list[dict[str, Any]] | None = None,
|
| 168 |
) -> str | None:
|
| 169 |
+
heuristic = _heuristic_addressed_to(participants, speaker_name, message)
|
| 170 |
+
if heuristic is not None:
|
| 171 |
+
return heuristic
|
| 172 |
+
|
| 173 |
prompt = ADDRESSED_TO_PROMPT.format(
|
| 174 |
roster_block=_format_roster_block(participants),
|
| 175 |
speaker=speaker_name,
|
|
@@ -28,6 +28,7 @@ from typing import Any, AsyncIterator
|
|
| 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,
|
|
@@ -86,10 +87,13 @@ class MajorityRulesDecision(DecisionMethod):
|
|
| 86 |
yield _sse("orchestrator", _msg_payload(msg))
|
| 87 |
|
| 88 |
ballots: list[dict[str, Any]] = []
|
| 89 |
-
for p in
|
| 90 |
-
|
| 91 |
-
|
| 92 |
-
|
|
|
|
|
|
|
|
|
|
| 93 |
ballot = {
|
| 94 |
"voter_id": p.participant_id,
|
| 95 |
"voter_name": p.name,
|
|
@@ -162,11 +166,14 @@ class MajorityRulesDecision(DecisionMethod):
|
|
| 162 |
yield _sse("orchestrator", _msg_payload(msg))
|
| 163 |
|
| 164 |
ballots: list[dict[str, Any]] = []
|
| 165 |
-
for p in
|
| 166 |
-
|
| 167 |
-
|
| 168 |
-
|
| 169 |
-
|
|
|
|
|
|
|
|
|
|
| 170 |
ballot = {
|
| 171 |
"voter_id": p.participant_id,
|
| 172 |
"voter_name": p.name,
|
|
|
|
| 28 |
from app.services.conversation.decisions.base import DecisionMethod
|
| 29 |
from app.services.conversation.voting import (
|
| 30 |
cast_vote_single,
|
| 31 |
+
gather_votes_parallel,
|
| 32 |
cast_vote_yesno,
|
| 33 |
extract_candidate_options,
|
| 34 |
tally_single_votes,
|
|
|
|
| 87 |
yield _sse("orchestrator", _msg_payload(msg))
|
| 88 |
|
| 89 |
ballots: list[dict[str, Any]] = []
|
| 90 |
+
for p, result in await gather_votes_parallel(
|
| 91 |
+
voters,
|
| 92 |
+
cast_vote_yesno,
|
| 93 |
+
session=session,
|
| 94 |
+
default_mode="yesno",
|
| 95 |
+
motion=motion,
|
| 96 |
+
):
|
| 97 |
ballot = {
|
| 98 |
"voter_id": p.participant_id,
|
| 99 |
"voter_name": p.name,
|
|
|
|
| 166 |
yield _sse("orchestrator", _msg_payload(msg))
|
| 167 |
|
| 168 |
ballots: list[dict[str, Any]] = []
|
| 169 |
+
for p, result in await gather_votes_parallel(
|
| 170 |
+
voters,
|
| 171 |
+
cast_vote_single,
|
| 172 |
+
session=session,
|
| 173 |
+
default_mode="single",
|
| 174 |
+
question=di.question,
|
| 175 |
+
options=options,
|
| 176 |
+
):
|
| 177 |
ballot = {
|
| 178 |
"voter_id": p.participant_id,
|
| 179 |
"voter_name": p.name,
|
|
@@ -26,6 +26,7 @@ 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
|
|
@@ -94,11 +95,14 @@ class RankedChoiceDecision(DecisionMethod):
|
|
| 94 |
|
| 95 |
ballots: list[dict[str, Any]] = []
|
| 96 |
rankings_only: list[list[int]] = []
|
| 97 |
-
for p in
|
| 98 |
-
|
| 99 |
-
|
| 100 |
-
|
| 101 |
-
|
|
|
|
|
|
|
|
|
|
| 102 |
ranking = result.get("ranking") or []
|
| 103 |
ballots.append({
|
| 104 |
"voter_id": p.participant_id,
|
|
|
|
| 26 |
from app.services.conversation.voting import (
|
| 27 |
cast_vote_ranking,
|
| 28 |
extract_candidate_options,
|
| 29 |
+
gather_votes_parallel,
|
| 30 |
run_irv,
|
| 31 |
)
|
| 32 |
from app.services.models import Phase
|
|
|
|
| 95 |
|
| 96 |
ballots: list[dict[str, Any]] = []
|
| 97 |
rankings_only: list[list[int]] = []
|
| 98 |
+
for p, result in await gather_votes_parallel(
|
| 99 |
+
voters,
|
| 100 |
+
cast_vote_ranking,
|
| 101 |
+
session=session,
|
| 102 |
+
default_mode="rank",
|
| 103 |
+
question=di.question,
|
| 104 |
+
options=options,
|
| 105 |
+
):
|
| 106 |
ranking = result.get("ranking") or []
|
| 107 |
ballots.append({
|
| 108 |
"voter_id": p.participant_id,
|
|
@@ -19,6 +19,7 @@ from typing import Any, AsyncIterator
|
|
| 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
|
|
@@ -114,10 +115,13 @@ class RobertsRulesVote(DecisionMethod):
|
|
| 114 |
yield _sse("orchestrator", _msg_payload(msg))
|
| 115 |
|
| 116 |
ballots: list[dict[str, Any]] = []
|
| 117 |
-
for p in
|
| 118 |
-
|
| 119 |
-
|
| 120 |
-
|
|
|
|
|
|
|
|
|
|
| 121 |
ballot = {
|
| 122 |
"voter_id": p.participant_id,
|
| 123 |
"voter_name": p.name,
|
|
|
|
| 19 |
from app.services.conversation.decisions.base import DecisionMethod
|
| 20 |
from app.services.conversation.voting import (
|
| 21 |
cast_vote_yesno,
|
| 22 |
+
gather_votes_parallel,
|
| 23 |
tally_yesno_votes,
|
| 24 |
)
|
| 25 |
from app.services.json_calls import orchestrator_call
|
|
|
|
| 115 |
yield _sse("orchestrator", _msg_payload(msg))
|
| 116 |
|
| 117 |
ballots: list[dict[str, Any]] = []
|
| 118 |
+
for p, result in await gather_votes_parallel(
|
| 119 |
+
voters,
|
| 120 |
+
cast_vote_yesno,
|
| 121 |
+
session=session,
|
| 122 |
+
default_mode="yesno",
|
| 123 |
+
motion=motion,
|
| 124 |
+
):
|
| 125 |
ballot = {
|
| 126 |
"voter_id": p.participant_id,
|
| 127 |
"voter_name": p.name,
|
|
@@ -19,10 +19,11 @@ 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 (
|
|
@@ -194,6 +195,36 @@ async def extract_candidate_options(
|
|
| 194 |
return options
|
| 195 |
|
| 196 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 197 |
async def cast_vote_single(
|
| 198 |
*,
|
| 199 |
session: Session,
|
|
|
|
| 19 |
"""
|
| 20 |
from __future__ import annotations
|
| 21 |
|
| 22 |
+
import asyncio
|
| 23 |
import json
|
| 24 |
import logging
|
| 25 |
import time
|
| 26 |
+
from typing import Any, Awaitable, Callable
|
| 27 |
|
| 28 |
from app.clients.llm_router import chat_completion
|
| 29 |
from app.services.json_calls import (
|
|
|
|
| 195 |
return options
|
| 196 |
|
| 197 |
|
| 198 |
+
async def gather_votes_parallel(
|
| 199 |
+
voters: list[Participant],
|
| 200 |
+
cast_fn: Callable[..., Awaitable[dict[str, Any]]],
|
| 201 |
+
*,
|
| 202 |
+
session: Session,
|
| 203 |
+
default_mode: str = "single",
|
| 204 |
+
**cast_kwargs: Any,
|
| 205 |
+
) -> list[tuple[Participant, dict[str, Any]]]:
|
| 206 |
+
"""Run ballot calls concurrently; roster order is preserved."""
|
| 207 |
+
if not voters:
|
| 208 |
+
return []
|
| 209 |
+
|
| 210 |
+
async def _one(p: Participant) -> tuple[Participant, dict[str, Any]]:
|
| 211 |
+
result = await cast_fn(session=session, participant=p, **cast_kwargs)
|
| 212 |
+
return p, result
|
| 213 |
+
|
| 214 |
+
gathered = await asyncio.gather(
|
| 215 |
+
*[_one(p) for p in voters],
|
| 216 |
+
return_exceptions=True,
|
| 217 |
+
)
|
| 218 |
+
out: list[tuple[Participant, dict[str, Any]]] = []
|
| 219 |
+
for p, item in zip(voters, gathered):
|
| 220 |
+
if isinstance(item, BaseException):
|
| 221 |
+
LOG.exception("Parallel vote failed for %s: %s", p.participant_id, item)
|
| 222 |
+
out.append((p, _vote_default(default_mode)))
|
| 223 |
+
else:
|
| 224 |
+
out.append(item)
|
| 225 |
+
return out
|
| 226 |
+
|
| 227 |
+
|
| 228 |
async def cast_vote_single(
|
| 229 |
*,
|
| 230 |
session: Session,
|
|
@@ -407,3 +407,15 @@ class Session:
|
|
| 407 |
# on RR output.
|
| 408 |
main_motion: str | None = None
|
| 409 |
proposed_motions: list[dict[str, Any]] = field(default_factory=list)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 407 |
# on RR output.
|
| 408 |
main_motion: str | None = None
|
| 409 |
proposed_motions: list[dict[str, Any]] = field(default_factory=list)
|
| 410 |
+
|
| 411 |
+
# Rolling summary for orchestrator judge prompts when the transcript
|
| 412 |
+
# grows past the compact-transcript char budget (see orchestrator_speed).
|
| 413 |
+
orchestrator_context_summary: str = ""
|
| 414 |
+
orchestrator_context_through_idx: int = -1
|
| 415 |
+
|
| 416 |
+
# Background task that builds per-participant contribution summaries
|
| 417 |
+
# for the Table View. Kicked off by run_conversation just before the
|
| 418 |
+
# decision phase so that by the time the user opens Table View the
|
| 419 |
+
# work is already done. `Any` rather than `asyncio.Task` to avoid the
|
| 420 |
+
# import-time dependency on a running loop.
|
| 421 |
+
contribution_summary_task: Any = None
|
|
@@ -26,6 +26,7 @@ import asyncio
|
|
| 26 |
import json
|
| 27 |
import logging
|
| 28 |
import time
|
|
|
|
| 29 |
from dataclasses import asdict
|
| 30 |
from typing import Any, AsyncIterator
|
| 31 |
|
|
@@ -55,6 +56,13 @@ from app.services.credential import (
|
|
| 55 |
refresh_credential_summary,
|
| 56 |
)
|
| 57 |
from app.services.json_calls import orchestrator_call
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 58 |
from app.services.resilience import run_resilient_turn
|
| 59 |
from app.services.models import (
|
| 60 |
DEFAULT_MAX_PARTICIPANTS,
|
|
@@ -421,7 +429,7 @@ async def _do_human_turn(
|
|
| 421 |
addressed: str | None = None
|
| 422 |
if classify_addressed:
|
| 423 |
addressed = await classify_addressed_to(
|
| 424 |
-
orchestrator_model_id=
|
| 425 |
participants=actives,
|
| 426 |
speaker_name=participant.name,
|
| 427 |
message=text,
|
|
@@ -501,6 +509,8 @@ async def _call_participant(
|
|
| 501 |
label: str,
|
| 502 |
max_tokens: int = 600,
|
| 503 |
timeout: float = 45.0,
|
|
|
|
|
|
|
| 504 |
) -> tuple[str, float, bool, str]:
|
| 505 |
"""Run one participant turn.
|
| 506 |
|
|
@@ -563,6 +573,25 @@ async def _call_participant(
|
|
| 563 |
"request": {"messages": api_messages, "max_tokens": max_tokens},
|
| 564 |
}
|
| 565 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 566 |
try:
|
| 567 |
result = await chat_completion(
|
| 568 |
resolved=resolved,
|
|
@@ -570,6 +599,7 @@ async def _call_participant(
|
|
| 570 |
temperature=0.7,
|
| 571 |
max_tokens=max_tokens,
|
| 572 |
timeout=timeout,
|
|
|
|
| 573 |
)
|
| 574 |
except Exception as exc:
|
| 575 |
LOG.exception("Participant %s call failed: %s", participant.participant_id, exc)
|
|
@@ -611,8 +641,10 @@ def _add_participant_message(
|
|
| 611 |
elapsed: float,
|
| 612 |
addressed_to: str | None = None,
|
| 613 |
replying_to: list[str] | None = None,
|
|
|
|
| 614 |
) -> dict[str, Any]:
|
| 615 |
msg = {
|
|
|
|
| 616 |
"speaker_id": participant.participant_id,
|
| 617 |
"speaker_name": participant.name,
|
| 618 |
"role": "participant",
|
|
@@ -676,55 +708,42 @@ async def _phase_initial_opinions(session: Session) -> AsyncIterator[str]:
|
|
| 676 |
yield _sse("status", {"message": "Phase 1: collecting independent first opinions..."})
|
| 677 |
|
| 678 |
actives = _active_participants(session)
|
| 679 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 680 |
if p.kind == "human":
|
| 681 |
-
|
| 682 |
-
|
| 683 |
-
|
| 684 |
-
|
| 685 |
-
"Share your initial opinion on the question. "
|
| 686 |
-
"You're speaking BEFORE seeing the other participants."
|
| 687 |
-
),
|
| 688 |
-
):
|
| 689 |
-
yield chunk
|
| 690 |
-
continue
|
| 691 |
-
# Phase 1 deliberately uses a *bare* prompt (no transcript) so each
|
| 692 |
-
# participant's first opinion is independent of the others.
|
| 693 |
-
prompt = INITIAL_OPINION_PROMPT.format(question=session.question)
|
| 694 |
-
turn = await run_resilient_turn(
|
| 695 |
-
session=session, participant=p,
|
| 696 |
-
user_prompt=prompt,
|
| 697 |
label="initial_opinion",
|
| 698 |
max_tokens=700,
|
| 699 |
-
call_participant=_call_participant,
|
| 700 |
)
|
| 701 |
-
for ev in turn.sse_events:
|
| 702 |
-
yield ev
|
| 703 |
-
if not turn.ok:
|
| 704 |
-
yield _sse("participant_error", {
|
| 705 |
-
"participant_id": p.participant_id,
|
| 706 |
-
"name": p.name,
|
| 707 |
-
"phase": session.phase.value,
|
| 708 |
-
})
|
| 709 |
-
if p.consecutive_failures >= session.limits.auto_disable_failures:
|
| 710 |
-
p.enabled = False
|
| 711 |
-
yield _sse("status", {
|
| 712 |
-
"message": (
|
| 713 |
-
f"{p.name} auto-disabled after "
|
| 714 |
-
f"{session.limits.auto_disable_failures} failures."
|
| 715 |
-
),
|
| 716 |
-
})
|
| 717 |
-
continue
|
| 718 |
-
speaker = turn.speaker
|
| 719 |
-
msg = _add_participant_message(
|
| 720 |
-
session, speaker, turn.text, phase=session.phase, elapsed=turn.elapsed,
|
| 721 |
-
)
|
| 722 |
-
session.initial_opinions[speaker.participant_id] = turn.text
|
| 723 |
-
yield _sse("message", _msg_payload(msg))
|
| 724 |
|
| 725 |
-
|
| 726 |
-
|
| 727 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 728 |
|
| 729 |
yield _sse("status", {"message": "Building Credential Summary..."})
|
| 730 |
creds = await build_credential_summary(
|
|
@@ -770,83 +789,72 @@ async def _phase_critique(session: Session, round_number: int) -> AsyncIterator[
|
|
| 770 |
})
|
| 771 |
cred_block = credentials_to_block(session.credential_summary)
|
| 772 |
actives = _active_participants(session)
|
| 773 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 774 |
if p.kind == "human":
|
| 775 |
-
|
| 776 |
-
|
| 777 |
-
classify_addressed=True,
|
| 778 |
-
prompt_context=(
|
| 779 |
-
f"Critique round {round_number} of {round_total}. "
|
| 780 |
-
"Push back on, agree with, or build on what others "
|
| 781 |
-
"have said. Address other participants by name."
|
| 782 |
-
),
|
| 783 |
-
):
|
| 784 |
-
yield chunk
|
| 785 |
-
continue
|
| 786 |
-
transcript = _format_history(session.messages)
|
| 787 |
-
# Snapshot pending threads BEFORE the call so we can both render
|
| 788 |
-
# them in the prompt and stamp them onto the outgoing message as
|
| 789 |
-
# `replying_to` (frontend pill).
|
| 790 |
-
pending = _pending_addressed_for(session, p)
|
| 791 |
pending_block = _format_pending_block(pending)
|
| 792 |
prompt = CRITIQUE_PROMPT.format(
|
| 793 |
round_number=round_number,
|
| 794 |
round_total=round_total,
|
| 795 |
question=session.question,
|
| 796 |
credential_summary=cred_block,
|
| 797 |
-
transcript=
|
| 798 |
pending_block=pending_block,
|
| 799 |
)
|
| 800 |
-
|
| 801 |
-
|
| 802 |
user_prompt=prompt,
|
| 803 |
label=f"critique_round_{round_number}",
|
| 804 |
max_tokens=700,
|
| 805 |
-
call_participant=_call_participant,
|
| 806 |
)
|
| 807 |
-
for ev in turn.sse_events:
|
| 808 |
-
yield ev
|
| 809 |
-
if not turn.ok:
|
| 810 |
-
yield _sse("participant_error", {
|
| 811 |
-
"participant_id": p.participant_id, "name": p.name,
|
| 812 |
-
"phase": session.phase.value,
|
| 813 |
-
})
|
| 814 |
-
if p.consecutive_failures >= session.limits.auto_disable_failures:
|
| 815 |
-
p.enabled = False
|
| 816 |
-
yield _sse("status", {
|
| 817 |
-
"message": (
|
| 818 |
-
f"{p.name} auto-disabled after "
|
| 819 |
-
f"{session.limits.auto_disable_failures} failures."
|
| 820 |
-
),
|
| 821 |
-
})
|
| 822 |
-
continue
|
| 823 |
-
speaker = turn.speaker
|
| 824 |
-
text, elapsed = turn.text, turn.elapsed
|
| 825 |
|
| 826 |
-
|
| 827 |
-
|
| 828 |
addressed = await classify_addressed_to(
|
| 829 |
-
orchestrator_model_id=
|
| 830 |
participants=_active_participants(session),
|
| 831 |
speaker_name=speaker.name,
|
| 832 |
-
message=text,
|
| 833 |
api_log=session.api_log,
|
| 834 |
)
|
| 835 |
_bump_orchestrator_count(session)
|
|
|
|
|
|
|
|
|
|
|
|
|
| 836 |
|
| 837 |
-
|
| 838 |
-
|
| 839 |
-
|
| 840 |
-
|
| 841 |
-
|
| 842 |
-
|
| 843 |
-
|
| 844 |
-
|
| 845 |
-
|
| 846 |
-
|
| 847 |
-
if _orchestrator_cap_hit(session):
|
| 848 |
-
async for chunk in _wait_for_continue(session, "orchestrator"):
|
| 849 |
-
yield chunk
|
| 850 |
|
| 851 |
|
| 852 |
async def _phase_status_assessment(session: Session) -> AsyncIterator[str]:
|
|
@@ -854,33 +862,18 @@ async def _phase_status_assessment(session: Session) -> AsyncIterator[str]:
|
|
| 854 |
yield _sse("status", {"message": "Phase 3: assessing whether more questions are needed..."})
|
| 855 |
|
| 856 |
cred_block = credentials_to_block(session.credential_summary)
|
| 857 |
-
|
| 858 |
-
# Refresh Credential Summary once after Phase 2 critique - participants
|
| 859 |
-
# have revealed a lot more about themselves through critique.
|
| 860 |
-
transcript = _format_history(session.messages)
|
| 861 |
-
refreshed = await refresh_credential_summary(
|
| 862 |
-
orchestrator_model_id=_orchestrator_model_id(session),
|
| 863 |
-
question=session.question,
|
| 864 |
-
participants=_active_participants(session),
|
| 865 |
-
existing=session.credential_summary,
|
| 866 |
-
critique_transcript=transcript,
|
| 867 |
-
api_log=session.api_log,
|
| 868 |
-
)
|
| 869 |
-
_bump_orchestrator_count(session)
|
| 870 |
-
if refreshed != session.credential_summary:
|
| 871 |
-
session.credential_summary = refreshed
|
| 872 |
-
yield _sse("credentials_updated", {
|
| 873 |
-
"stage": "refreshed",
|
| 874 |
-
"credentials": session.credential_summary,
|
| 875 |
-
})
|
| 876 |
-
cred_block = credentials_to_block(session.credential_summary)
|
| 877 |
|
| 878 |
for iteration in range(session.limits.status_assessment_max):
|
| 879 |
session.status_assessment_iterations = iteration + 1
|
|
|
|
|
|
|
|
|
|
|
|
|
| 880 |
prompt = STATUS_ASSESSMENT_PROMPT.format(
|
| 881 |
question=session.question,
|
| 882 |
credential_summary=cred_block,
|
| 883 |
-
transcript=
|
| 884 |
)
|
| 885 |
_raw, parsed = await orchestrator_call(
|
| 886 |
orchestrator_model_id=_orchestrator_model_id(session),
|
|
@@ -907,6 +900,27 @@ async def _phase_status_assessment(session: Session) -> AsyncIterator[str]:
|
|
| 907 |
yield _sse("orchestrator", _msg_payload(msg))
|
| 908 |
return
|
| 909 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 910 |
# Otherwise run targeted follow-ups
|
| 911 |
active_ids = {p.participant_id for p in _active_participants(session)}
|
| 912 |
for oq in open_qs:
|
|
@@ -966,13 +980,19 @@ async def _phase_status_assessment(session: Session) -> AsyncIterator[str]:
|
|
| 966 |
credential_summary=cred_block,
|
| 967 |
targeted_question=question_text,
|
| 968 |
)
|
|
|
|
|
|
|
| 969 |
turn = await run_resilient_turn(
|
| 970 |
session=session, participant=target,
|
| 971 |
user_prompt=prompt2,
|
| 972 |
label="targeted_followup",
|
| 973 |
max_tokens=600,
|
| 974 |
call_participant=_call_participant,
|
|
|
|
|
|
|
| 975 |
)
|
|
|
|
|
|
|
| 976 |
for ev in turn.sse_events:
|
| 977 |
yield ev
|
| 978 |
if not turn.ok:
|
|
@@ -990,6 +1010,7 @@ async def _phase_status_assessment(session: Session) -> AsyncIterator[str]:
|
|
| 990 |
msg = _add_participant_message(
|
| 991 |
session, speaker, text, phase=session.phase, elapsed=elapsed,
|
| 992 |
replying_to=replying_to,
|
|
|
|
| 993 |
)
|
| 994 |
yield _sse("message", _msg_payload(msg))
|
| 995 |
|
|
@@ -1014,54 +1035,56 @@ async def _phase_finalization(session: Session) -> AsyncIterator[str]:
|
|
| 1014 |
|
| 1015 |
cred_block = credentials_to_block(session.credential_summary)
|
| 1016 |
actives = _active_participants(session)
|
| 1017 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1018 |
if p.kind == "human":
|
| 1019 |
-
|
| 1020 |
-
|
| 1021 |
-
track_final_opinion=True,
|
| 1022 |
-
prompt_context=(
|
| 1023 |
-
"Phase 4: state your final opinion on the question, "
|
| 1024 |
-
"incorporating whatever you've learned in the discussion."
|
| 1025 |
-
),
|
| 1026 |
-
):
|
| 1027 |
-
yield chunk
|
| 1028 |
-
continue
|
| 1029 |
-
transcript = _format_history(session.messages)
|
| 1030 |
-
pending = _pending_addressed_for(session, p)
|
| 1031 |
pending_block = _format_pending_block(pending)
|
| 1032 |
prompt = FINALIZATION_PROMPT.format(
|
| 1033 |
question=session.question,
|
| 1034 |
credential_summary=cred_block,
|
| 1035 |
-
transcript=
|
| 1036 |
pending_block=pending_block,
|
| 1037 |
)
|
| 1038 |
-
|
| 1039 |
-
|
| 1040 |
user_prompt=prompt,
|
| 1041 |
label="finalization",
|
| 1042 |
max_tokens=600,
|
| 1043 |
-
call_participant=_call_participant,
|
| 1044 |
-
)
|
| 1045 |
-
for ev in turn.sse_events:
|
| 1046 |
-
yield ev
|
| 1047 |
-
if not turn.ok:
|
| 1048 |
-
yield _sse("participant_error", {
|
| 1049 |
-
"participant_id": p.participant_id, "name": p.name,
|
| 1050 |
-
"phase": session.phase.value,
|
| 1051 |
-
})
|
| 1052 |
-
continue
|
| 1053 |
-
speaker = turn.speaker
|
| 1054 |
-
text, elapsed = turn.text, turn.elapsed
|
| 1055 |
-
session.final_opinions[speaker.participant_id] = text
|
| 1056 |
-
msg = _add_participant_message(
|
| 1057 |
-
session, speaker, text, phase=session.phase, elapsed=elapsed,
|
| 1058 |
-
replying_to=_replying_to_ids(pending),
|
| 1059 |
)
|
| 1060 |
-
yield _sse("message", _msg_payload(msg))
|
| 1061 |
|
| 1062 |
-
|
| 1063 |
-
|
| 1064 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1065 |
|
| 1066 |
|
| 1067 |
async def _phase_consensus(session: Session) -> AsyncIterator[str]:
|
|
@@ -1160,31 +1183,9 @@ async def _phase_consensus(session: Session) -> AsyncIterator[str]:
|
|
| 1160 |
# Replicated here because the LLM-path code below also does
|
| 1161 |
# it, and we need it on the human path too.
|
| 1162 |
if consensus_turns % max(1, len(actives)) == 0:
|
| 1163 |
-
|
| 1164 |
-
|
| 1165 |
-
|
| 1166 |
-
question=session.question,
|
| 1167 |
-
transcript=transcript,
|
| 1168 |
-
alliance_groups=session.alliance_groups,
|
| 1169 |
-
api_log=session.api_log,
|
| 1170 |
-
)
|
| 1171 |
-
_bump_orchestrator_count(session)
|
| 1172 |
-
if status.get("status") == "majority":
|
| 1173 |
-
session.alliance_groups = await _refresh_alliance_groups(session, actives)
|
| 1174 |
-
msg = _add_orchestrator_message(
|
| 1175 |
-
session,
|
| 1176 |
-
f"Majority reached. {status.get('rationale', '')}".strip(),
|
| 1177 |
-
kind="status",
|
| 1178 |
-
)
|
| 1179 |
-
yield _sse("orchestrator", _msg_payload(msg))
|
| 1180 |
-
return
|
| 1181 |
-
if status.get("status") == "unproductive":
|
| 1182 |
-
msg = _add_orchestrator_message(
|
| 1183 |
-
session,
|
| 1184 |
-
f"Conversation no longer productive. {status.get('rationale', '')}".strip(),
|
| 1185 |
-
kind="status",
|
| 1186 |
-
)
|
| 1187 |
-
yield _sse("orchestrator", _msg_payload(msg))
|
| 1188 |
return
|
| 1189 |
continue
|
| 1190 |
|
|
@@ -1199,13 +1200,19 @@ async def _phase_consensus(session: Session) -> AsyncIterator[str]:
|
|
| 1199 |
# message records who this turn was supposed to be replying to.
|
| 1200 |
pending = _pending_addressed_for(session, speaker)
|
| 1201 |
|
|
|
|
|
|
|
| 1202 |
turn = await run_resilient_turn(
|
| 1203 |
session=session, participant=speaker,
|
| 1204 |
user_prompt=prompt,
|
| 1205 |
label="consensus",
|
| 1206 |
max_tokens=700,
|
| 1207 |
call_participant=_call_participant,
|
|
|
|
|
|
|
| 1208 |
)
|
|
|
|
|
|
|
| 1209 |
for ev in turn.sse_events:
|
| 1210 |
yield ev
|
| 1211 |
if not turn.ok:
|
|
@@ -1221,7 +1228,7 @@ async def _phase_consensus(session: Session) -> AsyncIterator[str]:
|
|
| 1221 |
text, elapsed = turn.text, turn.elapsed
|
| 1222 |
|
| 1223 |
addressed = await classify_addressed_to(
|
| 1224 |
-
orchestrator_model_id=
|
| 1225 |
participants=actives,
|
| 1226 |
speaker_name=speaker.name,
|
| 1227 |
message=text,
|
|
@@ -1234,6 +1241,7 @@ async def _phase_consensus(session: Session) -> AsyncIterator[str]:
|
|
| 1234 |
session, speaker, text, phase=session.phase, elapsed=elapsed,
|
| 1235 |
addressed_to=addressed,
|
| 1236 |
replying_to=_replying_to_ids(pending),
|
|
|
|
| 1237 |
)
|
| 1238 |
yield _sse("message", _msg_payload(msg))
|
| 1239 |
|
|
@@ -1246,33 +1254,46 @@ async def _phase_consensus(session: Session) -> AsyncIterator[str]:
|
|
| 1246 |
|
| 1247 |
# Status check every full round (every len(actives) turns)
|
| 1248 |
if consensus_turns % max(1, len(actives)) == 0:
|
| 1249 |
-
|
| 1250 |
-
|
| 1251 |
-
|
| 1252 |
-
question=session.question,
|
| 1253 |
-
transcript=transcript,
|
| 1254 |
-
alliance_groups=session.alliance_groups,
|
| 1255 |
-
api_log=session.api_log,
|
| 1256 |
-
)
|
| 1257 |
-
_bump_orchestrator_count(session)
|
| 1258 |
-
if status.get("status") == "majority":
|
| 1259 |
-
session.alliance_groups = await _refresh_alliance_groups(session, actives)
|
| 1260 |
-
msg = _add_orchestrator_message(
|
| 1261 |
-
session,
|
| 1262 |
-
f"Majority reached. {status.get('rationale', '')}".strip(),
|
| 1263 |
-
kind="status",
|
| 1264 |
-
)
|
| 1265 |
-
yield _sse("orchestrator", _msg_payload(msg))
|
| 1266 |
-
return
|
| 1267 |
-
if status.get("status") == "unproductive":
|
| 1268 |
-
msg = _add_orchestrator_message(
|
| 1269 |
-
session,
|
| 1270 |
-
f"Conversation no longer productive. {status.get('rationale', '')}".strip(),
|
| 1271 |
-
kind="status",
|
| 1272 |
-
)
|
| 1273 |
-
yield _sse("orchestrator", _msg_payload(msg))
|
| 1274 |
return
|
| 1275 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1276 |
|
| 1277 |
|
| 1278 |
async def _refresh_alliance_groups(
|
|
@@ -1376,10 +1397,13 @@ async def _phase_closure(session: Session) -> AsyncIterator[str]:
|
|
| 1376 |
yield _sse("status", {"message": "Phase 6: closure..."})
|
| 1377 |
|
| 1378 |
cred_block = credentials_to_block(session.credential_summary)
|
| 1379 |
-
transcript =
|
|
|
|
|
|
|
|
|
|
| 1380 |
|
| 1381 |
status = await assess_consensus_status(
|
| 1382 |
-
orchestrator_model_id=
|
| 1383 |
question=session.question,
|
| 1384 |
transcript=transcript,
|
| 1385 |
alliance_groups=session.alliance_groups,
|
|
@@ -1538,6 +1562,12 @@ async def run_conversation(session: Session) -> AsyncIterator[str]:
|
|
| 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():
|
|
@@ -1552,16 +1582,59 @@ async def run_conversation(session: Session) -> AsyncIterator[str]:
|
|
| 1552 |
# doesn't outlive the session in the module-level registry.
|
| 1553 |
human_io.drop_session(session.session_id)
|
| 1554 |
|
| 1555 |
-
# Build per-participant contribution summaries for the table view.
|
| 1556 |
-
try:
|
| 1557 |
-
await _build_contribution_summaries(session)
|
| 1558 |
-
except Exception as exc:
|
| 1559 |
-
LOG.warning("Failed to build contribution summaries: %s", exc)
|
| 1560 |
-
|
| 1561 |
yield _sse("system", {"text": "End of Chat", "phase": session.phase.value})
|
| 1562 |
yield _sse("done", {})
|
| 1563 |
|
| 1564 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1565 |
async def _build_contribution_summaries(session: Session) -> None:
|
| 1566 |
actives = _active_participants(session)
|
| 1567 |
roster = "\n".join(
|
|
|
|
| 26 |
import json
|
| 27 |
import logging
|
| 28 |
import time
|
| 29 |
+
import uuid
|
| 30 |
from dataclasses import asdict
|
| 31 |
from typing import Any, AsyncIterator
|
| 32 |
|
|
|
|
| 56 |
refresh_credential_summary,
|
| 57 |
)
|
| 58 |
from app.services.json_calls import orchestrator_call
|
| 59 |
+
from app.services.orchestrator_speed import (
|
| 60 |
+
_AiTurnResult,
|
| 61 |
+
_AiTurnSpec,
|
| 62 |
+
compact_transcript_for_orchestrator,
|
| 63 |
+
orchestrator_fast_model_id,
|
| 64 |
+
run_roster_ai_turns_parallel,
|
| 65 |
+
)
|
| 66 |
from app.services.resilience import run_resilient_turn
|
| 67 |
from app.services.models import (
|
| 68 |
DEFAULT_MAX_PARTICIPANTS,
|
|
|
|
| 429 |
addressed: str | None = None
|
| 430 |
if classify_addressed:
|
| 431 |
addressed = await classify_addressed_to(
|
| 432 |
+
orchestrator_model_id=orchestrator_fast_model_id(session),
|
| 433 |
participants=actives,
|
| 434 |
speaker_name=participant.name,
|
| 435 |
message=text,
|
|
|
|
| 509 |
label: str,
|
| 510 |
max_tokens: int = 600,
|
| 511 |
timeout: float = 45.0,
|
| 512 |
+
stream_events: list[str] | None = None,
|
| 513 |
+
stream_message_id: str | None = None,
|
| 514 |
) -> tuple[str, float, bool, str]:
|
| 515 |
"""Run one participant turn.
|
| 516 |
|
|
|
|
| 573 |
"request": {"messages": api_messages, "max_tokens": max_tokens},
|
| 574 |
}
|
| 575 |
|
| 576 |
+
msg_id = stream_message_id or str(uuid.uuid4())
|
| 577 |
+
on_text_delta_cb = None
|
| 578 |
+
if stream_events is not None:
|
| 579 |
+
stream_events.append(_sse("message_stream_start", {
|
| 580 |
+
"message_id": msg_id,
|
| 581 |
+
"speaker_id": participant.participant_id,
|
| 582 |
+
"speaker_name": participant.name,
|
| 583 |
+
"kind": participant.kind,
|
| 584 |
+
"phase": session.phase.value,
|
| 585 |
+
"model_id": participant.model_id,
|
| 586 |
+
"model_display": participant.display_name,
|
| 587 |
+
}))
|
| 588 |
+
|
| 589 |
+
def on_text_delta_cb(piece: str) -> None:
|
| 590 |
+
stream_events.append(_sse("message_delta", {
|
| 591 |
+
"message_id": msg_id,
|
| 592 |
+
"delta": piece,
|
| 593 |
+
}))
|
| 594 |
+
|
| 595 |
try:
|
| 596 |
result = await chat_completion(
|
| 597 |
resolved=resolved,
|
|
|
|
| 599 |
temperature=0.7,
|
| 600 |
max_tokens=max_tokens,
|
| 601 |
timeout=timeout,
|
| 602 |
+
on_text_delta=on_text_delta_cb,
|
| 603 |
)
|
| 604 |
except Exception as exc:
|
| 605 |
LOG.exception("Participant %s call failed: %s", participant.participant_id, exc)
|
|
|
|
| 641 |
elapsed: float,
|
| 642 |
addressed_to: str | None = None,
|
| 643 |
replying_to: list[str] | None = None,
|
| 644 |
+
message_id: str | None = None,
|
| 645 |
) -> dict[str, Any]:
|
| 646 |
msg = {
|
| 647 |
+
"message_id": message_id or str(uuid.uuid4()),
|
| 648 |
"speaker_id": participant.participant_id,
|
| 649 |
"speaker_name": participant.name,
|
| 650 |
"role": "participant",
|
|
|
|
| 708 |
yield _sse("status", {"message": "Phase 1: collecting independent first opinions..."})
|
| 709 |
|
| 710 |
actives = _active_participants(session)
|
| 711 |
+
|
| 712 |
+
async def _human_initial(p: Participant) -> AsyncIterator[str]:
|
| 713 |
+
async for chunk in _do_human_turn(
|
| 714 |
+
session, p, phase=session.phase, actives=actives,
|
| 715 |
+
track_initial_opinion=True,
|
| 716 |
+
prompt_context=(
|
| 717 |
+
"Share your initial opinion on the question. "
|
| 718 |
+
"You're speaking BEFORE seeing the other participants."
|
| 719 |
+
),
|
| 720 |
+
):
|
| 721 |
+
yield chunk
|
| 722 |
+
|
| 723 |
+
async def _post_initial(result: _AiTurnResult) -> dict[str, Any]:
|
| 724 |
+
session.initial_opinions[result.participant.participant_id] = result.turn.text
|
| 725 |
+
return {}
|
| 726 |
+
|
| 727 |
+
def _build_initial_spec(p: Participant) -> _AiTurnSpec | None:
|
| 728 |
if p.kind == "human":
|
| 729 |
+
return None
|
| 730 |
+
return _AiTurnSpec(
|
| 731 |
+
participant=p,
|
| 732 |
+
user_prompt=INITIAL_OPINION_PROMPT.format(question=session.question),
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 733 |
label="initial_opinion",
|
| 734 |
max_tokens=700,
|
|
|
|
| 735 |
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 736 |
|
| 737 |
+
async for chunk in run_roster_ai_turns_parallel(
|
| 738 |
+
session,
|
| 739 |
+
actives,
|
| 740 |
+
phase=session.phase,
|
| 741 |
+
build_spec=_build_initial_spec,
|
| 742 |
+
call_participant=_call_participant,
|
| 743 |
+
on_human_turn=_human_initial,
|
| 744 |
+
post_process=_post_initial,
|
| 745 |
+
):
|
| 746 |
+
yield chunk
|
| 747 |
|
| 748 |
yield _sse("status", {"message": "Building Credential Summary..."})
|
| 749 |
creds = await build_credential_summary(
|
|
|
|
| 789 |
})
|
| 790 |
cred_block = credentials_to_block(session.credential_summary)
|
| 791 |
actives = _active_participants(session)
|
| 792 |
+
# Freeze transcript + pending threads at round start so parallel
|
| 793 |
+
# turns see the same context and reply metadata stays consistent.
|
| 794 |
+
transcript_snapshot = _format_history(session.messages)
|
| 795 |
+
pending_snapshot = {
|
| 796 |
+
p.participant_id: _pending_addressed_for(session, p)
|
| 797 |
+
for p in actives
|
| 798 |
+
if p.kind != "human"
|
| 799 |
+
}
|
| 800 |
+
|
| 801 |
+
async def _human_critique(p: Participant) -> AsyncIterator[str]:
|
| 802 |
+
async for chunk in _do_human_turn(
|
| 803 |
+
session, p, phase=session.phase, actives=actives,
|
| 804 |
+
classify_addressed=True,
|
| 805 |
+
prompt_context=(
|
| 806 |
+
f"Critique round {round_number} of {round_total}. "
|
| 807 |
+
"Push back on, agree with, or build on what others "
|
| 808 |
+
"have said. Address other participants by name."
|
| 809 |
+
),
|
| 810 |
+
):
|
| 811 |
+
yield chunk
|
| 812 |
+
|
| 813 |
+
def _build_critique_spec(p: Participant) -> _AiTurnSpec | None:
|
| 814 |
if p.kind == "human":
|
| 815 |
+
return None
|
| 816 |
+
pending = pending_snapshot.get(p.participant_id, [])
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 817 |
pending_block = _format_pending_block(pending)
|
| 818 |
prompt = CRITIQUE_PROMPT.format(
|
| 819 |
round_number=round_number,
|
| 820 |
round_total=round_total,
|
| 821 |
question=session.question,
|
| 822 |
credential_summary=cred_block,
|
| 823 |
+
transcript=transcript_snapshot,
|
| 824 |
pending_block=pending_block,
|
| 825 |
)
|
| 826 |
+
return _AiTurnSpec(
|
| 827 |
+
participant=p,
|
| 828 |
user_prompt=prompt,
|
| 829 |
label=f"critique_round_{round_number}",
|
| 830 |
max_tokens=700,
|
|
|
|
| 831 |
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 832 |
|
| 833 |
+
async def _post_critique(result: _AiTurnResult) -> dict[str, Any]:
|
| 834 |
+
speaker = result.turn.speaker
|
| 835 |
addressed = await classify_addressed_to(
|
| 836 |
+
orchestrator_model_id=orchestrator_fast_model_id(session),
|
| 837 |
participants=_active_participants(session),
|
| 838 |
speaker_name=speaker.name,
|
| 839 |
+
message=result.turn.text,
|
| 840 |
api_log=session.api_log,
|
| 841 |
)
|
| 842 |
_bump_orchestrator_count(session)
|
| 843 |
+
return {
|
| 844 |
+
"addressed_to": addressed,
|
| 845 |
+
"replying_to": _replying_to_ids(result.pending),
|
| 846 |
+
}
|
| 847 |
|
| 848 |
+
async for chunk in run_roster_ai_turns_parallel(
|
| 849 |
+
session,
|
| 850 |
+
actives,
|
| 851 |
+
phase=session.phase,
|
| 852 |
+
build_spec=_build_critique_spec,
|
| 853 |
+
call_participant=_call_participant,
|
| 854 |
+
on_human_turn=_human_critique,
|
| 855 |
+
post_process=_post_critique,
|
| 856 |
+
):
|
| 857 |
+
yield chunk
|
|
|
|
|
|
|
|
|
|
| 858 |
|
| 859 |
|
| 860 |
async def _phase_status_assessment(session: Session) -> AsyncIterator[str]:
|
|
|
|
| 862 |
yield _sse("status", {"message": "Phase 3: assessing whether more questions are needed..."})
|
| 863 |
|
| 864 |
cred_block = credentials_to_block(session.credential_summary)
|
| 865 |
+
cred_refreshed = False
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 866 |
|
| 867 |
for iteration in range(session.limits.status_assessment_max):
|
| 868 |
session.status_assessment_iterations = iteration + 1
|
| 869 |
+
transcript = await compact_transcript_for_orchestrator(
|
| 870 |
+
session,
|
| 871 |
+
orchestrator_model_id=orchestrator_fast_model_id(session),
|
| 872 |
+
)
|
| 873 |
prompt = STATUS_ASSESSMENT_PROMPT.format(
|
| 874 |
question=session.question,
|
| 875 |
credential_summary=cred_block,
|
| 876 |
+
transcript=transcript,
|
| 877 |
)
|
| 878 |
_raw, parsed = await orchestrator_call(
|
| 879 |
orchestrator_model_id=_orchestrator_model_id(session),
|
|
|
|
| 900 |
yield _sse("orchestrator", _msg_payload(msg))
|
| 901 |
return
|
| 902 |
|
| 903 |
+
# Refresh credentials only when follow-ups are actually needed.
|
| 904 |
+
if not cred_refreshed:
|
| 905 |
+
cred_refreshed = True
|
| 906 |
+
full_transcript = _format_history(session.messages)
|
| 907 |
+
refreshed = await refresh_credential_summary(
|
| 908 |
+
orchestrator_model_id=_orchestrator_model_id(session),
|
| 909 |
+
question=session.question,
|
| 910 |
+
participants=_active_participants(session),
|
| 911 |
+
existing=session.credential_summary,
|
| 912 |
+
critique_transcript=full_transcript,
|
| 913 |
+
api_log=session.api_log,
|
| 914 |
+
)
|
| 915 |
+
_bump_orchestrator_count(session)
|
| 916 |
+
if refreshed != session.credential_summary:
|
| 917 |
+
session.credential_summary = refreshed
|
| 918 |
+
yield _sse("credentials_updated", {
|
| 919 |
+
"stage": "refreshed",
|
| 920 |
+
"credentials": session.credential_summary,
|
| 921 |
+
})
|
| 922 |
+
cred_block = credentials_to_block(session.credential_summary)
|
| 923 |
+
|
| 924 |
# Otherwise run targeted follow-ups
|
| 925 |
active_ids = {p.participant_id for p in _active_participants(session)}
|
| 926 |
for oq in open_qs:
|
|
|
|
| 980 |
credential_summary=cred_block,
|
| 981 |
targeted_question=question_text,
|
| 982 |
)
|
| 983 |
+
stream_events: list[str] = []
|
| 984 |
+
stream_msg_id = str(uuid.uuid4())
|
| 985 |
turn = await run_resilient_turn(
|
| 986 |
session=session, participant=target,
|
| 987 |
user_prompt=prompt2,
|
| 988 |
label="targeted_followup",
|
| 989 |
max_tokens=600,
|
| 990 |
call_participant=_call_participant,
|
| 991 |
+
stream_events=stream_events,
|
| 992 |
+
stream_message_id=stream_msg_id,
|
| 993 |
)
|
| 994 |
+
for ev in stream_events:
|
| 995 |
+
yield ev
|
| 996 |
for ev in turn.sse_events:
|
| 997 |
yield ev
|
| 998 |
if not turn.ok:
|
|
|
|
| 1010 |
msg = _add_participant_message(
|
| 1011 |
session, speaker, text, phase=session.phase, elapsed=elapsed,
|
| 1012 |
replying_to=replying_to,
|
| 1013 |
+
message_id=stream_msg_id,
|
| 1014 |
)
|
| 1015 |
yield _sse("message", _msg_payload(msg))
|
| 1016 |
|
|
|
|
| 1035 |
|
| 1036 |
cred_block = credentials_to_block(session.credential_summary)
|
| 1037 |
actives = _active_participants(session)
|
| 1038 |
+
transcript_snapshot = _format_history(session.messages)
|
| 1039 |
+
pending_snapshot = {
|
| 1040 |
+
p.participant_id: _pending_addressed_for(session, p)
|
| 1041 |
+
for p in actives
|
| 1042 |
+
if p.kind != "human"
|
| 1043 |
+
}
|
| 1044 |
+
|
| 1045 |
+
async def _human_final(p: Participant) -> AsyncIterator[str]:
|
| 1046 |
+
async for chunk in _do_human_turn(
|
| 1047 |
+
session, p, phase=session.phase, actives=actives,
|
| 1048 |
+
track_final_opinion=True,
|
| 1049 |
+
prompt_context=(
|
| 1050 |
+
"Phase 4: state your final opinion on the question, "
|
| 1051 |
+
"incorporating whatever you've learned in the discussion."
|
| 1052 |
+
),
|
| 1053 |
+
):
|
| 1054 |
+
yield chunk
|
| 1055 |
+
|
| 1056 |
+
def _build_final_spec(p: Participant) -> _AiTurnSpec | None:
|
| 1057 |
if p.kind == "human":
|
| 1058 |
+
return None
|
| 1059 |
+
pending = pending_snapshot.get(p.participant_id, [])
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1060 |
pending_block = _format_pending_block(pending)
|
| 1061 |
prompt = FINALIZATION_PROMPT.format(
|
| 1062 |
question=session.question,
|
| 1063 |
credential_summary=cred_block,
|
| 1064 |
+
transcript=transcript_snapshot,
|
| 1065 |
pending_block=pending_block,
|
| 1066 |
)
|
| 1067 |
+
return _AiTurnSpec(
|
| 1068 |
+
participant=p,
|
| 1069 |
user_prompt=prompt,
|
| 1070 |
label="finalization",
|
| 1071 |
max_tokens=600,
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1072 |
)
|
|
|
|
| 1073 |
|
| 1074 |
+
async def _post_final(result: _AiTurnResult) -> dict[str, Any]:
|
| 1075 |
+
session.final_opinions[result.participant.participant_id] = result.turn.text
|
| 1076 |
+
return {"replying_to": _replying_to_ids(result.pending)}
|
| 1077 |
+
|
| 1078 |
+
async for chunk in run_roster_ai_turns_parallel(
|
| 1079 |
+
session,
|
| 1080 |
+
actives,
|
| 1081 |
+
phase=session.phase,
|
| 1082 |
+
build_spec=_build_final_spec,
|
| 1083 |
+
call_participant=_call_participant,
|
| 1084 |
+
on_human_turn=_human_final,
|
| 1085 |
+
post_process=_post_final,
|
| 1086 |
+
):
|
| 1087 |
+
yield chunk
|
| 1088 |
|
| 1089 |
|
| 1090 |
async def _phase_consensus(session: Session) -> AsyncIterator[str]:
|
|
|
|
| 1183 |
# Replicated here because the LLM-path code below also does
|
| 1184 |
# it, and we need it on the human path too.
|
| 1185 |
if consensus_turns % max(1, len(actives)) == 0:
|
| 1186 |
+
terminal = await _consensus_status_terminal_sse(session, actives)
|
| 1187 |
+
if terminal:
|
| 1188 |
+
yield terminal
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1189 |
return
|
| 1190 |
continue
|
| 1191 |
|
|
|
|
| 1200 |
# message records who this turn was supposed to be replying to.
|
| 1201 |
pending = _pending_addressed_for(session, speaker)
|
| 1202 |
|
| 1203 |
+
stream_events: list[str] = []
|
| 1204 |
+
stream_msg_id = str(uuid.uuid4())
|
| 1205 |
turn = await run_resilient_turn(
|
| 1206 |
session=session, participant=speaker,
|
| 1207 |
user_prompt=prompt,
|
| 1208 |
label="consensus",
|
| 1209 |
max_tokens=700,
|
| 1210 |
call_participant=_call_participant,
|
| 1211 |
+
stream_events=stream_events,
|
| 1212 |
+
stream_message_id=stream_msg_id,
|
| 1213 |
)
|
| 1214 |
+
for ev in stream_events:
|
| 1215 |
+
yield ev
|
| 1216 |
for ev in turn.sse_events:
|
| 1217 |
yield ev
|
| 1218 |
if not turn.ok:
|
|
|
|
| 1228 |
text, elapsed = turn.text, turn.elapsed
|
| 1229 |
|
| 1230 |
addressed = await classify_addressed_to(
|
| 1231 |
+
orchestrator_model_id=orchestrator_fast_model_id(session),
|
| 1232 |
participants=actives,
|
| 1233 |
speaker_name=speaker.name,
|
| 1234 |
message=text,
|
|
|
|
| 1241 |
session, speaker, text, phase=session.phase, elapsed=elapsed,
|
| 1242 |
addressed_to=addressed,
|
| 1243 |
replying_to=_replying_to_ids(pending),
|
| 1244 |
+
message_id=stream_msg_id,
|
| 1245 |
)
|
| 1246 |
yield _sse("message", _msg_payload(msg))
|
| 1247 |
|
|
|
|
| 1254 |
|
| 1255 |
# Status check every full round (every len(actives) turns)
|
| 1256 |
if consensus_turns % max(1, len(actives)) == 0:
|
| 1257 |
+
terminal = await _consensus_status_terminal_sse(session, actives)
|
| 1258 |
+
if terminal:
|
| 1259 |
+
yield terminal
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1260 |
return
|
| 1261 |
+
|
| 1262 |
+
|
| 1263 |
+
async def _consensus_status_terminal_sse(
|
| 1264 |
+
session: Session,
|
| 1265 |
+
actives: list[Participant],
|
| 1266 |
+
) -> str | None:
|
| 1267 |
+
"""Run a consensus status check. Returns an SSE chunk when the phase
|
| 1268 |
+
should end (majority or unproductive), else None."""
|
| 1269 |
+
transcript = await compact_transcript_for_orchestrator(
|
| 1270 |
+
session,
|
| 1271 |
+
orchestrator_model_id=orchestrator_fast_model_id(session),
|
| 1272 |
+
)
|
| 1273 |
+
status = await assess_consensus_status(
|
| 1274 |
+
orchestrator_model_id=orchestrator_fast_model_id(session),
|
| 1275 |
+
question=session.question,
|
| 1276 |
+
transcript=transcript,
|
| 1277 |
+
alliance_groups=session.alliance_groups,
|
| 1278 |
+
api_log=session.api_log,
|
| 1279 |
+
)
|
| 1280 |
+
_bump_orchestrator_count(session)
|
| 1281 |
+
if status.get("status") == "majority":
|
| 1282 |
+
session.alliance_groups = await _refresh_alliance_groups(session, actives)
|
| 1283 |
+
msg = _add_orchestrator_message(
|
| 1284 |
+
session,
|
| 1285 |
+
f"Majority reached. {status.get('rationale', '')}".strip(),
|
| 1286 |
+
kind="status",
|
| 1287 |
+
)
|
| 1288 |
+
return _sse("orchestrator", _msg_payload(msg))
|
| 1289 |
+
if status.get("status") == "unproductive":
|
| 1290 |
+
msg = _add_orchestrator_message(
|
| 1291 |
+
session,
|
| 1292 |
+
f"Conversation no longer productive. {status.get('rationale', '')}".strip(),
|
| 1293 |
+
kind="status",
|
| 1294 |
+
)
|
| 1295 |
+
return _sse("orchestrator", _msg_payload(msg))
|
| 1296 |
+
return None
|
| 1297 |
|
| 1298 |
|
| 1299 |
async def _refresh_alliance_groups(
|
|
|
|
| 1397 |
yield _sse("status", {"message": "Phase 6: closure..."})
|
| 1398 |
|
| 1399 |
cred_block = credentials_to_block(session.credential_summary)
|
| 1400 |
+
transcript = await compact_transcript_for_orchestrator(
|
| 1401 |
+
session,
|
| 1402 |
+
orchestrator_model_id=orchestrator_fast_model_id(session),
|
| 1403 |
+
)
|
| 1404 |
|
| 1405 |
status = await assess_consensus_status(
|
| 1406 |
+
orchestrator_model_id=orchestrator_fast_model_id(session),
|
| 1407 |
question=session.question,
|
| 1408 |
transcript=transcript,
|
| 1409 |
alliance_groups=session.alliance_groups,
|
|
|
|
| 1562 |
async for chunk in structure.run():
|
| 1563 |
yield chunk
|
| 1564 |
|
| 1565 |
+
# Kick off contribution summaries in the background just before
|
| 1566 |
+
# the decision phase. The Table View blocks on this task only if
|
| 1567 |
+
# the user opens it before it finishes - usually it'll be done
|
| 1568 |
+
# by then, so the table loads instantly.
|
| 1569 |
+
_start_contribution_summary_task(session)
|
| 1570 |
+
|
| 1571 |
decision_input = structure.build_decision_input()
|
| 1572 |
decision = decision_cls(session, decision_input)
|
| 1573 |
async for chunk in decision.run():
|
|
|
|
| 1582 |
# doesn't outlive the session in the module-level registry.
|
| 1583 |
human_io.drop_session(session.session_id)
|
| 1584 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1585 |
yield _sse("system", {"text": "End of Chat", "phase": session.phase.value})
|
| 1586 |
yield _sse("done", {})
|
| 1587 |
|
| 1588 |
|
| 1589 |
+
def _start_contribution_summary_task(session: Session) -> None:
|
| 1590 |
+
"""Schedule the contribution-summary build as a background task.
|
| 1591 |
+
|
| 1592 |
+
Idempotent: if a task is already in flight (or completed) we don't
|
| 1593 |
+
start another one. Errors in the background task are swallowed and
|
| 1594 |
+
logged - the Table View endpoint will fall back to a synchronous
|
| 1595 |
+
build if needed.
|
| 1596 |
+
"""
|
| 1597 |
+
if session.contribution_summary_task is not None:
|
| 1598 |
+
return
|
| 1599 |
+
if any((session.contribution_summaries or {}).values()):
|
| 1600 |
+
return
|
| 1601 |
+
|
| 1602 |
+
async def _runner() -> None:
|
| 1603 |
+
try:
|
| 1604 |
+
await _build_contribution_summaries(session)
|
| 1605 |
+
except Exception as exc: # noqa: BLE001
|
| 1606 |
+
LOG.warning(
|
| 1607 |
+
"Background contribution_summaries failed for %s: %s",
|
| 1608 |
+
session.session_id, exc,
|
| 1609 |
+
)
|
| 1610 |
+
|
| 1611 |
+
try:
|
| 1612 |
+
session.contribution_summary_task = asyncio.create_task(_runner())
|
| 1613 |
+
except RuntimeError:
|
| 1614 |
+
session.contribution_summary_task = None
|
| 1615 |
+
|
| 1616 |
+
|
| 1617 |
+
async def ensure_contribution_summaries(session: Session) -> None:
|
| 1618 |
+
"""Block on contribution summaries for the Table View.
|
| 1619 |
+
|
| 1620 |
+
Order of preference:
|
| 1621 |
+
1. Cached - return immediately.
|
| 1622 |
+
2. Background task in flight - await it.
|
| 1623 |
+
3. Nothing started - build synchronously.
|
| 1624 |
+
"""
|
| 1625 |
+
if any((session.contribution_summaries or {}).values()):
|
| 1626 |
+
return
|
| 1627 |
+
task = session.contribution_summary_task
|
| 1628 |
+
if task is not None and not task.done():
|
| 1629 |
+
try:
|
| 1630 |
+
await task
|
| 1631 |
+
except Exception as exc: # noqa: BLE001
|
| 1632 |
+
LOG.warning("contribution_summary_task await failed: %s", exc)
|
| 1633 |
+
if any((session.contribution_summaries or {}).values()):
|
| 1634 |
+
return
|
| 1635 |
+
await _build_contribution_summaries(session)
|
| 1636 |
+
|
| 1637 |
+
|
| 1638 |
async def _build_contribution_summaries(session: Session) -> None:
|
| 1639 |
actives = _active_participants(session)
|
| 1640 |
roster = "\n".join(
|
|
@@ -0,0 +1,303 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Speed-oriented orchestration helpers.
|
| 2 |
+
|
| 3 |
+
Parallel participant turns, compact orchestrator context, and fast-
|
| 4 |
+
model routing for lightweight classifier calls. Keeps the same
|
| 5 |
+
visible message count while shortening wall-clock time.
|
| 6 |
+
"""
|
| 7 |
+
from __future__ import annotations
|
| 8 |
+
|
| 9 |
+
import asyncio
|
| 10 |
+
import logging
|
| 11 |
+
import re
|
| 12 |
+
from dataclasses import dataclass, field
|
| 13 |
+
from typing import Any, AsyncIterator, Awaitable, Callable, TYPE_CHECKING
|
| 14 |
+
|
| 15 |
+
from app.services.resilience import ResilientTurnResult, run_resilient_turn
|
| 16 |
+
|
| 17 |
+
if TYPE_CHECKING:
|
| 18 |
+
from app.services.models import Participant, Phase, Session
|
| 19 |
+
|
| 20 |
+
LOG = logging.getLogger(__name__)
|
| 21 |
+
|
| 22 |
+
# Rough char budget for orchestrator-side prompts that would otherwise
|
| 23 |
+
# resend the entire transcript every few turns.
|
| 24 |
+
ORCHESTRATOR_TRANSCRIPT_CHAR_BUDGET = 14_000
|
| 25 |
+
_RECENT_TAIL_MESSAGES = 24
|
| 26 |
+
|
| 27 |
+
CallParticipantFn = Callable[..., Awaitable[tuple[str, float, bool, str]]]
|
| 28 |
+
|
| 29 |
+
|
| 30 |
+
@dataclass
|
| 31 |
+
class _AiTurnSpec:
|
| 32 |
+
participant: "Participant"
|
| 33 |
+
user_prompt: str
|
| 34 |
+
label: str
|
| 35 |
+
max_tokens: int
|
| 36 |
+
|
| 37 |
+
|
| 38 |
+
@dataclass
|
| 39 |
+
class _AiTurnResult:
|
| 40 |
+
participant: "Participant"
|
| 41 |
+
turn: ResilientTurnResult
|
| 42 |
+
pending: list[tuple[str, str, str]] = field(default_factory=list)
|
| 43 |
+
|
| 44 |
+
|
| 45 |
+
def orchestrator_fast_model_id(session: "Session") -> str:
|
| 46 |
+
"""Model for lightweight orchestrator classifiers (addressed-to, status)."""
|
| 47 |
+
from app.config import settings
|
| 48 |
+
from app.services.orchestrator import _orchestrator_model_id
|
| 49 |
+
|
| 50 |
+
fast = (getattr(settings, "orchestrator_fast_model", None) or "").strip()
|
| 51 |
+
if fast and settings.resolve_model(fast):
|
| 52 |
+
return fast
|
| 53 |
+
return _orchestrator_model_id(session)
|
| 54 |
+
|
| 55 |
+
|
| 56 |
+
def _format_history_for_orchestrator(
|
| 57 |
+
messages: list[dict[str, Any]],
|
| 58 |
+
*,
|
| 59 |
+
include_orchestrator: bool = True,
|
| 60 |
+
) -> str:
|
| 61 |
+
from app.services.orchestrator import _format_history
|
| 62 |
+
|
| 63 |
+
return _format_history(messages, include_orchestrator=include_orchestrator)
|
| 64 |
+
|
| 65 |
+
|
| 66 |
+
async def compact_transcript_for_orchestrator(
|
| 67 |
+
session: "Session",
|
| 68 |
+
*,
|
| 69 |
+
orchestrator_model_id: str,
|
| 70 |
+
) -> str:
|
| 71 |
+
"""Return a transcript block sized for orchestrator judge prompts.
|
| 72 |
+
|
| 73 |
+
Uses a rolling summary + recent tail when the full history exceeds
|
| 74 |
+
the char budget. Summaries are built lazily (one extra orchestrator
|
| 75 |
+
call) and cached on the session.
|
| 76 |
+
"""
|
| 77 |
+
from app.services.json_calls import orchestrator_call
|
| 78 |
+
from app.services.orchestrator import _bump_orchestrator_count
|
| 79 |
+
|
| 80 |
+
messages = session.messages
|
| 81 |
+
full = _format_history_for_orchestrator(messages)
|
| 82 |
+
if len(full) <= ORCHESTRATOR_TRANSCRIPT_CHAR_BUDGET:
|
| 83 |
+
return full
|
| 84 |
+
|
| 85 |
+
tail = messages[-_RECENT_TAIL_MESSAGES:]
|
| 86 |
+
tail_text = _format_history_for_orchestrator(tail)
|
| 87 |
+
through = len(messages) - len(tail)
|
| 88 |
+
if (
|
| 89 |
+
session.orchestrator_context_summary
|
| 90 |
+
and session.orchestrator_context_through_idx >= through - 2
|
| 91 |
+
):
|
| 92 |
+
return (
|
| 93 |
+
"[Earlier discussion summary]\n"
|
| 94 |
+
f"{session.orchestrator_context_summary}\n\n"
|
| 95 |
+
"[Recent messages]\n"
|
| 96 |
+
f"{tail_text}"
|
| 97 |
+
)
|
| 98 |
+
|
| 99 |
+
prompt = (
|
| 100 |
+
"Summarize the following group discussion for an orchestrator that "
|
| 101 |
+
"will judge consensus and open questions. Preserve names, stances, "
|
| 102 |
+
"and unresolved disagreements. Be concise (under 400 words).\n\n"
|
| 103 |
+
f"{full}"
|
| 104 |
+
)
|
| 105 |
+
raw, _ = await orchestrator_call(
|
| 106 |
+
orchestrator_model_id=orchestrator_model_id,
|
| 107 |
+
user_prompt=prompt,
|
| 108 |
+
label="orchestrator_transcript_summary",
|
| 109 |
+
api_log=session.api_log,
|
| 110 |
+
expect_json=False,
|
| 111 |
+
max_tokens=700,
|
| 112 |
+
temperature=0.2,
|
| 113 |
+
)
|
| 114 |
+
_bump_orchestrator_count(session)
|
| 115 |
+
summary = (raw or "").strip() or full[-ORCHESTRATOR_TRANSCRIPT_CHAR_BUDGET:]
|
| 116 |
+
session.orchestrator_context_summary = summary
|
| 117 |
+
session.orchestrator_context_through_idx = through
|
| 118 |
+
return (
|
| 119 |
+
"[Earlier discussion summary]\n"
|
| 120 |
+
f"{summary}\n\n"
|
| 121 |
+
"[Recent messages]\n"
|
| 122 |
+
f"{tail_text}"
|
| 123 |
+
)
|
| 124 |
+
|
| 125 |
+
|
| 126 |
+
async def _execute_ai_turn(
|
| 127 |
+
session: "Session",
|
| 128 |
+
spec: _AiTurnSpec,
|
| 129 |
+
call_participant: CallParticipantFn,
|
| 130 |
+
) -> _AiTurnResult:
|
| 131 |
+
from app.services.orchestrator import _pending_addressed_for
|
| 132 |
+
|
| 133 |
+
pending = _pending_addressed_for(session, spec.participant)
|
| 134 |
+
turn = await run_resilient_turn(
|
| 135 |
+
session=session,
|
| 136 |
+
participant=spec.participant,
|
| 137 |
+
user_prompt=spec.user_prompt,
|
| 138 |
+
label=spec.label,
|
| 139 |
+
max_tokens=spec.max_tokens,
|
| 140 |
+
call_participant=call_participant,
|
| 141 |
+
)
|
| 142 |
+
return _AiTurnResult(
|
| 143 |
+
participant=spec.participant,
|
| 144 |
+
turn=turn,
|
| 145 |
+
pending=pending,
|
| 146 |
+
)
|
| 147 |
+
|
| 148 |
+
|
| 149 |
+
PostProcessFn = Callable[
|
| 150 |
+
[_AiTurnResult],
|
| 151 |
+
Awaitable[dict[str, Any] | None],
|
| 152 |
+
]
|
| 153 |
+
|
| 154 |
+
|
| 155 |
+
async def run_roster_ai_turns_parallel(
|
| 156 |
+
session: "Session",
|
| 157 |
+
actives: list["Participant"],
|
| 158 |
+
*,
|
| 159 |
+
phase: "Phase",
|
| 160 |
+
build_spec: Callable[["Participant"], _AiTurnSpec | None],
|
| 161 |
+
call_participant: CallParticipantFn,
|
| 162 |
+
on_human_turn: Callable[
|
| 163 |
+
["Participant"],
|
| 164 |
+
AsyncIterator[str],
|
| 165 |
+
],
|
| 166 |
+
post_process: PostProcessFn | None = None,
|
| 167 |
+
) -> AsyncIterator[str]:
|
| 168 |
+
"""Run participant turns: humans sequentially, AI in parallel batches.
|
| 169 |
+
|
| 170 |
+
Walks `actives` in roster order. Consecutive AI participants are
|
| 171 |
+
executed with ``asyncio.gather``; results are applied in roster
|
| 172 |
+
order so the message log stays deterministic. Humans are awaited
|
| 173 |
+
one at a time via ``on_human_turn``.
|
| 174 |
+
|
| 175 |
+
Yields orchestrator SSE strings (status, message, errors, etc.).
|
| 176 |
+
"""
|
| 177 |
+
from app.services.orchestrator import (
|
| 178 |
+
_msg_payload,
|
| 179 |
+
_participant_msg_cap_hit,
|
| 180 |
+
_sse,
|
| 181 |
+
_wait_for_continue,
|
| 182 |
+
)
|
| 183 |
+
|
| 184 |
+
ai_batch: list[_AiTurnSpec] = []
|
| 185 |
+
|
| 186 |
+
async def flush_ai_batch() -> AsyncIterator[str]:
|
| 187 |
+
nonlocal ai_batch
|
| 188 |
+
if not ai_batch:
|
| 189 |
+
return
|
| 190 |
+
specs = ai_batch
|
| 191 |
+
ai_batch = []
|
| 192 |
+
results = await asyncio.gather(
|
| 193 |
+
*[
|
| 194 |
+
_execute_ai_turn(session, spec, call_participant)
|
| 195 |
+
for spec in specs
|
| 196 |
+
],
|
| 197 |
+
return_exceptions=True,
|
| 198 |
+
)
|
| 199 |
+
for spec, item in zip(specs, results):
|
| 200 |
+
if isinstance(item, BaseException):
|
| 201 |
+
LOG.exception(
|
| 202 |
+
"Parallel turn failed for %s: %s",
|
| 203 |
+
spec.participant.participant_id,
|
| 204 |
+
item,
|
| 205 |
+
)
|
| 206 |
+
yield _sse("participant_error", {
|
| 207 |
+
"participant_id": spec.participant.participant_id,
|
| 208 |
+
"name": spec.participant.name,
|
| 209 |
+
"phase": phase.value,
|
| 210 |
+
})
|
| 211 |
+
continue
|
| 212 |
+
extra: dict[str, Any] | None = None
|
| 213 |
+
if post_process is not None:
|
| 214 |
+
extra = await post_process(item) or {}
|
| 215 |
+
async for chunk in _emit_ai_turn_result(
|
| 216 |
+
session, item, phase=phase, extra=extra,
|
| 217 |
+
):
|
| 218 |
+
yield chunk
|
| 219 |
+
if _participant_msg_cap_hit(session):
|
| 220 |
+
async for chunk in _wait_for_continue(session, "messages"):
|
| 221 |
+
yield chunk
|
| 222 |
+
|
| 223 |
+
for p in actives:
|
| 224 |
+
if p.kind == "human":
|
| 225 |
+
async for chunk in flush_ai_batch():
|
| 226 |
+
yield chunk
|
| 227 |
+
async for chunk in on_human_turn(p):
|
| 228 |
+
yield chunk
|
| 229 |
+
continue
|
| 230 |
+
|
| 231 |
+
spec = build_spec(p)
|
| 232 |
+
if spec is None:
|
| 233 |
+
continue
|
| 234 |
+
ai_batch.append(spec)
|
| 235 |
+
|
| 236 |
+
async for chunk in flush_ai_batch():
|
| 237 |
+
yield chunk
|
| 238 |
+
|
| 239 |
+
|
| 240 |
+
async def _emit_ai_turn_result(
|
| 241 |
+
session: "Session",
|
| 242 |
+
result: _AiTurnResult,
|
| 243 |
+
*,
|
| 244 |
+
phase: "Phase",
|
| 245 |
+
extra: dict[str, Any] | None = None,
|
| 246 |
+
) -> AsyncIterator[str]:
|
| 247 |
+
"""Apply a completed AI turn to the session and yield SSE."""
|
| 248 |
+
from app.services.orchestrator import (
|
| 249 |
+
_add_participant_message,
|
| 250 |
+
_msg_payload,
|
| 251 |
+
_orchestrator_cap_hit,
|
| 252 |
+
_replying_to_ids,
|
| 253 |
+
_sse,
|
| 254 |
+
_wait_for_continue,
|
| 255 |
+
)
|
| 256 |
+
|
| 257 |
+
p = result.participant
|
| 258 |
+
turn = result.turn
|
| 259 |
+
for ev in turn.sse_events:
|
| 260 |
+
yield ev
|
| 261 |
+
if not turn.ok:
|
| 262 |
+
yield _sse("participant_error", {
|
| 263 |
+
"participant_id": p.participant_id,
|
| 264 |
+
"name": p.name,
|
| 265 |
+
"phase": phase.value,
|
| 266 |
+
})
|
| 267 |
+
if p.consecutive_failures >= session.limits.auto_disable_failures:
|
| 268 |
+
p.enabled = False
|
| 269 |
+
yield _sse("status", {
|
| 270 |
+
"message": (
|
| 271 |
+
f"{p.name} auto-disabled after "
|
| 272 |
+
f"{session.limits.auto_disable_failures} failures."
|
| 273 |
+
),
|
| 274 |
+
})
|
| 275 |
+
return
|
| 276 |
+
|
| 277 |
+
speaker = turn.speaker
|
| 278 |
+
meta = extra or {}
|
| 279 |
+
msg = _add_participant_message(
|
| 280 |
+
session,
|
| 281 |
+
speaker,
|
| 282 |
+
turn.text,
|
| 283 |
+
phase=phase,
|
| 284 |
+
elapsed=turn.elapsed,
|
| 285 |
+
addressed_to=meta.get("addressed_to"),
|
| 286 |
+
replying_to=meta.get(
|
| 287 |
+
"replying_to",
|
| 288 |
+
_replying_to_ids(result.pending),
|
| 289 |
+
),
|
| 290 |
+
message_id=meta.get("message_id"),
|
| 291 |
+
)
|
| 292 |
+
yield _sse("message", _msg_payload(msg))
|
| 293 |
+
|
| 294 |
+
if _orchestrator_cap_hit(session):
|
| 295 |
+
async for chunk in _wait_for_continue(session, "orchestrator"):
|
| 296 |
+
yield chunk
|
| 297 |
+
|
| 298 |
+
|
| 299 |
+
async def run_parallel_coroutines(
|
| 300 |
+
coros: list[Awaitable[Any]],
|
| 301 |
+
) -> list[Any]:
|
| 302 |
+
"""Gather with exception isolation (failed tasks become exceptions)."""
|
| 303 |
+
return await asyncio.gather(*coros, return_exceptions=True)
|
|
@@ -329,6 +329,8 @@ async def run_resilient_turn(
|
|
| 329 |
label: str,
|
| 330 |
max_tokens: int,
|
| 331 |
call_participant: CallParticipantFn,
|
|
|
|
|
|
|
| 332 |
) -> ResilientTurnResult:
|
| 333 |
"""Phase-aware wrapper around a single participant LLM turn.
|
| 334 |
|
|
@@ -356,6 +358,8 @@ async def run_resilient_turn(
|
|
| 356 |
user_prompt=user_prompt,
|
| 357 |
label=label,
|
| 358 |
max_tokens=max_tokens,
|
|
|
|
|
|
|
| 359 |
)
|
| 360 |
|
| 361 |
if ok and text.strip():
|
|
|
|
| 329 |
label: str,
|
| 330 |
max_tokens: int,
|
| 331 |
call_participant: CallParticipantFn,
|
| 332 |
+
stream_events: list[str] | None = None,
|
| 333 |
+
stream_message_id: str | None = None,
|
| 334 |
) -> ResilientTurnResult:
|
| 335 |
"""Phase-aware wrapper around a single participant LLM turn.
|
| 336 |
|
|
|
|
| 358 |
user_prompt=user_prompt,
|
| 359 |
label=label,
|
| 360 |
max_tokens=max_tokens,
|
| 361 |
+
stream_events=stream_events,
|
| 362 |
+
stream_message_id=stream_message_id,
|
| 363 |
)
|
| 364 |
|
| 365 |
if ok and text.strip():
|
|
@@ -753,9 +753,38 @@ export default function App() {
|
|
| 753 |
setSessionParticipants(data.participants || []);
|
| 754 |
},
|
| 755 |
onMessage: (data) => {
|
| 756 |
-
setMessages(prev =>
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 757 |
setStatusText('Conversation in progress...');
|
| 758 |
},
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 759 |
onOrchestrator: (data) => {
|
| 760 |
// Orchestrator events with kind == "status" but no text are
|
| 761 |
// status banners; bubble them into a message-style entry so
|
|
|
|
| 753 |
setSessionParticipants(data.participants || []);
|
| 754 |
},
|
| 755 |
onMessage: (data) => {
|
| 756 |
+
setMessages(prev => {
|
| 757 |
+
const mid = data?.message_id;
|
| 758 |
+
if (!mid) return [...prev, data];
|
| 759 |
+
const idx = prev.findIndex(m => m.message_id === mid);
|
| 760 |
+
if (idx >= 0) {
|
| 761 |
+
const next = [...prev];
|
| 762 |
+
next[idx] = { ...next[idx], ...data, streaming: false };
|
| 763 |
+
return next;
|
| 764 |
+
}
|
| 765 |
+
return [...prev, data];
|
| 766 |
+
});
|
| 767 |
setStatusText('Conversation in progress...');
|
| 768 |
},
|
| 769 |
+
onMessageStreamStart: (data) => {
|
| 770 |
+
setMessages(prev => [...prev, {
|
| 771 |
+
...data,
|
| 772 |
+
role: 'participant',
|
| 773 |
+
text: '',
|
| 774 |
+
streaming: true,
|
| 775 |
+
timestamp: Date.now() / 1000,
|
| 776 |
+
}]);
|
| 777 |
+
},
|
| 778 |
+
onMessageDelta: (data) => {
|
| 779 |
+
const mid = data?.message_id;
|
| 780 |
+
const delta = data?.delta || '';
|
| 781 |
+
if (!mid || !delta) return;
|
| 782 |
+
setMessages(prev => prev.map(m => (
|
| 783 |
+
m.message_id === mid
|
| 784 |
+
? { ...m, text: `${m.text || ''}${delta}` }
|
| 785 |
+
: m
|
| 786 |
+
)));
|
| 787 |
+
},
|
| 788 |
onOrchestrator: (data) => {
|
| 789 |
// Orchestrator events with kind == "status" but no text are
|
| 790 |
// status banners; bubble them into a message-style entry so
|
|
@@ -117,6 +117,8 @@ function eventHandlerKey(eventType) {
|
|
| 117 |
switch (eventType) {
|
| 118 |
case 'session': return 'onSession';
|
| 119 |
case 'message': return 'onMessage';
|
|
|
|
|
|
|
| 120 |
case 'orchestrator': return 'onOrchestrator';
|
| 121 |
case 'system': return 'onSystem';
|
| 122 |
case 'status': return 'onStatus';
|
|
|
|
| 117 |
switch (eventType) {
|
| 118 |
case 'session': return 'onSession';
|
| 119 |
case 'message': return 'onMessage';
|
| 120 |
+
case 'message_stream_start': return 'onMessageStreamStart';
|
| 121 |
+
case 'message_delta': return 'onMessageDelta';
|
| 122 |
case 'orchestrator': return 'onOrchestrator';
|
| 123 |
case 'system': return 'onSystem';
|
| 124 |
case 'status': return 'onStatus';
|