CCAI-Demo / backend /app /services /credential.py
NeonClary
Build credentials during Phase 1 and streamline human participant setup.
d4017c8
Raw
History Blame Contribute Delete
12.4 kB
"""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