stutiagrawal commited on
Commit
3da97ef
·
1 Parent(s): 04d08b6

display relevant evidence

Browse files
README.md CHANGED
@@ -1,8 +1,5 @@
1
  # ClaimCheck.AI — Agentic Fact Verification for Calls
2
 
3
- <img width="1317" height="780" alt="image" src="https://github.com/user-attachments/assets/9fa259e1-3527-4573-86ec-a27053370a03" />
4
-
5
-
6
  **ClaimCheck.AI** is an multi-agent AI platform that turns meeting audio (Zoom/phone) into an evidence-backed report:
7
  1) **ASR Agent** → transcript + timestamps
8
  2) **Claim Extraction (watsonx.ai LLM)** → JSON claims
@@ -75,6 +72,7 @@ IBM_EMBEDDINGS_MODEL_ID=ibm/granite-embedding-107m-multilingual # 384-dim
75
  IBM_RERANK_MODEL_ID=ibm/slate-30m-english-rtrvr-v2 # optional
76
  IBM_VERIFIER_MODEL_ID=ibm/granite-3-8b-instruct
77
  IBM_SUMMARY_MODEL_ID=ibm/granite-3-8b-instruct
 
78
 
79
  # Speech to Text (IBM)
80
  IBM_STT_URL=<your-ibm-stt-instance-url> # e.g. https://api.us-south.speech-to-text.watson.cloud.ibm.com/instances/XXXX
@@ -101,15 +99,7 @@ If you change the KB, rebuild the index by deleting the `kb/index/` folder.
101
  KMP_DUPLICATE_LIB_OK=TRUE OMP_NUM_THREADS=1 uvicorn app.main:app --reload
102
  ```
103
 
104
- ### 5) Run the UI
105
- ```bash
106
- cd ui
107
- pnpm install
108
- pnpm dev
109
- ```
110
- - If you change the UI dev port/host, add it to `allow_origins` in `app/main.py`.
111
-
112
- ### 6) Try it
113
 
114
  **Transcript path (no audio):**
115
  ```bash
 
1
  # ClaimCheck.AI — Agentic Fact Verification for Calls
2
 
 
 
 
3
  **ClaimCheck.AI** is an multi-agent AI platform that turns meeting audio (Zoom/phone) into an evidence-backed report:
4
  1) **ASR Agent** → transcript + timestamps
5
  2) **Claim Extraction (watsonx.ai LLM)** → JSON claims
 
72
  IBM_RERANK_MODEL_ID=ibm/slate-30m-english-rtrvr-v2 # optional
73
  IBM_VERIFIER_MODEL_ID=ibm/granite-3-8b-instruct
74
  IBM_SUMMARY_MODEL_ID=ibm/granite-3-8b-instruct
75
+ IBM_CLAIM_MODEL_ID=ibm/granite-3-8b-instruct
76
 
77
  # Speech to Text (IBM)
78
  IBM_STT_URL=<your-ibm-stt-instance-url> # e.g. https://api.us-south.speech-to-text.watson.cloud.ibm.com/instances/XXXX
 
99
  KMP_DUPLICATE_LIB_OK=TRUE OMP_NUM_THREADS=1 uvicorn app.main:app --reload
100
  ```
101
 
102
+ ### 5) Try it
 
 
 
 
 
 
 
 
103
 
104
  **Transcript path (no audio):**
105
  ```bash
app/agents/claims.py CHANGED
@@ -86,7 +86,7 @@ def _build_claims_payload(transcript: str) -> Dict[str, Any]:
86
  "input": prompt,
