stutiagrawal commited on
Commit
1315e90
·
1 Parent(s): c8154d1

refactor json parsing to be consistent across agents

Browse files
app/agents/claims.py CHANGED
@@ -1,24 +1,156 @@
1
- # app/agents/claims.py
2
- from typing import List, Dict
 
 
 
 
 
3
  from app.schemas.claim import Claim
4
- from app.agents.ibm_client import run_claim_extractor
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
5
 
6
  def extract_claims(segments: List[Dict]) -> List[Claim]:
7
- transcript = " ".join(s.get("text","") for s in segments).strip()
 
 
 
8
  if not transcript:
9
  return []
 
10
  data = run_claim_extractor(transcript)
11
- items = data.get("claims", [])
12
- claims: List[Claim] = []
 
13
  for i, c in enumerate(items):
14
  text = (c.get("text") or "").strip()
15
  if not text:
16
  continue
17
- claims.append(Claim(
18
  id=f"c{i}",
19
  text=text,
20
  speaker=c.get("speaker"),
21
- segment_idx=0, # map to real segment later using start/end
22
- confidence=float(c.get("confidence", 0.6))
23
  ))
24
- return claims
 
1
+ # app/agents/ibm_client.py
2
+ from __future__ import annotations
3
+ import os, json, re, time, requests
4
+ from typing import Dict, Any, List
5
+
6
+ from app.core.config import WATSONX_BASE_URL, WATSONX_PROJECT, IBM_CLAIM_MODEL_ID
7
+ from app.utils.auth import get_ibm_iam_token
8
  from app.schemas.claim import Claim
9
+ from app.utils.parse_json import parse_json_anywhere
10
+
11
+ GEN_URL = f"{WATSONX_BASE_URL.rstrip('/')}/ml/v1/text/generation?version=2023-05-29"
12
+
13
+ def _gen_post(payload: Dict[str, Any], retries: int = 4, timeout: int = 90) -> str:
14
+ """
15
+ POST to watsonx text/generation with basic retries for 429/5xx.
16
+ Returns the text (generated_text/output_text) or raises.
17
+ """
18
+ headers = {
19
+ "Accept": "application/json",
20
+ "Content-Type": "application/json",
21
+ "Authorization": f"Bearer {get_ibm_iam_token()}",
22
+ }
23
+ backoff = 1.5
24
+ for attempt in range(retries):
25
+ r = requests.post(GEN_URL, headers=headers, json=payload, timeout=timeout)
26
+ if r.status_code in (429, 500, 502, 503, 504):
27
+ time.sleep(backoff * (2 ** attempt))
28
+ continue
29
+ r.raise_for_status()
30
+ data = r.json()
31
+ results = data.get("results") or []
32
+ if results and isinstance(results, list):
33
+ return (results[0].get("generated_text") or results[0].get("output_text") or "").strip()
34
+ return (data.get("generated_text") or "").strip()
35
+ # final raise
36
+ r.raise_for_status()
37
+ return "" # unreachable, keeps linters happy
38
+
39
+
40
+ # =========================
41
+ # Claims Extraction (prompt + call)
42
+ # =========================
43
+
44
+ PROMPT_TEMPLATE = r"""
45
+ You extract factual claims from messy spoken transcripts.
46
+ Return strict JSON with this shape:
47
+ {
48
+ "claims": [
49
+ {"text": str, "speaker": str|null, "start": float, "end": float, "confidence": float}
50
+ ]
51
+ }
52
+
53
+ Guidelines:
54
+ - A "claim" is a checkable factual assertion (metrics, quantities, time-bound facts).
55
+ - Prefer sentences with numbers, percentages, dates, quantities, KPIs.
56
+ - Split multiple claims in one sentence into separate objects.
57
+ - If unsure about speaker or timestamps, set speaker=null and start/end=0.
58
+ - Do NOT include opinions, greetings, or questions unless they state a checkable fact.
59
+ - Output ONLY JSON. No prose.
60
+
61
+ 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.
62
+ Output: {
63
+ "claims": [
64
+ {"text":"We grew 40% quarter over quarter in Q2","speaker":null,"start":0.0,"end":0.0,"confidence":0.55},
65
+ {"text":"Customer churn fell to 2%","speaker":null,"start":0.0,"end":0.0,"confidence":0.55},
66
+ {"text":"Q2 growth was 12%","speaker":null,"start":0.0,"end":0.0,"confidence":0.7},
67
+ {"text":"Churn stabilized at 4% in Q2","speaker":null,"start":0.0,"end":0.0,"confidence":0.7}
68
+ ]
69
+ }
70
+
71
+ Input: We expanded into three new regions this year. Our operating margin improved by five points since Q1.
72
+ Output: {
73
+ "claims": [
74
+ {"text":"We expanded into 3 new regions this year","speaker":null,"start":0.0,"end":0.0,"confidence":0.6},
75
+ {"text":"Operating margin improved by 5 percentage points since Q1","speaker":null,"start":0.0,"end":0.0,"confidence":0.7}
76
+ ]
77
+ }
78
+
79
+ Input: {TRANSCRIPT}
80
+ Output:
81
+ """.strip()
82
+
83
+ def _build_claims_payload(transcript: str) -> Dict[str, Any]:
84
+ prompt = PROMPT_TEMPLATE.replace("{TRANSCRIPT}", transcript.strip())
85
+ return {
86
+ "input": prompt,
87
+ "parameters": {
88
+ "decoding_method": "greedy",
89
+ "max_new_tokens": 1000,
90
+ "min_new_tokens": 0,
91
+ "temperature": 0.0,
92
+ "repetition_penalty": 1.0,
93
+ "stop_sequences": ["\n\nInput:", "\nInput:"]
94
+ },
95
+ "model_id": IBM_CLAIM_MODEL_ID,
96
+ "project_id": WATSONX_PROJECT,
97
+ "moderations": {
98
+ "hap": {"input": {"enabled": False}, "output": {"enabled": False}},
99
+ "pii": {"input": {"enabled": False}, "output": {"enabled": False}}
100
+ }
101
+ }
102
+
103
+ def run_claim_extractor(transcript: str) -> Dict[str, Any]:
104
+ """
105
+ Calls watsonx to turn a transcript into {"claims":[...]} with robust parsing + auto-repair.
106
+ """
107
+ txt = _gen_post(_build_claims_payload(transcript))
108
+ parsed = parse_json_anywhere(txt, root_key="claims")
109
+ if parsed and parsed.get("claims"):
110
+ return parsed
111
+
112
+ # One-shot repair prompt (coerce to strict JSON) if the model added prose noise
113
+ repair_payload = {
114
+ "input": f"Return ONLY valid JSON object with key 'claims'. Fix and output JSON:\n\n{txt}",
115
+ "parameters": {"decoding_method": "greedy", "max_new_tokens": 400, "temperature": 0.0},
116
+ "model_id": CLAIM_MODEL_ID,
117
+ "project_id": WATSONX_PROJECT
118
+ }
119
+ repaired = _gen_post(repair_payload)
120
+ parsed2 = parse_json_anywhere(repaired, root_key="claims")
121
+ if parsed2 and parsed2.get("claims"):
122
+ return parsed2
123
+
124
+ # Debug preview (short) to help diagnose prompt drift
125
+ print("[claims][RAW OUTPUT]", (txt or repaired)[:600])
126
+ return {"claims": []}
127
+
128
+
129
+ # =========================
130
+ # Public: extract_claims (used by orchestrator)
131
+ # =========================
132
 
133
  def extract_claims(segments: List[Dict]) -> List[Claim]:
134
+ """
135
+ Aggregates segment texts -> calls run_claim_extractor -> returns List[Claim]
136
+ """
137
+ transcript = " ".join(s.get("text", "") for s in segments).strip()
138
  if not transcript:
139
  return []
140
+
141
  data = run_claim_extractor(transcript)
142
+ items = (data or {}).get("claims", [])
143
+ out: List[Claim] = []
144
+
145
  for i, c in enumerate(items):
146
  text = (c.get("text") or "").strip()
147
  if not text:
148
  continue
149
+ out.append(Claim(
150
  id=f"c{i}",
151
  text=text,
152
  speaker=c.get("speaker"),
153
+ segment_idx=0, # TODO: map to true segment via start/end if available
154
+ confidence=float(c.get("confidence", 0.6)),
155
  ))
156
+ return out
app/agents/ibm_client.py DELETED
@@ -1,108 +0,0 @@
1
- # app/agents/ibm_client.py
2
- import os, json, re, requests
3
- from typing import Dict, Any
4
- from app.core.config import WATSONX_BASE_URL, WATSONX_PROJECT
5
- from app.core.ibm_auth import ibm_iam_token # we wrote this earlier
6
-
7
- GEN_URL = f"{WATSONX_BASE_URL}/ml/v1/text/generation?version=2023-05-29"
8
- MODEL_ID = os.getenv("IBM_CLAIM_EXTRACTOR_MODEL", "ibm/granite-3-8b-instruct")
9
-
10
- # Few-shot, strict-JSON prompt. We’ll append the runtime transcript at the end.
11
- PROMPT_TEMPLATE = r"""
12
- You extract factual claims from messy spoken transcripts.
13
- Return strict JSON with this shape:
14
- {
15
- "claims": [
16
- {"text": str, "speaker": str|null, "start": float, "end": float, "confidence": float}
17
- ]
18
- }
19
-
20
- Guidelines:
21
- - A "claim" is a checkable factual assertion (metrics, quantities, time-bound facts).
22
- - Prefer sentences with numbers, percentages, dates, quantities, KPIs.
23
- - Split multiple claims in one sentence into separate objects.
24
- - If unsure about speaker or timestamps, set speaker=null and start/end=0.
25
- - Do NOT include opinions, greetings, or questions unless they state a checkable fact.
26
- - Output ONLY JSON. No prose.
27
-
28
- 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.
29
- Output: {
30
- "claims": [
31
- {"text":"We grew 40% quarter over quarter in Q2","speaker":null,"start":0.0,"end":0.0,"confidence":0.55},
32
- {"text":"Customer churn fell to 2%","speaker":null,"start":0.0,"end":0.0,"confidence":0.55},
33
- {"text":"Q2 growth was 12%","speaker":null,"start":0.0,"end":0.0,"confidence":0.7},
34
- {"text":"Churn stabilized at 4% in Q2","speaker":null,"start":0.0,"end":0.0,"confidence":0.7}
35
- ]
36
- }
37
-
38
- Input: We expanded into three new regions this year. Our operating margin improved by five points since Q1.
39
- Output: {
40
- "claims": [
41
- {"text":"We expanded into 3 new regions this year","speaker":null,"start":0.0,"end":0.0,"confidence":0.6},
42
- {"text":"Operating margin improved by 5 percentage points since Q1","speaker":null,"start":0.0,"end":0.0,"confidence":0.7}
43
- ]
44
- }
45
-
46
- Input: {TRANSCRIPT}
47
- Output:
48
- """.strip()
49
-
50
- def _build_payload(transcript: str) -> Dict[str, Any]:
51
- prompt = PROMPT_TEMPLATE.replace("{TRANSCRIPT}", transcript.strip())
52
- return {
53
- "input": prompt,
54
- "parameters": {
55
- "decoding_method": "greedy",
56
- "max_new_tokens": 200,
57
- "min_new_tokens": 0,
58
- "repetition_penalty": 1.0,
59
- # Stop when it tries to start another example
60
- "stop_sequences": ["\n\nInput:", "\nInput:"]
61
- },
62
- "model_id": MODEL_ID,
63
- "project_id": WATSONX_PROJECT,
64
- # Keep moderations minimal; you can re-enable if your org requires it
65
- "moderations": {
66
- "hap": {"input": {"enabled": False}, "output": {"enabled": False}},
67
- "pii": {"input": {"enabled": False}, "output": {"enabled": False}}
68
- }
69
- }
70
-
71
- def _post_generation(payload: Dict[str, Any]) -> str:
72
- headers = {
73
- "Accept": "application/json",
74
- "Content-Type": "application/json",
75
- "Authorization": f"Bearer {ibm_iam_token()}",
76
- }
77
- r = requests.post(GEN_URL, headers=headers, json=payload, timeout=90)
78
- if r.status_code != 200:
79
- raise RuntimeError(f"watsonx generation error: {r.status_code} {r.text}")
80
- data = r.json()
81
- # watsonx usually returns {"results":[{"generated_text":"..."}], ...}
82
- txt = ""
83
- if isinstance(data, dict) and "results" in data and data["results"]:
84
- txt = data["results"][0].get("generated_text", "")
85
- elif isinstance(data, dict) and "generated_text" in data:
86
- txt = data["generated_text"]
87
- return txt.strip()
88
-
89
- def _extract_json_block(text: str) -> Dict[str, Any]:
90
- """
91
- Try direct JSON parse; if that fails, find the first {...} block and parse it.
92
- """
93
- try:
94
- return json.loads(text)
95
- except Exception:
96
- pass
97
- m = re.search(r"\{.*\}", text, flags=re.DOTALL)
98
- if not m:
99
- return {"claims": []}
100
- try:
101
- return json.loads(m.group(0))
102
- except Exception:
103
- return {"claims": []}
104
-
105
- def run_claim_extractor(transcript: str) -> Dict[str, Any]:
106
- payload = _build_payload(transcript)
107
- out_text = _post_generation(payload)
108
- return _extract_json_block(out_text)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
app/agents/retriever.py CHANGED
@@ -4,55 +4,19 @@ import os, json, numpy as np, faiss, requests
4
 
5
  from app.schemas.claim import Claim
6
  from app.schemas.evidence import Evidence
7
- from app.core.config import WATSONX_BASE_URL as _BASE, WATSONX_PROJECT as _PROJECT, WATSONX_API_KEY as _APIKEY
8
-
9
- BASE_URL = (_BASE or "").rstrip("/") # avoid //ml
10
- PROJECT_ID = _PROJECT
11
- API_KEY = _APIKEY
12
- EMB_MODEL_ID = os.getenv("IBM_EMBEDDINGS_MODEL_ID", "").strip() # <-- REQUIRED
13
- RERANK_MODEL_ID = os.getenv("IBM_RERANK_MODEL_ID", "").strip() # optional
14
 
 
15
  IDX_DIR = "kb/index"
16
  IDX_PATH = f"{IDX_DIR}/kb.index"
17
  META_PATH = f"{IDX_DIR}/kb_meta.json"
18
  SNIPPETS = "kb/snippets.jsonl"
