Spaces:
Sleeping
Sleeping
File size: 12,424 Bytes
0bb4dfa d4017c8 0bb4dfa d4017c8 0bb4dfa 11bf9b7 0bb4dfa 11bf9b7 0bb4dfa d4017c8 11bf9b7 0bb4dfa 11bf9b7 0bb4dfa d4017c8 0bb4dfa 11bf9b7 0bb4dfa 11bf9b7 0bb4dfa 11bf9b7 0bb4dfa 11bf9b7 0bb4dfa 11bf9b7 d4017c8 11bf9b7 0bb4dfa | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 283 284 285 286 287 288 289 290 291 292 293 294 295 296 297 298 299 300 301 302 303 304 305 306 307 308 309 310 311 312 313 314 315 316 317 318 319 320 321 322 323 324 325 326 327 328 329 330 331 332 333 334 335 336 337 338 | """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
|