87
  "parameters": {
88
  "decoding_method": "greedy",
89
- "max_new_tokens": 1000,
90
  "min_new_tokens": 0,
91
  "temperature": 0.0,
92
  "repetition_penalty": 1.0,
 
86
  "input": prompt,
87
  "parameters": {
88
  "decoding_method": "greedy",
89
+ "max_new_tokens": 700,
90
  "min_new_tokens": 0,
91
  "temperature": 0.0,
92
  "repetition_penalty": 1.0,
app/agents/retriever.py CHANGED
@@ -1,10 +1,17 @@
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_ID, WATSONX_API_KEY as APIKEY, IBM_EMBEDDINGS_MODEL_ID as EMB_MODEL_ID, IBM_RERANK_MODEL_ID as RERANK_MODEL_ID, IBM_API_VERSION as VERSION, IBM_EMBEDDINGS_MODEL_ID as EMB_MODEL_ID, IBM_RERANK_MODEL_ID as RERANK_MODEL_ID, WATSONX_API_KEY as API_KEY
 
 
 
 
 
 
 
8
  from app.core.auth import get_ibm_iam_token
9
 
10
  BASE_URL = (BASE or "").rstrip("/")
@@ -57,12 +64,12 @@ def _ibm_rerank(query: str, docs: list[dict], top_n: int = 5) -> list[dict]:
57
  url = f"{BASE_URL}/ml/v1/text/rerank?version={VERSION}"
58
  hdr = {"Authorization": f"Bearer {get_ibm_iam_token()}",
59
  "Accept":"application/json","Content-Type":"application/json"}
 
 
 
60
  payload = {
61
- "input": {
62
- "query": query,
63
- "passages": [{"id": d["doc_id"], "text": d["snippet"]} for d in docs]
64
- },
65
- "model_id": RERANK_MODEL_ID, # <-- required
66
  "project_id": PROJECT_ID,
67
  "top_n": min(top_n, len(docs))
68
  }
@@ -70,10 +77,9 @@ def _ibm_rerank(query: str, docs: list[dict], top_n: int = 5) -> list[dict]:
70
  if r.status_code != 200:
71
  return docs
72
  order = r.json().get("results", [])
73
- id2doc = {d["doc_id"]: d for d in docs}
74
  out = []
75
  for it in order:
76
- d = id2doc.get(it["id"])
77
  if d:
78
  d = {**d, "score": it.get("relevance", d.get("score", d.get("score", 0.0)))}
79
  out.append(d)
@@ -126,6 +132,9 @@ def _build_or_load():
126
  json.dump(docs, open(META_PATH, "w"))
127
  return index, docs
128
 
 
 
 
129
  def _search(query_text: str, k: int = 8) -> list[dict]:
130
  index, meta = _build_or_load()
131
  try:
@@ -146,7 +155,16 @@ def _search(query_text: str, k: int = 8) -> list[dict]:
146
  hits = _ibm_rerank(query_text, hits, top_n=5) if _use_ibm() else hits
147
  except Exception as e:
148
  print(f"[retriever] IBM rerank failed, using original hits: {e}")
149
- return hits
 
 
 
 
 
 
 
 
 
150
 
151
  def retrieve_evidence_for_claims(claims: List[Claim], k: int = 8) -> Tuple[List[Claim], Dict[str, List[Evidence]]]:
152
  claim_to_evidence: Dict[str, List[Evidence]] = {}
 
1
  # app/agents/retriever.py
2
  from typing import List, Tuple, Dict
3
+ import os, json, re, 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 (
8
+ WATSONX_BASE_URL as BASE,
9
+ WATSONX_PROJECT as PROJECT_ID,
10
+ WATSONX_API_KEY as API_KEY,
11
+ IBM_EMBEDDINGS_MODEL_ID as EMB_MODEL_ID,
12
+ IBM_RERANK_MODEL_ID as RERANK_MODEL_ID,
13
+ IBM_API_VERSION as VERSION,
14
+ )
15
  from app.core.auth import get_ibm_iam_token
16
 
17
  BASE_URL = (BASE or "").rstrip("/")
 
64
  url = f"{BASE_URL}/ml/v1/text/rerank?version={VERSION}"
65
  hdr = {"Authorization": f"Bearer {get_ibm_iam_token()}",
66
  "Accept":"application/json","Content-Type":"application/json"}
67
+ # Use stable, unique ids per passage for rerank, then map back
68
+ passages = [{"id": str(i), "text": d["snippet"]} for i, d in enumerate(docs)]
69
+ id2doc = {str(i): d for i, d in enumerate(docs)}
70
  payload = {
71
+ "input": {"query": query, "passages": passages},
72
+ "model_id": RERANK_MODEL_ID,
 
 
 
73
  "project_id": PROJECT_ID,
74
  "top_n": min(top_n, len(docs))
75
  }
 
77
  if r.status_code != 200:
78
  return docs
79
  order = r.json().get("results", [])
 
80
  out = []
81
  for it in order:
82
+ d = id2doc.get(it.get("id"))
83
  if d:
84
  d = {**d, "score": it.get("relevance", d.get("score", d.get("score", 0.0)))}
85
  out.append(d)
 
132
  json.dump(docs, open(META_PATH, "w"))
133
  return index, docs
134
 
135
+ def _normalize_snippet(s: str) -> str:
136
+ return re.sub(r"\s+", " ", (s or "").strip()).lower()
137
+
138
  def _search(query_text: str, k: int = 8) -> list[dict]:
139
  index, meta = _build_or_load()
140
  try:
 
155
  hits = _ibm_rerank(query_text, hits, top_n=5) if _use_ibm() else hits
156
  except Exception as e:
157
  print(f"[retriever] IBM rerank failed, using original hits: {e}")
158
+ # Deduplicate by normalized snippet text while preserving order
159
+ seen_snippets = set()
160
+ deduped = []
161
+ for h in hits:
162
+ key = _normalize_snippet(h["snippet"])
163
+ if key in seen_snippets:
164
+ continue
165
+ seen_snippets.add(key)
166
+ deduped.append(h)
167
+ return deduped
168
 
169
  def retrieve_evidence_for_claims(claims: List[Claim], k: int = 8) -> Tuple[List[Claim], Dict[str, List[Evidence]]]:
170
  claim_to_evidence: Dict[str, List[Evidence]] = {}
app/agents/summarizer.py CHANGED
@@ -97,6 +97,8 @@ def make_report(
97
  claims: List[Claim],
98
  evidence_flat: List[Evidence],
99
  verdicts: List[Verdict],
 
 
100
  ) -> CallReport:
101
  """
102
  Build a CallReport:
@@ -123,7 +125,8 @@ def make_report(
123
  action_items=["Review claims vs. evidence and confirm metrics in source-of-truth."],
124
  claims=claims,
125
  verdicts=verdicts,
126
- evidence=evidence_flat
 
127
  )
128
 
129
  # 3) Prepare compact context + stats
@@ -198,5 +201,6 @@ def make_report(
198
  action_items=action_items,
199
  claims=claims,
200
  verdicts=verdicts,
201
- evidence=evidence_flat
 
202
  )
 
97
  claims: List[Claim],
98
  evidence_flat: List[Evidence],
99
  verdicts: List[Verdict],
100
+ *,
101
+ evidence_by_claim: Dict[str, List[Evidence]] | None = None,
102
  ) -> CallReport:
103
  """
104
  Build a CallReport:
 
125
  action_items=["Review claims vs. evidence and confirm metrics in source-of-truth."],
126
  claims=claims,
127
  verdicts=verdicts,
128
+ evidence=evidence_flat,
129
+ evidence_by_claim=evidence_by_claim or {},
130
  )
131
 
132
  # 3) Prepare compact context + stats
 
201
  action_items=action_items,
202
  claims=claims,
203
  verdicts=verdicts,
204
+ evidence=evidence_flat,
205
+ evidence_by_claim=evidence_by_claim or {},
206
  )
app/agents/verifier.py CHANGED
@@ -36,144 +36,150 @@ Evidence catalog (doc_id -> snippet) as JSON:
36
  Output JSON:
