File size: 7,474 Bytes
ca4ed58
 
1315e90
ca4ed58
1315e90
ca4ed58
 
 
 
1315e90
 
 
 
 
 
83fc25d
 
ca4ed58
 
1315e90
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
ca4ed58
1315e90
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
ca4ed58
 
 
 
 
 
1315e90
 
ca4ed58
 
1315e90
ca4ed58
1315e90
 
 
 
 
ca4ed58
 
 
 
 
 
 
 
 
 
1315e90
ca4ed58
 
1315e90
 
 
ca4ed58
 
 
 
 
3da97ef
 
ca4ed58
 
 
1315e90
 
ca4ed58
1315e90
 
ca4ed58
 
 
 
1315e90
ca4ed58
 
 
1315e90
 
 
 
 
 
 
 
 
 
3da97ef
 
1315e90
 
 
 
 
ca4ed58
1315e90
ca4ed58
 
1315e90
ca4ed58
1315e90
 
ca4ed58
 
 
 
 
 
 
1315e90
 
ca4ed58
 
 
 
 
 
 
 
 
 
 
 
 
1315e90
ca4ed58
1315e90
 
ca4ed58
 
1315e90
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
ca4ed58
 
1315e90
ca4ed58
1315e90
ca4ed58
1315e90
ca4ed58
 
 
1315e90
 
ca4ed58
 
 
 
 
3da97ef
 
ca4ed58
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
# app/agents/summarizer.py
from __future__ import annotations
import os, json, time, requests
from typing import List, Dict, Any

from app.schemas.report import CallReport
from app.schemas.claim import Claim
from app.schemas.evidence import Evidence
from app.schemas.verdict import Verdict
from app.core.config import (
    WATSONX_BASE_URL,
    WATSONX_PROJECT,
    IBM_SUMMARY_MODEL_ID as MODEL_ID,
    IBM_API_VERSION,
)
from app.core.auth import get_ibm_iam_token
from app.core.parse_json import parse_json_anywhere


# -------- Helpers --------

def _compact_segments(segments: List[Dict[str, Any]], max_chars: int = 6000) -> List[Dict[str, Any]]:
    """
    Keep the last ~N characters of transcript text to stay under token limits,
    preserving structure (start, end, speaker, text).
    """
    if not segments:
        return []
    # Prefer the tail of the conversation (most recent context is more salient)
    out = []
    total = 0
    for seg in reversed(segments):
        t = seg.get("text", "") or ""
        total += len(t)
        out.append({"start": seg.get("start", 0.0),
                    "end": seg.get("end", 0.0),
                    "speaker": seg.get("speaker"),
                    "text": t})
        if total >= max_chars:
            break
    return list(reversed(out))

def _verdict_stats(verdicts: List[Verdict]) -> Dict[str, int]:
    s = sum(1 for v in verdicts if v.label == "supported")
    r = sum(1 for v in verdicts if v.label == "refuted")
    i = sum(1 for v in verdicts if v.label == "insufficient")
    return {"supported": s, "refuted": r, "insufficient": i, "total": len(verdicts)}

def _http_post_with_retry(url: str, headers: dict, body: dict, timeout: int = 120, tries: int = 4, backoff: float = 1.5):
    for attempt in range(tries):
        r = requests.post(url, headers=headers, json=body, timeout=timeout)
        if r.status_code not in (429, 500, 502, 503, 504):
            r.raise_for_status()
            return r
        time.sleep(backoff * (2 ** attempt))
    # last try result:
    r.raise_for_status()
    return r


# -------- Prompt (kept tight & structured) --------

PROMPT = """You are a precise meeting summarizer for sales/stakeholder calls.
Given transcript segments, normalized claims, and their verification labels, produce a concise executive summary.

Return STRICT JSON only with:
{
  "call_summary": "string (<= 6 sentences, neutral, factual, cites concrete numbers/dates/KPIs when present)",
  "action_items": ["string", "..."]  // 1-6 imperative bullets; each starts with a verb
}

Guidance:
- Emphasize mismatches between stated claims and evidence.
- Prioritize concrete, verifiable metrics (%, $, dates, counts).
- Be concise; avoid fluff and opinions.

Context stats (for your awareness; do not invent numbers):
{VERDICT_STATS}

Segments (JSON):
{SEGMENTS_JSON}

Claims (JSON):
{CLAIMS_JSON}

Verdicts (JSON):
{VERDICTS_JSON}

Output JSON only (no extra text, no markdown, no backticks):
"""


