stutiagrawal commited on
Commit
ca4ed58
·
0 Parent(s):

Initial commit

Browse files
.gitignore ADDED
@@ -0,0 +1,6 @@
 
 
 
 
 
 
 
1
+ .venv/
2
+ __pycache__/
3
+ *.pyc
4
+ .env
5
+ kb/index/
6
+
Makefile ADDED
File without changes
README.md ADDED
@@ -0,0 +1,145 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # ClaimCheck.AI — Agentic Fact Verification for Calls (IBM watsonx)
2
+
3
+ **ClaimCheck.AI** is an agentic AI pipeline that turns meeting audio (Zoom/phone) into an evidence-backed report:
4
+ 1) **ASR Agent (IBM STT)** → transcript + timestamps
5
+ 2) **Claim Extraction (watsonx.ai LLM)** → JSON claims
6
+ 3) **Evidence Retrieval (watsonx.ai Embeddings + FAISS + optional Rerank)** → KB hits
7
+ 4) **Verification (watsonx.ai LLM)** → supported/refuted/insufficient + citations
8
+ 5) **Summarizer (watsonx.ai LLM)** → executive summary + action items
9
+
10
+ ## ✨ Why it matters
11
+ High-stakes calls contain promises and metrics (SLA, compliance, finance). ClaimCheck.AI verifies statements against your **trusted KB** so decisions are grounded in facts—not memory.
12
+
13
+ ---
14
+
15
+ ## 🔧 Project structure
16
+
17
+ ```
18
+ claim-check/
19
+ ├─ app/
20
+ │ ├─ agents/
21
+ │ │ ├─ claims.py # Claim extractor (watsonx.ai Prompt Lab / LLM)
22
+ │ │ ├─ retriever.py # IBM embeddings + FAISS + optional rerank
23
+ │ │ ├─ verifier.py # LLM verdicts (supported/refuted/insufficient)
24
+ │ │ └─ summarizer.py # LLM executive summary + action items
25
+ │ ├─ core/
26
+ │ │ ├─ config.py # env wiring (IBM base url, project, keys)
27
+ │ │ └─ json_utils.py # robust JSON extraction from LLM outputs
28
+ │ ├─ schemas/ # pydantic models (Claim, Evidence, Verdict, CallReport)
29
+ │ ├─ services/
30
+ │ │ └─ asr.py # IBM Speech to Text or Whisper (fallback)
31
+ │ └─ main.py # FastAPI: /health, /process-audio, /process-transcript
32
+ ├─ kb/
33
+ │ ├─ snippets.jsonl # your knowledge base (facts; one JSON per line)
34
+ │ └─ index/ # FAISS index (auto-built)
35
+ ├─ data/audio/ # demo audio files
36
+ ├─ .env # local secrets (NOT committed)
37
+ ├─ .env.sample # template for env vars (safe to commit)
38
+ ├─ requirements.txt
39
+ └─ README.md
40
+ ```
41
+
42
+ ---
43
+
44
+ ## 🧪 Quick start
45
+
46
+ ### 1) Python env
47
+ ```bash
48
+ python -m venv .venv
49
+ source .venv/bin/activate
50
+ pip install -r requirements.txt
51
+ ```
52
+
53
+ ### 2) Configure IBM (edit `.env`)
54
+ Copy the sample and fill in values from your IBM Cloud / watsonx project.
55
+
56
+ ```bash
57
+ cp .env.sample .env
58
+ ```
59
+
60
+ **Required env keys:**
61
+ ```
62
+ # IBM Core
63
+ WATSONX_BASE_URL=https://us-south.ml.cloud.ibm.com
64
+ WATSONX_PROJECT_ID=<your-watsonx-project-id>
65
+ WATSONX_API_KEY=<your-ibm-cloud-api-key>
66
+ IBM_API_VERSION=2023-05-29
67
+
68
+ # Models
69
+ IBM_EMBEDDINGS_MODEL_ID=ibm/granite-embedding-107m-multilingual # 384-dim
70
+ IBM_RERANK_MODEL_ID=ibm/slate-30m-english-rtrvr-v2 # optional
71
+ IBM_VERIFIER_MODEL_ID=ibm/granite-3-8b-instruct
72
+ IBM_SUMMARY_MODEL_ID=ibm/granite-3-8b-instruct
73
+
74
+ # Speech to Text (IBM)
75
+ IBM_STT_URL=<your-ibm-stt-instance-url> # e.g. https://api.us-south.speech-to-text.watson.cloud.ibm.com/instances/XXXX
76
+ IBM_STT_APIKEY=<your-ibm-stt-api-key>
77
+
78
+ # Whisper fallback (optional)
79
+ WHISPER_MODEL_SIZE=base
80
+ WHISPER_DEVICE=cpu
81
+ WHISPER_COMPUTE_TYPE=int8
82
+ ```
83
+
84
+ ### 3) Seed the KB
85
+ Put your facts in `kb/snippets.jsonl` (one JSON per line). Example:
86
+
87
+ ```jsonl
88
+ {"doc_id":"uptime_q2_report","source":"Global Uptime Dashboard","snippet":"Q2 2025 uptime was 99.982% globally; LATAM outage lowered regional uptime to 99.965%.","metadata":{"quarter":"Q2","year":2025}}
89
+ ```
90
+
91
+ If you change the KB, rebuild the index by deleting the `kb/index/` folder.
92
+
93
+ ### 4) Run the API
94
+ ```bash
95
+ # macOS OpenMP fix (optional) + run
96
+ KMP_DUPLICATE_LIB_OK=TRUE OMP_NUM_THREADS=1 uvicorn app.main:app --reload
97
+ ```
98
+
99
+ ### 5) Try it
100
+
101
+ **Transcript path (no audio):**
102
+ ```bash
103
+ curl -X POST http://127.0.0.1:8000/process-transcript -H "Content-Type: application/json" -d '{"text":"We achieved 99.99% uptime in Q2. P95 latency under 200 ms globally. Default retention is 30 days."}'
104
+ ```
105
+
106
+ **Audio path (IBM STT):**
107
+ ```bash
108
+ curl -X POST http://127.0.0.1:8000/process-audio -F "file=@data/audio/demo_call.wav"
109
+ ```
110
+
111
+ **Health:**
112
+ ```bash
113
+ curl http://127.0.0.1:8000/health/ibm
114
+ ```
115
+
116
+ ---
117
+
118
+ ## 🧠 How it works (agentic)
119
+
120
+ - **ASR Agent (IBM STT):** audio → timestamped segments (+ diarization)
121
+ - **Claim Extractor (watsonx.ai):** segments → `{id, text, speaker, start, end}`
122
+ - **Retriever (Embeddings + FAISS + Rerank):** claim → top KB snippets
123
+ - **Verifier (watsonx.ai):** claim + evidence → verdict + rationale + citation_ids
124
+ - **Summarizer (watsonx.ai):** executive summary + action items
125
+ - **Output:** `CallReport` JSON; easy to render as PDF/HTML
126
+
127
+ ---
128
+
129
+ ## 🛡️ Notes on data & security
130
+ - Do **not** commit `.env` or audio with sensitive content.
131
+ - Use IBM Cloud secrets manager / vault in production.
132
+ - All third-party calls are behind explicit env flags; the pipeline fails safe (insufficient) if evidence is missing.
133
+
134
+ ---
135
+
136
+ ## 🧰 Troubleshooting
137
+ - **Embeddings 400** → ensure `version` query param, body includes `"inputs"` and `"model_id"`.
138
+ - **FAISS dim mismatch** → delete `kb/index/` after changing embedding model.
139
+ - **OpenMP error (macOS)** → set `KMP_DUPLICATE_LIB_OK=TRUE` and `OMP_NUM_THREADS=1`.
140
+ - **JSON parse errors** → we use a robust extractor; check server logs `[RAW OUTPUT]`.
141
+
142
+ ---
143
+
144
+ ## 📄 License
145
+ MIT (or your choice). See `LICENSE`.
app/agents/claims.py ADDED
@@ -0,0 +1,24 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
app/agents/ibm_client.py ADDED
@@ -0,0 +1,108 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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) # -> {"claims":[...]}
app/agents/retriever.py ADDED
@@ -0,0 +1,198 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # app/agents/retriever.py
2
+ from typing import List, Tuple, Dict
3
+ 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 = {
59
+ "inputs": texts, # NOTE: plural
60
+ "model_id": EMB_MODEL_ID,
61
+ "project_id": PROJECT_ID
62
+ }
63
+ r = requests.post(url, headers=hdr, json=payload, timeout=60)
64
+ r.raise_for_status()
65
+ j = r.json()
66
+
67
+ # Accept either "data": [{"embedding": [...]}, ...] OR
68
+ # "results": [{"embedding": [...]}, ...]
69
+ items = None
70
+ if isinstance(j, dict):
71
+ if "data" in j:
72
+ items = j["data"]
73
+ elif "results" in j:
74
+ items = j["results"]
75
+
76
+ if not items or not isinstance(items, list):
77
+ # Print full response once to help diagnose, then fall back
78
+ print(f"[retriever] Unexpected embeddings schema: {j}")
79
+ raise RuntimeError("Embeddings response missing 'data'/'results'")
80
+
81
+ vecs = np.asarray([it.get("embedding") for it in items], dtype=np.float32)
82
+ if vecs.ndim != 2:
83
+ print(f"[retriever] Bad embedding shapes: {vecs.shape}")
84
+ raise RuntimeError("Embeddings returned with wrong dimensionality")
85
+ # normalize for cosine/IP
86
+ vecs /= (np.linalg.norm(vecs, axis=1, keepdims=True) + 1e-12)
87
+ return vecs
88
+
89
+
90
+ 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": {
98
+ "query": query,
99
+ "passages": [{"id": d["doc_id"], "text": d["snippet"]} for d in docs]
100
+ },
101
+ "model_id": RERANK_MODEL_ID, # <-- required
102
+ "project_id": PROJECT_ID,
103
+ "top_n": min(top_n, len(docs))
104
+ }
105
+ r = requests.post(url, headers=hdr, json=payload, timeout=60)
106
+ if r.status_code != 200:
107
+ return docs
108
+ order = r.json().get("results", [])
109
+ id2doc = {d["doc_id"]: d for d in docs}
110
+ out = []
111
+ for it in order:
112
+ d = id2doc.get(it["id"])
113
+ if d:
114
+ d = {**d, "score": it.get("relevance", d.get("score", d.get("score", 0.0)))}
115
+ out.append(d)
116
+ return out or docs
117
+
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:
124
+ from sentence_transformers import SentenceTransformer
125
+ _embedder = SentenceTransformer("all-MiniLM-L6-v2")
126
+ vecs = _embedder.encode(texts, normalize_embeddings=True)
127
+ return np.asarray(vecs, dtype=np.float32)
128
+
129
+ def _use_ibm():
130
+ # use IBM only if all pieces exist
131
+ return bool(BASE_URL and PROJECT_ID and API_KEY and EMB_MODEL_ID)
132
+
133
+ def _load_snippets() -> list[dict]:
134
+ docs = []
135
+ with open(SNIPPETS) as f:
136
+ for line in f:
137
+ line = line.strip()
138
+ if line:
139
+ docs.append(json.loads(line))
140
+ if not docs:
141
+ raise RuntimeError("No KB snippets found. Please populate kb/snippets.jsonl")
142
+ return docs
143
+
144
+ def _build_or_load():
145
+ os.makedirs(IDX_DIR, exist_ok=True)
146
+ if os.path.exists(IDX_PATH) and os.path.exists(META_PATH):
147
+ return faiss.read_index(IDX_PATH), json.load(open(META_PATH))
148
+
149
+ docs = _load_snippets()
150
+ texts = [d["snippet"] for d in docs]
151
+ try:
152
+ embs = _ibm_embed(texts) if _use_ibm() else _local_embed(texts)
153
+ except Exception as e:
154
+ # Fallback to local embeddings if IBM call fails, but surface why
155
+ print(f"[retriever] IBM embeddings failed, falling back to local: {e}")
156
+ embs = _local_embed(texts)
157
+
158
+ index = faiss.IndexFlatIP(embs.shape[1])
159
+ index.add(embs.astype("float32"))
160
+ faiss.write_index(index, IDX_PATH)
161
+ json.dump(docs, open(META_PATH, "w"))
162
+ return index, docs
163
+
164
+ def _search(query_text: str, k: int = 8) -> list[dict]:
165
+ index, meta = _build_or_load()
166
+ try:
167
+ q = _ibm_embed([query_text]) if _use_ibm() else _local_embed([query_text])
168
+ except Exception as e:
169
+ print(f"[retriever] IBM query embed failed, using local: {e}")
170
+ q = _local_embed([query_text])
171
+ D, I = index.search(q.astype("float32"), k)
172
+ hits = []
173
+ for rank, idx in enumerate(I[0].tolist()):
174
+ d = meta[idx]
175
+ hits.append({
176
+ "doc_id": d["doc_id"], "source": d.get("source","KB"),
177
+ "snippet": d["snippet"], "score": float(D[0][rank]),
178
+ "metadata": d.get("metadata", {})
179
+ })
180
+ try:
181
+ hits = _ibm_rerank(query_text, hits, top_n=5) if _use_ibm() else hits
182
+ except Exception as e:
183
+ print(f"[retriever] IBM rerank failed, using original hits: {e}")
184
+ return hits
185
+
186
+ def retrieve_evidence_for_claims(claims: List[Claim], k: int = 8) -> Tuple[List[Claim], Dict[str, List[Evidence]]]:
187
+ claim_to_evidence: Dict[str, List[Evidence]] = {}
188
+ for cl in claims:
189
+ hits = _search(cl.text, k=k)
190
+ ev_list = [
191
+ Evidence(
192
+ doc_id=h["doc_id"], source=h["source"], snippet=h["snippet"],
193
+ score=h["score"], metadata=h["metadata"]
194
+ )
195
+ for h in hits[:5]
196
+ ]
197
+ claim_to_evidence[cl.id] = ev_list
198
+ return claims, claim_to_evidence
app/agents/summarizer.py ADDED
@@ -0,0 +1,143 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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}
49
+
50
+ Claims (JSON):
51
+ {CLAIMS_JSON}
52
+
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],
63
+ evidence_flat: List[Evidence],
64
+ verdicts: List[Verdict],
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",
93
+ "Content-Type": "application/json",
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,
101
+ "label": v.label,
102
+ "confidence": v.confidence,
103
+ "best_evidence_id": v.best_evidence_id,
104
+ "rationale": v.rationale
105
+ } for v in verdicts], ensure_ascii=False)),
106
+ "model_id": MODEL_ID,
107
+ "project_id": WATSONX_PROJECT,
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,
139
+ claims=claims,
140
+ verdicts=verdicts,
141
+ evidence=evidence_flat
142
+ )
143
+ return report
app/agents/verifier.py ADDED
@@ -0,0 +1,180 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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:
33
+ {
34
+ "verdicts": [
35
+ {"claim_id": "string", "label": "supported|refuted|insufficient", "confidence": 0.0, "citation_ids": ["doc_id", "..."], "rationale": "string"}
36
+ ]
37
+ }
38
+ # No extra text, no markdown, no backticks.
39
+
40
+ Rules:
41
+ - "supported" if at least one evidence snippet directly supports the claim.
42
+ - "refuted" if any evidence directly contradicts the claim.
43
+ - "insufficient" if evidence is not enough to decide.
44
+ - Cite relevant evidence doc_ids in "citation_ids".
45
+ - Keep "rationale" ≤ 2 sentences.
46
+
47
+ Claims (JSON):
48
+ {CLAIMS_JSON}
49
+
50
+ Evidence catalog (doc_id -> snippet) as JSON:
51
+ {EVIDENCE_JSON}
52
+
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
+ """
97
+ claims: list of Claim (must have .id and .text)
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(
123
+ claim_id=c.id,
124
+ label="insufficient",
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
170
+ have = {v.claim_id for v in out}
171
+ for c in claims:
172
+ if c.id not in have:
173
+ out.append(Verdict(
174
+ claim_id=c.id,
175
+ label="insufficient",
176
+ confidence=0.4,
177
+ best_evidence_id=top_ev.get(c.id, ""),
178
+ rationale="No explicit verdict returned; marking as insufficient."
179
+ ))
180
+ return out
app/core/config.py ADDED
@@ -0,0 +1,11 @@
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # app/core/config.py
2
+ import os
3
+ from dotenv import load_dotenv
4
+ load_dotenv()
5
+
6
+ WHISPER_COMPUTE_TYPE = os.getenv("WHISPER_COMPUTE_TYPE", "")
7
+ WHISPER_DEVICE = os.getenv("WHISPER_DEVICE", "")
8
+ 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", "")
app/core/ibm_auth.py ADDED
@@ -0,0 +1,20 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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 ADDED
@@ -0,0 +1,49 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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"},
25
+ json={"inputs":["probe"],"model_id":EMB,"project_id":WATSONX_PROJECT},
26
+ timeout=60,
27
+ )
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"},
39
+ json={
40
+ "input": prompt,
41
+ "model_id": model_id,
42
+ "project_id": WATSONX_PROJECT,
43
+ "parameters": {"decoding_method":"greedy","max_new_tokens":64}
44
+ },
45
+ timeout=90,
46
+ )
47
+ r.raise_for_status()
48
+ txt = (r.json().get("results") or [{}])[0].get("generated_text","")
49
+ return {"ok": True, "preview": txt[:120]}
app/core/json_utils.py ADDED
@@ -0,0 +1,48 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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/core/orchestrator.py ADDED
@@ -0,0 +1,42 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # app/core/orchestrator.py
2
+ from typing import Optional, List, Dict, Any
3
+ from app.schemas.report import CallReport
4
+ from app.schemas.claim import Claim
5
+ from app.schemas.evidence import Evidence
6
+ from app.schemas.verdict import Verdict
7
+ from app.services.asr import transcribe
8
+ from app.agents.claims import extract_claims
9
+ from app.agents.retriever import retrieve_evidence_for_claims
10
+ from app.agents.verifier import verify
11
+ from app.agents.summarizer import make_report
12
+ import os
13
+ os.environ.setdefault("KMP_DUPLICATE_LIB_OK", "TRUE")
14
+ os.environ.setdefault("OMP_NUM_THREADS", "1")
15
+
16
+
17
+ def process_call(audio_path: Optional[str] = None, transcript: Optional[str] = None) -> CallReport:
18
+ print("[orchestrator] START")
19
+
20
+ segments = transcribe(audio_path) if audio_path else [
21
+ {"start":0.0,"end":0.0,"speaker":"A","text": transcript or ""}]
22
+
23
+ # 2) Claim extraction (IBM)
24
+ claims: List[Claim] = extract_claims(segments)
25
+ print(f"[orchestrator] Claims extracted: {len(claims)}")
26
+ if not claims:
27
+ print("[orchestrator] No claims found; building minimal report.")
28
+ return make_report(segments, [], [], [])
29
+
30
+ # 3) Evidence retrieval (IBM embeddings + optional rerank)
31
+ claims, evmap = retrieve_evidence_for_claims(claims, k=8)
32
+ ev_count = sum(len(v) for v in evmap.values())
33
+ evidence_flat = [e for lst in evmap.values() for e in lst]
34
+
35
+ verdicts: List[Verdict] = verify(claims, evmap)
36
+ print(f"[orchestrator] Verifier produced {len(verdicts)} verdicts")
37
+
38
+ # 5) Summarize
39
+ report = make_report(segments, claims, evidence_flat, verdicts)
40
+ print(report.call_summary)
41
+ print("[orchestrator] DONE")
42
+ return report
app/main.py ADDED
@@ -0,0 +1,37 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # app/main.py
2
+ import os
3
+ from fastapi import FastAPI
4
+ from fastapi import Body
5
+ from fastapi import UploadFile, File
6
+ from app.core.orchestrator import process_call
7
+ from app.core.ibm_sanity import sanity_embeddings, sanity_generation
8
+
9
+ app = FastAPI(title="ClaimCheck")
10
+
11
+ @app.get("/health")
12
+ def health():
13
+ return {"status": "ok", "service": "ClaimCheck"}
14
+
15
+ @app.get("/health/ibm")
16
+ def health_ibm():
17
+ emb = sanity_embeddings()
18
+ claim = sanity_generation(os.getenv("IBM_CLAIM_MODEL_ID",""), "Say OK")
19
+ verify = sanity_generation(os.getenv("IBM_VERIFIER_MODEL_ID",""), "Say OK")
20
+ return {"embeddings": emb, "claim_gen": claim, "verify_gen": verify}
21
+
22
+
23
+ @app.post("/process-transcript")
24
+ def process_transcript(text: str = Body(..., embed=True)):
25
+ """
26
+ Accepts raw transcript text and returns a CallReport JSON.
27
+ For now, the orchestrator returns a dummy report (no AI).
28
+ """
29
+ report = process_call(transcript=text)
30
+ return report
31
+
32
+ @app.post("/process-audio")
33
+ async def process_audio(file: UploadFile = File(...)):
34
+ path = f"data/audio/{file.filename}"
35
+ with open(path, "wb") as f:
36
+ f.write(await file.read())
37
+ return process_call(audio_path=path)
app/schemas/base.py ADDED
File without changes
app/schemas/claim.py ADDED
@@ -0,0 +1,9 @@
 
 
 
 
 
 
 
 
 
 
1
+ from pydantic import BaseModel
2
+ from typing import List, Optional
3
+ class Claim(BaseModel):
4
+ id: str
5
+ text: str
6
+ speaker: Optional[str] = None
7
+ segment_idx: Optional[int] = None
8
+ entities: List[str] = []
9
+ confidence: float = 0.0
app/schemas/evidence.py ADDED
@@ -0,0 +1,8 @@
 
 
 
 
 
 
 
 
 
1
+ from pydantic import BaseModel
2
+ from typing import Dict
3
+ class Evidence(BaseModel):
4
+ doc_id: str
5
+ source: str
6
+ snippet: str
7
+ score: float
8
+ metadata: Dict[str, str] = {}
app/schemas/report.py ADDED
@@ -0,0 +1,13 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from pydantic import BaseModel
2
+ from typing import List
3
+ from .claim import Claim
4
+ from .evidence import Evidence
5
+ from .verdict import Verdict
6
+
7
+ class CallReport(BaseModel):
8
+ call_summary: str
9
+ claim_table: List[dict]
10
+ action_items: List[str] = []
11
+ claims: List[Claim]
12
+ verdicts: List[Verdict]
13
+ evidence: List[Evidence]
app/schemas/verdict.py ADDED
@@ -0,0 +1,7 @@
 
 
 
 
 
 
 
 
1
+ from pydantic import BaseModel
2
+ class Verdict(BaseModel):
3
+ claim_id: str
4
+ label: str # supported | refuted | insufficient
5
+ confidence: float
6
+ best_evidence_id: str
7
+ rationale: str
app/services/asr.py ADDED
@@ -0,0 +1,42 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from typing import List, Dict
2
+ from faster_whisper import WhisperModel
3
+ import os
4
+
5
+ # Load environment variables
6
+ WHISPER_MODEL_SIZE = os.getenv("WHISPER_MODEL_SIZE", "base")
7
+ WHISPER_DEVICE = os.getenv("WHISPER_DEVICE", "cpu")
8
+ WHISPER_COMPUTE_TYPE = os.getenv("WHISPER_COMPUTE_TYPE", "int8")
9
+
10
+ _whisper = WhisperModel(
11
+ WHISPER_MODEL_SIZE,
12
+ device=WHISPER_DEVICE,
13
+ compute_type=WHISPER_COMPUTE_TYPE
14
+ )
15
+
16
+ def transcribe(audio_path: str) -> List[Dict]:
17
+ """
18
+ Transcribe an audio file using faster-whisper and return our standard
19
+ list of segments: [{start, end, speaker, text}].
20
+ """
21
+ # beam_size=1 is fastest; raise for a bit more accuracy.
22
+ segments, info = _whisper.transcribe(
23
+ audio_path,
24
+ language="en",
25
+ vad_filter=True,
26
+ beam_size=1
27
+ )
28
+
29
+ out = []
30
+ for seg in segments:
31
+ out.append({
32
+ "start": float(seg.start),
33
+ "end": float(seg.end),
34
+ "speaker": "A", # no diarization here; we can add later
35
+ "text": seg.text.strip()
36
+ })
37
+
38
+ # If there were no segments (edge case), return a single empty segment
39
+ if not out:
40
+ out = [{"start": 0.0, "end": 0.0, "speaker": "A", "text": ""}]
41
+
42
+ return out
data/audio/call_script.txt ADDED
@@ -0,0 +1,7 @@
 
 
 
 
 
 
 
 
1
+ A: Good morning! You’re interested in our Gold Credit Card? It’s got a 17 percent APR and no annual fee. Really popular right now.
2
+ C: That sounds good. Any other fees I should know about?
3
+ A: No, not really. For personal loans we sometimes have a fee, but that’s usually negligible.
4
+ C: Also, how long do you keep call recordings?
5
+ A: Just about 30 days, then they’re gone.
6
+ C: Great. And I’m also thinking about a small investment product you offer. Any risks?
7
+ A: No major risks, it’s a stable product.
eval/eval.py ADDED
File without changes
eval/gold_claims.csv ADDED
File without changes
kb/snippets.jsonl ADDED
@@ -0,0 +1,19 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {"doc_id":"uptime_q2_report","source":"Global Uptime Dashboard","snippet":"Q2 2025 uptime was 99.982% globally, with LATAM experiencing a 3-hour outage on May 14, lowering uptime in that region to 99.965%.","metadata":{"type":"uptime","quarter":"Q2","year":2025}}
2
+ {"doc_id":"latency_q2_report","source":"Performance Metrics","snippet":"P95 latency in Q2 2025 averaged 198ms globally, but APAC had spikes to 250ms during peak hours in June.","metadata":{"type":"latency","quarter":"Q2","year":2025}}
3
+ {"doc_id":"uptime_incident_apac","source":"Incident Report","snippet":"Outage in APAC data center on June 21 lasted 45 minutes and impacted 12% of customer requests.","metadata":{"type":"incident","date":"2025-06-21"}}
4
+ {"doc_id":"uptime_contract","source":"SLA Agreement","snippet":"Service provider guarantees 99.99% uptime per month; failure results in service credits.","metadata":{"type":"sla","effective_date":"2024-05-01"}}
5
+ {"doc_id":"uptime_customer_feedback","source":"Customer Survey","snippet":"Several enterprise customers in LATAM reported downtime exceeding 2 hours in May 2025.","metadata":{"type":"feedback","month":"2025-05"}}
6
+
7
+ {"doc_id":"relief_payouts","source":"Disaster Relief Payment System","snippet":"As of July 20, 2025, 87% of registered affected citizens had received payments. Delays remain for 13% due to missing bank details or unresolved eligibility verification.","metadata":{"type":"payout_report","date":"2025-07-20"}}
8
+ {"doc_id":"relief_registry","source":"National Citizen Registry","snippet":"Registry lists 142,300 citizens marked as affected; payment records show 124,801 completed transfers.","metadata":{"type":"registry_report","date":"2025-07-20"}}
9
+ {"doc_id":"relief_remote_area_issues","source":"NGO Field Report","snippet":"Field agents in remote areas reported difficulty verifying identities, delaying payouts to hundreds of citizens.","metadata":{"type":"field_report","date":"2025-07-18"}}
10
+ {"doc_id":"relief_policy_doc","source":"Government Policy Document","snippet":"All affected citizens are eligible for relief payments; disbursement is to be completed within 30 days of disaster declaration.","metadata":{"type":"policy","effective_date":"2025-06-01"}}
11
+ {"doc_id":"relief_complaints","source":"Public Complaints Hotline","snippet":"Calls received in late July indicate some citizens in mountain regions still have not received payment.","metadata":{"type":"complaint","month":"2025-07"}}
12
+
13
+ {"doc_id":"aml_yearly_trend","source":"Annual Compliance Review 2024","snippet":"Overall AML coverage rate improved from 92% in 2023 to 97% in 2024.","metadata":{"type":"trend","year":2024}}
14
+ {"doc_id":"uptime_yearly_trend","source":"Annual Service Report 2024","snippet":"Annual uptime for 2024 was 99.975% with improvements in redundancy and failover systems.","metadata":{"type":"trend","year":2024}}
15
+ {"doc_id":"relief_previous_disaster","source":"Relief Audit 2024","snippet":"In the 2024 floods, 99% of affected citizens were compensated within 45 days.","metadata":{"type":"historical","year":2024}}
16
+
17
+ {"doc_id":"aml_press_release","source":"Bank Press Release","snippet":"We are proud to announce we achieved 100% AML screening compliance for all transactions in Q2 2025.","metadata":{"type":"press","quarter":"Q2","year":2025}}
18
+ {"doc_id":"uptime_press_release","source":"Cloud Provider Blog","snippet":"Our infrastructure delivered 99.99% uptime globally with record-low latency this quarter.","metadata":{"type":"press","quarter":"Q2","year":2025}}
19
+ {"doc_id":"relief_press_release","source":"Govt News Bulletin","snippet":"The government has successfully disbursed relief payments to all citizens affected by the July disaster.","metadata":{"type":"press","date":"2025-07-21"}}
orchestrate/agent_graph.md ADDED
File without changes
orchestrate/tools.md ADDED
File without changes
prompts/claim_extractor.json ADDED
File without changes
prompts/summarizer.json ADDED
File without changes
prompts/verifier.json ADDED
File without changes
requirements.txt ADDED
@@ -0,0 +1,5 @@
 
 
 
 
 
 
1
+ fastapi
2
+ uvicorn[standard]
3
+ pydantic>=2
4
+ python-dotenv
5
+ requests