37
  """
38
 
 
39
  def _gen(url: str, body: dict, timeout: int = 120) -> str:
40
- """Low-level call to watsonx text/generation; returns raw model text."""
41
- tok = get_ibm_iam_token()
42
- headers = {
43
- "Authorization": f"Bearer {tok}",
44
- "Accept": "application/json",
45
- "Content-Type": "application/json",
46
- }
47
- r = requests.post(url, headers=headers, json=body, timeout=timeout)
48
- r.raise_for_status()
49
- j = r.json()
50
- res = j.get("results") or []
51
- return (res[0].get("generated_text") if res else "") or ""
 
52
 
53
  def _post_generation(prompt: str) -> dict:
54
- """Call model → parse with parse_json_anywhere(root='verdicts') → repair once if needed."""
55
- url = f"{WATSONX_BASE_URL.rstrip('/')}/ml/v1/text/generation?version={IBM_API_VERSION}"
56
- body = {
57
- "input": prompt,
58
- "model_id": IBM_VERIFIER_MODEL_ID,
59
- "project_id": WATSONX_PROJECT,
60
- "parameters": {
61
- "decoding_method": "greedy",
62
- "max_new_tokens": 600,
63
- "min_new_tokens": 0,
64
- "repetition_penalty": 1.0,
65
- "temperature": 0.0,
66
- },
67
- }
68
-
69
- text = _gen(url, body)
70
- parsed = parse_json_anywhere(text, root_key="verdicts")
71
- if parsed and parsed.get("verdicts"):
72
- return parsed
73
-
74
- # One-shot repair: coerce to strict JSON with 'verdicts' root
75
- repair_body = {
76
- "input": (
77
- "Return ONLY valid JSON object with root key 'verdicts' "
78
- "(no prose, no markdown). If invalid, fix and output JSON:\n\n" + text
79
- ),
80
- "model_id": IBM_VERIFIER_MODEL_ID,
81
- "project_id": WATSONX_PROJECT,
82
- "parameters": {
83
- "decoding_method": "greedy",
84
- "max_new_tokens": 400,
85
- "temperature": 0.0,
86
- },
87
- }
88
- repaired = _gen(url, repair_body)
89
- reparsed = parse_json_anywhere(repaired, root_key="verdicts")
90
- if reparsed and reparsed.get("verdicts"):
91
- return reparsed
92
-
93
- # Debug preview if still not parsable
94
- print("[verifier] RAW OUTPUT >>>", (text or repaired)[:1000])
95
- return {"verdicts": []}
 
96
 
97
  def verify(claims: List[Claim], evidence_map: Dict[str, List[Evidence]]) -> List[Verdict]:
98
- """
99
- claims: list of Claim (must have .id and .text)
100
- evidence_map: claim_id -> List[Evidence] (must have .doc_id, .snippet)
101
- returns: List[Verdict]
102
- """
103
- # 1) Flatten evidence to a doc_id -> snippet catalog
104
- doc_catalog: Dict[str, str] = {}
105
- for lst in evidence_map.values():
106
- for e in lst:
107
- doc_catalog.setdefault(e.doc_id, e.snippet)
108
-
109
- # 2) Minimal claims JSON for the LLM
110
- claims_json = [{"id": c.id, "text": c.text} for c in claims]
111
-
112
- # 3) Build prompt
113
- prompt = (
114
- PROMPT
115
- .replace("{CLAIMS_JSON}", json.dumps(claims_json, ensure_ascii=False))
116
- .replace("{EVIDENCE_JSON}", json.dumps(doc_catalog, ensure_ascii=False))
117
- )
118
-
119
- # 4) Call model + robust parse
120
- try:
121
- parsed = _post_generation(prompt)
122
- except Exception as e:
123
- # Fail-safe: mark all as insufficient
124
- print(f"[verifier] generation failed: {e}")
125
- return [
126
- Verdict(
127
- claim_id=c.id,
128
- label="insufficient",
129
- confidence=0.4,
130
- best_evidence_id="",
131
- rationale="Verifier offline; defaulting to insufficient."
132
- )
133
- for c in claims
134
- ]
135
-
136
- # 5) Convert to Verdict[]
137
- allowed = {"supported", "refuted", "insufficient"}
138
- items = parsed.get("verdicts", []) or []
139
-
140
- # Tiebreaker: top retrieved evidence per claim
141
- top_ev: Dict[str, str] = {}
142
- for c in claims:
143
- evs = evidence_map.get(c.id, [])
144
- best = max(evs, key=lambda e: e.score, default=None)
145
- top_ev[c.id] = best.doc_id if best else ""
146
-
147
- out: List[Verdict] = []
148
- for it in items:
149
- cid = it.get("claim_id", "")
150
- label = (it.get("label") or "").lower()
151
- conf = float(it.get("confidence", 0.5))
152
- cites = it.get("citation_ids") or []
153
- rationale = (it.get("rationale") or "")[:300]
154
- if label not in allowed:
155
- label = "insufficient"
156
-
157
- # choose best_evidence_id from cited doc_ids or fallback to top_ev
158
- best_id = next((d for d in cites if d in doc_catalog), "") or top_ev.get(cid, "")
159
-
160
- out.append(Verdict(
161
- claim_id=cid,
162
- label=label,
163
- confidence=conf,
164
- best_evidence_id=best_id,
165
- rationale=rationale
166
- ))
167
-
168
- # Ensure every claim has a verdict
169
- have = {v.claim_id for v in out}
170
- for c in claims:
171
- if c.id not in have:
172
- out.append(Verdict(
173
- claim_id=c.id,
174
- label="insufficient",
175
- confidence=0.4,
176
- best_evidence_id=top_ev.get(c.id, ""),
177
- rationale="No explicit verdict returned; marking as insufficient."
178
- ))
179
- return out
 
 
 
 
36
  Output JSON:
37
  """
38
 
39
+
40
  def _gen(url: str, body: dict, timeout: int = 120) -> str:
41
+ """Low-level call to watsonx text/generation; returns raw model text."""
42
+ tok = get_ibm_iam_token()
43
+ headers = {
44
+ "Authorization": f"Bearer {tok}",
45
+ "Accept": "application/json",
46
+ "Content-Type": "application/json",
47
+ }
48
+ r = requests.post(url, headers=headers, json=body, timeout=timeout)
49
+ r.raise_for_status()
50
+ j = r.json()
51
+ res = j.get("results") or []
52
+ return (res[0].get("generated_text") if res else "") or ""
53
+
54
 
