grazz7 commited on
Commit
b22c324
·
1 Parent(s): 551e646

added chatbot codes

Browse files
README.md CHANGED
@@ -1,3 +1,50 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
  ---
2
  license: mit
3
  ---
 
1
+ ## Project Overview
2
+ This respository was created as part for an internship report, containing the source codes and respurces in developing the Retrieval-Augmented Generation(RAG) banking chatbot.
3
+
4
+ RAG allows the chatbot to answer questions based on the knowledge base instead of only using the Large Language Model (LLM).
5
+
6
+ ## Objectives
7
+ - Develop a chatbot capable of answering questions based on public dataset(s)
8
+ - Implement a Retrieval-Augmented Generation (RAG) pipeline
9
+ - Generate embeddings for efficient document retrieval
10
+ - Build a vector database
11
+ - Improve response accuracy by providing relevant context to the language model
12
+ - Implement advanced requirements
13
+ - Evaluate the chatbot's performance
14
+
15
+ ## Installation
16
+ Virtual environment:
17
+ python -m venv venv
18
+ venv\Scripts\activate
19
+
20
+ Libraries:
21
+ pip install -r requirements.txt
22
+
23
+ Run the chatbot:
24
+ streamlit run app.py
25
+
26
+ ## How it works
27
+ The chatbot follows a Retrieval-Augmented Generation (RAG) workflow:
28
+
29
+ 1. Documents are collected and preprocessed.
30
+ 2. The documents are split into smaller text chunks.
31
+ 3. Each chunk is converted into vector embeddings.
32
+ 4. The embeddings are stored in a vector database.
33
+ 5. When a user submits a query:
34
+ - The query is embedded into a vector.
35
+ - The vector database retrieves the most relevant document chunks.
36
+ - The retrieved context is combined with the user's query.
37
+ - The LLM generates an appropriate response using the retrieved information.
38
+
39
+ ## Requirements
40
+ - Python 3.10 or above
41
+ - LangChain
42
+ - Ollama
43
+ - Vector database (ChromaDB)
44
+ - Sentence Transformer / Embedding model
45
+ - python-dotenv
46
+ - Other dependencies listed in `requirements.txt`
47
+
48
  ---
49
  license: mit
50
  ---
agentic_workflow/__init__.py ADDED
File without changes
agentic_workflow/config.py ADDED
@@ -0,0 +1,8 @@
 
 
 
 
 
 
 
 
 