19
-
20
- # ---------- IBM helpers ----------
21
- _tok, _exp = None, 0
22
- def _ibm_token():
23
- import time
24
- global _tok, _exp
25
- now = time.time()
26
- if _tok and now < _exp - 60:
27
- return _tok
28
- r = requests.post(
29
- "https://iam.cloud.ibm.com/identity/token",
30
- data={
31
- "grant_type": "urn:ibm:params:oauth:grant-type:apikey",
32
- "apikey": API_KEY
33
- },
34
- headers={"Content-Type": "application/x-www-form-urlencoded"},
35
- timeout=30
36
- )
37
- r.raise_for_status()
38
- data = r.json(); _tok = data["access_token"]; _exp = now + 3000
39
- return _tok
40
-
41
- def _assert_ibm_ready():
42
- missing = []
43
- if not BASE_URL: missing.append("WATSONX_BASE_URL")
44
- if not PROJECT_ID: missing.append("WATSONX_PROJECT_ID")
45
- if not API_KEY: missing.append("WATSONX_API_KEY")
46
- if not EMB_MODEL_ID: missing.append("IBM_EMBEDDINGS_MODEL_ID")
47
- if missing:
48
- raise RuntimeError(f"IBM embeddings not configured. Missing: {', '.join(missing)}")
49
-
50
- VERSION = os.getenv("IBM_API_VERSION", "2023-05-29")
51
  BASE_URL = BASE_URL.rstrip("/")
52
 
53
  def _ibm_embed(texts: list[str]) -> np.ndarray:
54
  url = f"{BASE_URL}/ml/v1/text/embeddings?version={VERSION}"
55
- hdr = {"Authorization": f"Bearer {_ibm_token()}",
56
  "Accept": "application/json",
57
  "Content-Type": "application/json"}