55
  def _post_generation(prompt: str) -> dict:
56
+ """Call model → parse with parse_json_anywhere(root='verdicts') → repair once if needed."""
57
+ url = f"{WATSONX_BASE_URL.rstrip('/')}/ml/v1/text/generation?version={IBM_API_VERSION}"
58
+ body = {
59
+ "input": prompt,
60
+ "model_id": IBM_VERIFIER_MODEL_ID,
61
+ "project_id": WATSONX_PROJECT,
62
+ "parameters": {
63
+ "decoding_method": "greedy",
64
+ "max_new_tokens": 600,
65
+ "min_new_tokens": 0,
66
+ "repetition_penalty": 1.0,
67
+ "temperature": 0.0,
68
+ },
69
+ }
70
+
71
+ text = _gen(url, body)
72
+ parsed = parse_json_anywhere(text, root_key="verdicts")
73
+ if parsed and parsed.get("verdicts"):
74
+ return parsed
75
+
76
+ # One-shot repair: coerce to strict JSON with 'verdicts' root
77
+ repair_body = {
78
+ "input": (
79
+ "Return ONLY valid JSON object with root key 'verdicts' "
80
+ "(no prose, no markdown). If invalid, fix and output JSON:\n\n" + text
81
+ ),
82
+ "model_id": IBM_VERIFIER_MODEL_ID,
83
+ "project_id": WATSONX_PROJECT,
84
+ "parameters": {
85
+ "decoding_method": "greedy",
86
+ "max_new_tokens": 400,
87
+ "temperature": 0.0,
88
+ },
89
+ }
90
+ repaired = _gen(url, repair_body)
91
+ reparsed = parse_json_anywhere(repaired, root_key="verdicts")
92
+ if reparsed and reparsed.get("verdicts"):
93
+ return reparsed
94
+
95
+ # Debug preview if still not parsable
96
+ print("[verifier] RAW OUTPUT >>>", (text or repaired)[:1000])
97
+ return {"verdicts": []}
98
+
99
 
100
  def verify(claims: List[Claim], evidence_map: Dict[str, List[Evidence]]) -> List[Verdict]:
101
+ """
102
+ claims: list of Claim (must have .id and .text)
103
+ evidence_map: claim_id -> List[Evidence] (must have .doc_id, .snippet)
104
+ returns: List[Verdict]
105
+ """
106
+ # 1) Flatten evidence to a doc_id -> snippet catalog
107
+ doc_catalog: Dict[str, str] = {}
108
+ for lst in evidence_map.values():
109
+ for e in lst:
110
+ doc_catalog.setdefault(e.doc_id, e.snippet)
111
+
112
+ # 2) Minimal claims JSON for the LLM
113
+ claims_json = [{"id": c.id, "text": c.text} for c in claims]
114
+
115
+ # 3) Build prompt
116
+ prompt = (
117
+ PROMPT
118
+ .replace("{CLAIMS_JSON}", json.dumps(claims_json, ensure_ascii=False))
119
+ .replace("{EVIDENCE_JSON}", json.dumps(doc_catalog, ensure_ascii=False))
120
+ )
121
+
122
+ # 4) Call model + robust parse
123
+ try:
124
+ parsed = _post_generation(prompt)
125
+ except Exception as e:
126
+ # Fail-safe: mark all as insufficient
127
+ print(f"[verifier] generation failed: {e}")
128
+ return [
129
+ Verdict(
130
+ claim_id=c.id,
131
+ label="insufficient",
132
+ confidence=0.4,
133
+ best_evidence_id="",
134
+ rationale="Verifier offline; defaulting to insufficient.",
135
+ citation_ids=[],
136
+ )
137
+ for c in claims
138
+ ]
139
+
140
+ # 5) Convert to Verdict[]
141
+ allowed = {"supported", "refuted", "insufficient"}
142
+ items = parsed.get("verdicts", []) or []
143
+
144
+ # Tiebreaker: top retrieved evidence per claim
145
+ top_ev: Dict[str, str] = {}
146
+ for c in claims:
147
+ evs = evidence_map.get(c.id, [])
148
+ best = max(evs, key=lambda e: e.score, default=None)
149
+ top_ev[c.id] = best.doc_id if best else ""
150
+
151
+ out: List[Verdict] = []
152
+ for it in items:
153
+ cid = it.get("claim_id", "")
154
+ label = (it.get("label") or "").lower()
155
+ conf = float(it.get("confidence", 0.5))
156
+ cites = it.get("citation_ids") or []
157
+ rationale = (it.get("rationale") or "")[:300]
158
+ if label not in allowed:
159
+ label = "insufficient"
160
+
161
+ # choose best_evidence_id from cited doc_ids or fallback to top_ev
162
+ best_id = next((d for d in cites if d in doc_catalog), "") or top_ev.get(cid, "")
163
+
164
+ out.append(Verdict(
165
+ claim_id=cid,
166
+ label=label,
167
+ confidence=conf,
168
+ best_evidence_id=best_id,
169
+ rationale=rationale,
170
+ citation_ids=cites,
171
+ ))
172
+
173
+ # Ensure every claim has a verdict
174
+ have = {v.claim_id for v in out}
175
+ for c in claims:
176
+ if c.id not in have:
177
+ out.append(Verdict(
178
+ claim_id=c.id,
179
+ label="insufficient",
180
+ confidence=0.4,
181
+ best_evidence_id=top_ev.get(c.id, ""),
182
+ rationale="No explicit verdict returned; marking as insufficient.",
183
+ citation_ids=[],
184
+ ))
185
+ return out
app/core/orchestrator.py CHANGED
@@ -9,35 +9,69 @@ 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
 
14
  os.environ.setdefault("KMP_DUPLICATE_LIB_OK", "TRUE")