1
+ CHROMA_PATH = "./chroma_db"
2
+ COLLECTION_NAME = "knowledge"
3
+ CORRECTIONS_COLLECTION = "corrections"
4
+ EMBED_MODEL = "multi-qa-mpnet-base-dot-v1"
5
+ LLM_MODEL = "llama3.2"
6
+ CONFIDENCE_THRESHOLD = 0.75
7
+ MAX_RETRIES = 3
8
+ TOP_K = 3
agentic_workflow/evaluator.py ADDED
@@ -0,0 +1,60 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from agentic_workflow.config import LLM_MODEL
2
+ from langchain_ollama import ChatOllama
3
+ import re
4
+ import json
5
+ from langchain_core.messages import HumanMessage, SystemMessage
6
+
7
+ llm = ChatOllama(model=LLM_MODEL, temperature=0)
8
+
9
+ def evaluate_response(query: str, response: str) -> dict:
10
+ """
11
+ Scores the draft response and decides whether to retry.
12
+
13
+ Returns a dict with keys:
14
+ - score: int 1-10
15
+ - pass: bool → True means use this response
16
+ - reason: str → short explanation
17
+ - suggestion: str → how to improve (if not passing)
18
+ """
19
+ system = """
20
+ You are a strict response quality evaluator. Given a user query and a draft response, return ONLY valid JSON:
21
+ - "score": integer 1-10 (10 = excellent)
22
+ - "pass": true if score >= 7, false otherwise
23
+ - "reason": one-sentence explanation
24
+ - "suggestion": if not passing, a short instruction to improve the response; else ""
25
+
26
+ Return ONLY the JSON object. No explanation.
27
+ """
28
+
29
+ response_eval = llm.invoke([
30
+ SystemMessage(content=system),
31
+ HumanMessage(content=f"Query: {query}\n\nDraft response: {response}")
32
+ ])
33
+ raw = response_eval.content.strip()
34
+
35
+ raw = re.sub(r"^```(?:json)?\s*|\s*```$", "", raw).strip()
36
+
37
+ try:
38
+ parsed = json.loads(raw)
39
+ except json.JSONDecodeError:
40
+ parsed = {}
41
+
42
+ return {
43
+ "score": 5,
44
+ "pass": False,
45
+ "reason": "Evaluation failed",
46
+ "suggestion": "Retry with more detail",
47
+ **parsed
48
+ }
49
+
50
+ # result = evaluate_response(
51
+ # query="What is the refund policy?",
52
+ # response="According to the context provided earlier, the cancellation procedure involves a notice period of 30 days. The premium paid by you will be returned on a pro-rata basis or 25% of the annual premium, whichever is higher, will be retained. Any cancellation request sent after 30 days of commencement of the policy will be refunded on a pro-rata basis."
53
+ # )
54
+ # print("Test 1 (good response):", result)
55
+
56
+ # result = evaluate_response(
57
+ # query="What is the refund policy?",
58
+ # response="You can get a refund if you contact support."
59
+ # )
60
+ # print("Test 3 (vague response):", result)
agentic_workflow/generator.py ADDED
@@ -0,0 +1,38 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import json
2
+ import chromadb
3
+ from agentic_workflow.config import LLM_MODEL
4
+ from langchain_ollama import ChatOllama
5
+ from langchain_core.messages import HumanMessage, SystemMessage, AIMessage
6
+
7
+ llm = ChatOllama(model=LLM_MODEL, temperature=0)
8
+
9
+ def generate_llm_response(query: str, context: list[str], history: list[dict], suggestion: str = "") -> str:
10
+ system_parts = ["You are a helpful assistant."]
11
+ if suggestion:
12
+ system_parts.append(f"Improvement hint from previous attempt: {suggestion}")
13
+
14
+ messages = [SystemMessage(content=" ".join(system_parts))]
15
+
16
+ # Convert history dicts to LangChain message objects
17
+ for turn in history:
18
+ if turn["role"] == "user":
19
+ messages.append(HumanMessage(content=turn["content"]))
20
+ elif turn["role"] == "assistant":
21
+ messages.append(AIMessage(content=turn["content"]))
22
+
23
+ # Append context to the user query if available
24
+ user_content = query
25
+ if context:
26
+ user_content = "Context:\n" + "\n".join(context) + "\n\nQuestion: " + query
27
+
28
+ messages.append(HumanMessage(content=user_content))
29
+
30
+ response = llm.invoke(messages)
31
+ return response.content
32
+
33
+ # result = generate_llm_response(
34
+ # query="What is the refund policy?",
35
+ # context=[],
36
+ # history=[]
37
+ # )
38
+ # print("Test 2:", result)
agentic_workflow/intent.py ADDED
@@ -0,0 +1,55 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import json
2
+ import chromadb
3
+ from agentic_workflow.config import LLM_MODEL
4
+ from langchain_ollama import ChatOllama
5
+ import re
6
+ from langchain_core.messages import HumanMessage, SystemMessage
7
+
8
+ client = chromadb.PersistentClient(path="./chroma_db")
9
+ llm = ChatOllama(model=LLM_MODEL, temperature=0)
10
+
11
+ # intent analysis
12
+ def analyse_intent(query: str) -> dict:
13
+
14
+ system = """
15
+ You are an intent classifier. Given a user query, return ONLY valid JSON with:
16
+ - "intent": one of "factual", "conversational", "follow_up", "sensitive"
17
+ - "needs_context": true if retrieval from a knowledge base is needed, false otherwise
18
+ - "needs_history": true if this looks like a follow-up to a prior turn, false otherwise
19
+
20
+ Rules:
21
+ - Greetings / chit-chat → conversational, needs_context: false
22
+ - Questions about facts / documents → factual, needs_context: true
23
+ - "you said earlier" / pronouns like "it" / "that" → follow_up, needs_history: true
24
+ - Personal data, health, finance → sensitive
25
+ Return ONLY the JSON object. No explanation.
26
+ """
27
+
28
+ response = llm.invoke([
29
+ SystemMessage(content=system),
30
+ HumanMessage(content=query)
31
+ ])
32
+ raw = response.content.strip()
33
+ # Strip markdown fences if present
34
+ raw = re.sub(r"^```(?:json)?\s*|\s*```$", "", raw).strip()
35
+
36
+ try:
37
+ parsed = json.loads(raw)
38
+ except json.JSONDecodeError:
39
+ parsed = {}
40
+
41
+ return {
42
+ "intent": "factual",
43
+ "needs_context": True,
44
+ "needs_history": False,
45
+ **parsed
46
+ }
47
+
48
+ # query= "How do I top-up the value of my card when I am abroad"
49
+ # trace = []
50
+ # intent_result = analyse_intent(query)
51
+ # trace.append({
52
+ # "step": "intent_analysis",
53
+ # "result": intent_result
54
+ # })
55
+ # print(f"[Intent] {intent_result}")
agentic_workflow/pii.py ADDED
@@ -0,0 +1,30 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from presidio_analyzer import AnalyzerEngine, PatternRecognizer, Pattern
2
+ from presidio_analyzer.nlp_engine import NlpEngineProvider
3
+ from presidio_anonymizer import AnonymizerEngine
4
+
5
+ _provider = NlpEngineProvider(nlp_configuration={
6
+ "nlp_engine_name": "spacy",
7
+ "models": [{"lang_code": "en", "model_name": "en_core_web_lg"}]
8
+ })
9
+
10
+ analyzer = AnalyzerEngine(nlp_engine=_provider.create_engine())
11
+ anonymizer = AnonymizerEngine()
12
+
13
+ analyzer.registry.add_recognizer(PatternRecognizer(
14
+ supported_entity="ACCOUNT_NUMBER",
15
+ patterns=[Pattern(name="account_number", regex=r"\d{3}-\d{3}-\d{3}", score=0.8)],
16
+ context=["account", "acct", "num", "number", "no.", "#"]
17
+ ))
18
+
19
+ _ENTITIES = ["ACCOUNT_NUMBER", "PERSON", "PHONE_NUMBER", "EMAIL_ADDRESS", "CREDIT_CARD"]
20
+
21
+ # 2 funcs
22
+ def check_pii(text: str) -> bool:
23
+ """Returns True if PII is detected — used by planner to block the query."""
24
+ results = analyzer.analyze(text=text, language="en", entities=_ENTITIES)
25
+ return len(results) > 0
26
+
27
+ def scrub_pii(text: str) -> str:
28
+ """Replaces PII with placeholders — use this if you want to proceed with a sanitised query instead of blocking."""
29
+ results = analyzer.analyze(text=text, language="en", entities=_ENTITIES)
30
+ return anonymizer.anonymize(text=text, analyzer_results=results).text
agentic_workflow/planner.py ADDED
@@ -0,0 +1,101 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import json
2
+ import chromadb
3
+ from agentic_workflow.config import LLM_MODEL
4
+ from langchain_ollama import ChatOllama
5
+ from dataclasses import dataclass, field
6
+
7
+ llm = ChatOllama(model=LLM_MODEL, temperature=0)
8
+
9
+ @dataclass
10
+ class PlanStep:
11
+ name: str # e.g. "retrieval", "pii_check"
12
+ enabled: bool # whether this step should run
13
+ reason: str # why it was enabled or skipped
14
+
15
+ @dataclass
16
+ class Plan:
17
+ steps: list[PlanStep] = field(default_factory=list)
18
+
19
+ def is_enabled(self, step_name: str) -> bool:
20
+ """Quick lookup — used by the pipeline to check if a step should run."""
21
+ return any(s.name == step_name and s.enabled for s in self.steps)
22
+
23
+ def summary(self) -> None:
24
+ """Prints a readable breakdown of the plan."""
25
+ print("\n── Execution Plan ──────────────────────────")
26
+ for s in self.steps:
27
+ status = "RUN " if s.enabled else "SKIP"
28
+ print(f" [{status}] {s.name:<20} → {s.reason}")
29
+ print("────────────────────────────────────────────\n")
30
+
31
+ def create_plan(intent_result: dict) -> Plan:
32
+ """
33
+ Reads the intent analysis output and decides which steps
34
+ the pipeline should execute, with a reason for each decision.
35
+
36
+ Args:
37
+ intent_result: dict returned by analyse_intent(), e.g.
38
+ {
39
+ "intent": "factual",
40
+ "needs_context": True,
41
+ "needs_history": False
42
+ }
43
+
44
+ Returns:
45
+ Plan object with a PlanStep for each possible action.
46
+ """
47
+ intent = intent_result.get("intent", "factual")
48
+ needs_context = intent_result.get("needs_context", True)
49
+ needs_history = intent_result.get("needs_history", False)
50
+
51
+ steps = []
52
+
53
+ # PII check
54
+ # Always runs — sensitive queries must be gated regardless of intent
55
+ steps.append(PlanStep(
56
+ name="pii_check",
57
+ enabled=True,
58
+ reason="Always runs to protect against personal data leakage"
59
+ ))
60
+
61
+ # Vector retrieval
62
+ # Only runs for factual queries that need external knowledge
63
+ steps.append(PlanStep(
64
+ name="retrieval",
65
+ enabled=needs_context,
66
+ reason=(
67
+ "Query requires knowledge base context"
68
+ if needs_context
69
+ else f"Skipped — '{intent}' intent does not need retrieval"
70
+ )
71
+ ))
72
+
73
+ # Conversation history
74
+ # Only runs when the query references a prior turn
75
+ steps.append(PlanStep(
76
+ name="history",
77
+ enabled=needs_history,
78
+ reason=(
79
+ "Query appears to reference a prior turn"
80
+ if needs_history
81
+ else "Skipped — query is self-contained"
82
+ )
83
+ ))
84
+
85
+ # LLM generation
86
+ # Always runs — we always need a response
87
+ steps.append(PlanStep(
88
+ name="llm_generation",
89
+ enabled=True,
90
+ reason="Always runs to generate the final response"
91
+ ))
92
+
93
+ # Quality evaluation
94
+ # Always runs — every response should be checked before returning
95
+ steps.append(PlanStep(
96
+ name="quality_eval",
97
+ enabled=True,
98
+ reason="Always runs to verify response quality before returning"
99
+ ))
100
+
101
+ return Plan(steps=steps)
agentic_workflow/retriever.py ADDED
@@ -0,0 +1,56 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # from chroma.chroma_client import knowledge_col, corrections_col
2
+ # from chatbot_embed import embed
3
+ # from agentic_workflow.config import TOP_K
4
+ from sentence_transformers import SentenceTransformer
5
+ import chromadb
6
+ from pathlib import Path
7
+
8
+ base_dir = Path(__file__).resolve().parent.parent
9
+ embed_model = SentenceTransformer("multi-qa-mpnet-base-dot-v1")
10
+ db_path = base_dir / "chroma" / "chroma_db"
11
+ client = chromadb.PersistentClient(path= str(db_path))
12
+
13
+ print(client.list_collections())
14
+
15
+ def retrieve_from_vector_db(query: str, collection_name: str = "bank_faq", k: int = 3) -> list[dict]:
16
+ """
17
+ Embeds the query using the same model used during ingestion,
18
+ queries ChromaDB, and returns chunks with their similarity scores.
19
+ """
20
+
21
+ collection = client.get_collection(name=collection_name)
22
+
23
+ # Embed the query with the same model used at ingestion
24
+ query_embedding = embed_model.encode(query).tolist()
25
+
26
+ results = collection.query(
27
+ query_embeddings=[query_embedding],
28
+ n_results=k,
29
+ include=["documents", "metadatas", "distances"]
30
+ )
31
+
32
+ # ChromaDB returns distances (lower = more similar), convert to similarity score
33
+ chunks = []
34
+ for doc, metadata, distance in zip(
35
+ results["documents"][0],
36
+ results["metadatas"][0],
37
+ results["distances"][0]
38
+ ):
39
+ chunks.append({
40
+ "text": doc,
41
+ "metadata": metadata,
42
+ "similarity_score": round(1 - distance, 4) # convert distance → similarity
43
+ })
44
+
45
+ return chunks
46
+
47
+ results = retrieve_from_vector_db(query="What are the requirements to open a bank account?", k=3)
48
+
49
+ # print("Test 1: ")
50
+ # for i, chunk in enumerate(results, 1):
51
+ # print(f" Chunk {i}:")
52
+ # print(f" Score: {chunk['similarity_score']}")
53
+ # print(f" Metadata: {chunk['metadata']}")
54
+ # print(f" Text: {chunk['text'][:100]}...")
55
+ # print()
56
+
app.py ADDED
@@ -0,0 +1,98 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import streamlit as st
2
+ from chroma.chatbot_retriever import ask
3
+ from chroma.chatbot_chroma_db import create_new_chat, delete_chat, load_chats_from_db
4
+
5
+ # Initialize session state
6
+ if "chats" not in st.session_state:
7
+ loaded = load_chats_from_db()
8
+ if loaded:
9
+ st.session_state.chats = loaded
10
+ else:
11
+ initial_chat_id = create_new_chat()
12
+ st.session_state.chats = {"Chat 1": {"messages": [], "chat_id": initial_chat_id}}
13
+
14
+ if "active_chat" not in st.session_state:
15
+ st.session_state.active_chat = "Chat 1"
16
+
17
+
18
+ # st.write(list(st.session_state.chats.keys()))
19
+
20
+ # print(st.session_state.active_chat)
21
+
22
+
23
+ # sidebar
24
+ with st.sidebar:
25
+ st.title("Bank Chatbot")
26
+ # New Chat button
27
+ if st.button("+ New Chat", use_container_width=True):
28
+ new_name = f"Chat {len(st.session_state.chats) + 1}"
29
+ new_chat_id = create_new_chat()
30
+ st.session_state.chats[new_name] = {"messages": [], "chat_id": new_chat_id}
31
+ st.session_state.active_chat = new_name
32
+ st.rerun()
33
+
34
+ st.divider()
35
+
36
+ # List all chats as buttons
37
+ for chat_name in list(st.session_state.chats.keys()): # list() avoids mutation during loop
38
+ col1, col2 = st.columns([4, 1])
39
+
40
+ with col1:
41
+ if st.button(chat_name, key=f"select_{chat_name}", use_container_width=True):
42
+ st.session_state.active_chat = chat_name
43
+ st.rerun()
44
+
45
+ with col2:
46
+ if st.button("🗑", key=f"delete_{chat_name}"):
47
+ delete_chat(st.session_state.chats[chat_name]["chat_id"])
48
+
49
+ del st.session_state.chats[chat_name]
50
+
51
+ # if deleted chat was active, switch to another
52
+ if st.session_state.active_chat == chat_name:
53
+ remaining = list(st.session_state.chats.keys())
54
+ if remaining:
55
+ st.session_state.active_chat = remaining[0]
56
+ else:
57
+ # no chats left, create a fresh one
58
+ new_chat_id = create_new_chat()
59
+
60
+ st.session_state.chats["Chat 1"] = {
61
+ "messages": [],
62
+ "chat_id": new_chat_id
63
+ }
64
+ st.session_state.active_chat = "Chat 1"
65
+
66
+ st.rerun()
67
+
68
+ st.header(st.session_state.active_chat)
69
+
70
+ active = st.session_state.active_chat
71
+ # st.write(st.session_state.chats)
72
+ # Display history for the active chat
73
+ for msg in st.session_state.chats[active]["messages"]:
74
+ with st.chat_message(msg["role"]):
75
+ st.write(msg["content"])
76
+
77
+ if prompt := st.chat_input("Ask me anything..."):
78
+ # if "chat_id" not in st.session_state:
79
+ # st.session_state.chat_id = create_new_chat()
80
+
81
+ active = st.session_state.active_chat
82
+ chat_id = st.session_state.chats[active]["chat_id"]
83
+
84
+ # saves user message
85
+ st.session_state.chats[active]["messages"].append({"role": "user", "content": prompt})
86
+
87
+ # with st.chat_message("user"):
88
+ # st.write(prompt)
89
+
90
+ # add response from RAG chain, passing session chat history
91
+ # with st.chat_message("assistant"):
92
+ with st.spinner("Thinking..."):
93
+ response = ask(prompt, chat_id = chat_id, chat_name=active)
94
+ # st.write(response['answer'])
95
+
96
+
97
+ st.session_state.chats[active]["messages"].append({"role": "assistant", "content": response})
98
+ st.rerun()
data_preparation/chatbot_data_ingest.py ADDED
@@ -0,0 +1,66 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from chonkie import SemanticChunker
2
+ from sentence_transformers import SentenceTransformer
3
+ from pathlib import Path
4
+ import pandas as pd
5
+
6
+ data_dir = Path("../datasets")
7
+ train_faq_df = pd.read_csv(data_dir / "processed" / "bank_faq" /"train_faq.csv")
8
+
9
+ # Initialize with an embedding model
10
+ chunker = SemanticChunker(
11
+ embedding_model="multi-qa-mpnet-base-dot-v1",
12
+ chunk_size=200
13
+ )
14
+
15
+ chunked_docs = []
16
+ for idx, row in train_faq_df.iterrows():
17
+ question = row["Question"]
18
+ ans = row["Answer"]
19
+ domain = row["Class"]
20
+
21
+ if len(ans.split()) < 200:
22
+ # short — keep as single doc, embed q+a together
23
+ chunked_docs.append({
24
+ "text": row["QA_pair"], # for embedding
25
+ "question": question,
26
+ "answer": ans,
27
+ "domain": domain,
28
+ "source_id": str(idx),
29
+ "chunk_num": "0"
30
+ })
31
+ else:
32
+ # long — chunk only the answer
33
+ chunks = chunker.chunk(ans)
34
+ for chunk_num, chunk in enumerate(chunks):
35
+ chunked_docs.append({
36
+ "text": f"Q: {question} A: {chunk.text}", # question anchors every chunk
37
+ "question": question,
38
+ "answer": chunk.text,
39
+ "domain": domain,
40
+ "source_id": str(idx),
41
+ "chunk_num": str(chunk_num)
42
+ })
43
+
44
+ print(f"Total chunks: {len(chunked_docs)}")
45
+
46
+ print(chunked_docs[0])
47
+
48
+
49
+
50
+ model = SentenceTransformer("multi-qa-mpnet-base-dot-v1")
51
+
52
+ texts = [doc["text"] for doc in chunked_docs]
53
+ embeddings = model.encode(texts, batch_size=64, show_progress_bar=True)
54
+
55
+ embed_faq_df = pd.DataFrame(chunked_docs)
56
+ embed_faq_df["embedding"] = embeddings.tolist()
57
+
58
+ embed_faq_df.to_csv(data_dir / "processed" / "bank_faq" / "embed_faq.csv", index=False)
59
+ print(embed_faq_df.shape)
60
+ print(embed_faq_df.head())
61
+
62
+ # for doc in chunked_docs:
63
+ # print(len(doc["text"]), "|", doc["text"][:80])
64
+
65
+ # cleaned_faq_df["token_count"] = cleaned_faq_df["QA_pair"].apply(lambda x: len(x.split()))
66
+ # print(cleaned_faq_df["token_count"].describe())
data_preparation/chatbot_data_kb.py ADDED
@@ -0,0 +1,24 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import pandas as pd
2
+ import json
3
+ import ollama
4
+ import re
5
+ from tqdm import tqdm
6
+ tqdm.pandas()
7
+ from pathlib import Path
8
+
9
+ data_dir = Path("../datasets")
10
+
11
+ # faq qa pair
12
+ train_faq_df = pd.read_csv(data_dir / "processed" / "bank_faq" / "train_faq.csv")
13
+ train_faq_df["QA_pair"] = "Q: " + train_faq_df["Question"] + "\nA: " + train_faq_df["Answer"]
14
+ train_faq_df.to_csv(data_dir / "processed" / "bank_faq" / "train_faq.csv", index=False)
15
+
16
+ # train_faq_df = train_faq_df[["Question", "Answer", "QA_pair", "Class"]].rename(columns={
17
+ # "Question": "question",
18
+ # "Answer": "answer",
19
+ # "Class": "domain"
20
+ # })
21
+ # train_faq_df["source"] = "faq"
22
+ # train_faq_df["industry"] = "banking"
23
+
24
+
data_preparation/chatbot_data_prep.py ADDED
@@ -0,0 +1,167 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # data preparation for chatbot
2
+ from pathlib import Path
3
+ import pandas as pd
4
+ import re
5
+ from tqdm import tqdm
6
+ from nltk.tokenize import word_tokenize
7
+ import nltk
8
+ from wordfreq import word_frequency, zipf_frequency
9
+ try:
10
+ nltk.data.find("tokenizers/punkt")
11
+ except LookupError:
12
+ nltk.download("punkt")
13
+ from nltk.corpus import words
14
+
15
+ nltk.download('words', quiet=True)
16
+ from lingua import Language
17
+ from lingua import LanguageDetectorBuilder
18
+ from gibberish_detector import detector
19
+ tqdm.pandas()
20
+ from sklearn.model_selection import train_test_split
21
+
22
+ # load datasets
23
+ data_dir = Path("../datasets")
24
+
25
+ faq_df = pd.read_csv(data_dir / "raw" / "BankFAQs.csv")
26
+ support_df = pd.read_csv(data_dir / "raw" / "customer_support_data.csv")
27
+
28
+ print("FAQ dataset")
29
+ print(faq_df.head())
30
+ print("Customer Support dataset")
31
+ print(support_df.head())
32
+
33
+ eng_words = set(word.lower() for word in words.words())
34
+
35
+ # load the gibberish detection model
36
+ Detector = detector.create_from_model('gibberish-detector.model')
37
+
38
+ stopwords = {
39
+ "ho", "rahi", "hai", "ke", "mein", "raha", "hoon", "kar"
40
+ }
41
+
42
+ # cleaning customer support dataset
43
+ def clean_text(text):
44
+ # handle missing values
45
+ if pd.isna(text):
46
+ return ""
47
+
48
+ text = text.replace("’", "'").replace("’", "'")
49
+ # text = re.sub(r"[^a-zA-Z0-9'\s-]", ' ', text)
50
+ # text = re.sub(r'\s+', ' ', text).strip()
51
+
52
+ # split into phrases by punctuation
53
+ phrases = re.split(r'[.!?,;:/]', text)
54
+ # print(phrases)
55
+
56
+ clean_phrases = []
57
+ total_removed = 0
58
+
59
+ for phrase in phrases:
60
+ phrase = phrase.strip()
61
+ if not phrase:
62
+ continue
63
+
64
+ tokens = re.findall(r"[a-zA-Z0-9]*[0-9][a-zA-Z][a-zA-Z0-9]*|[a-zA-Z]+(?:'[a-zA-Z]+)*|[0-9]+(?:-[0-9]+)*", phrase)
65
+
66
+ real_words = []
67
+ removed_words = []
68
+
69
+ for token in tokens:
70
+ if re.fullmatch(r"[a-zA-Z0-9]*[0-9][a-zA-Z]+", token):
71
+ real_words.append(token)
72
+ continue
73
+
74
+ if re.fullmatch(r"[0-9]+(?:-[0-9]+)*", token):
75
+ real_words.append(token)
76
+ continue
77
+
78
+ token_lower = token.lower()
79
+
80
+ if token_lower in stopwords:
81
+ removed_words.append(token)
82
+ continue
83
+
84
+ if Detector.is_gibberish(token_lower):
85
+ # print(token)
86
+ removed_words.append(token)
87
+ continue
88
+
89
+ if token_lower in {"a", "i"}:
90
+ real_words.append(token)
91
+ continue
92
+
93
+ # contractions - keep directly without English check
94
+ if "'" in token_lower:
95
+ real_words.append(token)
96
+ continue
97
+
98
+ if token_lower in eng_words or zipf_frequency(token_lower, "en") >= 2.0:
99
+ real_words.append(token)
100
+ continue
101
+
102
+ removed_words.append(token)
103
+
104
+ # print("Real Words: ", real_words)
105
+ # print(f" Removed words : {removed_words} ({len(removed_words)} removed)")
106
+
107
+ if not real_words:
108
+ continue
109
+
110
+ # keep phrases when at least a reasonable portion of tokens are English-like
111
+ # if len(real_words) / len(tokens) < 0.25:
112
+ # continue
113
+
114
+ if len(removed_words) >= len(real_words):
115
+ continue
116
+
117
+ total_removed += len(removed_words)
118
+ clean_phrases.append(" ".join(real_words))
119
+
120
+ # print(f"\nTotal words removed: {total_removed}")
121
+ return " ".join(clean_phrases)
122
+
123
+ # cleaned_support_df = support_df[support_df["language"] == "en"][["conv_id", "turn_index", "role", "text", "industry", "product", "outcome", "issue_type", "overall_urgency"]].copy()
124
+ # cleaned_support_df["text"] = cleaned_support_df["text"].progress_apply(clean_text)
125
+
126
+ # cleaned_support_df = cleaned_support_df[cleaned_support_df["text"].str.strip() != ""]
127
+ # cleaned_support_df = cleaned_support_df.dropna(subset=["text"])
128
+ # cleaned_support_df.to_csv(data_dir / "processed" / "customer_support" / "cleaned_support_text.csv", index=False)
129
+
130
+
131
+
132
+ # row_text = support_df.loc[support_df["language"] == "en", "text"].iloc[4]
133
+
134
+ # cleaned_text = clean_text(row_text)
135
+ # print(row_text)
136
+ # print(cleaned_text)
137
+
138
+
139
+ # cleaning bank faqs
140
+ faq_df = faq_df.drop_duplicates(subset=["Question", "Answer"])
141
+
142
+ def clean_text_faq(text):
143
+ if not isinstance(text, str):
144
+ return text
145
+ text = re.sub(r"[\r\n\t]+", " ", text) # normalise line breaks
146
+ text = re.sub(r"[^\x00-\x7F]+", "", text)
147
+ text = re.sub(r"<<\s*>>", "", text)
148
+ text = re.sub(r" {2,}", " ", text) # collapse spaces
149
+ return text.strip()
150
+
151
+ for col in ["Question", "Answer"]:
152
+ faq_df[col] = faq_df[col].progress_apply(clean_text_faq)
153
+ faq_df.to_csv(data_dir / "processed" / "bank_faq" / "cleaned_faq.csv", index=False)
154
+
155
+ # split dataset for train/test
156
+ train_faq_df, test_faq_df = train_test_split(
157
+ faq_df,
158
+ test_size=0.2,
159
+ stratify=faq_df["Class"],
160
+ random_state=42
161
+ )
162
+
163
+ print(len(train_faq_df))
164
+ print(len(test_faq_df))
165
+
166
+ train_faq_df.to_csv(data_dir / "processed" / "bank_faq" / "train_faq.csv", index=False)
167
+ test_faq_df.to_csv(data_dir / "processed" / "bank_faq" / "test_faq.csv", index=False)
data_preparation/gibberish-detector.model ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:dafd84bd85ed88afa837ebc7121210e97eeb61053513f659800f495a2e813e63
3
+ size 26296
datasets/processed/bank_faq/cleaned_faq.csv ADDED
The diff for this file is too large to render. See raw diff
 
