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