Spaces:
Runtime error
Runtime error
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
|