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

from app.core.config import WATSONX_BASE_URL, WATSONX_PROJECT, IBM_CLAIM_MODEL_ID
from app.core.auth import get_ibm_iam_token
from app.schemas.claim import Claim
from app.core.parse_json import parse_json_anywhere

GEN_URL = f"{WATSONX_BASE_URL.rstrip('/')}/ml/v1/text/generation?version=2023-05-29"

def _gen_post(payload: Dict[str, Any], retries: int = 4, timeout: int = 90) -> str:
    """
    POST to watsonx text/generation with basic retries for 429/5xx.
    Returns the text (generated_text/output_text) or raises.
    """
    headers = {
        "Accept": "application/json",
        "Content-Type": "application/json",
        "Authorization": f"Bearer {get_ibm_iam_token()}",
    }
    backoff = 1.5
    for attempt in range(retries):
        r = requests.post(GEN_URL, headers=headers, json=payload, timeout=timeout)
        if r.status_code in (429, 500, 502, 503, 504):
            time.sleep(backoff * (2 ** attempt))
            continue
        r.raise_for_status()
        data = r.json()
        results = data.get("results") or []
        if results and isinstance(results, list):
            return (results[0].get("generated_text") or results[0].get("output_text") or "").strip()
        return (data.get("generated_text") or "").strip()
    # final raise
    r.raise_for_status()
    return ""  # unreachable, keeps linters happy


# =========================
# Claims Extraction (prompt + call)
# =========================

PROMPT_TEMPLATE = r"""
You extract factual claims from messy spoken transcripts.
Return strict JSON with this shape:
{
  "claims": [
    {"text": str, "speaker": str|null, "start": float, "end": float, "confidence": float}
  ]
}

Guidelines:
- A "claim" is a checkable factual assertion (metrics, quantities, time-bound facts).
- Prefer sentences with numbers, percentages, dates, quantities, KPIs.
- Split multiple claims in one sentence into separate objects.
- If unsure about speaker or timestamps, set speaker=null and start/end=0.
- Do NOT include opinions, greetings, or questions unless they state a checkable fact.
- Output ONLY JSON. No prose.

Input: We grew forty percent quarter over quarter in Q2. Customer churn fell to two percent. According to the CRM, Q2 growth was twelve percent. Churn stabilized at four percent in Q2.
Output: {
  "claims": [
    {"text":"We grew 40% quarter over quarter in Q2","speaker":null,"start":0.0,"end":0.0,"confidence":0.55},
    {"text":"Customer churn fell to 2%","speaker":null,"start":0.0,"end":0.0,"confidence":0.55},
    {"text":"Q2 growth was 12%","speaker":null,"start":0.0,"end":0.0,"confidence":0.7},
    {"text":"Churn stabilized at 4% in Q2","speaker":null,"start":0.0,"end":0.0,"confidence":0.7}
  ]
}

Input: We expanded into three new regions this year. Our operating margin improved by five points since Q1.
Output: {
  "claims": [
    {"text":"We expanded into 3 new regions this year","speaker":null,"start":0.0,"end":0.0,"confidence":0.6},
    {"text":"Operating margin improved by 5 percentage points since Q1","speaker":null,"start":0.0,"end":0.0,"confidence":0.7}
  ]
}

Input: {TRANSCRIPT}
Output:
""".strip()

def _build_claims_payload(transcript: str) -> Dict[str, Any]:
    prompt = PROMPT_TEMPLATE.replace("{TRANSCRIPT}", transcript.strip())
    return {
        "input": prompt,
        "parameters": {
            "decoding_method": "greedy",
            "max_new_tokens": 700,
            "min_new_tokens": 0,
            "temperature": 0.0,
            "repetition_penalty": 1.0,
            "stop_sequences": ["\n\nInput:", "\nInput:"]
        },
        "model_id": IBM_CLAIM_MODEL_ID,
        "project_id": WATSONX_PROJECT,
        "moderations": {
            "hap": {"input": {"enabled": False}, "output": {"enabled": False}},
            "pii": {"input": {"enabled": False}, "output": {"enabled": False}}
        }
    }

def run_claim_extractor(transcript: str) -> Dict[str, Any]:
    """
    Calls watsonx to turn a transcript into {"claims":[...]} with robust parsing + auto-repair.
    """
    txt = _gen_post(_build_claims_payload(transcript))
    parsed = parse_json_anywhere(txt, root_key="claims")
    if parsed and parsed.get("claims"):
        return parsed

    # One-shot repair prompt (coerce to strict JSON) if the model added prose noise
    repair_payload = {
        "input": f"Return ONLY valid JSON object with key 'claims'. Fix and output JSON:\n\n{txt}",
        "parameters": {"decoding_method": "greedy", "max_new_tokens": 400, "temperature": 0.0},
        "model_id": IBM_CLAIM_MODEL_ID,
        "project_id": WATSONX_PROJECT
    }
    repaired = _gen_post(repair_payload)
    parsed2 = parse_json_anywhere(repaired, root_key="claims")
    if parsed2 and parsed2.get("claims"):
        return parsed2

    # Debug preview (short) to help diagnose prompt drift
    print("[claims][RAW OUTPUT]", (txt or repaired)[:600])
    return {"claims": []}


# =========================
# Helper Functions
# =========================

def _find_speaker_for_claim(claim_text: str, segments: List[Dict]) -> str:
    """
    Find which speaker made a claim by matching claim text to transcript segments.
    Uses fuzzy matching to handle slight variations in wording.
    """
    claim_words = set(claim_text.lower().split())
    best_match_speaker = None
    best_match_score = 0

    for segment in segments:
        segment_text = segment.get("text", "").lower()
        segment_words = set(segment_text.split())

        # Calculate overlap score (Jaccard similarity)
        if segment_words:
            intersection = claim_words.intersection(segment_words)
            union = claim_words.union(segment_words)
            score = len(intersection) / len(union) if union else 0

            # Also check if claim is a substring (for exact matches)
            if claim_text.lower() in segment_text or any(word in segment_text for word in claim_words if len(word) > 3):
                score += 0.2  # Boost for substring matches

            if score > best_match_score:
                best_match_score = score
                best_match_speaker = segment.get("speaker")

    # Only return speaker if we have a reasonable confidence match
    return best_match_speaker if best_match_score > 0.2 else None

# =========================
# Public: extract_claims (used by orchestrator)
# =========================

def extract_claims(segments: List[Dict]) -> List[Claim]:
    """
    Aggregates segment texts -> calls run_claim_extractor -> returns List[Claim]
    """
    transcript = " ".join(s.get("text", "") for s in segments).strip()
    if not transcript:
        return []

    data = run_claim_extractor(transcript)
    items = (data or {}).get("claims", [])
    out: List[Claim] = []

    for i, c in enumerate(items):
        text = (c.get("text") or "").strip()
        if not text:
            continue

        # Map claim to speaker by finding which segment contains this text
        speaker = _find_speaker_for_claim(text, segments)

        out.append(Claim(
            id=f"c{i}",
            text=text,
            speaker=speaker or c.get("speaker"),
            segment_idx=0,  # TODO: map to true segment via start/end if available
            confidence=float(c.get("confidence", 0.6)),
        ))
    return out