File size: 9,222 Bytes
7b823c7
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
"""
rag.py
─────────────────────────────────────────────
Core RAG (Retrieval Augmented Generation) pipeline.
Loads the vector store, retrieves relevant chunks for a query,
and uses Groq's free Llama 3 API to generate an answer.

Includes:
- Improved retrieval (more chunks, relevance scoring)
- Fallback to general medical knowledge when verified docs
  don't have enough info β€” clearly labeled either way.
─────────────────────────────────────────────
"""

import os
from dotenv import load_dotenv
from langchain_community.embeddings import HuggingFaceEmbeddings
from langchain_community.vectorstores import Chroma
from langchain_groq import ChatGroq
from langchain.prompts import ChatPromptTemplate

load_dotenv()

BASE_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
VECTORSTORE_DIR = os.path.join(BASE_DIR, "vectorstore")

GROQ_API_KEY = os.getenv("GROQ_API_KEY")

# Relevance threshold β€” Chroma returns L2 distance (lower = more similar).
# Chunks above this distance are considered "not relevant enough" to trust.
RELEVANCE_DISTANCE_THRESHOLD_ENGLISH = 1.25
RELEVANCE_DISTANCE_THRESHOLD_OTHER = 1.15

# ─────────────────────────────────────────────
#  PROMPT β€” grounded answer (verified sources found)
# ─────────────────────────────────────────────
GROUNDED_PROMPT = """You are a helpful Rural Healthcare Assistant. Answer the question using \
ONLY the verified context below, which comes from government health guidelines and medical \
knowledge sources.

Rules:
1. Answer clearly and simply β€” assume the person may not have medical knowledge.
2. Keep answers concise but complete β€” use simple language, avoid jargon.
3. For anything serious or urgent, always recommend visiting the nearest health center or doctor.
4. Respond in the same language the question was asked in.
5. Do not mention "the context" or "the document" explicitly β€” just answer naturally.
6. Always respond in the same language as the question shown above (in the "Question:" line below) β€” typically English unless the question itself contains Hindi or another language.

Conversation so far:
{history}

Context:
{context}

Question: {question}

Answer:"""

# ─────────────────────────────────────────────
#  PROMPT β€” general fallback (no good verified match found)
# ─────────────────────────────────────────────
FALLBACK_PROMPT = """You are a helpful Rural Healthcare Assistant. The verified knowledge base \
does NOT contain enough information to answer this question, so you must answer using your own \
general medical knowledge instead.

Rules:
1. Answer clearly and simply β€” assume the person may not have medical knowledge.
2. Keep answers concise but complete.
3. Always recommend visiting the nearest health center or doctor for confirmation, since this \
answer is not from a verified local source.
4. Respond in the same language the question was asked in.
5. Be accurate and conservative β€” if you are not confident, say so honestly.
6. Always respond in the same language as the question shown above (in the "Question:" line below) β€” typically English unless the question itself contains Hindi or another language.

Conversation so far:
{history}

Question: {question}

Answer:"""


def load_vectorstore():
    """Load the persisted Chroma vector store."""
    if not os.path.exists(VECTORSTORE_DIR):
        raise FileNotFoundError(
            "Vector store not found! Please run 'python src/ingest.py' first."
        )

    embeddings = HuggingFaceEmbeddings(
        model_name="sentence-transformers/all-MiniLM-L6-v2"
    )
    vectordb = Chroma(
        persist_directory=VECTORSTORE_DIR,
        embedding_function=embeddings
    )
    return vectordb


def get_llm():
    """Initialize the free Groq-hosted Llama 3 model."""
    if not GROQ_API_KEY or GROQ_API_KEY == "paste_your_groq_api_key_here":
        raise ValueError(
            "GROQ_API_KEY not set! Please add your key to the .env file."
        )

    return ChatGroq(
        groq_api_key=GROQ_API_KEY,
        model_name="llama-3.1-8b-instant",
        temperature=0.3,
        max_tokens=600,
    )


