File size: 14,488 Bytes
0ee953b
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
"""
JAIM - RAG Pipeline (v3)
Core Retrieval-Augmented Generation engine with:
- BAAI/bge-large-en-v1.5 embeddings (1024 dims)
- Query Expansion via Groq (3 rephrasings + original = 4 queries)
- Deduplication by parent entry ID β†’ single best match to LLM
- Forward + Backward chaining inference engine

Uses `requests` for HTTP calls for Python 3.14 compatibility.
"""

import os
import json
import time
import requests
from dotenv import load_dotenv
from sentence_transformers import SentenceTransformer
from inference.engine import VrikshayurvedaInferenceEngine

load_dotenv()

# ─── Config ──────────────────────────────────────────────────────────────────
PINECONE_API_KEY = os.getenv("PINECONE_API_KEY")
GROQ_API_KEY = os.getenv("GROQ_API_KEY")
INDEX_NAME = os.getenv("PINECONE_INDEX_NAME", "jaim3")
EMBEDDING_MODEL = "BAAI/bge-large-en-v1.5"
GROQ_MODEL = "llama-3.3-70b-versatile"
# BGE spec: queries must be prefixed with this instruction
QUERY_PREFIX = "Represent this sentence for searching relevant passages: "
TOP_K = 3


class JAIMPipeline:
    """
    JAIM RAG Pipeline v3:
    1. Expands user query into 4 variants via Groq
    2. Encodes all variants using BGE-large-en-v1.5 (with query prefix)
    3. Retrieves top-K from Pinecone for each variant
    4. Deduplicates by parent_id, keeps highest score per entry
    5. Runs forward + backward chaining inference engine
    6. Sends the best match's context + inference grounding to Groq LLM
    """

    def __init__(self):
        print("πŸ”„ Initializing JAIM Pipeline...")

        print("   πŸ“¦ Loading BGE-large-en-v1.5 embedding model...")
        self.embed_model = SentenceTransformer(EMBEDDING_MODEL)

        print("   🌲 Connecting to Pinecone...")
        self.pinecone_host = self._get_pinecone_host()
        print(f"      Index host: {self.pinecone_host}")

        print("   πŸ€– Groq (Llama 3.3 70B) configured")
        print("   🧠 Initializing forward/backward chaining inference engine...")
        self.inference_engine = VrikshayurvedaInferenceEngine()
        print("βœ… JAIM Pipeline ready!\n")

    # ─── Pinecone Host Discovery ──────────────────────────────────────────────
    def _get_pinecone_host(self) -> str:
        """Get the Pinecone index host URL via the control plane API."""
        url = f"https://api.pinecone.io/indexes/{INDEX_NAME}"
        headers = {"Api-Key": PINECONE_API_KEY}
        resp = requests.get(url, headers=headers, timeout=15)
        resp.raise_for_status()
        return resp.json()["host"]

    # ─── Query Expansion via Groq ─────────────────────────────────────────────
    def expand_query(self, query: str) -> list[str]:
        """
        Use Groq to generate 3 rephrasings of the user query.
        Returns [original, rephrasing1, rephrasing2, rephrasing3].
        """
        url = "https://api.groq.com/openai/v1/chat/completions"
        headers = {
            "Authorization": f"Bearer {GROQ_API_KEY}",
            "Content-Type": "application/json",
        }
        prompt = f"""Generate exactly 3 different rephrasings of the following plant symptom query.
Each rephrasing should use different vocabulary while preserving the meaning.
Return ONLY a valid JSON array of 3 strings. No extra text, no markdown.

Original query: "{query}"
"""
        payload = {
            "model": GROQ_MODEL,
            "messages": [
                {"role": "system", "content": "You rephrase queries. Return only valid JSON arrays."},
                {"role": "user", "content": prompt},
            ],
            "temperature": 0.7,
            "max_tokens": 256,
        }

        for attempt in range(3):
            resp = requests.post(url, json=payload, headers=headers, timeout=15)
            if resp.status_code == 429:
                time.sleep(2 ** (attempt + 1))
                continue
            resp.raise_for_status()
            break
        else:
            print("   ⚠️ Query expansion failed (rate limited), using original only")
            return [query]

        content = resp.json()["choices"][0]["message"]["content"].strip()
        if content.startswith("```"):
            content = content.split("\n", 1)[1].rsplit("```", 1)[0].strip()

        try:
            rephrasings = json.loads(content)
            return [query] + rephrasings[:3]
        except json.JSONDecodeError:
            print("   ⚠️ Query expansion parse error, using original only")
            return [query]

    # ─── Pinecone Retrieval ───────────────────────────────────────────────────
    def retrieve_single(self, query_embedding: list, top_k: int = TOP_K) -> list[dict]:
        """Query Pinecone with a single embedding vector."""
        url = f"https://{self.pinecone_host}/query"
        headers = {
            "Api-Key": PINECONE_API_KEY,
            "Content-Type": "application/json",
        }
        payload = {
            "vector": query_embedding,
            "topK": top_k,
            "includeMetadata": True,
        }

        resp = requests.post(url, json=payload, headers=headers, timeout=15)
        resp.raise_for_status()

        matches = []
        for match in resp.json().get("matches", []):
            matches.append({
                "id": match["id"],
                "score": round(match["score"], 4),
                "metadata": match.get("metadata", {})
            })
        return matches

    def retrieve_with_variants(self, variants: list[str], top_k: int = TOP_K) -> list[dict]:
        """
        Retrieval pipeline using pre-expanded query variants:
        1. Embed each variant with BGE-large (query prefix applied)
        2. Retrieve top-K from Pinecone for each variant
        3. Deduplicate by parent_id, keep highest score per entry
        """
        print(f"   πŸ”„ Retrieving with {len(variants)} query variants")

        all_matches = []
        for variant in variants:
            prefixed = f"{QUERY_PREFIX}{variant}"
            embedding = self.embed_model.encode(prefixed).tolist()
            matches = self.retrieve_single(embedding, top_k)
            all_matches.extend(matches)

        # Deduplicate by parent_id, keep highest score per entry
        best_by_parent = {}
        for match in all_matches:
            parent = match["metadata"].get("parent_id", match["id"])
            if parent not in best_by_parent or match["score"] > best_by_parent[parent]["score"]:
                best_by_parent[parent] = match

        # Sort by score descending
        deduped = sorted(best_by_parent.values(), key=lambda x: x["score"], reverse=True)
        return deduped

    # ─── Context Building ─────────────────────────────────────────────────────
    def build_context(self, matches: list[dict]) -> str:
        """Build context from the single highest-scoring deduplicated match."""
        if not matches:
            return "No relevant entries found in the Vrikshayurveda database."

        # Use only the single highest-scoring match
        match = matches[0]
        meta = match["metadata"]
        possible_causes = meta.get("possible_causes", "N/A")
        if isinstance(possible_causes, list):
            possible_causes = ", ".join(possible_causes)

        return (
            f"--- Best Match (Relevance: {match['score']}) ---\n"
            f"Disorder Type: {meta.get('disorder', 'N/A')}\n"
            f"Cause (Dosha): {meta.get('cause_given', 'N/A')}\n"
            f"Symptoms: {meta.get('symptoms', 'N/A')}\n"
            f"Cause Elaborated: {meta.get('cause_elaborated', 'N/A')}\n"
            f"Possible Causes: {possible_causes}\n"
            f"Ayurvedic Treatment: {meta.get('treatment_material', 'N/A')}\n"
        )

    # ─── LLM Response Generation ──────────────────────────────────────────────
    def _format_inference_context(self, inference_result) -> str:
        """Convert chaining inference output into compact prompt context."""
        if inference_result is None:
            return "No deterministic diagnosis available."
        if hasattr(inference_result, 'llm_context'):
            return inference_result.llm_context
        return "No inference context available."

    def generate_response(self, query: str, context: str, inference_result=None) -> str:
        """Use Groq REST API (Llama 3.3 70B) to generate a response."""
        system_prompt = """You are JAIM (Jnana AI for Marga β€” Knowledge AI for the Path), an expert Ayurvedic plant care assistant powered by the ancient Vrikshayurveda (Science of Plant Life).

You help farmers, gardeners, and plant enthusiasts diagnose plant disorders and recommend traditional Ayurvedic treatments based on the Vrikshayurveda text by Surapala.

IMPORTANT RULES:
1. Base your response PRIMARILY on the retrieved Vrikshayurveda knowledge provided.
2. The Inference Engine Analysis provides a formal reasoning chain β€” use it to validate and strengthen your response.
3. Provide the specific Ayurvedic treatment from the retrieved data based on the matching symptoms.
4. You may add brief modern context to help the user understand, but always prioritize the traditional treatment.
5. Be clear, structured, and helpful.
6. If the query doesn't match any known disorder well, say so honestly."""

        llm_grounding = self._format_inference_context(inference_result)

        user_message = f"""═══════════════════════════════════════
[Inference Engine Analysis]
═══════════════════════════════════════
{llm_grounding}

═══════════════════════════════════════
[Retrieved Vrikshayurveda Passages]
═══════════════════════════════════════
{context}

═══════════════════════════════════════
[User Question]
═══════════════════════════════════════
{query}

Please provide a comprehensive, well-structured response:"""

        url = "https://api.groq.com/openai/v1/chat/completions"
        headers = {
            "Authorization": f"Bearer {GROQ_API_KEY}",
            "Content-Type": "application/json",
        }
        payload = {
            "model": GROQ_MODEL,
            "messages": [
                {"role": "system", "content": system_prompt},
                {"role": "user", "content": user_message},
            ],
            "temperature": 0.7,
            "max_tokens": 2048,
        }

        # Retry with backoff for rate limits
        for attempt in range(3):
            resp = requests.post(url, json=payload, headers=headers, timeout=30)
            if resp.status_code == 429:
                wait_time = 2 ** (attempt + 1)
                print(f"   ⏳ Rate limited, retrying in {wait_time}s...")
                time.sleep(wait_time)
                continue
            resp.raise_for_status()
            break
        else:
            return "⚠️ Rate limit exceeded. Please try again in a minute."

        result = resp.json()

        try:
            return result["choices"][0]["message"]["content"]
        except (KeyError, IndexError):
            return f"Error parsing response: {json.dumps(result, indent=2)}"

    # ─── Full RAG Pipeline ────────────────────────────────────────────────────
    def query(self, user_query: str, top_k: int = TOP_K) -> dict:
        """
        Full RAG pipeline:
        expand β†’ retrieve β†’ infer (chaining) β†’ build context β†’ generate response.
        """
        # Step 1: Expand query into variants
        expanded_queries = self.expand_query(user_query)
        print(f"   πŸ”„ Query expanded into {len(expanded_queries)} variants")

        # Step 2: Retrieve with pre-expanded variants
        matches = self.retrieve_with_variants(expanded_queries, top_k)

        # Step 3: Extract symptom text from retrieved matches for inference
        retrieved_chunks = [
            m["metadata"].get("symptoms", "")
            for m in matches
            if m.get("metadata", {}).get("symptoms")
        ]

        # Step 4: Run forward + backward chaining inference engine
        diagnosis_result = self.inference_engine.diagnose(
            user_query=user_query,
            expanded_queries=expanded_queries,
            retrieved_chunks=retrieved_chunks,
        )

        # Step 5: Build context and generate LLM response
        context = self.build_context(matches)
        response = self.generate_response(
            user_query, context, inference_result=diagnosis_result
        )

        return {
            "query": user_query,
            "response": response,
            "diagnosis": diagnosis_result,
            "sources": matches[:top_k],
            "num_sources": len(matches)
        }


# ─── Quick Test ───────────────────────────────────────────────────────────────
if __name__ == "__main__":
    pipeline = JAIMPipeline()

    test_query = "My tree trunk is bent and the fruits are hard and not juicy"
    print(f"πŸ” Query: {test_query}\n")

    result = pipeline.query(test_query)

    print("=" * 60)
    print("πŸ“‹ JAIM RESPONSE:")
    print("=" * 60)
    print(result["response"])
    print("\n" + "=" * 60)
    print(f"πŸ“Š Sources used: {result['num_sources']}")
    for src in result["sources"]:
        print(f"   β€’ {src['id']} (score: {src['score']})")