15
  os.environ.setdefault("OMP_NUM_THREADS", "1")
16
 
17
 
 
 
 
 
18
  def process_call(audio_path: Optional[str] = None, transcript: Optional[str] = None) -> CallReport:
19
- print("[orchestrator] START")
20
-
21
- segments = transcribe(audio_path) if audio_path else [
22
- {"start":0.0,"end":0.0,"speaker":"A","text": transcript or ""}]
23
-
24
- # 2) Claim extraction (IBM)
25
- claims: List[Claim] = extract_claims(segments)
26
- print(f"[orchestrator] Claims extracted: {len(claims)}")
27
- if not claims:
28
- print("[orchestrator] No claims found; building minimal report.")
29
- return make_report(segments, [], [], [])
30
-
31
- # 3) Evidence retrieval (IBM embeddings + optional rerank)
32
- claims, evmap = retrieve_evidence_for_claims(claims, k=8)
33
- ev_count = sum(len(v) for v in evmap.values())
34
- evidence_flat = [e for lst in evmap.values() for e in lst]
35
-
36
- verdicts: List[Verdict] = verify(claims, evmap)
37
- print(f"[orchestrator] Verifier produced {len(verdicts)} verdicts")
38
-
39
- # 5) Summarize
40
- report = make_report(segments, claims, evidence_flat, verdicts)
41
- print(report.call_summary)
42
- print("[orchestrator] DONE")
43
- return report
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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, re, json
13
 
14
  os.environ.setdefault("KMP_DUPLICATE_LIB_OK", "TRUE")
15
  os.environ.setdefault("OMP_NUM_THREADS", "1")
16
 
17
 
18
+ def _norm_snippet(s: str) -> str:
19
+ return re.sub(r"\s+", " ", (s or "").strip()).lower()
20
+
21
+
22
  def process_call(audio_path: Optional[str] = None, transcript: Optional[str] = None) -> CallReport:
23
+ print("[orchestrator] START")
24
+
25
+ segments = transcribe(audio_path) if audio_path else [
26
+ {"start":0.0,"end":0.0,"speaker":"A","text": transcript or ""}]
27
+
28
+ # 2) Claim extraction (IBM)
29
+ claims: List[Claim] = extract_claims(segments)
30
+ print(f"[orchestrator] Claims extracted: {len(claims)}")
31
+ if not claims:
32
+ print("[orchestrator] No claims found; building minimal report.")
33
+ return make_report(segments, [], [], [], evidence_by_claim={})
34
+
35
+ # 3) Evidence retrieval (IBM embeddings + optional rerank)
36
+ claims, evmap = retrieve_evidence_for_claims(claims, k=8)
37
+ ev_count = sum(len(v) for v in evmap.values())
38
+
39
+ # Print evidence per claim (detailed)
40
+ print("[orchestrator] Evidence per claim (top k):")
41
+ for c in claims:
42
+ evs = evmap.get(c.id, [])
43
+ print(f" - Claim {c.id}: {c.text}")
44
+ for i, e in enumerate(evs, 1):
45
+ snippet_preview = _norm_snippet(e.snippet)[:200]
46
+ meta_preview = ""
47
+ try:
48
+ meta_preview = json.dumps(e.metadata, ensure_ascii=False)[:160]
49
+ except Exception:
50
+ meta_preview = str(e.metadata)[:160]
51
+ print(f" {i:02d}. {e.source or e.doc_id} id={e.doc_id} score={e.score:.2f}")
52
+ print(f" {snippet_preview}")
53
+ if e.metadata:
54
+ print(f" meta: {meta_preview}")
55
+
56
+ # Flatten and deduplicate global evidence by normalized snippet, keeping highest score
57
+ flat: List[Evidence] = [e for lst in evmap.values() for e in lst]
58
+ by_snippet: Dict[str, Evidence] = {}
59
+ for e in flat:
60
+ key = _norm_snippet(e.snippet)
61
+ best = by_snippet.get(key)
62
+ if not best or (e.score or 0.0) > (best.score or 0.0):
63
+ by_snippet[key] = e
64
+ evidence_flat = list(by_snippet.values())
65
+
66
+ verdicts: List[Verdict] = verify(claims, evmap)
67
+ print(f"[orchestrator] Verifier produced {len(verdicts)} verdicts")
68
+ print("[orchestrator] Verdicts with citations:")
69
+ for v in verdicts:
70
+ cites = getattr(v, "citation_ids", [])
71
+ print(f" - {v.claim_id}: {v.label} conf={v.confidence:.2f} best={v.best_evidence_id} cites={cites}")
72
+
73
+ # 5) Summarize
74
+ report = make_report(segments, claims, evidence_flat, verdicts, evidence_by_claim=evmap)
75
+ print(report.call_summary)
76
+ print("[orchestrator] DONE")
77
+ return report
app/schemas/evidence.py CHANGED
@@ -1,8 +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] = {}
 
1
  from pydantic import BaseModel
2
+ from typing import Dict, Any
3
  class Evidence(BaseModel):
4
  doc_id: str
5
  source: str
6
  snippet: str
7
  score: float
8
+ metadata: Dict[str, Any] = {}
app/schemas/report.py CHANGED
@@ -1,13 +1,14 @@
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]
 
 
1
  from pydantic import BaseModel
2
+ from typing import List, Dict
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]
14
+ evidence_by_claim: Dict[str, List[Evidence]] = {}
app/schemas/verdict.py CHANGED
@@ -1,7 +1,9 @@
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
 
 
1
  from pydantic import BaseModel
2
+ from typing import List
3
  class Verdict(BaseModel):
