File size: 11,658 Bytes
18e58fa
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
import os
import sys
import json
from dotenv import load_dotenv

load_dotenv()
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))

from embeddings.embedder import search
from sentence_transformers import CrossEncoder
from api.utils import detect_jurisdiction, auto_context_depth, tier_sources, strip_think_tags

# ─── Reranker ────────────────────────────────────────────────────────────────
_reranker = None
def get_reranker():
    global _reranker
    if _reranker is None:
        print("Initializing Reranker (Lazy)...")
        _reranker = CrossEncoder('BAAI/bge-reranker-base')
    return _reranker

# ─── Provider Config ──────────────────────────────────────────────────────────
OLLAMA_URL   = os.environ.get("OLLAMA_URL", "http://localhost:11434/api/chat")
OLLAMA_MODEL = os.environ.get("OLLAMA_MODEL", "llama3:latest")

OPENROUTER_API_KEY = os.environ.get("OPENROUTER_API_KEY")
OPENROUTER_MODEL   = "meta-llama/llama-3.3-70b-instruct:free"

GROQ_API_KEY = os.environ.get("GROQ_API_KEY")
GROQ_MODEL   = "llama-3.3-70b-versatile"


LLM_PROVIDER = os.environ.get("LLM_PROVIDER", "openrouter")

# ─── System Prompt ────────────────────────────────────────────────────────────
SYSTEM_PROMPT = """You are LexRAG, an expert AI counsel for UAE and Indian law, taxation, and accounting.

RULES:
1. If context documents are provided and relevant, answer ONLY from them. Cite source title and jurisdiction.
2. If context is insufficient or the question is off-topic, answer helpfully using your knowledge. Prefix ALL such paragraphs with [INDEPENDENT ANALYSIS].
3. Be concise and structured. Use bullet points for lists of rules or rates.
4. Always end your response with a one-line tag: JURISDICTION: India | UAE | Both | General
5. Never fabricate statute numbers or case names."""

# ─── Retrieval + Reranking ────────────────────────────────────────────────────
def search_and_rerank(question: str, jurisdiction: str = None, top_k: int = 5) -> list:
    filters = {}
    if jurisdiction and jurisdiction != "Both":
        filters["jurisdiction"] = [jurisdiction, "Both"]
    initial = search(question, top_k=20, filters=filters if filters else None)
    if not initial:
        return []
    try:
        pairs  = [[question, d["text"]] for d in initial]
        scores = get_reranker().predict(pairs)
        for i, s in enumerate(scores):
            initial[i]["rerank_score"] = float(s)
        ranked = sorted(initial, key=lambda x: x["rerank_score"], reverse=True)
        return ranked[:top_k]
    except Exception as e:
        print(f"Rerank error: {e}")
        return initial[:top_k]

# ─── Prompt Builder ───────────────────────────────────────────────────────────
def build_prompt(query: str, context_docs: list, history: list = None, confidence: str = "GROUNDED") -> str:
    if context_docs:
        ctx = "\n\n---\n\n".join([
            f"[Source: {d['source']} | Jurisdiction: {d['jurisdiction']} | Date: {d.get('date','')}]\n"
            f"Title: {d['doc_title']}\n\n{d['text']}"
            for d in context_docs
        ])
    else:
        ctx = "No relevant documents found."

    hist_str = ""
    if history:
        hist_str = "CONVERSATION HISTORY:\n" + "\n".join(
            f"{h['role'].upper()}: {h['content']}" for h in history
        ) + "\n\n"

    fallback = ""
    if confidence == "SYNTHESIZED":
        fallback = "\nNote: No strong document matches found. Provide an independent analysis based on your knowledge and mark paragraphs with [INDEPENDENT ANALYSIS].\n"

    return f"""{hist_str}CONTEXT DOCUMENTS:
{ctx}
{fallback}
QUESTION: {query}"""

# ─── Streaming Generators ────────────────────────────────────────────────────
import httpx

async def stream_groq(messages: list, model: str = None):
    model    = model or GROQ_MODEL
    in_think = False
    async with httpx.AsyncClient(timeout=120.0) as client:
        async with client.stream(
            "POST", "https://api.groq.com/openai/v1/chat/completions",
            headers={"Authorization": f"Bearer {GROQ_API_KEY}", "Content-Type": "application/json"},
            json={"model": model, "messages": messages, "stream": True}
        ) as resp:
            if resp.status_code != 200:
                err_body = await resp.aread()
                try: err_json = json.loads(err_body)
                except: err_json = {"error": {"message": err_body.decode()}}
                msg = err_json.get("error", {}).get("message", "Unknown Groq error")
                raise Exception(f"Groq API Error ({resp.status_code}): {msg}")

            async for line in resp.aiter_lines():
                if not line.startswith("data: "): continue
                data = line[6:]
                if data.strip() == "[DONE]": break
                try:
                    pdata = json.loads(data)
                    if "error" in pdata:
                        raise Exception(f"Groq Stream Error: {pdata['error'].get('message', 'Unknown')}")
                    token = pdata["choices"][0]["delta"].get("content", "")
                    if not token: continue
                    clean, in_think = strip_think_tags(token, in_think)
                    if clean: yield clean
                except Exception as e:
                    if "Stream Error" in str(e) or "API Error" in str(e): raise e
                    pass