# -------- Public API --------

def make_report(
    segments: List[Dict[str, Any]],
    claims: List[Claim],
    evidence_flat: List[Evidence],
    verdicts: List[Verdict],
    *,
    evidence_by_claim: Dict[str, List[Evidence]] | None = None,
) -> CallReport:
    """
    Build a CallReport:
      - call_summary + action_items from IBM LLM (robust parse)
      - claim_table derived from verdicts (+ best evidence id)
    """

    # 1) Build claim table from verdicts (always succeeds)
    id2claim = {c.id: c.text for c in claims}
    claim_table: List[Dict[str, Any]] = []
    for v in verdicts:
        claim_text = id2claim.get(v.claim_id, "")
        claim_table.append({
            "claim": claim_text,
            "status": v.label.capitalize(),
            "evidence_source": v.best_evidence_id or ""
        })

    # 2) Short-circuit if we have no content to summarize
    if not segments and not claims:
        return CallReport(
            call_summary="",
            claim_table=claim_table,
            action_items=["Review claims vs. evidence and confirm metrics in source-of-truth."],
            claims=claims,
            verdicts=verdicts,
            evidence=evidence_flat,
            evidence_by_claim=evidence_by_claim or {},
        )

    # 3) Prepare compact context + stats
    compact = _compact_segments(segments, max_chars=6000)
    stats = _verdict_stats(verdicts)

    # 4) Call IBM Granite (watsonx) for structured summary
    call_summary = ""
    action_items: List[str] = []

    try:
        tok = get_ibm_iam_token()
        url = f"{WATSONX_BASE_URL.rstrip('/')}/ml/v1/text/generation?version={IBM_API_VERSION}"
        headers = {
            "Authorization": f"Bearer {tok}",
            "Accept": "application/json",
            "Content-Type": "application/json",
        }
        body = {
            "input": PROMPT \
                .replace("{VERDICT_STATS}", json.dumps(stats, ensure_ascii=False)) \
                .replace("{SEGMENTS_JSON}", json.dumps(compact, ensure_ascii=False)) \
                .replace("{CLAIMS_JSON}", json.dumps([{"id": c.id, "text": c.text} for c in claims], ensure_ascii=False)) \
                .replace("{VERDICTS_JSON}", json.dumps([{
                    "claim_id": v.claim_id,
                    "label": v.label,
                    "confidence": v.confidence,
                    "best_evidence_id": v.best_evidence_id,
                    "rationale": v.rationale
                } for v in verdicts], ensure_ascii=False)),
            "model_id": MODEL_ID,
            "project_id": WATSONX_PROJECT,
            "parameters": {
                "decoding_method": "greedy",
                "temperature": 0.0,
                "max_new_tokens": 400,
                "min_new_tokens": 0,
                "repetition_penalty": 1.0,
                "stop_sequences": ["\n\n", "\nOutput JSON", "\nSegments (JSON):"]
            }
        }

        resp = _http_post_with_retry(url, headers, body, timeout=120)
        out = resp.json()
        gen = (out.get("results") or [{}])[0].get("generated_text", "") or ""

        # Robust parse (accepts full JSON, partials, or multiple JSON objects)
        parsed = parse_json_anywhere(gen, root_key=None)  # expecting a single dict with keys above
        if isinstance(parsed, dict):
            call_summary = (parsed.get("call_summary") or "").strip()
            action_items = parsed.get("action_items") or []
        else:
            # If parse returns a list (rare), try first dict
            if parsed and isinstance(parsed, list) and isinstance(parsed[0], dict):
                call_summary = (parsed[0].get("call_summary") or "").strip()
                action_items = parsed[0].get("action_items") or []

        if not isinstance(action_items, list):
            action_items = [str(action_items)]

    except Exception as e:
        # 5) Fallback: build a terse summary from first few segments and stats
        print(f"[summarizer] watsonx generation failed: {e}")
        texts = [s.get("text","") for s in compact if s.get("text")]
        joined = " ".join(texts)[:450].strip()
        call_summary = (joined + "…") if joined else ""

    # 6) Return CallReport
    return CallReport(
        call_summary=call_summary,
        claim_table=claim_table,
        action_items=action_items,
        claims=claims,
        verdicts=verdicts,
        evidence=evidence_flat,
        evidence_by_claim=evidence_by_claim or {},
    )