def answer_question(query, vectordb, llm, chat_history=None, k=20):
    """
    Retrieve relevant chunks for the query and generate an answer.
    Falls back to general knowledge (clearly labeled) if no chunk is relevant enough.

    Returns: (answer_text, list_of_source_documents, is_verified: bool)
    """

    history_text = ""
    import re
    current_is_hindi = bool(re.search(r'[\u0900-\u097F\u0A00-\u0A7F]', query))
    followup_signals = ["it", "this", "that", "they", "them", "ΰ€‡ΰ€Έΰ₯‡", "ΰ€―ΰ€Ή", "ΰ€΅ΰ€Ή", "ਇਹ", "ਉਹ"]
    is_likely_followup = any(f" {word} " in f" {query.lower()} " for word in followup_signals) or len(query.split()) <= 4

    if chat_history and is_likely_followup:
        for h in chat_history[-2:]:
            msg_is_hindi = bool(re.search(r'[\u0900-\u097F\u0A00-\u0A7F]', h.get("content", "")))
            if msg_is_hindi == current_is_hindi:
                role = "User" if h["role"] == "user" else "Assistant"
                history_text += f"{role}: {h['content']}\n"

    # Step 1: Retrieve relevant chunks WITH relevance scores
    import re
    has_non_latin = bool(re.search(r'[\u0900-\u097F\u0A00-\u0A7F]', query))
    results = vectordb.similarity_search_with_score(query, k=k)
    if not has_non_latin:
        results = [(doc, score) for doc, score in results
                   if doc.metadata.get("category") != "patient_record"]

    # Step 2: Filter to only chunks that pass the relevance threshold
    threshold = RELEVANCE_DISTANCE_THRESHOLD_OTHER if has_non_latin else RELEVANCE_DISTANCE_THRESHOLD_ENGLISH
    relevant_results = [(doc, score) for doc, score in results if score <= threshold]

    if not relevant_results:
        # ── FALLBACK: no good match found in verified docs ──
        try:
            prompt = ChatPromptTemplate.from_template(FALLBACK_PROMPT)
            chain = prompt | llm
            response = chain.invoke({"question": query, "history": history_text})
            return response.content, [], False
        except Exception as e:
            return "⚠️ The service is temporarily busy. Please try again in a moment.", [], None

    # ── GROUNDED: build context from relevant chunks ──
    context_parts = []
    sources = []
    for doc, score in relevant_results[:5]:
        context_parts.append(doc.page_content)
        source_name = doc.metadata.get("source", "Unknown")
        if source_name not in sources:
            sources.append(source_name)

    context = "\n\n---\n\n".join(context_parts)

    try:
        prompt = ChatPromptTemplate.from_template(GROUNDED_PROMPT)
        chain = prompt | llm

        response = chain.invoke({
            "context": context,
            "question": query,
            "history": history_text
        })

        return response.content, sources, True
    except Exception as e:
        return "⚠️ The service is temporarily busy. Please try again in a moment.", [], None

SYMPTOM_CHECKER_PROMPT = """You are a careful Rural Healthcare symptom-checker assistant. \
Based on the symptoms described below, provide a structured assessment.

Rules:
1. List 2-3 possible (NOT definitive) conditions that match these symptoms.
2. Rate urgency as: LOW, MEDIUM, or HIGH.
3. Give clear next-step advice (home care vs see doctor vs emergency).
4. Always state this is NOT a diagnosis.
5. Respond in the same language as the input.

Symptoms described: {symptoms}

Provide your assessment in this format:
**Possible considerations:** ...
**Urgency level:** ...
**Recommended action:** ...
"""

def check_symptoms(symptoms_text, llm):
    """Structured symptom assessment β€” separate from general Q&A."""
    prompt = ChatPromptTemplate.from_template(SYMPTOM_CHECKER_PROMPT)
    chain = prompt | llm
    response = chain.invoke({"symptoms": symptoms_text})
    return response.content

if __name__ == "__main__":
    # Quick test mode β€” run this file directly to test in terminal
    print("πŸ”§ Loading vector store...")
    vectordb = load_vectorstore()
    print("πŸ”§ Loading LLM...")
    llm = get_llm()

    print("\nβœ… Ready! Ask a health question (type 'quit' to exit)\n")

    while True:
        query = input("πŸ™‹ You: ")
        if query.lower() in ["quit", "exit"]:
            break

        answer, sources, is_verified = answer_question(query, vectordb, llm)
        tag = "βœ… VERIFIED" if is_verified else "🌐 GENERAL KNOWLEDGE"
        print(f"\nπŸ€– [{tag}] Assistant: {answer}")
        if sources:
            print(f"\nπŸ“š Sources: {', '.join(sources)}")
        print()