async def stream_openrouter(messages: list, model: str = None):
    model    = model or OPENROUTER_MODEL
    in_think = False
    async with httpx.AsyncClient(timeout=120.0) as client:
        async with client.stream(
            "POST", "https://openrouter.ai/api/v1/chat/completions",
            headers={"Authorization": f"Bearer {OPENROUTER_API_KEY}", "Content-Type": "application/json"},
            json={"model": model, "messages": messages, "stream": True}
        ) as resp:
            if resp.status_code != 200:
                err_body = await resp.aread()
                try: err_json = json.loads(err_body)
                except: err_json = {"error": {"message": err_body.decode()}}
                msg = err_json.get("error", {}).get("message", "Unknown OpenRouter error")
                raise Exception(f"OpenRouter API Error ({resp.status_code}): {msg}")

            async for line in resp.aiter_lines():
                if not line.startswith("data: "): continue
                data = line[6:]
                if data.strip() == "[DONE]": break
                try:
                    pdata = json.loads(data)
                    if "error" in pdata:
                        raise Exception(f"OpenRouter Stream Error: {pdata['error'].get('message', 'Unknown')}")
                    token = pdata["choices"][0]["delta"].get("content", "")
                    if not token: continue
                    clean, in_think = strip_think_tags(token, in_think)
                    if clean: yield clean
                except Exception as e:
                    if "Stream Error" in str(e) or "API Error" in str(e): raise e
                    pass

async def stream_ollama(messages: list, model: str = None):
    model = model or OLLAMA_MODEL
    async with httpx.AsyncClient(timeout=180.0) as client:
        async with client.stream(
            "POST", OLLAMA_URL,
            json={"model": model, "messages": messages, "stream": True}
        ) as resp:
            if resp.status_code != 200:
                err_body = await resp.aread()
                raise Exception(f"Ollama API Error ({resp.status_code}): {err_body.decode()}")
            async for line in resp.aiter_lines():
                if not line.strip():
                    continue
                try:
                    chunk = json.loads(line)
                    token = chunk.get("message", {}).get("content", "")
                    if token: yield token
                    if chunk.get("done"): break
                except Exception:
                    pass

async def stream_provider(messages: list, provider: str, model: str = None):
    if provider == "groq":
        async for t in stream_groq(messages, model): yield t
    elif provider == "openrouter":
        async for t in stream_openrouter(messages, model): yield t
    elif provider == "ollama":
        async for t in stream_ollama(messages, model): yield t
    else:
        async for t in stream_groq(messages, model): yield t

# ─── Legacy sync query (CLI) ──────────────────────────────────────────────────
def query_rag(question: str, jurisdiction: str = None, source_type: str = None,
              top_k: int = None, provider: str = None, session_id: str = "default") -> dict:
    from api.memory import save_message, get_history
    from api.utils import parse_citations
    provider   = provider or LLM_PROVIDER
    top_k      = top_k or auto_context_depth(question)
    jurisdiction = jurisdiction or detect_jurisdiction(question)
    docs       = search_and_rerank(question, jurisdiction, top_k)
    confidence = tier_sources(docs)
    history    = get_history(session_id, limit=5)
    prompt     = build_prompt(question, docs, history, confidence)
    messages   = [{"role": "system", "content": SYSTEM_PROMPT}, {"role": "user", "content": prompt}]
    try:
        import httpx as _h
        if provider == "groq":
            r = _h.Client(timeout=60).post(
                "https://api.groq.com/openai/v1/chat/completions",
                headers={"Authorization": f"Bearer {GROQ_API_KEY}"},
                json={"model": GROQ_MODEL, "messages": messages}
            ).json()["choices"][0]["message"]["content"]
        elif provider == "openrouter":
            r = _h.Client(timeout=60).post(
                "https://openrouter.ai/api/v1/chat/completions",
                headers={"Authorization": f"Bearer {OPENROUTER_API_KEY}"},
                json={"model": OPENROUTER_MODEL, "messages": messages}
            ).json()["choices"][0]["message"]["content"]
        else:
            r = "Provider not supported in sync mode."
        r = parse_citations(r)
    except Exception as e:
        r = f"Error: {e}"
    save_message(session_id, "user", question)
    save_message(session_id, "assistant", r, sources=docs, provider=provider)
    return {"answer": r, "sources": [{"title": d["doc_title"], "source": d["source"],
            "jurisdiction": d["jurisdiction"], "type": d["source_type"], "url": d["url"],
            "score": round(d.get("rerank_score", 0), 3)} for d in docs],
            "context_used": len(docs), "provider": provider, "session_id": session_id,
            "confidence": confidence, "jurisdiction": jurisdiction}

if __name__ == "__main__":
    r = query_rag("What is the GST rate on online gaming contest entry fees in India?")
    print("ANSWER:", r["answer"][:300])
    print("CONFIDENCE:", r["confidence"])
    print("JURISDICTION:", r["jurisdiction"])