"""Auto-select N participants for a question. Backs the optional "Select N Automatically" toggle in the participants dropdown. The orchestrator LLM ranks the full candidate pool for relevance to the question and returns the top N. The service: - formats a compact candidates block (id + name + role role-prompt snippet) so the LLM can pick deliberately; - runs the call through `orchestrator_call` (which strips think traces and is JSON-tolerant); - validates every returned id against the candidate pool, drops invented ones, and pads with the next-best unused candidates if the LLM under-delivered. If the orchestrator call fails entirely, we fall back to the first N candidates in the order received, so the user still gets a working chat instead of a hard error. """ from __future__ import annotations import logging from typing import Any from app.services.json_calls import orchestrator_call from app.services.prompts import AUTO_SELECT_PARTICIPANTS_PROMPT LOG = logging.getLogger(__name__) _ROLE_SNIPPET_CHARS = 320 def _candidate_block(candidates: list[dict[str, Any]]) -> str: """Render one line per candidate: id, name, kind, model, role snippet. Role prompts are truncated so a roster of ~30 candidates fits in a single orchestrator call without crowding out the question. """ lines: list[str] = [] for i, c in enumerate(candidates, start=1): role = (c.get("role_prompt") or "").strip() if len(role) > _ROLE_SNIPPET_CHARS: role = role[:_ROLE_SNIPPET_CHARS].rstrip() + "..." lines.append( f"{i}. id={c.get('participant_id')} | name={c.get('name')} " f"| kind={c.get('kind')} | model={c.get('model_id')}\n" f" role: {role or '(no role description)'}" ) return "\n".join(lines) def _validate_and_pad( selected_raw: list[str] | None, candidates: list[dict[str, Any]], count: int, ) -> list[str]: """Keep only LLM-returned ids that exist in the candidate pool, de-dupe while preserving the LLM's ranking, and pad with the next unused candidates (in input order) up to `count`. """ valid_ids = {c.get("participant_id") for c in candidates if c.get("participant_id")} chosen: list[str] = [] seen: set[str] = set() for sid in selected_raw or []: if not isinstance(sid, str): continue if sid in valid_ids and sid not in seen: chosen.append(sid) seen.add(sid) if len(chosen) == count: break if len(chosen) < count: # Pad with the first unused candidates in the order received. for c in candidates: pid = c.get("participant_id") if not pid or pid in seen: continue chosen.append(pid) seen.add(pid) if len(chosen) == count: break return chosen[:count] async def auto_select_participants( *, orchestrator_model_id: str, question: str, candidates: list[dict[str, Any]], count: int, api_log: list[dict[str, Any]] | None = None, ) -> dict[str, Any]: """Return {"selected": [participant_id, ...], "rationale": str}. `selected` is always exactly `count` long (padded from the candidate pool if the LLM under-delivers). Never raises on LLM errors - those degrade to a first-N fallback so the caller can proceed to /chat/start. """ n_target = max(1, min(count, len(candidates))) if not candidates: return {"selected": [], "rationale": "No candidates provided."} # Single-candidate / under-supplied pools have nothing to pick from. if len(candidates) <= n_target: return { "selected": [c["participant_id"] for c in candidates if c.get("participant_id")], "rationale": "Candidate pool was at or below the requested count; using all.", } prompt = AUTO_SELECT_PARTICIPANTS_PROMPT.format( question=question.strip(), candidates_block=_candidate_block(candidates), count=n_target, ) _raw, parsed = await orchestrator_call( orchestrator_model_id=orchestrator_model_id, user_prompt=prompt, label="auto_select_participants", api_log=api_log, max_tokens=512, temperature=0.2, ) selected_raw: list[str] | None = None rationale = "" if isinstance(parsed, dict): if isinstance(parsed.get("selected"), list): selected_raw = [str(x) for x in parsed["selected"]] if isinstance(parsed.get("rationale"), str): rationale = parsed["rationale"].strip() selected = _validate_and_pad(selected_raw, candidates, n_target) if not rationale: rationale = "Selected by relevance to the question." if not selected_raw: rationale = "Auto-select fell back to the first candidates (LLM unavailable)." return {"selected": selected, "rationale": rationale}