File size: 6,574 Bytes
ca4ed58
 
3da97ef
ca4ed58
 
 
3da97ef
 
 
 
 
 
 
 
83fc25d
ca4ed58
1315e90
ca4ed58
 
 
 
 
 
 
 
1315e90
ca4ed58
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1315e90
ca4ed58
3da97ef
 
 
ca4ed58
3da97ef
 
ca4ed58
 
 
 
 
 
 
 
 
3da97ef
ca4ed58
 
 
 
 
 
 
 
1315e90
ca4ed58
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
3da97ef
 
 
ca4ed58
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
3da97ef
 
 
 
 
 
 
 
 
 
ca4ed58
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
# app/agents/retriever.py
from typing import List, Tuple, Dict
import os, json, re, numpy as np, faiss, requests

from app.schemas.claim import Claim
from app.schemas.evidence import Evidence
from app.core.config import (
    WATSONX_BASE_URL as BASE,
    WATSONX_PROJECT as PROJECT_ID,
    WATSONX_API_KEY as API_KEY,
    IBM_EMBEDDINGS_MODEL_ID as EMB_MODEL_ID,
    IBM_RERANK_MODEL_ID as RERANK_MODEL_ID,
    IBM_API_VERSION as VERSION,
)
from app.core.auth import get_ibm_iam_token

BASE_URL = (BASE or "").rstrip("/")
IDX_DIR   = "kb/index"
IDX_PATH  = f"{IDX_DIR}/kb.index"
META_PATH = f"{IDX_DIR}/kb_meta.json"
SNIPPETS  = "kb/snippets.jsonl"
BASE_URL = BASE_URL.rstrip("/")

def _ibm_embed(texts: list[str]) -> np.ndarray:
    url = f"{BASE_URL}/ml/v1/text/embeddings?version={VERSION}"
    hdr = {"Authorization": f"Bearer {get_ibm_iam_token()}",
           "Accept": "application/json",
           "Content-Type": "application/json"}
    payload = {
        "inputs": texts,                  # NOTE: plural
        "model_id": EMB_MODEL_ID,
        "project_id": PROJECT_ID
    }
    r = requests.post(url, headers=hdr, json=payload, timeout=60)
    r.raise_for_status()
    j = r.json()

    # Accept either "data": [{"embedding": [...]}, ...]  OR
    # "results": [{"embedding": [...]}, ...]
    items = None
    if isinstance(j, dict):
        if "data" in j:
            items = j["data"]
        elif "results" in j:
            items = j["results"]

    if not items or not isinstance(items, list):
        # Print full response once to help diagnose, then fall back
        print(f"[retriever] Unexpected embeddings schema: {j}")
        raise RuntimeError("Embeddings response missing 'data'/'results'")

    vecs = np.asarray([it.get("embedding") for it in items], dtype=np.float32)
    if vecs.ndim != 2:
        print(f"[retriever] Bad embedding shapes: {vecs.shape}")
        raise RuntimeError("Embeddings returned with wrong dimensionality")
    # normalize for cosine/IP
    vecs /= (np.linalg.norm(vecs, axis=1, keepdims=True) + 1e-12)
    return vecs


def _ibm_rerank(query: str, docs: list[dict], top_n: int = 5) -> list[dict]:
    if not docs or not RERANK_MODEL_ID:
        return docs
    url = f"{BASE_URL}/ml/v1/text/rerank?version={VERSION}"
    hdr = {"Authorization": f"Bearer {get_ibm_iam_token()}",
           "Accept":"application/json","Content-Type":"application/json"}
    # Use stable, unique ids per passage for rerank, then map back
    passages = [{"id": str(i), "text": d["snippet"]} for i, d in enumerate(docs)]
    id2doc = {str(i): d for i, d in enumerate(docs)}
    payload = {
        "input": {"query": query, "passages": passages},
        "model_id": RERANK_MODEL_ID,
        "project_id": PROJECT_ID,
        "top_n": min(top_n, len(docs))
    }
    r = requests.post(url, headers=hdr, json=payload, timeout=60)
    if r.status_code != 200:
        return docs
    order = r.json().get("results", [])
    out = []
    for it in order:
        d = id2doc.get(it.get("id"))
        if d:
            d = {**d, "score": it.get("relevance", d.get("score", d.get("score", 0.0)))}
            out.append(d)
    return out or docs