datasets/processed/bank_faq/embed_faq.csv ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:20343606ef18ffe9b32cc95cf9690a9da8bf8fa9eb926001bf9d6c252c641ca1
3
+ size 21668106
datasets/processed/bank_faq/test_faq.csv ADDED
The diff for this file is too large to render. See raw diff
 
datasets/processed/bank_faq/train_faq.csv ADDED
The diff for this file is too large to render. See raw diff
 
datasets/processed/bank_faq/train_faq_df.csv ADDED
The diff for this file is too large to render. See raw diff
 
datasets/processed/customer_support/cleaned_support_text.csv ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:1558e646f928077b899259269f269eb88df382b2c2d2c4172be278bb0fe38c22
3
+ size 35090455
datasets/raw/BankFAQs.csv ADDED
The diff for this file is too large to render. See raw diff
 
datasets/raw/customer_support_data.csv ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:5951c6349f8bb9a73410daea8ab635fbaa498f1d920eb57bd738c45170ec6360
3
+ size 628177158
eval_pipeline.py ADDED
@@ -0,0 +1,432 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from pathlib import Path
2
+ import pandas as pd
3
+ # import math, random
4
+ from sentence_transformers import SentenceTransformer
5
+ from langchain_huggingface import HuggingFaceEmbeddings
6
+ from langchain_ollama import ChatOllama
7
+ from langchain_core.messages import HumanMessage, SystemMessage
8
+ import chromadb
9
+ from langchain_chroma import Chroma
10
+ from sklearn.metrics.pairwise import cosine_similarity
11
+ from chroma.chatbot_retriever import ask
12
+ from tqdm import tqdm
13
+ import random
14
+ import json
15
+ import numpy as np
16
+ import re
17
+
18
+ model = HuggingFaceEmbeddings(model_name="multi-qa-mpnet-base-dot-v1")
19
+ client = chromadb.PersistentClient(path="chroma/chroma_db")
20
+ data_dir = Path("datasets")
21
+ llm = ChatOllama(model="llama3.2", temperature=0)
22
+
23
+ vectorstore = Chroma(
24
+ client = client,
25
+ collection_name = "bank_faq",
26
+ embedding_function = model
27
+ )
28
+
29
+ retriever = vectorstore.as_retriever(
30
+ search_type = "similarity",
31
+ search_kwargs = {"k": 3}
32
+ )
33
+
34
+
35
+ # test_docs = vectorstore.similarity_search("what are the fees", k=3)
36
+ # print("Search results:", test_docs)
37
+
38
+ # test_docs_scores = vectorstore.similarity_search_with_score("what are the fees", k=3)
39
+ # print("With scores:", test_docs_scores)
40
+
41
+ from difflib import SequenceMatcher
42
+
43
+ # def is_correct(predicted: str, expected: str, threshold=0.6) -> bool:
44
+ # ratio = SequenceMatcher(None, predicted.lower(), expected.lower()).ratio()
45
+ # return ratio >= threshold
46
+
47
+ def llm_judge(query, expected, predicted, llm):
48
+ prompt = f"""
49
+ You are evaluating a question answering system.
50
+
51
+ Question: {query}
52
+ Expected Answer: {expected}
53
+ Predicted Answer: {predicted}
54
+
55
+ IMPORTANT RULES:
56
+ - Focus on whether the MEANING and KEY INFORMATION is the same
57
+ - As long as most of the words matches with the expected answer, do not score as 0.0
58
+ - Ignore differences in formatting (bullet points vs numbered list vs plain text)
59
+ - Ignore minor wording differences as long as the information is the same
60
+ - Mark as correct if the predicted answer covers all the key points in the expected answer
61
+ - Mark as incorrect ONLY if key information is missing or wrong
62
+
63
+ Respond ONLY in this JSON format, no other text:
64
+ - "correct": True if the meaning of the response matches with the expected answer, otherwise False
65
+ - "score": 0.0 - 1.0
66
+ - "reason": one-sentence explanation
67
+ """
68
+
69
+ response = llm.invoke(prompt)
70
+
71
+ # If response has a 'content' attribute (e.g. an AIMessage object), use response.content. Otherwise, use response itself.
72
+ # text = str(getattr(response, "content", response))
73
+ # print(text)
74
+ # Remove Markdown code block markers and trim any leading/trailing whitespace.
75
+ # text = re.sub(r"```json|```", "", text).strip()
76
+ # Search for the first JSON object in the text.
77
+ # \{.*\} matches everything between the first '{' and the last '}'
78
+ raw = response.content.strip()
79
+ raw = re.sub(r"^```(?:json)?\s*|\s*```$", "", raw).strip()
80
+
81
+
82
+
83
+ try:
84
+ parsed = json.loads(raw)
85
+ except json.JSONDecodeError as e:
86
+ print(f"JSON parse failed: {e}\nRaw text: {raw!r}")
87
+ parsed = {}
88
+
89
+ return {
90
+ "correct": False,
91
+ "score": 0.0,
92
+ "reason": "Predicted does not match with expected",
93
+ **parsed
94
+ }
95
+
96
+ # from chroma.chatbot_retriever import ask
97
+ train_dataset = pd.read_csv(data_dir/"processed"/"bank_faq" /"train_faq.csv")
98
+ test_dataset = pd.read_csv(data_dir/"processed"/"bank_faq" /"test_faq.csv")
99
+
100
+ train_dataset = train_dataset.rename(columns={
101
+ "Question": "query",
102
+ "Answer": "expected_answer"
103
+ }).to_dict(orient="records")
104
+
105
+ # test_dataset = test_dataset.rename(columns={
106
+ # "Question": "query",
107
+ # "Answer": "expected_answer"
108
+ # }).to_dict(orient="records")
109
+
110
+ def retrieval_success_check(expected, retrieved_text, threshold=0.6):
111
+ # embed both the expected answer and the retrieved docs into vector space
112
+ expected_vec = model.embed_query(expected)
113
+ retrieved_vec = model.embed_query(retrieved_text)
114
+
115
+ # measure how semantically similar they are (0 = no match, 1 = identical)
116
+ score = cosine_similarity([expected_vec], [retrieved_vec])[0][0]
117
+
118
+ # consider retrieval successful if similarity meets the threshold
119
+ return score >= threshold
120
+
121
+ # train_dataset = [
122
+ # {
123
+ # "query": item["question"],
124
+ # "expected_answer": flatten_answer(item["answer"]),
125
+ # "relevant_doc_ids": extract_doc_ids(item["answer"])
126
+ # }
127
+ # for item in train_db
128
+ # ]
129
+
130
+ # test_dataset = [
131
+ # {
132
+ # "query": row["Question"],
133
+ # "expected_answer": row["Answer"],
134
+ # "relevant_doc_ids": [] # unknown for unseen data
135
+ # }
136
+ # for _, row in test_df.iterrows()
137
+ # ]
138
+
139
+ # def evaluate(dataset: list[dict], split: str = "", sample: int = None) -> float:
140
+ # data = random.sample(dataset, sample) if sample else dataset
141
+ # correct = 0
142
+ # for item in tqdm(data, desc=f"Evaluating {split}", unit="q"):
143
+ # predicted = ask(query=item["query"], persist=False)
144
+ # if is_correct(predicted, item["expected_answer"]):
145
+ # correct += 1
146
+ # return correct / len(data)
147
+
148
+ # train_accuracy = evaluate(train_dataset, split="train", sample=200)
149
+ # test_accuracy = evaluate(test_dataset, split="test", sample=100)
150
+
151
+ # print(f"Train accuracy: {train_accuracy:.2%}")
152
+ # print(f"Test accuracy: {test_accuracy:.2%}")
153
+
154
+ # import matplotlib.pyplot as plt
155
+ # import seaborn as sns
156
+ # labels = ["Train", "Test"]
157
+ # accuracies = [train_accuracy, test_accuracy]
158
+
159
+ # plt.figure(figsize=(6, 4))
160
+ # plt.bar(labels, accuracies, color=["steelblue", "coral"], width=0.4)
161
+ # plt.ylim(0, 1)
162
+ # plt.ylabel("Accuracy")
163
+ # plt.title("Chatbot Accuracy — Train vs Test")
164
+
165
+ # for i, v in enumerate(accuracies):
166
+ # plt.text(i, v + 0.02, f"{v:.1%}", ha="center", fontweight="bold")
167
+
168
+ # plt.tight_layout()
169
+ # plt.savefig("accuracy.png")
170
+ # plt.show()
171
+
172
+
173
+ # def evaluate(dataset: list[dict], split: str = "", sample: int = None):
174
+ # data = random.sample(dataset, sample) if sample else dataset
175
+
176
+ # results = []
177
+
178
+ # correct = 0
179
+ # hallucinated = 0
180
+ # retrieval_failures = 0
181
+ # total_recall = 0
182
+ # total_precision = 0
183
+
184
+ # for item in tqdm(data, desc=f"Evaluating {split}", unit="q"):
185
+
186
+ # query = item["query"]
187
+ # expected = item["expected_answer"]
188
+ # relevant_doc_ids = item["relevant_doc_ids"] # ← from your tagged dataset
189
+
190
+ # # run chatbot
191
+ # predicted = ask(query=query, persist=False)
192
+
193
+ # # --- retrieval ---
194
+ # retrieved_docs = retriever.invoke(query)
195
+ # recall, precision = retrieval_metrics(retrieved_docs, relevant_doc_ids, k=K)
196
+
197
+ # retrieval_success = recall > 0 # at least one relevant chunk retrieved
198
+
199
+ # total_recall += recall
200
+ # total_precision += precision
201
+
202
+ # if not retrieval_success:
203
+ # retrieval_failures += 1
204
+
205
+ # # --- correctness ---
206
+ # correct_flag = is_correct(predicted, expected)
207
+
208
+ # # --- hallucination ---
209
+ # # hallucination = retrieval worked but LLM still got it wrong
210
+ # # (if retrieval failed, it's a retrieval problem, not hallucination)
211
+ # hallucinated_flag = retrieval_success and not correct_flag
212
+
213
+ # if correct_flag:
214
+ # correct += 1
215
+
216
+ # if hallucinated_flag:
217
+ # hallucinated += 1
218
+
219
+ # results.append({
220
+ # "query": query,
221
+ # "expected": expected,
222
+ # "predicted": predicted,
223
+ # "relevant_doc_ids": relevant_doc_ids,
224
+ # "retrieved_doc_ids": [doc.metadata["doc_id"] for doc in retrieved_docs[:K]],
225
+ # "retrieval_success": retrieval_success,
226
+ # f"recall@{K}": recall,
227
+ # f"precision@{K}": precision,
228
+ # "correct": correct_flag,
229
+ # "hallucinated": hallucinated_flag
230
+ # })
231
+
232
+ # metrics = {
233
+ # "split": split,
234
+ # "accuracy": correct / len(data),
235
+ # "hallucination_rate": hallucinated / len(data),
236
+ # "retrieval_failure_rate": retrieval_failures / len(data),
237
+ # f"recall@{K}": total_recall / len(data),
238
+ # f"precision@{K}": total_precision / len(data),
239
+ # "samples": len(data)
240
+ # }
241
+
242
+ # return metrics, results
243
+
244
+ def measure_hallucination(query: str, response: str, chunks: list[dict], llm) -> dict:
245
+ """
246
+ Uses the LLM to check whether the response contains claims
247
+ not supported by the retrieved chunks.
248
+
249
+ Returns:
250
+ - hallucinated: bool
251
+ - confidence: "high" | "medium" | "low"
252
+ - reason: short explanation
253
+ """
254
+ context = "\n".join(c["text"] for c in chunks) if chunks else "No context retrieved."
255
+
256
+ system = """
257
+ You are a hallucination detector. Given a context, a query, and a response,
258
+ determine if the response contains information NOT supported by the context.
259
+ Return ONLY valid JSON:
260
+ - "hallucinated": true if the response contains unsupported claims, false otherwise
261
+ - "confidence": "high", "medium", or "low"
262
+ - "reason": one-sentence explanation
263
+
264
+ Return ONLY the JSON object. No explanation.
265
+ """
266
+
267
+ result = llm.invoke([
268
+ SystemMessage(content=system),
269
+ HumanMessage(content=(
270
+ f"Context:\n{context}\n\n"
271
+ f"Query: {query}\n\n"
272
+ f"Response: {response}"
273
+ ))
274
+ ])
275
+
276
+ raw = result.content.strip()
277
+ raw = re.sub(r"^```(?:json)?\s*|\s*```$", "", raw).strip()
278
+
279
+ try:
280
+ parsed = json.loads(raw)
281
+ except json.JSONDecodeError:
282
+ parsed = {}
283
+
284
+ return {
285
+ "hallucinated": True,
286
+ "confidence": "low",
287
+ "reason": "Hallucination check failed",
288
+ **parsed
289
+ }
290
+
291
+
292
+ FALLBACK_PHRASES = [
293
+ "I don't have information on that.",
294
+ ]
295
+
296
+ def evaluate(dataset: list[dict], split: str = "", sample: int = None):
297
+ data = random.sample(dataset, sample) if sample else dataset
298
+
299
+ results = []
300
+
301
+ correct = 0
302
+ hallucinated = 0
303
+ retrieval_failures = 0
304
+
305
+ for item in tqdm(data, desc=f"Evaluating {split}", unit="q"):
306
+
307
+ query = item["query"]
308
+ expected = item["expected_answer"]
309
+
310
+ # run chatbot
311
+ predicted = ask(query=query, persist=False)
312
+
313
+ # Score-aware retrieval
314
+ docs_with_scores = vectorstore.similarity_search_with_score(query, k=3)
315
+ # print("Docs: ", docs_with_scores)
316
+ # --- retrieval check (simple proxy) ---
317
+ # retrieved_docs = retriever.invoke(query)
318
+
319
+ retrieved_docs = [doc for doc, _ in docs_with_scores]
320
+ confidence_scores = [round(1 / (1 + score), 4) for _, score in docs_with_scores]
321
+ avg_confidence = round(sum(confidence_scores) / len(confidence_scores), 4) if confidence_scores else 0.0
322
+
323
+ # retrieved_text = " ".join(retrieved_docs).lower()
324
+ retrieved_text = " ".join([doc.page_content for doc in retrieved_docs]).lower()
325
+ expected_in_context = expected.lower() in retrieved_text
326
+
327
+ retrieval_success = retrieval_success_check(expected, retrieved_text)
328
+
329
+ # --- correctness ---
330
+ # correct_flag = is_correct(predicted, expected)
331
+
332
+ is_fallback = any(phrase.lower() in predicted.lower() for phrase in FALLBACK_PHRASES)
333
+
334
+ if is_fallback:
335
+ judgement = {
336
+ "correct": False,
337
+ "score": 0.0,
338
+ "reason": "Model returned a fallback response"
339
+ }
340
+ hallucinated_flag = False
341
+ hallucination_detail = {
342
+ "hallucinated": False,
343
+ "confidence": "high",
344
+ "reason": "Model does not know the answer"
345
+ }
346
+ else:
347
+ judgement = llm_judge(query, expected, predicted, llm)
348
+
349
+ if judgement["correct"]:
350
+ hallucinated_flag = False
351
+ hallucination_detail = {
352
+ "hallucinated": False,
353
+ "confidence": "high",
354
+ "reason": "Response is correct"
355
+ }
356
+ correct += 1
357
+ else:
358
+ chunks = [
359
+ {
360
+ "text": doc.page_content,
361
+ "similarity_score": score
362
+ }
363
+ for doc, score in docs_with_scores
364
+ ]
365
+ hallucination_detail = measure_hallucination(query, predicted, chunks, llm)
366
+ hallucinated_flag = hallucination_detail["hallucinated"]
367
+
368
+ # hallucinated_flag = not expected_in_context and not correct_flag and not is_fallback
369
+
370
+ # if correct_flag:
371
+ # correct += 1
372
+
373
+ if hallucinated_flag:
374
+ hallucinated += 1
375
+
376
+ if not retrieval_success:
377
+ retrieval_failures += 1
378
+
379
+ results.append({
380
+ "query": query,
381
+ "expected": expected,
382
+ "predicted": predicted,
383
+ "retrieval_success": bool(retrieval_success),
384
+ "correct": judgement["correct"],
385
+ "score": judgement["score"],
386
+ "reason": judgement["reason"],
387
+ "is_fallback": is_fallback,
388
+ "hallucination_detail": hallucination_detail,
389
+ "confidence_scores": confidence_scores,
390
+ "avg_confidence": avg_confidence
391
+ })
392
+
393
+ metrics = {
394
+ "split": split,
395
+ "accuracy": correct / len(data),
396
+ "hallucination_rate": hallucinated / len(data),
397
+ "retrieval_failure_rate": retrieval_failures / len(data),
398
+ "samples": len(data)
399
+ }
400
+
401
+ return metrics, results
402
+
403
+ def save_results(metrics, results, filename="eval_results.json"):
404
+ output = {
405
+ "metrics": metrics,
406
+ "detailed_results": results
407
+ }
408
+
409
+ with open(filename, "w") as f:
410
+ json.dump(output, f, indent=2)
411
+
412
+ train_metrics, train_results = evaluate(train_dataset, "train", sample=200)
413
+ # test_metrics, test_results = evaluate(test_dataset, "test", sample=20)
414
+
415
+ # Bucket your failures:
416
+ # retrieval_failed = len([x for x in test_results if not x["retrieval_success"]])
417
+ # retrieval_ok_but_wrong = len([x for x in test_results if x["retrieval_success"] and not x["correct"]])
418
+ # fallbacks = len([x for x in test_results if x["is_fallback"]])
419
+
420
+ # print(retrieval_failed)
421
+ # print(retrieval_ok_but_wrong)
422
+ # print(fallbacks)
423
+
424
+ save_results(train_metrics, train_results, "train_eval.json")
425
+ # save_results(test_metrics, test_results, "test_eval.json")
426
+
427
+
428
+
429
+
430
+
431
+
432
+
requirements.txt ADDED
@@ -0,0 +1,23 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ chonkie==1.6.7
2
+ chromadb==1.5.9
3
+ gibberish_detector==0.1.1
4
+ huggingface_hub==1.10.2
5
+ huggingface_hub==1.16.1
6
+ langchain_chroma==1.1.0
7
+ langchain_classic==1.0.8
8
+ langchain_community==0.4.2
9
+ langchain_core==1.4.8
10
+ langchain_huggingface==1.2.2
11
+ langchain_ollama==1.1.0
12
+ lingua==4.16.2
13
+ nltk==3.9.4
14
+ numpy==2.5.1
15
+ pandas==3.0.3
16
+ presidio_analyzer==2.2.363
17
+ presidio_anonymizer==2.2.363
18
+ scikit_learn==1.9.0
19
+ sentence_transformers==5.5.1
20
+ streamlit==1.57.0
21
+ tqdm==4.67.1
22
+ tqdm==4.67.3
23
+ wordfreq==3.1.1