4
+ claim_id: str
5
+ label: str # supported | refuted | insufficient
6
+ confidence: float
7
+ best_evidence_id: str
8
+ rationale: str
9
+ citation_ids: List[str] = []
requirements.txt CHANGED
@@ -1,5 +1,223 @@
1
- fastapi
2
- uvicorn[standard]
3
- pydantic>=2
4
- python-dotenv
5
- requests
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ annotated-types==0.7.0
2
+ anyio==4.10.0
3
+ av==15.0.0
4
+ certifi==2025.8.3
5
+ charset-normalizer==3.4.3
6
+ click==8.2.1
7
+ coloredlogs==15.0.1
8
+ ctranslate2==4.6.0
9
+ distro==1.9.0
10
+ faiss-cpu==1.12.0
11
+ fastapi==0.116.1
12
+ faster-whisper==1.2.0
13
+ filelock==3.18.0
14
+ flatbuffers==25.2.10
15
+ fsspec==2025.7.0
16
+ h11==0.16.0
17
+ hf-xet==1.1.7
18
+ httpcore==1.0.9
19
+ httptools==0.6.4
20
+ httpx==0.28.1
21
+ huggingface-hub==0.34.4
22
+ humanfriendly==10.0
23
+ idna==3.10
24
+ Jinja2==3.1.6
25
+ jiter==0.10.0
26
+ joblib==1.5.1
27
+ MarkupSafe==3.0.2
28
+ mpmath==1.3.0
29
+ networkx==3.5
30
+ numpy==2.3.2
31
+ onnxruntime==1.22.1
32
+ openai==1.99.9
33
+ packaging==25.0
34
+ pillow==11.3.0
35
+ protobuf==6.31.1
36
+ pydantic==2.11.7
37
+ pydantic_core==2.33.2
38
+ pydub==0.25.1
39
+ pyobjc==11.1
40
+ pyobjc-core==11.1
41
+ pyobjc-framework-Accessibility==11.1
42
+ pyobjc-framework-Accounts==11.1
43
+ pyobjc-framework-AddressBook==11.1
44
+ pyobjc-framework-AdServices==11.1
45
+ pyobjc-framework-AdSupport==11.1
46
+ pyobjc-framework-AppleScriptKit==11.1
47
+ pyobjc-framework-AppleScriptObjC==11.1
48
+ pyobjc-framework-ApplicationServices==11.1
49
+ pyobjc-framework-AppTrackingTransparency==11.1
50
+ pyobjc-framework-AudioVideoBridging==11.1
51
+ pyobjc-framework-AuthenticationServices==11.1
52
+ pyobjc-framework-AutomaticAssessmentConfiguration==11.1
53
+ pyobjc-framework-Automator==11.1
54
+ pyobjc-framework-AVFoundation==11.1
55
+ pyobjc-framework-AVKit==11.1
56
+ pyobjc-framework-AVRouting==11.1
57
+ pyobjc-framework-BackgroundAssets==11.1
58
+ pyobjc-framework-BrowserEngineKit==11.1
59
+ pyobjc-framework-BusinessChat==11.1
60
+ pyobjc-framework-CalendarStore==11.1
61
+ pyobjc-framework-CallKit==11.1
62
+ pyobjc-framework-Carbon==11.1
63
+ pyobjc-framework-CFNetwork==11.1
64
+ pyobjc-framework-Cinematic==11.1
65
+ pyobjc-framework-ClassKit==11.1
66
+ pyobjc-framework-CloudKit==11.1
67
+ pyobjc-framework-Cocoa==11.1
68
+ pyobjc-framework-Collaboration==11.1
69
+ pyobjc-framework-ColorSync==11.1
70
+ pyobjc-framework-Contacts==11.1
71
+ pyobjc-framework-ContactsUI==11.1
72
+ pyobjc-framework-CoreAudio==11.1
73
+ pyobjc-framework-CoreAudioKit==11.1
74
+ pyobjc-framework-CoreBluetooth==11.1
75
+ pyobjc-framework-CoreData==11.1
76
+ pyobjc-framework-CoreHaptics==11.1
77
+ pyobjc-framework-CoreLocation==11.1
78
+ pyobjc-framework-CoreMedia==11.1
79
+ pyobjc-framework-CoreMediaIO==11.1
80
+ pyobjc-framework-CoreMIDI==11.1
81
+ pyobjc-framework-CoreML==11.1
82
+ pyobjc-framework-CoreMotion==11.1
83
+ pyobjc-framework-CoreServices==11.1
84
+ pyobjc-framework-CoreSpotlight==11.1
85
+ pyobjc-framework-CoreText==11.1
86
+ pyobjc-framework-CoreWLAN==11.1
87
+ pyobjc-framework-CryptoTokenKit==11.1
88
+ pyobjc-framework-DataDetection==11.1
89
+ pyobjc-framework-DeviceCheck==11.1
90
+ pyobjc-framework-DeviceDiscoveryExtension==11.1
91
+ pyobjc-framework-DictionaryServices==11.1
92
+ pyobjc-framework-DiscRecording==11.1
93
+ pyobjc-framework-DiscRecordingUI==11.1
94
+ pyobjc-framework-DiskArbitration==11.1
95
+ pyobjc-framework-DVDPlayback==11.1
96
+ pyobjc-framework-EventKit==11.1
97
+ pyobjc-framework-ExceptionHandling==11.1
98
+ pyobjc-framework-ExecutionPolicy==11.1
99
+ pyobjc-framework-ExtensionKit==11.1
100
+ pyobjc-framework-ExternalAccessory==11.1
101
+ pyobjc-framework-FileProvider==11.1
102
+ pyobjc-framework-FileProviderUI==11.1
103
+ pyobjc-framework-FinderSync==11.1
104
+ pyobjc-framework-FSEvents==11.1
105
+ pyobjc-framework-FSKit==11.1
106
+ pyobjc-framework-GameCenter==11.1
107
+ pyobjc-framework-GameController==11.1
108
+ pyobjc-framework-GameKit==11.1
109
+ pyobjc-framework-GameplayKit==11.1
110
+ pyobjc-framework-HealthKit==11.1
111
+ pyobjc-framework-ImageCaptureCore==11.1
112
+ pyobjc-framework-InputMethodKit==11.1
113
+ pyobjc-framework-InstallerPlugins==11.1
114
+ pyobjc-framework-InstantMessage==11.1
115
+ pyobjc-framework-Intents==11.1
116
+ pyobjc-framework-IntentsUI==11.1
117
+ pyobjc-framework-IOBluetooth==11.1
118
+ pyobjc-framework-IOBluetoothUI==11.1
119
+ pyobjc-framework-IOSurface==11.1
120
+ pyobjc-framework-iTunesLibrary==11.1
121
+ pyobjc-framework-KernelManagement==11.1
122
+ pyobjc-framework-LatentSemanticMapping==11.1
123
+ pyobjc-framework-LaunchServices==11.1
124
+ pyobjc-framework-libdispatch==11.1
125
+ pyobjc-framework-libxpc==11.1
126
+ pyobjc-framework-LinkPresentation==11.1
127
+ pyobjc-framework-LocalAuthentication==11.1
128
+ pyobjc-framework-LocalAuthenticationEmbeddedUI==11.1
129
+ pyobjc-framework-MailKit==11.1
130
+ pyobjc-framework-MapKit==11.1
131
+ pyobjc-framework-MediaAccessibility==11.1
132
+ pyobjc-framework-MediaExtension==11.1
133
+ pyobjc-framework-MediaLibrary==11.1
134
+ pyobjc-framework-MediaPlayer==11.1
135
+ pyobjc-framework-MediaToolbox==11.1
136
+ pyobjc-framework-Metal==11.1
137
+ pyobjc-framework-MetalFX==11.1
138
+ pyobjc-framework-MetalKit==11.1
139
+ pyobjc-framework-MetalPerformanceShaders==11.1
140
+ pyobjc-framework-MetalPerformanceShadersGraph==11.1
141
+ pyobjc-framework-MetricKit==11.1
142
+ pyobjc-framework-MLCompute==11.1
143
+ pyobjc-framework-ModelIO==11.1
144
+ pyobjc-framework-MultipeerConnectivity==11.1
145
+ pyobjc-framework-NaturalLanguage==11.1
146
+ pyobjc-framework-NetFS==11.1
147
+ pyobjc-framework-Network==11.1
148
+ pyobjc-framework-NetworkExtension==11.1
149
+ pyobjc-framework-NotificationCenter==11.1
150
+ pyobjc-framework-OpenDirectory==11.1
151
+ pyobjc-framework-OSAKit==11.1
152
+ pyobjc-framework-OSLog==11.1
153
+ pyobjc-framework-PassKit==11.1
154
+ pyobjc-framework-PencilKit==11.1
155
+ pyobjc-framework-PHASE==11.1
156
+ pyobjc-framework-Photos==11.1
157
+ pyobjc-framework-PhotosUI==11.1
158
+ pyobjc-framework-PreferencePanes==11.1
159
+ pyobjc-framework-PushKit==11.1
160
+ pyobjc-framework-Quartz==11.1
161
+ pyobjc-framework-QuickLookThumbnailing==11.1
162
+ pyobjc-framework-ReplayKit==11.1
163
+ pyobjc-framework-SafariServices==11.1
164
+ pyobjc-framework-SafetyKit==11.1
165
+ pyobjc-framework-SceneKit==11.1
166
+ pyobjc-framework-ScreenCaptureKit==11.1
167
+ pyobjc-framework-ScreenSaver==11.1
168
+ pyobjc-framework-ScreenTime==11.1
169
+ pyobjc-framework-ScriptingBridge==11.1
170
+ pyobjc-framework-SearchKit==11.1
171
+ pyobjc-framework-Security==11.1
172
+ pyobjc-framework-SecurityFoundation==11.1
173
+ pyobjc-framework-SecurityInterface==11.1
174
+ pyobjc-framework-SecurityUI==11.1
175
+ pyobjc-framework-SensitiveContentAnalysis==11.1
176
+ pyobjc-framework-ServiceManagement==11.1
177
+ pyobjc-framework-SharedWithYou==11.1
178
+ pyobjc-framework-SharedWithYouCore==11.1
179
+ pyobjc-framework-ShazamKit==11.1
180
+ pyobjc-framework-Social==11.1
181
+ pyobjc-framework-SoundAnalysis==11.1
182
+ pyobjc-framework-Speech==11.1
183
+ pyobjc-framework-SpriteKit==11.1
184
+ pyobjc-framework-StoreKit==11.1
185
+ pyobjc-framework-Symbols==11.1
186
+ pyobjc-framework-SyncServices==11.1
187
+ pyobjc-framework-SystemConfiguration==11.1
188
+ pyobjc-framework-SystemExtensions==11.1
189
+ pyobjc-framework-ThreadNetwork==11.1
190
+ pyobjc-framework-UniformTypeIdentifiers==11.1
191
+ pyobjc-framework-UserNotifications==11.1
192
+ pyobjc-framework-UserNotificationsUI==11.1
193
+ pyobjc-framework-VideoSubscriberAccount==11.1
194
+ pyobjc-framework-VideoToolbox==11.1
195
+ pyobjc-framework-Virtualization==11.1
196
+ pyobjc-framework-Vision==11.1
197
+ pyobjc-framework-WebKit==11.1
198
+ python-dotenv==1.1.1
199
+ python-multipart==0.0.20
200
+ pyttsx3==2.99
201
+ PyYAML==6.0.2
202
+ regex==2025.7.34
203
+ requests==2.32.4
204
+ safetensors==0.6.2
205
+ scikit-learn==1.7.1
206
+ scipy==1.16.1
207
+ sentence-transformers==5.1.0
208
+ setuptools==80.9.0
209
+ sniffio==1.3.1
210
+ starlette==0.47.2
211
+ sympy==1.14.0
212
+ threadpoolctl==3.6.0
213
+ tokenizers==0.21.4
214
+ torch==2.8.0
215
+ tqdm==4.67.1
216
+ transformers==4.55.2
217
+ typing-inspection==0.4.1
218
+ typing_extensions==4.14.1
219
+ urllib3==2.5.0
220
+ uvicorn==0.35.0
221
+ uvloop==0.21.0
222
+ watchfiles==1.1.0
223
+ websockets==15.0.1
ui/claimcheck-ui/src/components/ClaimsTable.tsx CHANGED
@@ -25,7 +25,9 @@ export function ClaimsTable() {
25
  data.sort((a: any, b: any) => (sortAsc ? (a.verdict?.confidence ?? 0) - (b.verdict?.confidence ?? 0) : (b.verdict?.confidence ?? 0) - (a.verdict?.confidence ?? 0)))
26
 
27
  return data.map((r: any) => {
28
- const evid = evidence.find((e) => e.doc_id === r.verdict?.best_evidence_id)
 
 
29
  return { ...r, evidence: evid }
30
  })
31
  }, [run, filter, sortAsc])
 