# ---------- Local embeddings fallback ----------
_embedder = None

def _local_embed(texts: list[str]) -> np.ndarray:
    global _embedder
    if _embedder is None:
        from sentence_transformers import SentenceTransformer
        _embedder = SentenceTransformer("all-MiniLM-L6-v2")
    vecs = _embedder.encode(texts, normalize_embeddings=True)
    return np.asarray(vecs, dtype=np.float32)

def _use_ibm():
    # use IBM only if all pieces exist
    return bool(BASE_URL and PROJECT_ID and API_KEY and EMB_MODEL_ID)

def _load_snippets() -> list[dict]:
    docs = []
    with open(SNIPPETS) as f:
        for line in f:
            line = line.strip()
            if line:
                docs.append(json.loads(line))
    if not docs:
        raise RuntimeError("No KB snippets found. Please populate kb/snippets.jsonl")
    return docs

def _build_or_load():
    os.makedirs(IDX_DIR, exist_ok=True)
    if os.path.exists(IDX_PATH) and os.path.exists(META_PATH):
        return faiss.read_index(IDX_PATH), json.load(open(META_PATH))

    docs = _load_snippets()
    texts = [d["snippet"] for d in docs]
    try:
        embs = _ibm_embed(texts) if _use_ibm() else _local_embed(texts)
    except Exception as e:
        # Fallback to local embeddings if IBM call fails, but surface why
        print(f"[retriever] IBM embeddings failed, falling back to local: {e}")
        embs = _local_embed(texts)

    index = faiss.IndexFlatIP(embs.shape[1])
    index.add(embs.astype("float32"))
    faiss.write_index(index, IDX_PATH)
    json.dump(docs, open(META_PATH, "w"))
    return index, docs

def _normalize_snippet(s: str) -> str:
    return re.sub(r"\s+", " ", (s or "").strip()).lower()

def _search(query_text: str, k: int = 8) -> list[dict]:
    index, meta = _build_or_load()
    try:
        q = _ibm_embed([query_text]) if _use_ibm() else _local_embed([query_text])
    except Exception as e:
        print(f"[retriever] IBM query embed failed, using local: {e}")
        q = _local_embed([query_text])
    D, I = index.search(q.astype("float32"), k)
    hits = []
    for rank, idx in enumerate(I[0].tolist()):
        d = meta[idx]
        hits.append({
            "doc_id": d["doc_id"], "source": d.get("source","KB"),
            "snippet": d["snippet"], "score": float(D[0][rank]),
            "metadata": d.get("metadata", {})
        })
    try:
        hits = _ibm_rerank(query_text, hits, top_n=5) if _use_ibm() else hits
    except Exception as e:
        print(f"[retriever] IBM rerank failed, using original hits: {e}")
    # Deduplicate by normalized snippet text while preserving order
    seen_snippets = set()
    deduped = []
    for h in hits:
        key = _normalize_snippet(h["snippet"])
        if key in seen_snippets:
            continue
        seen_snippets.add(key)
        deduped.append(h)
    return deduped

def retrieve_evidence_for_claims(claims: List[Claim], k: int = 8) -> Tuple[List[Claim], Dict[str, List[Evidence]]]:
    claim_to_evidence: Dict[str, List[Evidence]] = {}
    for cl in claims:
        hits = _search(cl.text, k=k)
        ev_list = [
            Evidence(
                doc_id=h["doc_id"], source=h["source"], snippet=h["snippet"],
                score=h["score"], metadata=h["metadata"]
            )
            for h in hits[:5]
        ]
        claim_to_evidence[cl.id] = ev_list
    return claims, claim_to_evidence