58
  payload = {
@@ -91,7 +55,7 @@ def _ibm_rerank(query: str, docs: list[dict], top_n: int = 5) -> list[dict]:
91
  if not docs or not RERANK_MODEL_ID:
92
  return docs
93
  url = f"{BASE_URL}/ml/v1/text/rerank?version={VERSION}"
94
- hdr = {"Authorization": f"Bearer {_ibm_token()}",
95
  "Accept":"application/json","Content-Type":"application/json"}
96
  payload = {
97
  "input": {
@@ -118,6 +82,7 @@ def _ibm_rerank(query: str, docs: list[dict], top_n: int = 5) -> list[dict]:
118
 
119
  # ---------- Local embeddings fallback ----------
120
  _embedder = None
 
121
  def _local_embed(texts: list[str]) -> np.ndarray:
122
  global _embedder
123
  if _embedder is None:
 
4
 
5
  from app.schemas.claim import Claim
6
  from app.schemas.evidence import Evidence
7
+ from app.core.config import WATSONX_BASE_URL as BASE, WATSONX_PROJECT as PROJECT_ID, WATSONX_API_KEY as APIKEY, IBM_EMBEDDINGS_MODEL_ID as EMB_MODEL_ID, IBM_RERANK_MODEL_ID as RERANK_MODEL_ID, IBM_API_VERSION as VERSION, IBM_EMBEDDINGS_MODEL_ID as EMB_MODEL_ID, IBM_RERANK_MODEL_ID as RERANK_MODEL_ID, WATSONX_API_KEY as API_KEY
8
+ from app.utils.auth import get_ibm_iam_token
 
 
 
 
 
9
 
10
+ BASE_URL = (BASE or "").rstrip("/")
11
  IDX_DIR = "kb/index"
12
  IDX_PATH = f"{IDX_DIR}/kb.index"
13
  META_PATH = f"{IDX_DIR}/kb_meta.json"
14
  SNIPPETS = "kb/snippets.jsonl"
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
15
  BASE_URL = BASE_URL.rstrip("/")
16
 
17
  def _ibm_embed(texts: list[str]) -> np.ndarray:
18
  url = f"{BASE_URL}/ml/v1/text/embeddings?version={VERSION}"
19
+ hdr = {"Authorization": f"Bearer {get_ibm_iam_token()}",
20
  "Accept": "application/json",
21
  "Content-Type": "application/json"}
22
  payload = {
 
55
  if not docs or not RERANK_MODEL_ID:
56
  return docs
57
  url = f"{BASE_URL}/ml/v1/text/rerank?version={VERSION}"
58
+ hdr = {"Authorization": f"Bearer {get_ibm_iam_token()}",
59
  "Accept":"application/json","Content-Type":"application/json"}
60
  payload = {
61
  "input": {
 
82
 
83
  # ---------- Local embeddings fallback ----------
84
  _embedder = None
85
+
86
  def _local_embed(texts: list[str]) -> np.ndarray:
87
  global _embedder
88
  if _embedder is None:
app/agents/summarizer.py CHANGED
@@ -1,48 +1,81 @@
1
  # app/agents/summarizer.py
2
  from __future__ import annotations
3
- import os, json, requests, re
4
  from typing import List, Dict, Any
 
5
  from app.schemas.report import CallReport
6
  from app.schemas.claim import Claim
7
  from app.schemas.evidence import Evidence
8
  from app.schemas.verdict import Verdict
9
- from app.core.config import WATSONX_BASE_URL, WATSONX_PROJECT, WATSONX_API_KEY
10
- from app.core.json_utils import extract_json_obj
11
-
12
- VERSION = os.getenv("IBM_API_VERSION", "2023-05-29")
13
- MODEL_ID = os.getenv("IBM_SUMMARY_MODEL_ID", "ibm/granite-3-8b-instruct")
14
-
15
- def _iam_token() -> str:
16
- r = requests.post(
17
- "https://iam.cloud.ibm.com/identity/token",
18
- data={"grant_type":"urn:ibm:params:oauth:grant-type:apikey","apikey":WATSONX_API_KEY},
19
- headers={"Content-Type":"application/x-www-form-urlencoded"},
20
- timeout=30
21
- )
22
- r.raise_for_status()
23
- return r.json()["access_token"]
24
 
25
- _JSON = re.compile(r"\{[\s\S]*\}\s*$")
26
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
27
 
28
- # Replace _safe_json with:
29
- def _safe_json(s: str) -> dict:
30
- return extract_json_obj(s)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
31
 
32
- # ---- Prompt for structured output ----
33
  PROMPT = """You are a precise meeting summarizer for sales/stakeholder calls.
34
  Given transcript segments, normalized claims, and their verification labels, produce a concise executive summary.
35
 
36
  Return STRICT JSON only with:
37
  {
38
- "call_summary": "string, <= 6 sentences, neutral, factual",
39
- "action_items": ["string", "..."] // 1-6 bullets, imperative
40
  }
41
 
42
- Guidelines:
43
  - Emphasize mismatches between stated claims and evidence.
44
- - Mention concrete metrics (%, $, dates) when present.
45
- - Avoid fluff and opinions; be concise and factual.
 
 
 
46
 
47
  Segments (JSON):
48
  {SEGMENTS_JSON}
@@ -53,10 +86,12 @@ Claims (JSON):
53
  Verdicts (JSON):
54
  {VERDICTS_JSON}
55
 
56
- Now return JSON only:
57
  """
58
 
59
- # ---- Public API ----
 
 
60
  def make_report(
61
  segments: List[Dict[str, Any]],
62
  claims: List[Claim],
@@ -65,28 +100,43 @@ def make_report(
65
  ) -> CallReport:
66
  """
67
  Build a CallReport:
68
- - call_summary + action_items from IBM LLM
69
- - claim_table from verdicts (+ best evidence id)
70
  """
71
- # ---- Prepare claim_table from verdicts
72
- # Map claim_id -> claim text
73
  id2claim = {c.id: c.text for c in claims}
74
  claim_table: List[Dict[str, Any]] = []
75
  for v in verdicts:
76
  claim_text = id2claim.get(v.claim_id, "")
77
- row = {
78
  "claim": claim_text,
79
  "status": v.label.capitalize(),
80
  "evidence_source": v.best_evidence_id or ""
81
- }
82
- claim_table.append(row)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
83
 
84
- # ---- Try IBM Granite for summary/action items
85
  call_summary = ""
86
  action_items: List[str] = []
 
87
  try:
88
- tok = _iam_token()
89
- url = f"{WATSONX_BASE_URL.rstrip('/')}/ml/v1/text/generation?version={VERSION}"
90
  headers = {
91
  "Authorization": f"Bearer {tok}",
92
  "Accept": "application/json",
@@ -94,7 +144,8 @@ def make_report(
94
  }
95
  body = {
96
  "input": PROMPT \
97
- .replace("{SEGMENTS_JSON}", json.dumps(segments, ensure_ascii=False)) \
 
98
  .replace("{CLAIMS_JSON}", json.dumps([{"id": c.id, "text": c.text} for c in claims], ensure_ascii=False)) \
99
  .replace("{VERDICTS_JSON}", json.dumps([{
100
  "claim_id": v.claim_id,
@@ -108,31 +159,40 @@ def make_report(
108
  "parameters": {
109
  "decoding_method": "greedy",
110
  "temperature": 0.0,
111
- "max_new_tokens": 300,
112
  "min_new_tokens": 0,
113
- "repetition_penalty": 1.0
 
114
  }
115
  }
116
- r = requests.post(url, headers=headers, json=body, timeout=120)
117
- r.raise_for_status()
118
- out = r.json()
119
- gen = (out.get("results") or [{}])[0].get("generated_text", "")
120
- parsed = _safe_json(gen)
121
- call_summary = (parsed.get("call_summary") or "").strip()
122
- action_items = parsed.get("action_items") or []
 
 
 
 
 
 
 
 
 
123
  if not isinstance(action_items, list):
124
  action_items = [str(action_items)]
 
125
  except Exception as e:
126
- # Fallback: extract a terse summary from first few segments
127
  print(f"[summarizer] watsonx generation failed: {e}")
128
- texts = [s.get("text","") for s in segments if s.get("text")]
129
  joined = " ".join(texts)[:450].strip()
130
  call_summary = (joined + "…") if joined else ""
131
- if not action_items:
132
- action_items = ["Review claims vs. evidence and confirm metrics in source-of-truth."]
133
 
134
- # ---- Build CallReport dataclass (schema)
135
- report = CallReport(
136
  call_summary=call_summary,
137
  claim_table=claim_table,
138
  action_items=action_items,
@@ -140,4 +200,3 @@ def make_report(
140
  verdicts=verdicts,
141
  evidence=evidence_flat
142
  )
143
- return report
 
1
  # app/agents/summarizer.py
2
  from __future__ import annotations
3
+ import os, json, time, requests
4
  from typing import List, Dict, Any
5
+
6
  from app.schemas.report import CallReport
7
  from app.schemas.claim import Claim
8
  from app.schemas.evidence import Evidence
9
  from app.schemas.verdict import Verdict
10
+ from app.core.config import (
11
+ WATSONX_BASE_URL,
12
+ WATSONX_PROJECT,
13
+ IBM_SUMMARY_MODEL_ID as MODEL_ID,
14
+ IBM_API_VERSION,
15
+ )
16
+ from app.utils.auth import get_ibm_iam_token
17
+ from app.utils.parse_json import parse_json_anywhere
 
 
 
 
 
 
 
18
 
 
19
 
20
+ # -------- Helpers --------
21
+
22
+ def _compact_segments(segments: List[Dict[str, Any]], max_chars: int = 6000) -> List[Dict[str, Any]]:
23
+ """
24
+ Keep the last ~N characters of transcript text to stay under token limits,
25
+ preserving structure (start, end, speaker, text).
26
+ """
27
+ if not segments:
28
+ return []
29
+ # Prefer the tail of the conversation (most recent context is more salient)
30
+ out = []
31
+ total = 0
32
+ for seg in reversed(segments):
33
+ t = seg.get("text", "") or ""
34
+ total += len(t)
35
+ out.append({"start": seg.get("start", 0.0),
36
+ "end": seg.get("end", 0.0),
37
+ "speaker": seg.get("speaker"),
38
+ "text": t})
39
+ if total >= max_chars:
40
+ break
41
+ return list(reversed(out))
42
 
43
+ def _verdict_stats(verdicts: List[Verdict]) -> Dict[str, int]:
44
+ s = sum(1 for v in verdicts if v.label == "supported")
45
+ r = sum(1 for v in verdicts if v.label == "refuted")
46
+ i = sum(1 for v in verdicts if v.label == "insufficient")
47
+ return {"supported": s, "refuted": r, "insufficient": i, "total": len(verdicts)}
48
+
49
+ def _http_post_with_retry(url: str, headers: dict, body: dict, timeout: int = 120, tries: int = 4, backoff: float = 1.5):
50
+ for attempt in range(tries):
51
+ r = requests.post(url, headers=headers, json=body, timeout=timeout)
52
+ if r.status_code not in (429, 500, 502, 503, 504):
53
+ r.raise_for_status()
54
+ return r
55
+ time.sleep(backoff * (2 ** attempt))
56
+ # last try result:
57
+ r.raise_for_status()
58
+ return r
59
+
60
+
61
+ # -------- Prompt (kept tight & structured) --------
62
 
 
63
  PROMPT = """You are a precise meeting summarizer for sales/stakeholder calls.
64
  Given transcript segments, normalized claims, and their verification labels, produce a concise executive summary.
65
 
66
  Return STRICT JSON only with:
67
  {
68
+ "call_summary": "string (<= 6 sentences, neutral, factual, cites concrete numbers/dates/KPIs when present)",
69
+ "action_items": ["string", "..."] // 1-6 imperative bullets; each starts with a verb
70
  }
71
 
72
+ Guidance:
73
  - Emphasize mismatches between stated claims and evidence.
74
+ - Prioritize concrete, verifiable metrics (%, $, dates, counts).
75
+ - Be concise; avoid fluff and opinions.
76
+
77
+ Context stats (for your awareness; do not invent numbers):
78
+ {VERDICT_STATS}
79
 
80
  Segments (JSON):
81
  {SEGMENTS_JSON}
 
86
  Verdicts (JSON):
87
  {VERDICTS_JSON}
88
 
89
+ Output JSON only (no extra text, no markdown, no backticks):
90
  """
91
 
92
+
93
+ # -------- Public API --------
94
+
95
  def make_report(
96
  segments: List[Dict[str, Any]],
97
  claims: List[Claim],
 
100
  ) -> CallReport:
101
  """
102
  Build a CallReport:
103
+ - call_summary + action_items from IBM LLM (robust parse)
104
+ - claim_table derived from verdicts (+ best evidence id)
105
  """
106
+
107
+ # 1) Build claim table from verdicts (always succeeds)
108
  id2claim = {c.id: c.text for c in claims}
109
  claim_table: List[Dict[str, Any]] = []
110
  for v in verdicts:
111
  claim_text = id2claim.get(v.claim_id, "")
112
+ claim_table.append({
113
  "claim": claim_text,
114
  "status": v.label.capitalize(),
115
  "evidence_source": v.best_evidence_id or ""
116
+ })
117
+
118
+ # 2) Short-circuit if we have no content to summarize
119
+ if not segments and not claims:
120
+ return CallReport(
121
+ call_summary="",
122
+ claim_table=claim_table,
123
+ action_items=["Review claims vs. evidence and confirm metrics in source-of-truth."],
124
+ claims=claims,
125
+ verdicts=verdicts,
126
+ evidence=evidence_flat
127
+ )
128
+
129
+ # 3) Prepare compact context + stats
130
+ compact = _compact_segments(segments, max_chars=6000)
131
+ stats = _verdict_stats(verdicts)
132
 
133
+ # 4) Call IBM Granite (watsonx) for structured summary
134
  call_summary = ""
135
  action_items: List[str] = []
136
+
137
  try:
138
+ tok = get_ibm_iam_token()
139
+ url = f"{WATSONX_BASE_URL.rstrip('/')}/ml/v1/text/generation?version={IBM_API_VERSION}"
140
  headers = {
141
  "Authorization": f"Bearer {tok}",
142
  "Accept": "application/json",
 
144
  }
145
  body = {
146
  "input": PROMPT \
147
+ .replace("{VERDICT_STATS}", json.dumps(stats, ensure_ascii=False)) \
148
+ .replace("{SEGMENTS_JSON}", json.dumps(compact, ensure_ascii=False)) \
149
  .replace("{CLAIMS_JSON}", json.dumps([{"id": c.id, "text": c.text} for c in claims], ensure_ascii=False)) \
150
  .replace("{VERDICTS_JSON}", json.dumps([{
151
  "claim_id": v.claim_id,
 
159
  "parameters": {
160
  "decoding_method": "greedy",
161
  "temperature": 0.0,
162
+ "max_new_tokens": 400,
163
  "min_new_tokens": 0,
164
+ "repetition_penalty": 1.0,
165
+ "stop_sequences": ["\n\n", "\nOutput JSON", "\nSegments (JSON):"]
166
  }
167
  }
168
+
169
+ resp = _http_post_with_retry(url, headers, body, timeout=120)
170
+ out = resp.json()
171
+ gen = (out.get("results") or [{}])[0].get("generated_text", "") or ""
172
+
173
+ # Robust parse (accepts full JSON, partials, or multiple JSON objects)
174
+ parsed = parse_json_anywhere(gen, root_key=None) # expecting a single dict with keys above
175
+ if isinstance(parsed, dict):
176
+ call_summary = (parsed.get("call_summary") or "").strip()
177
+ action_items = parsed.get("action_items") or []
178
+ else:
179
+ # If parse returns a list (rare), try first dict
180
+ if parsed and isinstance(parsed, list) and isinstance(parsed[0], dict):
181
+ call_summary = (parsed[0].get("call_summary") or "").strip()
182
+ action_items = parsed[0].get("action_items") or []
183
+
184
  if not isinstance(action_items, list):
185
  action_items = [str(action_items)]
186
+
187
  except Exception as e:
188
+ # 5) Fallback: build a terse summary from first few segments and stats
189
  print(f"[summarizer] watsonx generation failed: {e}")
190
+ texts = [s.get("text","") for s in compact if s.get("text")]
191
  joined = " ".join(texts)[:450].strip()
192
  call_summary = (joined + "…") if joined else ""
 
 
193
 
194
+ # 6) Return CallReport
195
+ return CallReport(
196
  call_summary=call_summary,
197
  claim_table=claim_table,
198
  action_items=action_items,
 
200
  verdicts=verdicts,
201
  evidence=evidence_flat
202
  )
 
app/agents/verifier.py CHANGED
@@ -1,32 +1,15 @@
1
  # app/agents/verifier.py
2
  from __future__ import annotations
3
- import os, json, re, requests
4
  from typing import List, Dict
 
5
  from app.schemas.claim import Claim
6
  from app.schemas.evidence import Evidence
7
  from app.schemas.verdict import Verdict
8
- from app.core.config import WATSONX_BASE_URL, WATSONX_PROJECT, WATSONX_API_KEY
9
- from app.core.json_utils import extract_json_obj
10
-
11
-
12
- VERSION = os.getenv("IBM_API_VERSION", "2023-05-29")
13
- MODEL_ID = os.getenv("IBM_VERIFIER_MODEL_ID", "ibm/granite-3-8b-instruct")
14
- # ---- IAM helper ----
15
- def _iam_token() -> str:
16
- r = requests.post(
17
- "https://iam.cloud.ibm.com/identity/token",
18
- data={
19
- "grant_type": "urn:ibm:params:oauth:grant-type:apikey",
20
- "apikey": WATSONX_API_KEY,
21
- },
22
- headers={"Content-Type": "application/x-www-form-urlencoded"},
23
- timeout=30,
24
- )
25
- r.raise_for_status()
26
- return r.json()["access_token"]
27
 
28
-
29
- # Strengthen the prompt header:
30
  PROMPT = """You are a precise fact verifier.
31
  Return STRICT JSON ONLY. Your first character MUST be '{' and your last character MUST be '}'.
32
  Schema:
@@ -53,44 +36,63 @@ Evidence catalog (doc_id -> snippet) as JSON:
53
  Output JSON:
54
  """
55
 
56
- # Replace _safe_json with:
57
- def _safe_json(s: str) -> dict:
58
- return extract_json_obj(s)
59
-
60
- # In _post_generation() keep as-is, but add debug preview if parse fails:
61
- def _post_generation(prompt: str) -> dict:
62
- url = f"{WATSONX_BASE_URL.rstrip('/')}/ml/v1/text/generation?version={VERSION}"
63
- tok = _iam_token()
64
  headers = {
65
  "Authorization": f"Bearer {tok}",
66
  "Accept": "application/json",
67
  "Content-Type": "application/json",
68
  }
 
 
 
 
 
 
 
 
 
69
  body = {
70
  "input": prompt,
71
- "model_id": MODEL_ID,
72
  "project_id": WATSONX_PROJECT,
73
  "parameters": {
74
  "decoding_method": "greedy",
75
- "max_new_tokens": 400,
76
  "min_new_tokens": 0,
77
  "repetition_penalty": 1.0,
78
  "temperature": 0.0,
79
  },
80
  }
81
- r = requests.post(url, headers=headers, json=body, timeout=120)
82
- r.raise_for_status()
83
- out = r.json()
84
- results = out.get("results") or []
85
- text = (results[0] or {}).get("generated_text", "") if results else ""
86
- if not text:
87
- raise RuntimeError(f"Empty generation response: {out}")
88
- try:
89
- return _safe_json(text)
90
- except Exception as e:
91
- print("[verifier] RAW OUTPUT >>>", text[:1000])
92
- raise
93
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
94
 
95
  def verify(claims: List[Claim], evidence_map: Dict[str, List[Evidence]]) -> List[Verdict]:
96
  """
@@ -98,25 +100,27 @@ def verify(claims: List[Claim], evidence_map: Dict[str, List[Evidence]]) -> List
98
  evidence_map: claim_id -> List[Evidence] (must have .doc_id, .snippet)
99
  returns: List[Verdict]
100
  """
101
- # 1) Flatten evidence to a doc_id -> snippet map (only those we surfaced)
102
  doc_catalog: Dict[str, str] = {}
103
  for lst in evidence_map.values():
104
  for e in lst:
105
- # Don't overwrite if duplicate doc_ids appear across claims
106
  doc_catalog.setdefault(e.doc_id, e.snippet)
107
 
108
  # 2) Minimal claims JSON for the LLM
109
  claims_json = [{"id": c.id, "text": c.text} for c in claims]
110
 
111
- # 3) Render prompt
112
- prompt = PROMPT.replace("{CLAIMS_JSON}", json.dumps(claims_json, ensure_ascii=False)) \
113
- .replace("{EVIDENCE_JSON}", json.dumps(doc_catalog, ensure_ascii=False))
 
 
 
114
 
115
- # 4) Call watsonx
116
  try:
117
  parsed = _post_generation(prompt)
118
  except Exception as e:
119
- # Fallback: everything "insufficient"
120
  print(f"[verifier] generation failed: {e}")
121
  return [
122
  Verdict(
@@ -125,45 +129,40 @@ def verify(claims: List[Claim], evidence_map: Dict[str, List[Evidence]]) -> List
125
  confidence=0.4,
126
  best_evidence_id="",
127
  rationale="Verifier offline; defaulting to insufficient."
128
- ) for c in claims
 
129
  ]
130
 
131
- # 5) Validate + convert to Verdict[]
132
- out: List[Verdict] = []
133
  allowed = {"supported", "refuted", "insufficient"}
134
- items = parsed.get("verdicts", [])
135
- # Build a quick lookup of top evidence per claim (by retriever score) as tiebreaker
 
136
  top_ev: Dict[str, str] = {}
137
  for c in claims:
138
  evs = evidence_map.get(c.id, [])
139
  best = max(evs, key=lambda e: e.score, default=None)
140
  top_ev[c.id] = best.doc_id if best else ""
141
 
 
142
  for it in items:
143
  cid = it.get("claim_id", "")
144
  label = (it.get("label") or "").lower()
145
  conf = float(it.get("confidence", 0.5))
146
  cites = it.get("citation_ids") or []
147
- rationale = it.get("rationale") or ""
148
-
149
  if label not in allowed:
150
  label = "insufficient"
151
 
152
- # Choose best_evidence_id:
153
- best_id = ""
154
- for d in cites:
155
- if d in doc_catalog:
156
- best_id = d
157
- break
158
- if not best_id:
159
- best_id = top_ev.get(cid, "")
160
 
161
  out.append(Verdict(
162
  claim_id=cid,
163
  label=label,
164
  confidence=conf,
165
  best_evidence_id=best_id,
166
- rationale=rationale[:300]
167
  ))
168
 
169
  # Ensure every claim has a verdict
 
1
  # app/agents/verifier.py
2
  from __future__ import annotations
3
+ import os, json, requests
4
  from typing import List, Dict
5
+
6
  from app.schemas.claim import Claim
7
  from app.schemas.evidence import Evidence
8
  from app.schemas.verdict import Verdict
9
+ from app.core.config import WATSONX_BASE_URL, WATSONX_PROJECT, IBM_VERIFIER_MODEL_ID, IBM_API_VERSION
10
+ from app.utils.auth import get_ibm_iam_token
11
+ from app.utils.parse_json import parse_json_anywhere
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
12
 
 
 
13
  PROMPT = """You are a precise fact verifier.
14
  Return STRICT JSON ONLY. Your first character MUST be '{' and your last character MUST be '}'.
15
  Schema:
 
36
  Output JSON:
37
  """
38
 
39
+ def _gen(url: str, body: dict, timeout: int = 120) -> str:
40
+ """Low-level call to watsonx text/generation; returns raw model text."""
41
+ tok = get_ibm_iam_token()
 
 
 
 
 
42
  headers = {
43
  "Authorization": f"Bearer {tok}",
44
  "Accept": "application/json",
45
  "Content-Type": "application/json",
46
  }
47
+ r = requests.post(url, headers=headers, json=body, timeout=timeout)
48
+ r.raise_for_status()
49
+ j = r.json()
50
+ res = j.get("results") or []
51
+ return (res[0].get("generated_text") if res else "") or ""
52
+
53
+ def _post_generation(prompt: str) -> dict:
54
+ """Call model → parse with parse_json_anywhere(root='verdicts') → repair once if needed."""
55
+ url = f"{WATSONX_BASE_URL.rstrip('/')}/ml/v1/text/generation?version={IBM_API_VERSION}"
56
  body = {
57
  "input": prompt,
58
+ "model_id": IBM_VERIFIER_MODEL_ID,
59
  "project_id": WATSONX_PROJECT,
60
  "parameters": {
61
  "decoding_method": "greedy",
62
+ "max_new_tokens": 600,
63
  "min_new_tokens": 0,
64
  "repetition_penalty": 1.0,
65
  "temperature": 0.0,
66
  },
67
  }
 
 
 
 
 
 
 
 
 
 
 
 
68
 
69
+ text = _gen(url, body)
70
+ parsed = parse_json_anywhere(text, root_key="verdicts")
71
+ if parsed and parsed.get("verdicts"):
72
+ return parsed
73
+
74
+ # One-shot repair: coerce to strict JSON with 'verdicts' root
75
+ repair_body = {
76
+ "input": (
77
+ "Return ONLY valid JSON object with root key 'verdicts' "
78
+ "(no prose, no markdown). If invalid, fix and output JSON:\n\n" + text
79
+ ),
80
+ "model_id": IBM_VERIFIER_MODEL_ID,
81
+ "project_id": WATSONX_PROJECT,
82
+ "parameters": {
83
+ "decoding_method": "greedy",
84
+ "max_new_tokens": 400,
85
+ "temperature": 0.0,
86
+ },
87
+ }
88
+ repaired = _gen(url, repair_body)
89
+ reparsed = parse_json_anywhere(repaired, root_key="verdicts")
90
+ if reparsed and reparsed.get("verdicts"):
91
+ return reparsed
92
+
93
+ # Debug preview if still not parsable
94
+ print("[verifier] RAW OUTPUT >>>", (text or repaired)[:1000])
95
+ return {"verdicts": []}
96
 
97
  def verify(claims: List[Claim], evidence_map: Dict[str, List[Evidence]]) -> List[Verdict]:
98
  """
 
100
  evidence_map: claim_id -> List[Evidence] (must have .doc_id, .snippet)
101
  returns: List[Verdict]
102
  """
103
+ # 1) Flatten evidence to a doc_id -> snippet catalog
104
  doc_catalog: Dict[str, str] = {}
105
  for lst in evidence_map.values():
106
  for e in lst:
 
107
  doc_catalog.setdefault(e.doc_id, e.snippet)
108
 
109
  # 2) Minimal claims JSON for the LLM
110
  claims_json = [{"id": c.id, "text": c.text} for c in claims]
111
 
112
+ # 3) Build prompt
113
+ prompt = (
114
+ PROMPT
115
+ .replace("{CLAIMS_JSON}", json.dumps(claims_json, ensure_ascii=False))
116
+ .replace("{EVIDENCE_JSON}", json.dumps(doc_catalog, ensure_ascii=False))
117
+ )
118
 
119
+ # 4) Call model + robust parse
120
  try:
121
  parsed = _post_generation(prompt)
122
  except Exception as e:
123
+ # Fail-safe: mark all as insufficient
124
  print(f"[verifier] generation failed: {e}")
125
  return [
126
  Verdict(
 
129
  confidence=0.4,
130
  best_evidence_id="",
131
  rationale="Verifier offline; defaulting to insufficient."
132
+ )
133
+ for c in claims
134
  ]
135
 
136
+ # 5) Convert to Verdict[]
 
137
  allowed = {"supported", "refuted", "insufficient"}
138
+ items = parsed.get("verdicts", []) or []
139
+
140
+ # Tiebreaker: top retrieved evidence per claim
141
  top_ev: Dict[str, str] = {}
142
  for c in claims:
143
  evs = evidence_map.get(c.id, [])
144
  best = max(evs, key=lambda e: e.score, default=None)
145
  top_ev[c.id] = best.doc_id if best else ""
146
 
147
+ out: List[Verdict] = []
148
  for it in items:
149
  cid = it.get("claim_id", "")
150
  label = (it.get("label") or "").lower()
151
  conf = float(it.get("confidence", 0.5))
152
  cites = it.get("citation_ids") or []
153
+ rationale = (it.get("rationale") or "")[:300]
 
154
  if label not in allowed:
155
  label = "insufficient"
156
 
157
+ # choose best_evidence_id from cited doc_ids or fallback to top_ev
158
+ best_id = next((d for d in cites if d in doc_catalog), "") or top_ev.get(cid, "")
 
 
 
 
 
 
159
 
160
  out.append(Verdict(
161
  claim_id=cid,
162
  label=label,
163
  confidence=conf,
164
  best_evidence_id=best_id,
165
+ rationale=rationale
166
  ))
167
 
168
  # Ensure every claim has a verdict
app/core/config.py CHANGED
@@ -9,3 +9,9 @@ WHISPER_MODEL_SIZE = os.getenv("WHISPER_MODEL_SIZE", "")
9
  WATSONX_BASE_URL = os.getenv("WATSONX_BASE_URL", "")
10
  WATSONX_PROJECT = os.getenv("WATSONX_PROJECT_ID", "")
11
  WATSONX_API_KEY = os.getenv("WATSONX_API_KEY", "")
 
 
 
 
 
 
 
9
  WATSONX_BASE_URL = os.getenv("WATSONX_BASE_URL", "")
10
  WATSONX_PROJECT = os.getenv("WATSONX_PROJECT_ID", "")
11
  WATSONX_API_KEY = os.getenv("WATSONX_API_KEY", "")
12
+ IBM_API_VERSION = os.getenv("IBM_API_VERSION", "")
13
+ IBM_EMBEDDINGS_MODEL_ID = os.getenv("IBM_EMBEDDINGS_MODEL_ID", "")
14
+ IBM_RERANK_MODEL_ID = os.getenv("IBM_RERANK_MODEL_ID", "")
15
+ IBM_CLAIM_MODEL_ID = os.getenv("IBM_CLAIM_MODEL_ID", "")
16
+ IBM_VERIFIER_MODEL_ID = os.getenv("IBM_VERIFIER_MODEL_ID", "")
17
+ IBM_SUMMARY_MODEL_ID = os.getenv("IBM_SUMMARY_MODEL_ID", "")
app/core/ibm_auth.py DELETED
@@ -1,20 +0,0 @@
1
- # app/core/ibm_auth.py
2
- import requests, time, os
3
- from app.core.config import WATSONX_API_KEY
4
-
5
- _iam = {"tok": None, "exp": 0}
6
-
7
- def ibm_iam_token():
8
- now = time.time()
9
- if _iam["tok"] and now < _iam["exp"] - 60:
10
- return _iam["tok"]
11
- r = requests.post(
12
- "https://iam.cloud.ibm.com/identity/token",
13
- data={"grant_type":"urn:ibm:params:oauth:grant-type:apikey","apikey":WATSONX_API_KEY},
14
- headers={"Content-Type":"application/x-www-form-urlencoded"}
15
- )
16
- r.raise_for_status()
17
- tok = r.json()["access_token"]
18
- _iam["tok"] = tok
19
- _iam["exp"] = now + 3000 # ~50 min
20
- return tok
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
app/core/ibm_sanity.py CHANGED
@@ -1,24 +1,16 @@
1
  # app/core/ibm_sanity.py
2
  import os, requests, json, numpy as np
3
  from app.core.config import WATSONX_BASE_URL, WATSONX_PROJECT, WATSONX_API_KEY
 
4
 
5
  VERSION = os.getenv("IBM_API_VERSION", "2023-05-29")
6
  EMB = os.getenv("IBM_EMBEDDINGS_MODEL_ID", "")
7
  CLAIM = os.getenv("IBM_CLAIM_MODEL_ID", "")
8
  VERIFY = os.getenv("IBM_VERIFIER_MODEL_ID", "")
9
 
10
- def _iam_token() -> str:
11
- r = requests.post(
12
- "https://iam.cloud.ibm.com/identity/token",
13
- data={"grant_type":"urn:ibm:params:oauth:grant-type:apikey","apikey":WATSONX_API_KEY},
14
- headers={"Content-Type":"application/x-www-form-urlencoded"},
15
- timeout=30
16
- )
17
- r.raise_for_status()
18
- return r.json()["access_token"]
19
 
20
  def sanity_embeddings():
21
- tok = _iam_token()
22
  r = requests.post(
23
  f"{WATSONX_BASE_URL.rstrip('/')}/ml/v1/text/embeddings?version={VERSION}",
24
  headers={"Authorization":f"Bearer {tok}","Accept":"application/json","Content-Type":"application/json"},
@@ -28,11 +20,12 @@ def sanity_embeddings():
28
  r.raise_for_status()
29
  j = r.json()
30
  items = j.get("data") or j.get("results") or []
31
- dim = len(items[0]["embedding"])
32
  return {"ok": True, "dim": dim}
33
 
 
34
  def sanity_generation(model_id: str, prompt: str):
35
- tok = _iam_token()
36
  r = requests.post(
37
  f"{WATSONX_BASE_URL.rstrip('/')}/ml/v1/text/generation?version={VERSION}",
38
  headers={"Authorization":f"Bearer {tok}","Accept":"application/json","Content-Type":"application/json"},
 
1
  # app/core/ibm_sanity.py
2
  import os, requests, json, numpy as np
3
  from app.core.config import WATSONX_BASE_URL, WATSONX_PROJECT, WATSONX_API_KEY
4
+ from app.utils.auth import get_ibm_iam_token
5
 
6
  VERSION = os.getenv("IBM_API_VERSION", "2023-05-29")
7
  EMB = os.getenv("IBM_EMBEDDINGS_MODEL_ID", "")
8
  CLAIM = os.getenv("IBM_CLAIM_MODEL_ID", "")
9
  VERIFY = os.getenv("IBM_VERIFIER_MODEL_ID", "")
10
 
 
 
 
 
 
 
 
 
 
11
 
12
  def sanity_embeddings():
13
+ tok = get_ibm_iam_token()
14
  r = requests.post(
15
  f"{WATSONX_BASE_URL.rstrip('/')}/ml/v1/text/embeddings?version={VERSION}",
16
  headers={"Authorization":f"Bearer {tok}","Accept":"application/json","Content-Type":"application/json"},
 
20
  r.raise_for_status()
21
  j = r.json()
22
  items = j.get("data") or j.get("results") or []
23
+ dim = len(items[0]["embedding"]) if items else 0
24
  return {"ok": True, "dim": dim}
25
 
26
+
27
  def sanity_generation(model_id: str, prompt: str):
28
+ tok = get_ibm_iam_token()
29
  r = requests.post(
30
  f"{WATSONX_BASE_URL.rstrip('/')}/ml/v1/text/generation?version={VERSION}",
31
  headers={"Authorization":f"Bearer {tok}","Accept":"application/json","Content-Type":"application/json"},
app/core/json_utils.py DELETED
@@ -1,48 +0,0 @@
1
- # app/core/json_utils.py
2
- from __future__ import annotations
3
- import json, re
4
-
5
- # Fast path: last {...} block
6
- _LAST_JSON = re.compile(r"\{[\s\S]*\}\s*$")
7
-
8
- def extract_json_obj(text: str) -> dict:
9
- s = text.strip()
10
- # 1) Try last-JSON regex (often enough)
11
- m = _LAST_JSON.search(s)
12
- if m:
13
- try:
14
- return json.loads(m.group(0))
15
- except Exception:
16
- pass
17
-
18
- # 2) Balanced-brace scan: find the largest valid JSON object
19
- best = None
20
- stack = 0
21
- start = None
22
- for i, ch in enumerate(s):
23
- if ch == '{':
24
- if stack == 0:
25
- start = i
26
- stack += 1
27
- elif ch == '}':
28
- if stack > 0:
29
- stack -= 1
30
- if stack == 0 and start is not None:
31
- candidate = s[start:i+1]
32
- try:
33
- obj = json.loads(candidate)
34
- best = obj # keep last valid (usually the largest)
35
- except Exception:
36
- pass
37
- if best is not None:
38
- return best
39
-
40
- # 3) Try to strip common wrappers like code fences
41
- s2 = s.strip("` \n\t")
42
- try:
43
- return json.loads(s2)
44
- except Exception:
45
- pass
46
-
47
- # 4) Give up with a readable error
48
- raise ValueError("Could not extract JSON object from model text")
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
app/utils/auth.py ADDED
@@ -0,0 +1,29 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import time
2
+ import requests
3
+ from app.core.config import WATSONX_API_KEY
4
+
5
+
6
+ _iam_cache = {"token": None, "expiry": 0.0}
7
+
8
+
9
+ def get_ibm_iam_token() -> str:
10
+ now = time.time()
11
+ token = _iam_cache.get("token")
12
+ expiry = _iam_cache.get("expiry", 0.0)
13
+ if token and now < (expiry - 60):
14
+ return token
15
+
16
+ response = requests.post(
17
+ "https://iam.cloud.ibm.com/identity/token",
18
+ data={
19
+ "grant_type": "urn:ibm:params:oauth:grant-type:apikey",
20
+ "apikey": WATSONX_API_KEY,
21
+ },
22
+ headers={"Content-Type": "application/x-www-form-urlencoded"},
23
+ timeout=30,
24
+ )
25
+ response.raise_for_status()
26
+ token = response.json()["access_token"]
27
+ _iam_cache["token"] = token
28
+ _iam_cache["expiry"] = now + 3000 # ~50 minutes
29
+ return token
app/utils/parse_json.py ADDED
@@ -0,0 +1,172 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import json
2
+ import re
3
+ from typing import Any, Optional, Union
4
+
5
+
6
+ def _strip_trailing_commas(s: str) -> str:
7
+ """Remove trailing commas before ] or }"""
8
+ return re.sub(r',\s*(\]|\})', r'\1', s)
9
+
10
+
11
+ def _balance_brackets(s: str) -> str:
12
+ """Balance unclosed braces/brackets for incomplete JSON."""
13
+ opens = {"{": "}", "[": "]"}
14
+ stack = []
15
+ for ch in s:
16
+ if ch in opens:
17
+ stack.append(opens[ch])
18
+ elif ch in opens.values() and stack and ch == stack[-1]:
19
+ stack.pop()
20
+ return s + "".join(reversed(stack))
21
+
22
+
23
+ def _find_json_like(text: str) -> Optional[str]:
24
+ """
25
+ Finds the first plausible JSON substring in the text.
26
+ Can match dict `{...}` or list `[...]`.
27
+ """
28
+ m = re.search(r'(\{|\[)', text)
29
+ if not m:
30
+ return None
31
+ start = m.start()
32
+
33
+ # Try to find last closing bracket
34
+ last_brace = max(text.rfind("}"), text.rfind("]"))
35
+ if last_brace == -1:
36
+ last_brace = len(text)
37
+
38
+ candidate = text[start:last_brace + 1]
39
+ return candidate.strip()
40
+
41
+
42
+ def parse_json_anywhere(
43
+ text: str,
44
+ root_key: Optional[str] = None
45
+ ) -> Union[dict, list, None]:
46
+ """
47
+ Universal JSON parser for LLM output.
48
+ - Accepts JSON object or array
49
+ - Repairs common LLM formatting issues
50
+ - Optionally extracts by `root_key`
51
+ - Handles incomplete JSON (unbalanced braces/brackets)
52
+
53
+ Args:
54
+ text: raw LLM output
55
+ root_key: if provided, returns only the value at that key
56
+ (works even if wrapped in an array)
57
+
58
+ Returns:
59
+ Parsed JSON object, list, or None if all parsing fails
60
+ """
61
+ if not text:
62
+ return None
63
+
64
+ # 1) Direct parse attempt
65
+ try:
66
+ data = json.loads(text)
67
+ return _extract_root(data, root_key)
68
+ except Exception:
69
+ pass
70
+
71
+ # 2) Extract JSON-ish substring from text
72
+ candidate = _find_json_like(text)
73
+ if candidate:
74
+ # repair commas & bracket balance
75
+ repaired = _balance_brackets(_strip_trailing_commas(candidate))
76
+
77
+ for blob in (candidate, repaired):
78
+ try:
79
+ data = json.loads(blob)
80
+ return _extract_root(data, root_key)
81
+ except Exception:
82
+ continue
83
+
84
+ return None
85
+
86
+
87
+ def _extract_root(data: Any, root_key: Optional[str]) -> Any:
88
+ """Get data by root key if needed; handle array-wrapped objects."""
89
+ if not root_key:
90
+ return data
91
+
92
+ if isinstance(data, dict) and root_key in data:
93
+ return data
94
+ if isinstance(data, list):
95
+ for item in data:
96
+ if isinstance(item, dict) and root_key in item:
97
+ return item
98
+ return {root_key: []}
99
+
100
+
101
+ import re, json
102
+ from json import JSONDecoder
103
+ from typing import Any, Optional
104
+
105
+ _JSON_OBJ = re.compile(r'\{')
106
+
107
+ def iter_json_objects(s: str):
108
+ """Yield every JSON object found in a string."""
109
+ dec = JSONDecoder()
110
+ for m in _JSON_OBJ.finditer(s or ""):
111
+ try:
112
+ obj, _ = dec.raw_decode(s, m.start())
113
+ yield obj
114
+ except Exception:
115
+ continue
116
+
117
+ def strip_footers(text: str) -> str:
118
+ """Remove common model footnotes like '*Note:' or 'Note:'."""
119
+ lines = (text or "").splitlines()
120
+ clean = []
121
+ for ln in lines:
122
+ if ln.lstrip().lower().startswith(("*note", "note:")):
123
+ break
124
+ clean.append(ln)
125
+ return "\n".join(clean)
126
+
127
+ def merge_json_blocks(blocks: list[dict], root_key: str) -> dict:
128
+ """
129
+ Merge multiple dicts with the same list-valued root_key,
130
+ de-dupe by object identity (stringified) for now.
131
+ """
132
+ merged = []
133
+ seen = set()
134
+ for b in blocks:
135
+ vals = b.get(root_key) or []
136
+ if not isinstance(vals, list):
137
+ continue
138
+ for v in vals:
139
+ key = json.dumps(v, sort_keys=True)
140
+ if key not in seen:
141
+ merged.append(v)
142
+ seen.add(key)
143
+ return {root_key: merged}
144
+
145
+ def parse_json_anywhere(text: str, root_key: Optional[str] = None) -> dict[str, Any]:
146
+ """
147
+ Parse potentially messy LLM output into JSON. If root_key is given,
148
+ only keep JSON objects that have that key (list-valued).
149
+ """
150
+ if not text:
151
+ return {root_key or "": []}
152
+ text = strip_footers(text.strip())
153
+
154
+ # try naive load first
155
+ try:
156
+ obj = json.loads(text)
157
+ if not root_key or root_key in obj:
158
+ return obj
159
+ except Exception:
160
+ pass
161
+
162
+ blocks = []
163
+ for obj in iter_json_objects(text):
164
+ if not isinstance(obj, dict):
165
+ continue
166
+ if root_key and root_key not in obj:
167
+ continue
168
+ blocks.append(obj)
169
+
170
+ if not blocks:
171
+ return {root_key or "": []}
172
+ return merge_json_blocks(blocks, root_key) if root_key else blocks[0]
eval/eval.py DELETED
File without changes
eval/gold_claims.csv DELETED
File without changes
orchestrate/agent_graph.md DELETED
File without changes
orchestrate/tools.md DELETED
File without changes