Spaces:
Runtime error
Runtime error
Commit ·
ca4ed58
0
Parent(s):
Initial commit
Browse files- .gitignore +6 -0
- Makefile +0 -0
- README.md +145 -0
- app/agents/claims.py +24 -0
- app/agents/ibm_client.py +108 -0
- app/agents/retriever.py +198 -0
- app/agents/summarizer.py +143 -0
- app/agents/verifier.py +180 -0
- app/core/config.py +11 -0
- app/core/ibm_auth.py +20 -0
- app/core/ibm_sanity.py +49 -0
- app/core/json_utils.py +48 -0
- app/core/orchestrator.py +42 -0
- app/main.py +37 -0
- app/schemas/base.py +0 -0
- app/schemas/claim.py +9 -0
- app/schemas/evidence.py +8 -0
- app/schemas/report.py +13 -0
- app/schemas/verdict.py +7 -0
- app/services/asr.py +42 -0
- data/audio/call_script.txt +7 -0
- eval/eval.py +0 -0
- eval/gold_claims.csv +0 -0
- kb/snippets.jsonl +19 -0
- orchestrate/agent_graph.md +0 -0
- orchestrate/tools.md +0 -0
- prompts/claim_extractor.json +0 -0
- prompts/summarizer.json +0 -0
- prompts/verifier.json +0 -0
- requirements.txt +5 -0
.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
|