25
  data.sort((a: any, b: any) => (sortAsc ? (a.verdict?.confidence ?? 0) - (b.verdict?.confidence ?? 0) : (b.verdict?.confidence ?? 0) - (a.verdict?.confidence ?? 0)))
26
 
27
  return data.map((r: any) => {
28
+ const cited = r.verdict?.citation_ids as string[] | undefined
29
+ const firstId = (cited && cited[0]) || r.verdict?.best_evidence_id
30
+ const evid = firstId ? evidence.find((e) => e.doc_id === firstId) : undefined
31
  return { ...r, evidence: evid }
32
  })
33
  }, [run, filter, sortAsc])
ui/claimcheck-ui/src/components/EvidenceDrawer.tsx CHANGED
@@ -1,5 +1,6 @@
1
  import { useMemo } from 'react'
2
  import { useAppStore } from '@/store/appStore'
 
3
 
4
  export function EvidenceDrawer() {
5
  const run = useAppStore((s) => s.runs.find((r) => r.id === s.currentRunId))
@@ -11,10 +12,24 @@ export function EvidenceDrawer() {
11
  const verdict = useMemo(() => run?.report?.verdicts.find((v) => v.claim_id === claimId), [run, claimId])
12
 
13
  const evidences = useMemo(() => {
14
- if (!run?.report || !verdict) return []
15
- const sorted = [...run.report.evidence].sort((a, b) => (b.score ?? 0) - (a.score ?? 0))
 
 
 
 
 
 
 
 
 
 
 
 
 
 
16
  return sorted.slice(0, 5)
17
- }, [run, verdict])
18
 
19
  if (!isOpen) return null
20
 
@@ -37,8 +52,8 @@ export function EvidenceDrawer() {
37
  <div>
38
  <div className="text-xs text-muted-foreground">Top Evidence</div>
39
  <ul className="mt-2 space-y-3">
40
- {evidences.map((e) => (
41
- <li key={e.doc_id} className="rounded-md border p-3">
42
  <div className="text-xs text-muted-foreground">{e.source || e.doc_id} • score {(e.score ?? 0).toFixed(2)}</div>
43
  <div className="mt-1 text-sm whitespace-pre-wrap">{e.snippet}</div>
44
  {e.metadata && (
 
1
  import { useMemo } from 'react'
2
  import { useAppStore } from '@/store/appStore'
3
+ import type { Evidence } from '@/lib/types'
4
 
5
  export function EvidenceDrawer() {
6
  const run = useAppStore((s) => s.runs.find((r) => r.id === s.currentRunId))
 
12
  const verdict = useMemo(() => run?.report?.verdicts.find((v) => v.claim_id === claimId), [run, claimId])
13
 
14
  const evidences = useMemo(() => {
15
+ if (!run?.report) return [] as Evidence[]
16
+ const report = run.report
17
+
18
+ // 1) Prefer cited evidence for this claim
19
+ const citedIds = verdict?.citation_ids || []
20
+ if (citedIds.length > 0) {
21
+ const cited = report.evidence.filter((e) => citedIds.includes(e.doc_id))
22
+ if (cited.length > 0) return cited
23
+ }
24
+
25
+ // 2) Fallback to per-claim retrieved evidence
26
+ const byClaim = report.evidence_by_claim?.[claimId ?? ''] || []
27
+ if (byClaim.length > 0) return byClaim
28
+
29
+ // 3) Final fallback: top-scored global evidence
30
+ const sorted = [...report.evidence].sort((a, b) => (b.score ?? 0) - (a.score ?? 0))
31
  return sorted.slice(0, 5)
32
+ }, [run, verdict, claimId])
33
 
34
  if (!isOpen) return null
35
 
 
52
  <div>
53
  <div className="text-xs text-muted-foreground">Top Evidence</div>
54
  <ul className="mt-2 space-y-3">
55
+ {evidences.map((e, idx) => (
56
+ <li key={`${e.doc_id}-${idx}`} className="rounded-md border p-3">
57
  <div className="text-xs text-muted-foreground">{e.source || e.doc_id} • score {(e.score ?? 0).toFixed(2)}</div>
58
  <div className="mt-1 text-sm whitespace-pre-wrap">{e.snippet}</div>
59
  {e.metadata && (
ui/claimcheck-ui/src/lib/types.ts CHANGED
@@ -23,6 +23,7 @@ export type Verdict = {
23
  confidence?: number
24
  best_evidence_id?: string
25
  rationale?: string
 
26
  }
27
 
28
  export type CallReport = {
@@ -32,6 +33,7 @@ export type CallReport = {
32
  claims: Claim[]
33
  evidence: Evidence[]
34
  verdicts: Verdict[]
 
35
  }
36
 
37
  export type InputKind = 'audio' | 'transcript' | 'sample'
 
23
  confidence?: number
24
  best_evidence_id?: string
25
  rationale?: string
26
+ citation_ids?: string[]
27
  }
28
 
29
  export type CallReport = {
 
33
  claims: Claim[]
34
  evidence: Evidence[]
35
  verdicts: Verdict[]
36
+ evidence_by_claim?: Record<string, Evidence[]>
37
  }
38
 
39
  export type InputKind = 'audio' | 'transcript' | 'sample'