Spaces:
Running
Running
| """Credential Summary builder + refresher. | |
| The Credential Summary is a JSON dict (participant_id -> assessment) | |
| threaded into every later participant turn. Each LLM participant's entry | |
| is built concurrently during Phase 1 (as their initial opinion lands) | |
| and is only rebuilt later if their backing model changes. | |
| """ | |
| from __future__ import annotations | |
| import json | |
| import logging | |
| from typing import Any | |
| from app.services.json_calls import orchestrator_call | |
| from app.services.prompts import ( | |
| CREDENTIAL_BUILD_PROMPT, | |
| CREDENTIAL_REFRESH_PROMPT, | |
| HUMAN_CREDENTIAL_FROM_PROFILE_PROMPT, | |
| SINGLE_PARTICIPANT_CREDENTIAL_BUILD_PROMPT, | |
| ) | |
| from app.utils.sanitize import strip_thinking | |
| LOG = logging.getLogger(__name__) | |
| def _format_participants_block( | |
| participants: list[Any], | |
| initial_opinions: dict[str, str], | |
| ) -> str: | |
| """Render one block per participant containing role prompt + first opinion.""" | |
| lines: list[str] = [] | |
| for p in participants: | |
| opinion = strip_thinking(initial_opinions.get(p.participant_id, "")) | |
| lines.append(f"--- Participant id: {p.participant_id} ---") | |
| lines.append(f"Name: {p.name}") | |
| lines.append(f"Role prompt: {p.role_prompt}") | |
| lines.append(f"First opinion: {opinion}") | |
| lines.append("") | |
| return "\n".join(lines).strip() | |
| def credentials_to_block(credentials: list[dict[str, Any]]) -> str: | |
| """Render the credentials list back into a string for use inside | |
| participant prompts (so we can keep them readable rather than | |
| embedding raw JSON in role prompts).""" | |
| if not credentials: | |
| return "(no credential summary available yet)" | |
| lines: list[str] = [] | |
| for c in credentials: | |
| lines.append(f"- {c.get('name', c.get('participant_id', '?'))} " | |
| f"(id={c.get('participant_id', '?')})") | |
| if c.get("expertise"): | |
| lines.append(f" Expertise: {c['expertise']}") | |
| if c.get("personality"): | |
| lines.append(f" Style: {c['personality']}") | |
| if c.get("credibility_for_question") is not None: | |
| lines.append(f" Credibility on this question: {c['credibility_for_question']:.2f}") | |
| if c.get("bias_to_watch"): | |
| lines.append(f" Bias to watch: {c['bias_to_watch']}") | |
| return "\n".join(lines) | |
| async def build_credential_summary( | |
| *, | |
| orchestrator_model_id: str, | |
| question: str, | |
| participants: list[Any], | |
| initial_opinions: dict[str, str], | |
| api_log: list[dict[str, Any]] | None = None, | |
| human_credential: dict[str, Any] | None = None, | |
| ) -> list[dict[str, Any]]: | |
| """Build the Credential Summary list. Returns an empty list on parse failure. | |
| Human participants (kind == "human") are NOT sent to the LLM - | |
| their credential was generated from the user's profile text in the | |
| HumanParticipantModal. We prepend that entry to the front of the | |
| returned list so the human always appears first in the modal / | |
| export, and we exclude them from the LLM input so the orchestrator | |
| isn't asked to fabricate facts about a person. | |
| """ | |
| llm_participants = [p for p in participants if getattr(p, "kind", "") != "human"] | |
| creds: list[dict[str, Any]] = [] | |
| if llm_participants: | |
| block = _format_participants_block(llm_participants, initial_opinions) | |
| prompt = CREDENTIAL_BUILD_PROMPT.format( | |
| question=question, | |
| participants_block=block, | |
| ) | |
| _raw, parsed = await orchestrator_call( | |
| orchestrator_model_id=orchestrator_model_id, | |
| user_prompt=prompt, | |
| label="build_credentials", | |
| api_log=api_log, | |
| max_tokens=2048, | |
| ) | |
| if isinstance(parsed, dict) and isinstance(parsed.get("credentials"), list): | |
| creds = parsed["credentials"] | |
| creds = _normalize_creds(creds, llm_participants) | |
| if human_credential: | |
| creds = [normalize_one_credential(human_credential)] + creds | |
| return creds | |
| async def build_credential_for_participant( | |
| *, | |
| orchestrator_model_id: str, | |
| question: str, | |
| participant: Any, | |
| initial_opinion: str, | |
| api_log: list[dict[str, Any]] | None = None, | |
| ) -> dict[str, Any]: | |
| """Build one credential entry from a role prompt + Phase-1 opinion.""" | |
| opinion = strip_thinking(initial_opinion or "") | |
| prompt = SINGLE_PARTICIPANT_CREDENTIAL_BUILD_PROMPT.format( | |
| question=question, | |
| participant_id=participant.participant_id, | |
| name=participant.name, | |
| role_prompt=(participant.role_prompt or "").strip() or "(none)", | |
| first_opinion=opinion or "(no opinion recorded)", | |
| ) | |
| _raw, parsed = await orchestrator_call( | |
| orchestrator_model_id=orchestrator_model_id, | |
| user_prompt=prompt, | |
| label=f"build_credential:{participant.participant_id}", | |
| api_log=api_log, | |
| max_tokens=512, | |
| ) | |
| cred: dict[str, Any] = {} | |
| if isinstance(parsed, dict): | |
| if isinstance(parsed.get("credential"), dict): | |
| cred = parsed["credential"] | |
| elif isinstance(parsed.get("credentials"), list) and parsed["credentials"]: | |
| cred = parsed["credentials"][0] | |
| merged = { | |
| "participant_id": participant.participant_id, | |
| "name": participant.name, | |
| "expertise": cred.get("expertise", ""), | |
| "personality": cred.get("personality", ""), | |
| "credibility_for_question": cred.get("credibility_for_question", 0.5), | |
| "bias_to_watch": cred.get("bias_to_watch", ""), | |
| "is_human": False, | |
| } | |
| if not merged["expertise"]: | |
| merged["expertise"] = "(no credential available)" | |
| return normalize_one_credential(merged) | |
| def assemble_credential_summary_list( | |
| *, | |
| participants: list[Any], | |
| credential_entries_by_pid: dict[str, dict[str, Any]], | |
| human_credential: dict[str, Any] | None = None, | |
| ) -> list[dict[str, Any]]: | |
| """Merge per-participant credential rows in roster order (human first).""" | |
| creds: list[dict[str, Any]] = [] | |
| if human_credential: | |
| creds.append(normalize_one_credential(human_credential)) | |
| for p in participants: | |
| if getattr(p, "kind", "") == "human": | |
| continue | |
| row = credential_entries_by_pid.get(p.participant_id) | |
| if row: | |
| creds.append(row) | |
| else: | |
| creds.append(normalize_one_credential({ | |
| "participant_id": p.participant_id, | |
| "name": p.name, | |
| "expertise": "(no credential available)", | |
| "personality": "", | |
| "credibility_for_question": 0.5, | |
| "bias_to_watch": "", | |
| "is_human": False, | |
| })) | |
| return creds | |
| async def build_human_credential_from_profile( | |
| *, | |
| orchestrator_model_id: str, | |
| question: str, | |
| name: str, | |
| profile_text: str, | |
| participant_id: str = "", | |
| api_log: list[dict[str, Any]] | None = None, | |
| ) -> dict[str, Any]: | |
| """Turn a human's freeform self-description into a structured credential. | |
| Uses the same assessment rubric as Phase-1 credential building for | |
| LLM participants (expertise, style, credibility, bias) but sources | |
| only the profile text — equivalent to a persona role prompt. | |
| """ | |
| q = (question or "").strip() | |
| if q: | |
| question_block = f"Question:\n<<<\n{q}\n>>>\n\n" | |
| else: | |
| question_block = ( | |
| "Discussion question: (not specified yet). Assess the " | |
| "participant in general terms; credibility_for_question " | |
| "should reflect their likely relevance once a topic is " | |
| "chosen.\n\n" | |
| ) | |
| prompt = HUMAN_CREDENTIAL_FROM_PROFILE_PROMPT.format( | |
| question_block=question_block, | |
| name=name.strip(), | |
| profile_text=profile_text.strip(), | |
| ) | |
| _raw, parsed = await orchestrator_call( | |
| orchestrator_model_id=orchestrator_model_id, | |
| user_prompt=prompt, | |
| label="human_credential_from_profile", | |
| api_log=api_log, | |
| max_tokens=768, | |
| ) | |
| cred: dict[str, Any] = {} | |
| if isinstance(parsed, dict): | |
| if isinstance(parsed.get("credential"), dict): | |
| cred = parsed["credential"] | |
| elif isinstance(parsed.get("credentials"), list) and parsed["credentials"]: | |
| cred = parsed["credentials"][0] | |
| merged = { | |
| "participant_id": participant_id, | |
| "name": name.strip(), | |
| "expertise": cred.get("expertise", ""), | |
| "personality": cred.get("personality", ""), | |
| "credibility_for_question": cred.get("credibility_for_question", 0.55), | |
| "bias_to_watch": cred.get("bias_to_watch", ""), | |
| "is_human": True, | |
| } | |
| if not merged["expertise"] and profile_text.strip(): | |
| merged["expertise"] = profile_text.strip()[:500] | |
| return normalize_one_credential(merged) | |
| async def refresh_credential_summary( | |
| *, | |
| orchestrator_model_id: str, | |
| question: str, | |
| participants: list[Any], | |
| existing: list[dict[str, Any]], | |
| critique_transcript: str, | |
| api_log: list[dict[str, Any]] | None = None, | |
| ) -> list[dict[str, Any]]: | |
| """Refresh the Credential Summary after Phase 2 critique. | |
| Human entries (kind == "human") are passed through verbatim - we | |
| don't ask the LLM to second-guess the user's self-description. The | |
| LLM only refreshes credentials for LLM participants. | |
| """ | |
| if not existing: | |
| return existing | |
| human_pids = {p.participant_id for p in participants if getattr(p, "kind", "") == "human"} | |
| human_entries = [c for c in existing if c.get("participant_id") in human_pids] | |
| llm_entries = [c for c in existing if c.get("participant_id") not in human_pids] | |
| llm_participants = [p for p in participants if getattr(p, "kind", "") != "human"] | |
| if not llm_entries: | |
| return existing | |
| prompt = CREDENTIAL_REFRESH_PROMPT.format( | |
| question=question, | |
| credential_summary_json=json.dumps({"credentials": llm_entries}, indent=2), | |
| critique_transcript=critique_transcript, | |
| ) | |
| _raw, parsed = await orchestrator_call( | |
| orchestrator_model_id=orchestrator_model_id, | |
| user_prompt=prompt, | |
| label="refresh_credentials", | |
| api_log=api_log, | |
| max_tokens=2048, | |
| ) | |
| if isinstance(parsed, dict) and isinstance(parsed.get("credentials"), list): | |
| refreshed_llm = _normalize_creds(parsed["credentials"], llm_participants) | |
| return human_entries + refreshed_llm | |
| return existing | |
| def normalize_one_credential(c: dict[str, Any]) -> dict[str, Any]: | |
| """Clamp credibility to [0, 1] and ensure required keys exist on a | |
| single credential dict. Used for human-authored entries that bypass | |
| the LLM-side _normalize_creds roster pass.""" | |
| try: | |
| score = float(c.get("credibility_for_question", 0.5)) | |
| except Exception: | |
| score = 0.5 | |
| return { | |
| "participant_id": c.get("participant_id") or c.get("id") or "", | |
| "name": c.get("name", ""), | |
| "expertise": c.get("expertise", ""), | |
| "personality": c.get("personality", ""), | |
| "credibility_for_question": max(0.0, min(1.0, score)), | |
| "bias_to_watch": c.get("bias_to_watch", ""), | |
| "is_human": bool(c.get("is_human", False)), | |
| } | |
| def _normalize_creds( | |
| creds: list[dict[str, Any]], | |
| participants: list[Any], | |
| ) -> list[dict[str, Any]]: | |
| """Defensive cleanup: ensure credibility is a float in [0, 1] and that | |
| every participant has a row (fill in placeholders if the model dropped | |
| one).""" | |
| by_id: dict[str, dict[str, Any]] = {} | |
| for c in creds: | |
| pid = c.get("participant_id") or c.get("id") or "" | |
| if not pid: | |
| continue | |
| try: | |
| score = float(c.get("credibility_for_question", 0.5)) | |
| except Exception: | |
| score = 0.5 | |
| c["credibility_for_question"] = max(0.0, min(1.0, score)) | |
| by_id[pid] = c | |
| out: list[dict[str, Any]] = [] | |
| for p in participants: | |
| if p.participant_id in by_id: | |
| row = by_id[p.participant_id] | |
| row.setdefault("name", p.name) | |
| out.append(row) | |
| else: | |
| out.append({ | |
| "participant_id": p.participant_id, | |
| "name": p.name, | |
| "expertise": "(no credential available)", | |
| "personality": "", | |
| "credibility_for_question": 0.5, | |
| "bias_to_watch": "", | |
| }) | |
| return out | |