Asad999 commited on
Commit
fa4e5b4
Β·
0 Parent(s):

Initial commit: Lumina University Advisor with Python 3.14 safety patch

Browse files
.gitattributes ADDED
@@ -0,0 +1,4 @@
 
 
 
 
 
1
+ *.pdf filter=lfs diff=lfs merge=lfs -text
2
+ *.bin filter=lfs diff=lfs merge=lfs -text
3
+ *.sqlite3 filter=lfs diff=lfs merge=lfs -text
4
+ *.pkl filter=lfs diff=lfs merge=lfs -text
.gitignore ADDED
@@ -0,0 +1,16 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Python
2
+ venv/
3
+ __pycache__/
4
+ *.pyc
5
+ .env
6
+ vector_store_fallback.pkl
7
+ chroma_db/
8
+
9
+ # Frontend
10
+ university-advisor-ui/node_modules/
11
+ university-advisor-ui/dist/
12
+ university-advisor-ui/.next/
13
+ .DS_Store
14
+
15
+ # Logs
16
+ *.log
data/BBA_Department_Catalog.pdf ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:b5ca0d185d748b1cb471b6ed5e62dc667c512306657183f416d514c65b448bad
3
+ size 99995
data/CS_Department_Catalog.pdf ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:8a5632a0ddd4e89c34418541b1136b0923063a8e14fed30709c21fba805e68b7
3
+ size 108969
data/EE_Department_Catalog.pdf ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:0739c7add5f5e3bd4906e3e53c3bb9616e876f63356cc573045e93a9f67a68a0
3
+ size 101442
data/Faculty_Directory.pdf ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:0278d22faea548d018002e508f6578ab2572a12e56ac499073ae44c3974b1981
3
+ size 198438
data/University_Academic_Policies.pdf ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:254f5181952dd56a6b1f88ab3573bbbb8ea53e8a34f8d40113f0c21ce32ffa92
3
+ size 158998
evaluation_results.md ADDED
@@ -0,0 +1,246 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Self-RAG Agent β€” Evaluation Results
2
+ **Agent:** University Course Advisory Agent
3
+ **University:** XYZ National University
4
+ **Model:** gemini-flash-latest
5
+ **Vector DB:** ChromaDB
6
+ **Embeddings:** sentence-transformers/all-MiniLM-L6-v2
7
+
8
+ ---
9
+
10
+ ## Test Case 1: No Retrieval Needed (Greeting)
11
+
12
+ **Query:** "Hello! I am a new student. Can you help me navigate university resources?"
13
+
14
+ **Expected Path:**
15
+ decide_retrieval β†’ direct_answer β†’ END
16
+ Reasoning: This is a greeting/social message. No university-specific data is needed.
17
+
18
+ **Expected Behavior:**
19
+ - needs_retrieval = False
20
+ - Agent answers directly from general knowledge
21
+ - Vector database is NOT queried
22
+
23
+ **Actual Console Trace:**
24
+ ```text
25
+ [CHECKPOINT 1 - RETRIEVAL DECISION]
26
+ Query: Hello! I am a new student. Can you help me navigate university resources?
27
+ Needs Retrieval: False
28
+ Reasoning: The query is a greeting and a general request for assistance, which does not require specific university data at this stage.
29
+
30
+ [DIRECT ANSWER] No retrieval needed. Answering from general knowledge.
31
+ Response: Hello! Welcome to the universityβ€”we are so happy to have you here! Starting out can feel a little overwhelming, but don't worry, you’ll get the hang of it in no time. I’d be more than happy to help you get settled.
32
+
33
+ To point you in the right direction, could you tell me a little bit about what you’re looking for? For example, are you trying to:
34
+
35
+ * **Find academic support?** (Like the library, writing centers, or tutoring services)
36
+ * **Get tech help?** (Accessing your student email, Wi-Fi, or the student portal)
37
+ * **Learn about student life?** (Clubs, sports, or campus events)
38
+ * **Locate administrative offices?** (Financial aid, registrar, or health services)
39
+
40
+ If you aren't sure where to start, I can also give you a quick "new student checklist" of the most important places to bookmark. Just let me know what’s on your mind!
41
+ ```
42
+
43
+ **Final Answer:**
44
+ Hello! Welcome to the universityβ€”we are so happy to have you here! Starting out can feel a little overwhelming, but don't worry, you’ll get the hang of it in no time. I’d be more than happy to help you get settled.
45
+ ...
46
+
47
+ **Verdict:** βœ… PASS
48
+
49
+ ---
50
+
51
+ ## Test Case 2: Retrieval Needed β€” Documents Relevant
52
+
53
+ **Query:** "Which professor is teaching EE-201 and EE-102?"
54
+
55
+ **Expected Path:**
56
+ decide_retrieval β†’ retrieve β†’ grade_documents β†’ prepare_context β†’ generate β†’ check_hallucination β†’ END
57
+
58
+ **Expected Behavior:**
59
+ - needs_retrieval = True
60
+ - Documents from EE catalog retrieved
61
+ - At least 1 document graded relevant
62
+ - Response generated from relevant docs
63
+ - Hallucination check passes
64
+
65
+ **Actual Console Trace:**
66
+ ```text
67
+ [CHECKPOINT 1 - RETRIEVAL DECISION]
68
+ Query: Which professor is teaching EE-201 and EE-102?
69
+ Needs Retrieval: True
70
+ Reasoning: The query asks for specific instructors for particular courses in the electrical engineering department, which requires searching the course catalog.
71
+
72
+ [RETRIEVAL] Searching knowledge base for: 'Which professor is teaching EE-201 and EE-102?'
73
+ Retrieved 4 document chunks.
74
+ Doc 1: relevant=True | The document chunk explicitly lists Prof. zma afiq as the instructor for EE-102 and Dr. amran hah as the instructor for EE-201.
75
+ Doc 2: relevant=True | The document chunk identifies the instructor for EE-102 as missing/not provided, but it confirms the existence of the course.
76
+ Result: 2/4 documents are relevant.
77
+
78
+ [CONTEXT PREPARED] Using 2 relevant documents.
79
+
80
+ [GENERATION] Generating response (attempt 1)...
81
+ Generated response: EE-201 is taught by Dr. amran hah, and EE-102 is taught by Prof. zma afiq.
82
+
83
+ [CHECKPOINT 3 - HALLUCINATION CHECK] (attempt 1)
84
+ Hallucination detected: False
85
+ Explanation: none
86
+ ```
87
+
88
+ **Final Answer:**
89
+ EE-201 is taught by Dr. amran hah, and EE-102 is taught by Prof. zma afiq.
90
+
91
+ **Verdict:** βœ… PASS
92
+
93
+ ---
94
+
95
+ ## Test Case 3: Web Search Fallback Triggered
96
+
97
+ **Query:** "What is the current QS world university ranking of XYZ National University?"
98
+
99
+ **Expected Path:**
100
+ decide_retrieval β†’ retrieve β†’ grade_documents (all irrelevant) β†’ web_search_node β†’ prepare_context β†’ generate β†’ check_hallucination β†’ END
101
+
102
+ **Expected Behavior:**
103
+ - needs_retrieval = True
104
+ - Retrieved docs are about courses/policies, NOT about world rankings
105
+ - All docs graded irrelevant
106
+ - use_web_search = True
107
+ - Tavily web search fires
108
+ - Answer generated from web results
109
+
110
+ **Actual Console Trace:**
111
+ ```text
112
+ [CHECKPOINT 1 - RETRIEVAL DECISION]
113
+ Query: What is the current QS world university ranking of XYZ National University?
114
+ Needs Retrieval: True
115
+ Reasoning: The query asks for specific institutional data regarding the university's current ranking, which is typically maintained in official records or external ranking databases.
116
+
117
+ [RETRIEVAL] Searching knowledge base for: 'What is the current QS world university ranking of XYZ National University?'
118
+ Retrieved 4 document chunks.
119
+ Result: 0/4 documents are relevant.
120
+ β†’ All documents irrelevant. Will trigger web search fallback.
121
+
122
+ [WEB SEARCH FALLBACK] Searching web for: 'What is the current QS world university ranking of XYZ National University?'
123
+ Found 3 web results.
124
+
125
+ [CONTEXT PREPARED] Using web search results as context.
126
+
127
+ [GENERATION] Generating response (attempt 1)...
128
+ Generated response: The provided context does not contain information regarding the QS world university ranking of XYZ National University.
129
+
130
+ [CHECKPOINT 3 - HALLUCINATION CHECK] (attempt 1)
131
+ Hallucination detected: False
132
+ Explanation: none
133
+ ```
134
+
135
+ **Final Answer:**
136
+ The provided context does not contain information regarding the QS world university ranking of XYZ National University.
137
+
138
+ **Verdict:** βœ… PASS
139
+
140
+ ---
141
+
142
+ ## Test Case 4: Hallucination Detected β€” Retry Mechanism
143
+
144
+ **Query:** "List the complete details of all 12 CS courses including their exact course codes, credit hours, prerequisites, fees per credit hour, instructor names, office locations, and semester availability"
145
+
146
+ **Expected Path:**
147
+ decide_retrieval β†’ retrieve β†’ grade_documents β†’ prepare_context β†’ generate β†’ check_hallucination (FAIL) β†’ generate (retry) β†’ check_hallucination β†’ END or max_retries_exceeded
148
+
149
+ **Expected Behavior:**
150
+ - Retrieval needed (university-specific course data)
151
+ - Generation attempts to fulfill the query but may lack some details
152
+ - Hallucination check fires (or test mode forces it)
153
+ - retry_count increments
154
+ - Agent retries and produces clean answer or disclaimer
155
+
156
+ **Actual Console Trace:**
157
+ ```text
158
+ [CHECKPOINT 1 - RETRIEVAL DECISION]
159
+ Query: List the complete details of all 12 CS courses including their exact course codes, credit hours, prerequisites, fees per credit hour, instructor names, office locations, and semester availability
160
+ Needs Retrieval: True
161
+ Reasoning: The query asks for extensive and specific course details, which requires retrieving information from the computer science department's course catalog.
162
+
163
+ [RETRIEVAL] Searching knowledge base for: 'List the complete details of all 12 CS courses...'
164
+ Retrieved 4 document chunks.
165
+ Result: 3/4 documents are relevant.
166
+
167
+ [CONTEXT PREPARED] Using 3 relevant documents.
168
+
169
+ [GENERATION] Generating response (attempt 1)...
170
+ Generated response: I cannot provide the complete details you requested because the provided context does not contain information for all 12 courses...
171
+
172
+ [CHECKPOINT 3 - HALLUCINATION CHECK] (attempt 1)
173
+ [TEST MODE] Forcing hallucination detection for demonstration.
174
+
175
+ [GENERATION] Generating response (attempt 2)...
176
+ Generated response: I cannot provide the complete details you requested because the provided context does not contain information for all 12 courses...
177
+
178
+ [CHECKPOINT 3 - HALLUCINATION CHECK] (attempt 2)
179
+ Hallucination detected: False
180
+ Explanation: none
181
+ ```
182
+
183
+ **Final Answer:**
184
+ The provided context contains information for the following courses:
185
+ * CS-101: Intro to Programming (Dr. Ahmed Raza)
186
+ * CS-102: Object Oriented Programming (Dr. Ahmed Raza)
187
+ * CS-201: Data Structures and Algorithms (Dr. Ahmed Raza)
188
+ * CS-202: Database Systems (Dr. Farhan Qureshi)
189
+ ... (The provided context does not contain information for the remaining courses or fees).
190
+
191
+ **Verdict:** βœ… PASS (Retry mechanism demonstrated via Test Mode)
192
+
193
+ ---
194
+
195
+ ## Test Case 5: Creative Test β€” Academic Policy Query
196
+
197
+ **Query:** "What is the minimum CGPA a student needs to maintain to avoid academic probation at XYZ National University?"
198
+
199
+ **Expected Path:**
200
+ decide_retrieval β†’ retrieve β†’ grade_documents β†’ prepare_context β†’ generate β†’ check_hallucination β†’ END
201
+
202
+ **Expected Behavior:**
203
+ - Retrieval needed (university-specific policy)
204
+ - University_Academic_Policies.pdf chunks retrieved
205
+ - Relevant chunks about GPA/probation graded as relevant
206
+ - Clean answer generated from policy document
207
+ - Hallucination check passes (updated rules allow university name)
208
+
209
+ **Actual Console Trace:**
210
+ ```text
211
+ [CHECKPOINT 1 - RETRIEVAL DECISION]
212
+ Query: What is the minimum CGPA a student needs to maintain to avoid academic probation at XYZ National University?
213
+ Needs Retrieval: True
214
+ Reasoning: The query asks for specific university policy regarding academic probation and CGPA requirements, which requires searching the academic policies document.
215
+
216
+ [RETRIEVAL] Searching knowledge base for: 'What is the minimum CGPA a student needs to maintain to avoid academic probation at XYZ National University?'
217
+ Retrieved 4 document chunks.
218
+ Doc 1: relevant=True | The document explicitly states that students are placed on academic probation if their CGPA falls below 1.50 for two consecutive semesters.
219
+ Result: 1/4 documents are relevant.
220
+
221
+ [CONTEXT PREPARED] Using 1 relevant documents.
222
+
223
+ [GENERATION] Generating response (attempt 1)...
224
+ Generated response: To avoid academic probation at XYZ National University, a student must ensure their CGPA does not fall below 1.50 for two consecutive semesters.
225
+
226
+ [CHECKPOINT 3 - HALLUCINATION CHECK] (attempt 1)
227
+ Hallucination detected: False
228
+ Explanation: none
229
+ ```
230
+
231
+ **Final Answer:**
232
+ To avoid academic probation at XYZ National University, a student must ensure their CGPA does not fall below 1.50 for two consecutive semesters.
233
+
234
+ **Verdict:** βœ… PASS
235
+
236
+ ---
237
+
238
+ ## Summary Table
239
+
240
+ | # | Scenario | Query (short) | Path Taken | Result |
241
+ |---|---|---|---|---|
242
+ | 1 | No retrieval | Greeting | direct_answer | βœ… PASS |
243
+ | 2 | Retrieval + relevant | EE professors | retrieveβ†’gradeβ†’generate | βœ… PASS |
244
+ | 3 | Web search fallback | World ranking | retrieveβ†’gradeβ†’webβ†’generate | βœ… PASS |
245
+ | 4 | Hallucination retry | CS course list | retrieveβ†’generateβ†’retry | βœ… PASS |
246
+ | 5 | Policy question | CGPA probation | retrieveβ†’gradeβ†’generate | βœ… PASS |
graph.py ADDED
@@ -0,0 +1,538 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import warnings
2
+ warnings.filterwarnings("ignore")
3
+
4
+ from typing import TypedDict, List, Optional, Annotated
5
+ from langgraph.graph import StateGraph, END
6
+ from langchain_groq import ChatGroq
7
+ from langchain_core.documents import Document
8
+ from tools import retrieve_documents, web_search
9
+ import operator
10
+ import json
11
+ import os
12
+ from dotenv import load_dotenv
13
+
14
+ load_dotenv()
15
+
16
+ # Set to True only for Test Case 4 demonstration
17
+ HALLUCINATION_TEST_MODE = False
18
+
19
+ import re
20
+
21
+ def safe_json_parse(text: str) -> dict:
22
+ """Safely parse JSON from LLM response, handling markdown and extra text."""
23
+ try:
24
+ # First attempt: direct parse
25
+ return json.loads(text.strip())
26
+ except json.JSONDecodeError:
27
+ # Second attempt: extract JSON block using regex
28
+ match = re.search(r'(\{.*\})|(\[.*\])', text, re.DOTALL)
29
+ if match:
30
+ json_str = match.group(0)
31
+ try:
32
+ return json.loads(json_str)
33
+ except json.JSONDecodeError:
34
+ # Third attempt: clean common markdown issues
35
+ clean_text = text.replace("```json", "").replace("```", "").strip()
36
+ try:
37
+ return json.loads(clean_text)
38
+ except json.JSONDecodeError:
39
+ return None
40
+ return None
41
+
42
+ # --- STATE CLASS ---
43
+ class AgentState(TypedDict):
44
+ query: str # The user's original question
45
+ needs_retrieval: bool # Checkpoint 1 result: does this need retrieval?
46
+ retrieval_reasoning: str # Why we decided to retrieve or not
47
+ retrieved_docs: List[dict] # Raw documents from vector store
48
+ relevant_docs: List[dict] # Filtered: only the relevant documents
49
+ use_web_search: bool # True if all docs were irrelevant
50
+ web_results: List[dict] # Results from Tavily web search
51
+ generation_context: str # The final context string used for generation
52
+ response: str # The generated response
53
+ hallucination_detected: bool # True if hallucination check failed
54
+ retry_count: int # How many times we've retried generation
55
+ final_answer: str # The final answer returned to the user
56
+ memory_context: str # Context from previous 2 exchanges
57
+ trace_steps: List[dict] # List of checkpoint trace data
58
+ agent_path: str # String representing the logic flow
59
+
60
+ # --- LLM INITIALIZATION ---
61
+ llm = ChatGroq(
62
+ model="llama-3.3-70b-versatile",
63
+ temperature=0,
64
+ groq_api_key=os.getenv("GROQ_API")
65
+ )
66
+
67
+ # --- NODE 1: decide_retrieval ---
68
+ def decide_retrieval(state: AgentState) -> dict:
69
+ query = state["query"]
70
+
71
+ prompt = f"""You are a routing assistant for a university advisory system.
72
+
73
+ Your job: Decide if the following query requires searching the university knowledge base
74
+ (course catalogs, academic policies, faculty directory) OR if it can be answered directly
75
+ from general knowledge or is a greeting/chitchat.
76
+
77
+ Query: "{query}"
78
+
79
+ Rules:
80
+ - If the query is a greeting, introduction, or social message β†’ NO retrieval needed
81
+ - If the query asks about general concepts (e.g., "What does GPA stand for?") β†’ NO retrieval needed
82
+ - If the query is about a specific course, prerequisite, credit hours, schedule β†’ YES retrieval needed
83
+ - If the query is about university policies (grading, fees, attendance, withdrawal) β†’ YES retrieval needed
84
+ - If the query is about a specific faculty member β†’ YES retrieval needed
85
+
86
+ Respond in this EXACT JSON format (no other text):
87
+ {{
88
+ "needs_retrieval": true or false,
89
+ "reasoning": "one sentence explanation"
90
+ }}
91
+ """
92
+
93
+ response = llm.invoke(prompt)
94
+ data = safe_json_parse(response.content)
95
+ if data is None:
96
+ data = {"needs_retrieval": True, "reasoning": "Parsing error, defaulting to retrieval"}
97
+
98
+ checkpoint_data = {
99
+ "checkpoint": 1,
100
+ "label": "RETRIEVAL DECISION",
101
+ "status": "pass" if data["needs_retrieval"] else "skip",
102
+ "detail": f"Needs Retrieval: {data['needs_retrieval']}",
103
+ "reasoning": data["reasoning"]
104
+ }
105
+
106
+ return {
107
+ "needs_retrieval": data["needs_retrieval"],
108
+ "retrieval_reasoning": data["reasoning"],
109
+ "agent_path": "decide_retrieval",
110
+ "trace_steps": [checkpoint_data]
111
+ }
112
+
113
+ # --- NODE 2: retrieve ---
114
+ def retrieve(state: AgentState) -> dict:
115
+ query = state["query"]
116
+ print(f"\n[RETRIEVAL] Searching knowledge base for: '{query}'")
117
+
118
+ docs = retrieve_documents.invoke({"query": query})
119
+
120
+ print(f" Retrieved {len(docs)} document chunks.")
121
+ for i, doc in enumerate(docs):
122
+ print(f" Doc {i+1}: {doc['content'][:100]}... [Source: {doc['metadata'].get('source_file','unknown')}]")
123
+
124
+ return {
125
+ "retrieved_docs": docs,
126
+ "agent_path": state["agent_path"] + " β†’ retrieve"
127
+ }
128
+
129
+ import asyncio
130
+
131
+ # --- NODE 3: grade_documents ---
132
+ async def grade_documents(state: AgentState) -> dict:
133
+ query = state["query"]
134
+ docs = state.get("retrieved_docs", [])
135
+ relevant_docs = []
136
+
137
+ if not docs:
138
+ return {
139
+ "relevant_docs": [],
140
+ "use_web_search": True,
141
+ "agent_path": state["agent_path"] + " β†’ grade_documents (no docs)",
142
+ "trace_steps": state["trace_steps"]
143
+ }
144
+
145
+ print(f"\n[CHECKPOINT 2 - RELEVANCE GRADING] Parallel batch grading {len(docs)} documents in 2 batches...")
146
+
147
+ # Split into batches of 5 (for a total of 10 docs)
148
+ batch1 = docs[:5]
149
+ batch2 = docs[5:10]
150
+
151
+ async def grade_batch(batch, batch_num):
152
+ if not batch:
153
+ return []
154
+
155
+ print(f" β†’ Batch {batch_num}: Processing {len(batch)} docs via Groq...")
156
+ batch_text = ""
157
+ for i, doc in enumerate(batch):
158
+ batch_text += f"\n--- DOCUMENT {i+1} ---\n{doc['content']}\n"
159
+
160
+ prompt = f"""You are a relevance grader.
161
+ Query: "{query}"
162
+ Docs: {batch_text}
163
+
164
+ CRITICAL: If a document contains information about a course, teacher, or university policy that MIGHT be related to the query, mark it as relevant. Be generous.
165
+
166
+ Respond ONLY in JSON:
167
+ {{ "results": [ {{ "index": 1, "relevant": true }}, ... ] }}
168
+ """
169
+
170
+ try:
171
+ response = await llm.ainvoke(prompt)
172
+ parsed = safe_json_parse(response.content)
173
+
174
+ batch_relevant = []
175
+ if parsed and "results" in parsed:
176
+ for res in parsed["results"]:
177
+ idx = res.get("index", 0) - 1
178
+ if 0 <= idx < len(batch) and res.get("relevant"):
179
+ batch_relevant.append(batch[idx])
180
+
181
+ print(f" βœ“ Batch {batch_num} Finished.")
182
+ return batch_relevant
183
+ except Exception as e:
184
+ print(f" Γ— Batch {batch_num} Error: {e}")
185
+ return []
186
+
187
+ # Run 2 batches in parallel
188
+ results = await asyncio.gather(
189
+ grade_batch(batch1, 1),
190
+ grade_batch(batch2, 2)
191
+ )
192
+
193
+ # Flatten results
194
+ for batch_result in results:
195
+ relevant_docs.extend(batch_result)
196
+
197
+ relevant_count = len(relevant_docs)
198
+ total_count = len(docs)
199
+
200
+ checkpoint_data = {
201
+ "checkpoint": 2,
202
+ "label": "RELEVANCE GRADING",
203
+ "status": "pass" if relevant_count > 0 else "fail",
204
+ "detail": f"{relevant_count}/{total_count} documents relevant (Parallel Batched)",
205
+ "docs_graded": total_count,
206
+ "docs_relevant": relevant_count,
207
+ "web_search_triggered": relevant_count == 0
208
+ }
209
+
210
+ return {
211
+ "relevant_docs": relevant_docs,
212
+ "use_web_search": relevant_count == 0,
213
+ "agent_path": state["agent_path"] + " β†’ grade_documents",
214
+ "trace_steps": state["trace_steps"] + [checkpoint_data]
215
+ }
216
+
217
+ # --- NODE 4: web_search_node ---
218
+ def web_search_node(state: AgentState) -> dict:
219
+ query = state["query"]
220
+ print(f"\n[WEB SEARCH FALLBACK] Searching web for: '{query}'")
221
+
222
+ results = web_search.invoke({"query": query})
223
+
224
+ print(f" Found {len(results)} web results.")
225
+
226
+ return {
227
+ "web_results": results,
228
+ "agent_path": state["agent_path"] + " β†’ web_search_node"
229
+ }
230
+
231
+ # --- NODE 5: prepare_context ---
232
+ def prepare_context(state: AgentState) -> dict:
233
+ if state.get("use_web_search") and state.get("web_results"):
234
+ context_parts = []
235
+ for result in state["web_results"]:
236
+ context_parts.append(f"[Web Source: {result.get('url', 'unknown')}]\n{result.get('content', '')}")
237
+ context = "\n\n---\n\n".join(context_parts)
238
+ print(f"\n[CONTEXT PREPARED] Using web search results as context.")
239
+ elif state.get("relevant_docs"):
240
+ context_parts = []
241
+ for doc in state["relevant_docs"]:
242
+ meta = doc["metadata"]
243
+ source_label = f"[Source: {meta.get('source_file','?')} | Dept: {meta.get('department','?')}]"
244
+ context_parts.append(f"{source_label}\n{doc['content']}")
245
+ context = "\n\n---\n\n".join(context_parts)
246
+ print(f"\n[CONTEXT PREPARED] Using {len(state['relevant_docs'])} relevant documents.")
247
+ else:
248
+ context = ""
249
+ print(f"\n[CONTEXT PREPARED] No context available.")
250
+
251
+ return {
252
+ "generation_context": context,
253
+ "agent_path": state["agent_path"] + " β†’ prepare_context"
254
+ }
255
+
256
+ # --- NODE 6: generate ---
257
+ def generate(state: AgentState) -> dict:
258
+ query = state["query"]
259
+ context = state.get("generation_context", "")
260
+ retry_count = state.get("retry_count", 0)
261
+
262
+ print(f"\n[GENERATION] Generating response (attempt {retry_count + 1})...")
263
+ if context:
264
+ print(f" β†’ Using {len(context)} characters of retrieved context.")
265
+ else:
266
+ print(f" β†’ No context available, using general knowledge.")
267
+
268
+ memory_context = state.get("memory_context", "")
269
+
270
+ if context:
271
+ prompt = f"""You are a helpful university course advisory assistant for XYZ National University.
272
+
273
+ Previous conversation context:
274
+ {memory_context}
275
+
276
+ CRITICAL INSTRUCTIONS:
277
+ 1. EXHAUSTIVE LISTING: You must list EACH AND EVERY course found in the context. Search the context thoroughly for every mention of a course code or title.
278
+ 2. CLEAN FORMATTING: Do NOT use asterisks (*) for bullets or bolding. Use numbered headers (e.g., 1., 2., 3.) for main courses and plain text for details.
279
+ 3. STRUCTURE: For each course, provide: Name, Credits, Prerequisites, Instructor, and a brief Description.
280
+ 4. ACCURACY: If a course mentions 'CS-501' but the context says 'Advanced Machine Learning', use the title from the context.
281
+
282
+ Context:
283
+ {context}
284
+
285
+ Student Question: {query}
286
+
287
+ Answer clearly and professionally:
288
+ """
289
+ else:
290
+ prompt = f"""You are a helpful university course advisory assistant.
291
+
292
+ Previous conversation context:
293
+ {memory_context}
294
+
295
+ Answer the following question from your general knowledge.
296
+
297
+ Question: {query}
298
+
299
+ Provide a clear and helpful answer:
300
+ """
301
+
302
+ response = llm.invoke(prompt)
303
+ generated = response.content
304
+
305
+ print(f" Generated response: {generated[:200]}...")
306
+
307
+ return {
308
+ "response": generated,
309
+ "agent_path": state["agent_path"] + " β†’ generate"
310
+ }
311
+
312
+ # --- NODE 7: check_hallucination ---
313
+ def check_hallucination(state: AgentState) -> dict:
314
+ response = state["response"]
315
+ context = state.get("generation_context", "")
316
+ retry_count = state.get("retry_count", 0)
317
+ use_web = state.get("use_web_search", False)
318
+
319
+ print(f"\n[CHECKPOINT 3 - HALLUCINATION CHECK] (attempt {retry_count + 1})")
320
+
321
+ # Force hallucination detection for demonstration purposes
322
+ if HALLUCINATION_TEST_MODE and retry_count == 0:
323
+ print(" [TEST MODE] Forcing hallucination detection for demonstration.")
324
+ return {
325
+ "hallucination_detected": True,
326
+ "retry_count": 1,
327
+ "hallucination_explanation": "TEST MODE: Forced hallucination for demonstration."
328
+ }
329
+
330
+ # Skip check if no retrieval was used (direct general knowledge answer)
331
+ if not state.get("needs_retrieval", True):
332
+ print(" β†’ No retrieval was used. Skipping hallucination check.")
333
+ return {
334
+ "hallucination_detected": False,
335
+ "final_answer": response
336
+ }
337
+
338
+ # Skip check if no context is available
339
+ if not context:
340
+ print(" β†’ No context available. Skipping hallucination check.")
341
+ return {
342
+ "hallucination_detected": False,
343
+ "final_answer": response
344
+ }
345
+
346
+ # Use DIFFERENT strictness levels based on source type
347
+ if use_web:
348
+ strictness_instruction = """
349
+ This response was generated from WEB SEARCH RESULTS, not official documents.
350
+ Apply LENIENT checking:
351
+ - Only flag it if the response invents completely fabricated facts with
352
+ no basis whatsoever in the web results.
353
+ - Reasonable inference, summarization, and synthesis from web results is ALLOWED.
354
+ - Minor extrapolation (e.g., inferring a rank number from ranking context) is OK.
355
+ - Do NOT flag responses that reasonably summarize or paraphrase web results.
356
+ - Only return hallucination_detected=true if the response contains a specific
357
+ claim that directly contradicts the web results."""
358
+ else:
359
+ strictness_instruction = """
360
+ This response was generated from OFFICIAL UNIVERSITY DOCUMENTS.
361
+ Apply STRICT checking:
362
+ - Flag any specific fact (number, name, policy, fee, grade, course code)
363
+ that is NOT present in the context.
364
+ - Do NOT flag the university name "XYZ National University" as a hallucination.
365
+ - Do NOT flag polite conversational phrases or general framing.
366
+ - Do NOT flag minor rephrasing or summarization of document content."""
367
+
368
+ prompt = f"""You are a hallucination detector for a university advisory AI.
369
+
370
+ {strictness_instruction}
371
+
372
+ Context:
373
+ ---
374
+ {context}
375
+ ---
376
+
377
+ Generated Response:
378
+ ---
379
+ {response}
380
+ ---
381
+
382
+ Respond in this EXACT JSON format (no other text):
383
+ {{
384
+ "hallucination_detected": true or false,
385
+ "explanation": "specific invented fact found, or 'none' if clean"
386
+ }}
387
+ """
388
+
389
+ result = llm.invoke(prompt)
390
+ data = safe_json_parse(result.content)
391
+ if data is None:
392
+ data = {"hallucination_detected": False, "explanation": "Parsing error, assuming clean"}
393
+
394
+ print(f" Hallucination detected: {data['hallucination_detected']}")
395
+ print(f" Explanation: {data['explanation']}")
396
+
397
+ checkpoint_data = {
398
+ "checkpoint": 3,
399
+ "label": "HALLUCINATION CHECK",
400
+ "status": "fail" if data["hallucination_detected"] else "pass",
401
+ "detail": data["explanation"],
402
+ "retries_used": retry_count
403
+ }
404
+
405
+ if data["hallucination_detected"]:
406
+ new_retry_count = retry_count + 1
407
+ print(f" β†’ Hallucination found! Retry count: {new_retry_count}")
408
+
409
+ return {
410
+ "hallucination_detected": True,
411
+ "retry_count": new_retry_count,
412
+ "agent_path": state["agent_path"] + " β†’ check_hallucination",
413
+ "trace_steps": state["trace_steps"] + [checkpoint_data]
414
+ }
415
+ else:
416
+ return {
417
+ "hallucination_detected": False,
418
+ "final_answer": response,
419
+ "agent_path": state["agent_path"] + " β†’ check_hallucination β†’ END",
420
+ "trace_steps": state["trace_steps"] + [checkpoint_data]
421
+ }
422
+
423
+ # --- NODE 8: direct_answer ---
424
+ def direct_answer(state: AgentState) -> dict:
425
+ query = state["query"]
426
+ memory_context = state.get("memory_context", "")
427
+
428
+ print(f"\n[DIRECT ANSWER] No retrieval needed. Answering from general knowledge.")
429
+
430
+ prompt = f"""You are a friendly and helpful university course advisory assistant.
431
+
432
+ Previous conversation context:
433
+ {memory_context}
434
+
435
+ Answer the following message naturally and helpfully.
436
+
437
+ Message: {query}
438
+ """
439
+
440
+ response = llm.invoke(prompt)
441
+ print(f" Response: {response.content[:200]}...")
442
+
443
+ return {
444
+ "final_answer": response.content,
445
+ "needs_retrieval": False,
446
+ "agent_path": state["agent_path"] + " β†’ direct_answer β†’ END"
447
+ }
448
+
449
+ # --- NODE 9: max_retries_exceeded ---
450
+ MAX_RETRIES = 2
451
+
452
+ def max_retries_exceeded(state: AgentState) -> dict:
453
+ print(f"\n[MAX RETRIES EXCEEDED] Could not generate a verified response after {MAX_RETRIES} attempts.")
454
+ disclaimer = (
455
+ "I was unable to generate a verified response for your query. "
456
+ "The information I found may be incomplete or inconsistent. "
457
+ "Please contact the university directly or visit the official university portal for accurate information."
458
+ )
459
+ return {
460
+ "final_answer": disclaimer,
461
+ "agent_path": state["agent_path"] + " β†’ max_retries_exceeded β†’ END"
462
+ }
463
+
464
+ # --- CONDITIONAL EDGE FUNCTIONS ---
465
+ def route_after_retrieval_decision(state: AgentState) -> str:
466
+ if state["needs_retrieval"]:
467
+ return "retrieve"
468
+ else:
469
+ return "direct_answer"
470
+
471
+ def route_after_grading(state: AgentState) -> str:
472
+ if state["use_web_search"]:
473
+ return "web_search_node"
474
+ else:
475
+ return "prepare_context"
476
+
477
+ def route_after_hallucination_check(state: AgentState) -> str:
478
+ if not state["hallucination_detected"]:
479
+ return END
480
+ elif state.get("retry_count", 0) >= MAX_RETRIES:
481
+ return "max_retries_exceeded"
482
+ else:
483
+ return "generate"
484
+
485
+ # --- BUILD THE STATEGRAPH ---
486
+ def build_graph():
487
+ graph = StateGraph(AgentState)
488
+
489
+ graph.add_node("decide_retrieval", decide_retrieval)
490
+ graph.add_node("retrieve", retrieve)
491
+ graph.add_node("grade_documents", grade_documents)
492
+ graph.add_node("web_search_node", web_search_node)
493
+ graph.add_node("prepare_context", prepare_context)
494
+ graph.add_node("generate", generate)
495
+ graph.add_node("check_hallucination", check_hallucination)
496
+ graph.add_node("direct_answer", direct_answer)
497
+ graph.add_node("max_retries_exceeded", max_retries_exceeded)
498
+
499
+ graph.set_entry_point("decide_retrieval")
500
+
501
+ graph.add_edge("retrieve", "grade_documents")
502
+ graph.add_edge("web_search_node", "prepare_context")
503
+ graph.add_edge("prepare_context", "generate")
504
+ graph.add_edge("generate", "check_hallucination")
505
+ graph.add_edge("max_retries_exceeded", END)
506
+ graph.add_edge("direct_answer", END)
507
+
508
+ graph.add_conditional_edges(
509
+ "decide_retrieval",
510
+ route_after_retrieval_decision,
511
+ {
512
+ "retrieve": "retrieve",
513
+ "direct_answer": "direct_answer"
514
+ }
515
+ )
516
+
517
+ graph.add_conditional_edges(
518
+ "grade_documents",
519
+ route_after_grading,
520
+ {
521
+ "web_search_node": "web_search_node",
522
+ "prepare_context": "prepare_context"
523
+ }
524
+ )
525
+
526
+ graph.add_conditional_edges(
527
+ "check_hallucination",
528
+ route_after_hallucination_check,
529
+ {
530
+ END: END,
531
+ "max_retries_exceeded": "max_retries_exceeded",
532
+ "generate": "generate"
533
+ }
534
+ )
535
+
536
+ return graph.compile()
537
+
538
+ app = build_graph()
ingest.py ADDED
@@ -0,0 +1,96 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from langchain_community.document_loaders import PyPDFLoader
2
+ from langchain.text_splitter import RecursiveCharacterTextSplitter
3
+ from langchain_community.vectorstores import Chroma
4
+ from langchain_huggingface import HuggingFaceEmbeddings
5
+ import os
6
+
7
+ # --- EMBEDDING MODEL ---
8
+ embeddings = HuggingFaceEmbeddings(model_name="sentence-transformers/all-MiniLM-L6-v2")
9
+
10
+ # --- PDF LIST ---
11
+ PDF_SOURCES = [
12
+ {
13
+ "path": "data/CS_Department_Catalog.pdf",
14
+ "department": "Computer Science",
15
+ "doc_type": "course_catalog",
16
+ "course_level": "undergraduate_graduate"
17
+ },
18
+ {
19
+ "path": "data/EE_Department_Catalog.pdf",
20
+ "department": "Electrical Engineering",
21
+ "doc_type": "course_catalog",
22
+ "course_level": "undergraduate"
23
+ },
24
+ {
25
+ "path": "data/BBA_Department_Catalog.pdf",
26
+ "department": "Business Administration",
27
+ "doc_type": "course_catalog",
28
+ "course_level": "undergraduate"
29
+ },
30
+ {
31
+ "path": "data/University_Academic_Policies.pdf",
32
+ "department": "University",
33
+ "doc_type": "academic_policy",
34
+ "course_level": "all"
35
+ },
36
+ {
37
+ "path": "data/Faculty_Directory.pdf",
38
+ "department": "All",
39
+ "doc_type": "faculty_directory",
40
+ "course_level": "all"
41
+ }
42
+ ]
43
+
44
+ import shutil
45
+
46
+ def create_vector_store():
47
+ """
48
+ Creates a pure-Python vector store (pickle) to bypass ChromaDB/Python 3.14 crashes.
49
+ """
50
+ print("Initializing embeddings...")
51
+ from langchain_huggingface import HuggingFaceEmbeddings
52
+ embeddings_model = HuggingFaceEmbeddings(model_name="all-MiniLM-L6-v2")
53
+
54
+ all_chunks = []
55
+ # Increase size and overlap to keep headers with content
56
+ splitter = RecursiveCharacterTextSplitter(
57
+ chunk_size=1500,
58
+ chunk_overlap=400,
59
+ separators=["\n\n", "\n", ".", " ", ""]
60
+ )
61
+
62
+ for source in PDF_SOURCES:
63
+ if not os.path.exists(source["path"]):
64
+ print(f"Warning: File not found {source['path']}")
65
+ continue
66
+
67
+ print(f"Processing: {source['path']}")
68
+ loader = PyPDFLoader(source["path"])
69
+ pages = loader.load()
70
+
71
+ # Inject metadata directly into text to ensure context is never lost
72
+ for doc in pages:
73
+ prefix = f"[DEPARTMENT: {source['department']}] [DOC: {source['doc_type']}]\n"
74
+ doc.page_content = prefix + doc.page_content
75
+
76
+ doc.metadata["department"] = source["department"]
77
+ doc.metadata["doc_type"] = source["doc_type"]
78
+ doc.metadata["course_level"] = source["course_level"]
79
+ doc.metadata["source_file"] = source["path"]
80
+
81
+ chunks = splitter.split_documents(pages)
82
+ all_chunks.extend(chunks)
83
+
84
+ print(f"Embedding {len(all_chunks)} chunks (Pure Python)...")
85
+ texts = [chunk.page_content for chunk in all_chunks]
86
+ embeddings = embeddings_model.embed_documents(texts)
87
+
88
+ db_path = "vector_store_fallback.pkl"
89
+ import pickle
90
+ with open(db_path, "wb") as f:
91
+ pickle.dump({"chunks": all_chunks, "embeddings": embeddings}, f)
92
+
93
+ print(f"Saved {len(all_chunks)} chunks to {db_path}")
94
+
95
+ if __name__ == "__main__":
96
+ create_vector_store()
requirements.txt ADDED
@@ -0,0 +1,14 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ langchain==0.2.16
2
+ langchain-community==0.2.16
3
+ langchain-google-genai==1.0.10
4
+ langgraph==0.2.28
5
+ chromadb==0.5.5
6
+ pypdf==4.3.1
7
+ tiktoken>=0.7.0
8
+ pydantic==2.8.2
9
+ tavily-python==0.3.3
10
+ python-dotenv==1.0.1
11
+ sentence-transformers==3.0.1
12
+ langchain-huggingface==0.0.3
13
+ fastapi==0.111.0
14
+ uvicorn==0.30.1
scratch/check_retrieval.py ADDED
@@ -0,0 +1,17 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from ingest import get_retriever
2
+ import sys
3
+
4
+ def check_query(query):
5
+ retriever = get_retriever()
6
+ docs = retriever.invoke(query)
7
+ print(f"\n--- Search results for: '{query}' ---\n")
8
+ for i, doc in enumerate(docs):
9
+ print(f"Result {i+1} (Source: {doc.metadata.get('source_file')}):")
10
+ print(f"{doc.page_content}\n")
11
+ print("-" * 50)
12
+
13
+ if __name__ == "__main__":
14
+ teacher_query = "Farhan Qureshi"
15
+ if len(sys.argv) > 1:
16
+ teacher_query = sys.argv[1]
17
+ check_query(teacher_query)
self_rag_agent.py ADDED
@@ -0,0 +1,111 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from fastapi import FastAPI, HTTPException
2
+ from fastapi.middleware.cors import CORSMiddleware
3
+ from pydantic import BaseModel
4
+ from typing import List, Optional
5
+ import uvicorn
6
+ from dotenv import load_dotenv
7
+ import os
8
+
9
+ # Load environment variables
10
+ load_dotenv()
11
+
12
+ from graph import app as langgraph_app
13
+
14
+ class MemoryItem(BaseModel):
15
+ role: str
16
+ content: str
17
+
18
+ class ChatRequest(BaseModel):
19
+ query: str
20
+ window_memory: List[MemoryItem] = []
21
+
22
+ class CheckpointData(BaseModel):
23
+ checkpoint: int
24
+ label: str
25
+ status: str
26
+ detail: str
27
+ reasoning: Optional[str] = None
28
+ docs_graded: Optional[int] = None
29
+ docs_relevant: Optional[int] = None
30
+ web_search_triggered: Optional[bool] = False
31
+ retries_used: Optional[int] = 0
32
+
33
+ class ChatResponse(BaseModel):
34
+ answer: str
35
+ path: str
36
+ trace: List[CheckpointData]
37
+ used_web_search: bool
38
+ retry_count: int
39
+
40
+ app_api = FastAPI(title="XYZ University Advisory Agent API")
41
+
42
+ app_api.add_middleware(
43
+ CORSMiddleware,
44
+ allow_origins=["http://localhost:5173", "http://localhost:3000"],
45
+ allow_credentials=True,
46
+ allow_methods=["*"],
47
+ allow_headers=["*"],
48
+ )
49
+
50
+ @app_api.post("/chat", response_model=ChatResponse)
51
+ async def chat_endpoint(request: ChatRequest):
52
+ try:
53
+ # Build initial state including window memory as context
54
+ memory_context = ""
55
+ if request.window_memory:
56
+ memory_context = "\n\nRecent conversation context:\n"
57
+ for item in request.window_memory:
58
+ role_label = "Student" if item.role == "user" else "Advisor"
59
+ memory_context += f"{role_label}: {item.content}\n"
60
+
61
+ initial_state = {
62
+ "query": request.query,
63
+ "memory_context": memory_context,
64
+ "needs_retrieval": False,
65
+ "retrieval_reasoning": "",
66
+ "retrieved_docs": [],
67
+ "relevant_docs": [],
68
+ "use_web_search": False,
69
+ "web_results": [],
70
+ "generation_context": "",
71
+ "response": "",
72
+ "hallucination_detected": False,
73
+ "retry_count": 0,
74
+ "final_answer": "",
75
+ "trace_steps": [],
76
+ "agent_path": ""
77
+ }
78
+
79
+ final_state = await langgraph_app.ainvoke(initial_state)
80
+
81
+ # Build trace from final_state
82
+ trace = final_state.get("trace_steps", [])
83
+
84
+ return ChatResponse(
85
+ answer=final_state.get("final_answer", "No answer generated."),
86
+ path=final_state.get("agent_path", "unknown"),
87
+ trace=trace,
88
+ used_web_search=final_state.get("use_web_search", False),
89
+ retry_count=final_state.get("retry_count", 0)
90
+ )
91
+ except Exception as e:
92
+ print(f"Error in chat endpoint: {e}")
93
+ raise HTTPException(status_code=500, detail=str(e))
94
+
95
+ @app_api.get("/health")
96
+ async def health():
97
+ return {"status": "online", "agent": "XYZ University Advisory Agent"}
98
+
99
+ # --- SILENCE EXTERNAL NOISE ---
100
+ # These endpoints are added to stop 404 logs from external dashboards (VPTQ)
101
+ @app_api.get("/api/pipeline/status")
102
+ @app_api.get("/api/stats/pipeline-status")
103
+ async def pipeline_status():
104
+ return {"status": "idle", "message": "University Advisor Active"}
105
+
106
+ @app_api.post("/api/stats/run-pipeline")
107
+ async def run_pipeline():
108
+ return {"status": "skipped", "message": "Pipeline logic not applicable to RAG Agent"}
109
+
110
+ if __name__ == "__main__":
111
+ uvicorn.run("self_rag_agent:app_api", host="0.0.0.0", port=8000, reload=True)
tools.py ADDED
@@ -0,0 +1,71 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from langchain.tools import tool
2
+ from pydantic import BaseModel, Field
3
+ from langchain_community.tools.tavily_search import TavilySearchResults
4
+ import os
5
+ from dotenv import load_dotenv
6
+ import numpy as np
7
+ from langchain_huggingface import HuggingFaceEmbeddings
8
+
9
+ load_dotenv()
10
+
11
+ # Initialize embeddings once
12
+ embeddings_model = HuggingFaceEmbeddings(model_name="all-MiniLM-L6-v2")
13
+
14
+ # --- TOOL 1: retrieve_documents ---
15
+
16
+ class RetrieveInput(BaseModel):
17
+ query: str = Field(description="The user's question to search for in the university knowledge base")
18
+
19
+ @tool(args_schema=RetrieveInput)
20
+ def retrieve_documents(query: str, k: int = 15) -> list[dict]:
21
+ """
22
+ Retrieves relevant document chunks using Python 3.14 safe similarity search.
23
+ """
24
+ import pickle
25
+ db_path = "vector_store_fallback.pkl"
26
+ if not os.path.exists(db_path):
27
+ print(f"Error: {db_path} not found.")
28
+ return []
29
+
30
+ with open(db_path, "rb") as f:
31
+ data = pickle.load(f)
32
+
33
+ chunks = data["chunks"]
34
+ chunk_embeddings = np.array(data["embeddings"])
35
+
36
+ # Embed the query
37
+ query_embedding = np.array(embeddings_model.embed_query(query))
38
+
39
+ # Calculate cosine similarity: (A . B) / (||A|| * ||B||)
40
+ dot_products = np.dot(chunk_embeddings, query_embedding)
41
+ norms_chunks = np.linalg.norm(chunk_embeddings, axis=1)
42
+ norm_query = np.linalg.norm(query_embedding)
43
+ similarities = dot_products / (norms_chunks * norm_query)
44
+
45
+ # Get top k indices
46
+ top_indices = np.argsort(similarities)[::-1][:k]
47
+
48
+ results = []
49
+ for idx in top_indices:
50
+ results.append({
51
+ "content": chunks[idx].page_content,
52
+ "metadata": chunks[idx].metadata
53
+ })
54
+ return results
55
+
56
+ # --- TOOL 2: web_search ---
57
+
58
+ class WebSearchInput(BaseModel):
59
+ query: str = Field(description="Search query for finding information on the web when the knowledge base does not have the answer")
60
+
61
+ @tool(args_schema=WebSearchInput)
62
+ def web_search(query: str) -> list[dict]:
63
+ """
64
+ Performs a web search using Tavily to find information not available in the
65
+ university's internal knowledge base. Use this as a FALLBACK tool only when
66
+ all retrieved documents from the knowledge base were graded as irrelevant.
67
+ Returns a list of web search results with title, content, and URL.
68
+ """
69
+ search = TavilySearchResults(max_results=3, tavily_api_key=os.getenv("TAVILY_API_KEY"))
70
+ results = search.invoke(query)
71
+ return results
university-advisor-ui/index.html ADDED
@@ -0,0 +1,13 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ <!DOCTYPE html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="UTF-8" />
5
+ <link rel="icon" type="image/svg+xml" href="/vite.svg" />
6
+ <meta name="viewport" content="width=device-width, initial-scale=1.0" />
7
+ <title>XYZ University Advisory Agent</title>
8
+ </head>
9
+ <body class="bg-brand-bg font-body text-brand-text antialiased">
10
+ <div id="root"></div>
11
+ <script type="module" src="/src/main.tsx"></script>
12
+ </body>
13
+ </html>
university-advisor-ui/package-lock.json ADDED
The diff for this file is too large to render. See raw diff
 
university-advisor-ui/package.json ADDED
@@ -0,0 +1,32 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "name": "university-advisor-ui",
3
+ "private": true,
4
+ "version": "0.0.0",
5
+ "type": "module",
6
+ "scripts": {
7
+ "dev": "vite",
8
+ "build": "tsc && vite build",
9
+ "lint": "eslint . --ext ts,tsx --report-unused-disable-directives --max-warnings 0",
10
+ "preview": "vite preview"
11
+ },
12
+ "dependencies": {
13
+ "react": "^18.3.1",
14
+ "react-dom": "^18.3.1",
15
+ "lucide-react": "^0.383.0"
16
+ },
17
+ "devDependencies": {
18
+ "@types/react": "^18.3.3",
19
+ "@types/react-dom": "^18.3.0",
20
+ "@typescript-eslint/eslint-plugin": "^7.13.1",
21
+ "@typescript-eslint/parser": "^7.13.1",
22
+ "@vitejs/plugin-react": "^4.3.1",
23
+ "autoprefixer": "^10.4.19",
24
+ "eslint": "^8.57.0",
25
+ "eslint-plugin-react-hooks": "^4.6.2",
26
+ "eslint-plugin-react-refresh": "^0.4.7",
27
+ "postcss": "^8.4.38",
28
+ "tailwindcss": "^3.4.4",
29
+ "typescript": "^5.2.2",
30
+ "vite": "^5.3.1"
31
+ }
32
+ }
university-advisor-ui/postcss.config.js ADDED
@@ -0,0 +1,6 @@
 
 
 
 
 
 
 
1
+ export default {
2
+ plugins: {
3
+ tailwindcss: {},
4
+ autoprefixer: {},
5
+ },
6
+ }
university-advisor-ui/src/App.tsx ADDED
@@ -0,0 +1,161 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import React, { useState, useRef, useEffect } from 'react';
2
+ import Header from './components/Header';
3
+ import Sidebar from './components/Sidebar';
4
+ import MessageBubble from './components/MessageBubble';
5
+ import TracePanel from './components/TracePanel';
6
+ import InputBar from './components/InputBar';
7
+ import { Message, TraceStep, WindowMemoryItem } from './types';
8
+ import { sendMessage, ChatResponse } from './services/api';
9
+
10
+ const App: React.FC = () => {
11
+ const [messages, setMessages] = useState<Message[]>([
12
+ {
13
+ id: 'welcome',
14
+ role: 'assistant',
15
+ content: "Welcome to the XYZ University advisory portal. How can I help you today?",
16
+ timestamp: new Date(),
17
+ }
18
+ ]);
19
+ const [windowMemory, setWindowMemory] = useState<WindowMemoryItem[]>([]);
20
+ const [inputText, setInputText] = useState("");
21
+ const [isLoading, setIsLoading] = useState(false);
22
+ const [sidebarOpen, setSidebarOpen] = useState(true);
23
+ const [traceOpen, setTraceOpen] = useState(false);
24
+ const [activeTrace, setActiveTrace] = useState<{ steps: TraceStep[], path: string }>({ steps: [], path: "" });
25
+
26
+ const scrollRef = useRef<HTMLDivElement>(null);
27
+
28
+ useEffect(() => {
29
+ if (scrollRef.current) {
30
+ scrollRef.current.scrollTop = scrollRef.current.scrollHeight;
31
+ }
32
+ }, [messages]);
33
+
34
+ const updateWindowMemory = (userMsg: string, botMsg: string) => {
35
+ setWindowMemory(prev => {
36
+ const newItems: WindowMemoryItem[] = [
37
+ { role: 'user', content: userMsg },
38
+ { role: 'assistant', content: botMsg }
39
+ ];
40
+ const combined = [...prev, ...newItems];
41
+ return combined.slice(-4);
42
+ });
43
+ };
44
+
45
+ const handleSend = async (forcedQuery?: string) => {
46
+ const query = forcedQuery || inputText;
47
+ if (!query.trim() || isLoading) return;
48
+
49
+ const userMessage: Message = {
50
+ id: Date.now().toString(),
51
+ role: 'user',
52
+ content: query,
53
+ timestamp: new Date(),
54
+ };
55
+
56
+ setMessages(prev => [...prev, userMessage]);
57
+ setInputText("");
58
+ setIsLoading(true);
59
+
60
+ const botPlaceholderId = (Date.now() + 1).toString();
61
+ const botLoadingMessage: Message = {
62
+ id: botPlaceholderId,
63
+ role: 'assistant',
64
+ content: "",
65
+ timestamp: new Date(),
66
+ isLoading: true
67
+ };
68
+ setMessages(prev => [...prev, botLoadingMessage]);
69
+
70
+ try {
71
+ const response: ChatResponse = await sendMessage({
72
+ query,
73
+ window_memory: windowMemory
74
+ });
75
+
76
+ const traceSteps: TraceStep[] = response.trace.map((t, i) => ({
77
+ ...t,
78
+ id: `t-${Date.now()}-${i}`,
79
+ timestamp: new Date()
80
+ }));
81
+
82
+ const botMessage: Message = {
83
+ id: botPlaceholderId,
84
+ role: 'assistant',
85
+ content: response.answer,
86
+ timestamp: new Date(),
87
+ trace: traceSteps,
88
+ path: response.path
89
+ };
90
+
91
+ setMessages(prev => prev.map(m => m.id === botPlaceholderId ? botMessage : m));
92
+ setActiveTrace({ steps: traceSteps, path: response.path });
93
+ updateWindowMemory(query, response.answer);
94
+
95
+ } catch (error) {
96
+ const errorMessage: Message = {
97
+ id: botPlaceholderId,
98
+ role: 'assistant',
99
+ content: "System error: Failed to connect to university records.",
100
+ timestamp: new Date(),
101
+ };
102
+ setMessages(prev => prev.map(m => m.id === botPlaceholderId ? errorMessage : m));
103
+ } finally {
104
+ setIsLoading(false);
105
+ }
106
+ };
107
+
108
+ return (
109
+ <div className="flex flex-col h-screen bg-[#070B14] text-slate-200 overflow-hidden font-body">
110
+ <Header onToggleSidebar={() => setSidebarOpen(!sidebarOpen)} />
111
+
112
+ <div className="flex-1 flex overflow-hidden">
113
+ {/* Sidebar */}
114
+ <Sidebar
115
+ isOpen={sidebarOpen}
116
+ onQuickTopic={handleSend}
117
+ windowMemory={windowMemory}
118
+ />
119
+
120
+ {/* Main Chat Area */}
121
+ <main className="flex-1 flex flex-col relative overflow-hidden">
122
+ {/* Messages Feed */}
123
+ <div
124
+ ref={scrollRef}
125
+ className="flex-1 overflow-y-auto px-4 py-6 scroll-smooth"
126
+ >
127
+ <div className="max-w-4xl mx-auto w-full">
128
+ {messages.map((msg) => (
129
+ <MessageBubble
130
+ key={msg.id}
131
+ message={msg}
132
+ onTraceClick={() => setTraceOpen(true)}
133
+ />
134
+ ))}
135
+ </div>
136
+ </div>
137
+
138
+ {/* Trace Panel */}
139
+ <TracePanel
140
+ isOpen={traceOpen}
141
+ onToggle={() => setTraceOpen(!traceOpen)}
142
+ steps={activeTrace.steps}
143
+ path={activeTrace.path}
144
+ />
145
+
146
+ {/* Input Area */}
147
+ <div className="w-full">
148
+ <InputBar
149
+ value={inputText}
150
+ onChange={setInputText}
151
+ onSend={() => handleSend()}
152
+ isLoading={isLoading}
153
+ />
154
+ </div>
155
+ </main>
156
+ </div>
157
+ </div>
158
+ );
159
+ };
160
+
161
+ export default App;
university-advisor-ui/src/components/Header.tsx ADDED
@@ -0,0 +1,35 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import React from 'react';
2
+ import { GraduationCap, Menu } from 'lucide-react';
3
+
4
+ interface HeaderProps {
5
+ onToggleSidebar: () => void;
6
+ }
7
+
8
+ const Header: React.FC<HeaderProps> = ({ onToggleSidebar }) => {
9
+ return (
10
+ <header className="h-14 bg-[#040811] border-b border-slate-800 flex items-center justify-between px-4 shrink-0 z-50">
11
+ <div className="flex items-center gap-3">
12
+ <button
13
+ onClick={onToggleSidebar}
14
+ className="text-slate-500 hover:text-slate-300 transition-colors p-1"
15
+ >
16
+ <Menu size={18} />
17
+ </button>
18
+ <div className="flex items-center gap-2">
19
+ <GraduationCap size={18} className="text-amber-400" />
20
+ <span className="text-sm font-semibold text-slate-200"
21
+ style={{fontFamily: 'Playfair Display, serif'}}>
22
+ XYZ National University
23
+ </span>
24
+ </div>
25
+ </div>
26
+
27
+ <div className="flex items-center gap-2.5 bg-slate-900/50 px-3 py-1.5 rounded-full border border-slate-800">
28
+ <div className="w-1.5 h-1.5 rounded-full bg-emerald-500 animate-pulse shadow-[0_0_8px_rgba(16,185,129,0.5)]" />
29
+ <span className="text-[10px] font-mono text-slate-500 uppercase tracking-widest font-bold">Agent Online</span>
30
+ </div>
31
+ </header>
32
+ );
33
+ };
34
+
35
+ export default Header;
university-advisor-ui/src/components/InputBar.tsx ADDED
@@ -0,0 +1,57 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import React, { useRef, useEffect } from 'react';
2
+ import { Send, Loader2 } from 'lucide-react';
3
+
4
+ interface InputBarProps {
5
+ value: string;
6
+ onChange: (value: string) => void;
7
+ onSend: () => void;
8
+ isLoading: boolean;
9
+ }
10
+
11
+ const InputBar: React.FC<InputBarProps> = ({ value, onChange, onSend, isLoading }) => {
12
+ const textareaRef = useRef<HTMLTextAreaElement>(null);
13
+
14
+ useEffect(() => {
15
+ if (textareaRef.current) {
16
+ textareaRef.current.style.height = 'auto';
17
+ textareaRef.current.style.height = `${Math.min(textareaRef.current.scrollHeight, 120)}px`;
18
+ }
19
+ }, [value]);
20
+
21
+ const handleKeyDown = (e: React.KeyboardEvent) => {
22
+ if (e.key === 'Enter' && !e.shiftKey) {
23
+ e.preventDefault();
24
+ onSend();
25
+ }
26
+ };
27
+
28
+ return (
29
+ <div className="p-3 border-t border-slate-800 bg-[#040811] shrink-0">
30
+ <div className="flex gap-2 items-end max-w-4xl mx-auto w-full">
31
+ <textarea
32
+ ref={textareaRef}
33
+ value={value}
34
+ onChange={e => onChange(e.target.value)}
35
+ onKeyDown={handleKeyDown}
36
+ placeholder="Ask about courses, faculty, policies..."
37
+ rows={2}
38
+ disabled={isLoading}
39
+ className="flex-1 bg-slate-900 border border-slate-800 rounded-xl px-4 py-3 text-sm text-slate-200 placeholder-slate-600 focus:outline-none focus:ring-1 focus:ring-blue-600 resize-none font-body transition-all disabled:opacity-50"
40
+ />
41
+ <button
42
+ onClick={onSend}
43
+ disabled={isLoading || !value.trim()}
44
+ className="w-12 h-12 bg-blue-600 hover:bg-blue-700 disabled:bg-slate-800 disabled:text-slate-600 text-white rounded-xl flex items-center justify-center transition-all shrink-0 shadow-lg shadow-blue-900/10 hover:shadow-blue-900/30 active:scale-95"
45
+ >
46
+ {isLoading ? (
47
+ <Loader2 size={18} className="animate-spin" />
48
+ ) : (
49
+ <Send size={18} />
50
+ )}
51
+ </button>
52
+ </div>
53
+ </div>
54
+ );
55
+ };
56
+
57
+ export default InputBar;
university-advisor-ui/src/components/MessageBubble.tsx ADDED
@@ -0,0 +1,132 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import React from 'react';
2
+ import { GraduationCap, Loader2 } from 'lucide-react';
3
+ import { Message } from '../types';
4
+
5
+ interface MessageBubbleProps {
6
+ message: Message;
7
+ onTraceClick: (checkpoint: number) => void;
8
+ }
9
+
10
+ const MessageBubble: React.FC<MessageBubbleProps> = ({ message, onTraceClick }) => {
11
+ const isUser = message.role === "user";
12
+ const formatTime = (date: Date) => {
13
+ return new Date(date).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit', second: '2-digit' });
14
+ };
15
+
16
+ if (message.isLoading) {
17
+ return (
18
+ <div className="flex justify-start mb-6 animate-fade-slide-up">
19
+ <div className="max-w-[75%]">
20
+ <div className="flex items-center gap-1.5 mb-1.5 pl-1">
21
+ <GraduationCap size={12} className="text-amber-400" />
22
+ <span className="text-xs text-amber-400/80 font-semibold tracking-wide">XYZ Advisor</span>
23
+ </div>
24
+ <div className="bg-slate-900 border border-slate-800 rounded-2xl rounded-tl-sm px-4 py-3 border-l-2 border-l-blue-600 flex items-center gap-3">
25
+ <div className="flex gap-1">
26
+ <div className="w-1.5 h-1.5 rounded-full bg-blue-500 animate-typing-bounce" style={{ animationDelay: '0ms' }}></div>
27
+ <div className="w-1.5 h-1.5 rounded-full bg-blue-500 animate-typing-bounce" style={{ animationDelay: '150ms' }}></div>
28
+ <div className="w-1.5 h-1.5 rounded-full bg-blue-500 animate-typing-bounce" style={{ animationDelay: '300ms' }}></div>
29
+ </div>
30
+ <span className="text-xs text-slate-500 font-mono italic">Analyzing...</span>
31
+ </div>
32
+ </div>
33
+ </div>
34
+ );
35
+ }
36
+
37
+ if (isUser) {
38
+ return (
39
+ <div className="flex justify-end mb-4 animate-fade-slide-up">
40
+ <div className="max-w-[70%] bg-blue-900/40 border border-blue-800/50 rounded-2xl rounded-tr-sm px-4 py-3">
41
+ <p className="text-sm text-slate-200 leading-relaxed font-body">
42
+ {message.content}
43
+ </p>
44
+ <p className="text-xs text-slate-600 mt-1.5 text-right font-mono">
45
+ {formatTime(message.timestamp)}
46
+ </p>
47
+ </div>
48
+ </div>
49
+ );
50
+ }
51
+
52
+ const trace = message.trace;
53
+
54
+ return (
55
+ <div className="flex justify-start mb-6 animate-fade-slide-up">
56
+ <div className="max-w-[75%]">
57
+ {/* Advisor label */}
58
+ <div className="flex items-center gap-1.5 mb-1.5 pl-1">
59
+ <GraduationCap size={12} className="text-amber-400" />
60
+ <span className="text-xs text-amber-400/80 font-semibold tracking-wide">
61
+ XYZ Advisor
62
+ </span>
63
+ <span className="text-xs text-slate-700 font-mono ml-4">
64
+ {formatTime(message.timestamp)}
65
+ </span>
66
+ </div>
67
+
68
+ {/* Message content */}
69
+ <div className="bg-slate-900 border border-slate-800 rounded-2xl rounded-tl-sm px-4 py-3 border-l-2 border-l-blue-600 shadow-sm">
70
+ <p className="text-sm text-slate-200 leading-relaxed whitespace-pre-wrap font-body">
71
+ {message.content}
72
+ </p>
73
+ </div>
74
+
75
+ {/* Minimal checkpoint row */}
76
+ {trace && (
77
+ <div className="flex items-center gap-2 mt-2 pl-1">
78
+ {/* Checkpoint 1 - Retrieval */}
79
+ <div className="flex items-center gap-1.5">
80
+ <div className={`w-2 h-2 rounded-full ${
81
+ trace?.find(t => t.checkpoint === 1)?.status === 'pass'
82
+ ? 'bg-emerald-500' : 'bg-slate-600'
83
+ }`} />
84
+ <span className="text-[10px] font-mono text-slate-500 uppercase tracking-tight">
85
+ {trace?.find(t => t.checkpoint === 1)?.status === 'skip'
86
+ ? 'direct' : 'retrieved'}
87
+ </span>
88
+ </div>
89
+
90
+ <span className="text-slate-800 text-[10px]">β€’</span>
91
+
92
+ {/* Checkpoint 2 - Grading */}
93
+ <div className="flex items-center gap-1.5">
94
+ <div className={`w-2 h-2 rounded-full ${
95
+ trace?.find(t => t.checkpoint === 2)?.web_search_triggered
96
+ ? 'bg-amber-500'
97
+ : trace?.find(t => t.checkpoint === 2)?.status === 'pass'
98
+ ? 'bg-emerald-500' : 'bg-slate-600'
99
+ }`} />
100
+ <span className="text-[10px] font-mono text-slate-500 uppercase tracking-tight">
101
+ {trace?.find(t => t.checkpoint === 2)?.web_search_triggered
102
+ ? 'web'
103
+ : `${trace?.find(t => t.checkpoint === 2)?.docs_relevant ?? 0}/${trace?.find(t => t.checkpoint === 2)?.docs_graded ?? 0} docs`}
104
+ </span>
105
+ </div>
106
+
107
+ <span className="text-slate-800 text-[10px]">β€’</span>
108
+
109
+ {/* Checkpoint 3 - Hallucination */}
110
+ <div className="flex items-center gap-1.5">
111
+ <div className={`w-2 h-2 rounded-full ${
112
+ (trace?.find(t => t.checkpoint === 3)?.retries_used ?? 0) > 0
113
+ ? 'bg-amber-500' : 'bg-emerald-500'
114
+ }`} />
115
+ <span className="text-[10px] font-mono text-slate-500 uppercase tracking-tight">verified</span>
116
+ </div>
117
+
118
+ {/* Expand trace button */}
119
+ <button
120
+ onClick={() => onTraceClick(0)} // Pass 0 or any dummy, trace panel handles active message
121
+ className="ml-auto text-[10px] text-slate-600 hover:text-blue-400 font-mono transition-colors uppercase tracking-wider"
122
+ >
123
+ view trace β€Ί
124
+ </button>
125
+ </div>
126
+ )}
127
+ </div>
128
+ </div>
129
+ );
130
+ };
131
+
132
+ export default MessageBubble;
university-advisor-ui/src/components/Sidebar.tsx ADDED
@@ -0,0 +1,87 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import React from 'react';
2
+ import {
3
+ BookOpen,
4
+ Users,
5
+ ShieldCheck,
6
+ CreditCard,
7
+ Calendar,
8
+ GraduationCap
9
+ } from 'lucide-react';
10
+ import { WindowMemoryItem } from '../types';
11
+
12
+ interface SidebarProps {
13
+ onQuickTopic: (topic: string) => void;
14
+ windowMemory: WindowMemoryItem[];
15
+ isOpen: boolean;
16
+ }
17
+
18
+ const Sidebar: React.FC<SidebarProps> = ({ onQuickTopic, windowMemory, isOpen }) => {
19
+ if (!isOpen) return null;
20
+
21
+ const quickTopics = [
22
+ { label: "Prerequisites", icon: <BookOpen size={14} />, query: "What are the prerequisites for Advanced Algorithms?" },
23
+ { label: "Faculty", icon: <Users size={14} />, query: "Who are the professors in the Computer Science department?" },
24
+ { label: "Policies", icon: <ShieldCheck size={14} />, query: "What is the policy for academic probation?" },
25
+ { label: "Fee Structure", icon: <CreditCard size={14} />, query: "What are the tuition fees for the BSCS program?" },
26
+ { label: "Schedule", icon: <Calendar size={14} />, query: "When does the Fall 2025 semester start?" },
27
+ { label: "Requirements", icon: <GraduationCap size={14} />, query: "What are the total credit hours required for graduation?" },
28
+ ];
29
+
30
+ return (
31
+ <aside className="w-64 h-full bg-[#0D1117] border-r border-slate-800 flex flex-col relative shrink-0 transition-all duration-150">
32
+ <div className="p-4">
33
+ {/* Logo area */}
34
+ <div className="mb-6">
35
+ <div className="flex items-center gap-2 mb-1">
36
+ <GraduationCap size={18} className="text-amber-400" />
37
+ <span className="text-sm font-semibold text-slate-200"
38
+ style={{fontFamily: 'Playfair Display, serif'}}>
39
+ XYZ Advisory
40
+ </span>
41
+ </div>
42
+ <p className="text-xs text-slate-600 pl-6 font-body">
43
+ Self-RAG Course Agent
44
+ </p>
45
+ </div>
46
+
47
+ {/* Divider */}
48
+ <div className="border-t border-slate-800 mb-4" />
49
+
50
+ {/* Quick Topics */}
51
+ <p className="text-[10px] text-slate-600 uppercase tracking-[0.2em] mb-3 font-mono">
52
+ Quick Topics
53
+ </p>
54
+ <div className="flex flex-col gap-1">
55
+ {quickTopics.map(topic => (
56
+ <button
57
+ key={topic.label}
58
+ onClick={() => onQuickTopic(topic.query)}
59
+ className="flex items-center gap-3 text-left px-3 py-2 rounded-lg text-xs text-slate-400
60
+ hover:bg-slate-800 hover:text-slate-200
61
+ transition-colors duration-150 font-body"
62
+ >
63
+ <span className="opacity-50">{topic.icon}</span>
64
+ {topic.label}
65
+ </button>
66
+ ))}
67
+ </div>
68
+
69
+ {/* Context indicator at bottom */}
70
+ <div className="absolute bottom-4 left-4 right-4">
71
+ <div className="bg-slate-900 rounded-lg p-3 border border-slate-800 shadow-sm">
72
+ <p className="text-[10px] font-mono text-slate-600 mb-1 uppercase tracking-wider">
73
+ πŸ’¬ Memory window
74
+ </p>
75
+ <p className="text-[11px] text-slate-500 font-body">
76
+ {windowMemory.length > 0
77
+ ? `${windowMemory.length / 2} exchange(s) in context`
78
+ : 'No context yet'}
79
+ </p>
80
+ </div>
81
+ </div>
82
+ </div>
83
+ </aside>
84
+ );
85
+ };
86
+
87
+ export default Sidebar;
university-advisor-ui/src/components/TracePanel.tsx ADDED
@@ -0,0 +1,135 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import React, { useRef, useEffect } from 'react';
2
+ import {
3
+ Terminal,
4
+ ChevronUp,
5
+ ChevronDown,
6
+ CheckCircle2,
7
+ XCircle,
8
+ FastForward,
9
+ Loader2,
10
+ Globe,
11
+ RefreshCcw,
12
+ ArrowRight
13
+ } from 'lucide-react';
14
+ import { TraceStep } from '../types';
15
+
16
+ interface TracePanelProps {
17
+ isOpen: boolean;
18
+ onToggle: () => void;
19
+ steps: TraceStep[];
20
+ path: string;
21
+ }
22
+
23
+ const TracePanel: React.FC<TracePanelProps> = ({ isOpen, onToggle, steps, path }) => {
24
+ const panelRef = useRef<HTMLDivElement>(null);
25
+
26
+ useEffect(() => {
27
+ if (isOpen && panelRef.current) {
28
+ panelRef.current.scrollTop = panelRef.current.scrollHeight;
29
+ }
30
+ }, [isOpen, steps]);
31
+
32
+ return (
33
+ <div className={`w-full bg-[#050810] border-t border-brand-border transition-all duration-300 flex flex-col ${isOpen ? 'h-52' : 'h-10'}`}>
34
+ {/* Header Toggle */}
35
+ <button
36
+ onClick={onToggle}
37
+ className="h-10 px-6 flex items-center justify-between hover:bg-brand-border/30 transition-colors shrink-0 group"
38
+ >
39
+ <div className="flex items-center gap-3">
40
+ <Terminal size={14} className="text-brand-muted group-hover:text-brand-accent transition-colors" />
41
+ <span className="text-[11px] font-mono font-bold text-brand-muted group-hover:text-brand-text uppercase tracking-widest">
42
+ Agent Execution Trace
43
+ </span>
44
+ {path && isOpen && (
45
+ <div className="hidden md:flex items-center gap-2 ml-4 px-3 py-0.5 bg-brand-accent/10 rounded-full border border-brand-accent/20">
46
+ <span className="text-[9px] font-mono text-brand-accent/80 font-bold uppercase">Flow Path:</span>
47
+ <span className="text-[9px] font-mono text-brand-muted tracking-tighter overflow-hidden whitespace-nowrap max-w-[400px]">
48
+ {path}
49
+ </span>
50
+ </div>
51
+ )}
52
+ </div>
53
+ <div className="flex items-center gap-4">
54
+ {steps.length > 0 && !isOpen && (
55
+ <span className="text-[10px] font-mono text-brand-success/60 font-bold uppercase animate-pulse">
56
+ {steps.length} Steps Logged
57
+ </span>
58
+ )}
59
+ {isOpen ? <ChevronDown size={14} className="text-brand-muted" /> : <ChevronUp size={14} className="text-brand-muted" />}
60
+ </div>
61
+ </button>
62
+
63
+ {/* Content Area */}
64
+ <div
65
+ ref={panelRef}
66
+ className={`flex-1 overflow-y-auto px-6 py-4 space-y-4 font-mono text-xs ${isOpen ? 'opacity-100' : 'opacity-0 pointer-events-none'}`}
67
+ >
68
+ {steps.length === 0 ? (
69
+ <div className="h-full flex items-center justify-center text-brand-muted/20 flex-col gap-2">
70
+ <Terminal size={24} strokeWidth={1} />
71
+ <span className="text-[10px] uppercase tracking-[0.2em]">Awaiting execution data...</span>
72
+ </div>
73
+ ) : (
74
+ steps.map((step, idx) => {
75
+ const statusConfig = {
76
+ pass: { icon: <CheckCircle2 size={12} className="text-brand-success" />, color: 'text-brand-success' },
77
+ fail: { icon: <XCircle size={12} className="text-brand-danger" />, color: 'text-brand-danger' },
78
+ skip: { icon: <FastForward size={12} className="text-brand-muted" />, color: 'text-brand-muted' },
79
+ pending: { icon: <Loader2 size={12} className="text-brand-muted animate-spin" />, color: 'text-brand-muted' }
80
+ };
81
+
82
+ const checkpointConfig = {
83
+ 1: { label: 'C1', color: 'bg-brand-accent/20 text-brand-accent border-brand-accent/40' },
84
+ 2: { label: 'C2', color: 'bg-brand-warning/20 text-brand-warning border-brand-warning/40' },
85
+ 3: { label: 'C3', color: 'bg-brand-checkpoint/20 text-brand-checkpoint border-brand-checkpoint/40' }
86
+ };
87
+
88
+ const cp = checkpointConfig[step.checkpoint as 1|2|3];
89
+ const sc = statusConfig[step.status];
90
+
91
+ return (
92
+ <div key={idx} className="space-y-1.5 animate-fade-slide-up" style={{ animationDelay: `${idx * 50}ms` }}>
93
+ <div className="flex items-center gap-3">
94
+ <span className="text-brand-muted/40 tabular-nums">[{new Date(step.timestamp).toLocaleTimeString([], { hour12: false })}]</span>
95
+ <span className={`text-[9px] px-1.5 py-0.5 rounded border font-black ${cp.color}`}>{cp.label}</span>
96
+ <div className="shrink-0">{sc.icon}</div>
97
+ <span className="text-brand-text font-bold uppercase tracking-tight">{step.label}:</span>
98
+ <span className="text-brand-muted truncate">{step.detail}</span>
99
+ </div>
100
+
101
+ {step.reasoning && (
102
+ <div className="pl-24 flex gap-2">
103
+ <ArrowRight size={10} className="mt-1 text-brand-gold/40 shrink-0" />
104
+ <p className="text-brand-goldLight/70 italic leading-relaxed text-[11px] font-body">
105
+ {step.reasoning}
106
+ </p>
107
+ </div>
108
+ )}
109
+
110
+ {(step.web_search_triggered || step.retries_used! > 0) && (
111
+ <div className="pl-24 flex gap-2 pt-1">
112
+ {step.web_search_triggered && (
113
+ <span className="flex items-center gap-1.5 bg-brand-warning/10 text-brand-warning px-2 py-0.5 rounded border border-brand-warning/20 text-[9px] font-black uppercase">
114
+ <Globe size={10} />
115
+ Web Search Fallback
116
+ </span>
117
+ )}
118
+ {step.retries_used! > 0 && (
119
+ <span className="flex items-center gap-1.5 bg-brand-danger/10 text-brand-danger px-2 py-0.5 rounded border border-brand-danger/20 text-[9px] font-black uppercase">
120
+ <RefreshCcw size={10} />
121
+ Retry Count: {step.retries_used}
122
+ </span>
123
+ )}
124
+ </div>
125
+ )}
126
+ </div>
127
+ );
128
+ })
129
+ )}
130
+ </div>
131
+ </div>
132
+ );
133
+ };
134
+
135
+ export default TracePanel;
university-advisor-ui/src/index.css ADDED
@@ -0,0 +1,51 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ @import url('https://fonts.googleapis.com/css2?family=Syne:wght@400..800&family=Bricolage+Grotesque:opsz,wght@12..96,200..800&family=Azeret+Mono:ital,wght@0,100..900;1,100..900&display=swap');
2
+
3
+ @tailwind base;
4
+ @tailwind components;
5
+ @tailwind utilities;
6
+
7
+ @layer base {
8
+ body {
9
+ @apply bg-[#070B14] text-slate-200 font-body overflow-hidden selection:bg-blue-500/30;
10
+ }
11
+
12
+ h1, h2, h3, h4, h5, h6 {
13
+ @apply font-display tracking-tight;
14
+ }
15
+ }
16
+
17
+ @keyframes fadeSlideUp {
18
+ from { opacity: 0; transform: translateY(8px); }
19
+ to { opacity: 1; transform: translateY(0); }
20
+ }
21
+
22
+ @keyframes typingBounce {
23
+ 0%, 80%, 100% { transform: translateY(0); }
24
+ 40% { transform: translateY(-4px); }
25
+ }
26
+
27
+ .animate-fade-slide-up {
28
+ animation: fadeSlideUp 0.2s ease-out forwards;
29
+ }
30
+
31
+ .animate-typing-bounce {
32
+ animation: typingBounce 1s infinite;
33
+ }
34
+
35
+ /* Transitions */
36
+ * {
37
+ @apply transition-colors duration-150 ease-out;
38
+ }
39
+
40
+ /* Custom Scrollbar */
41
+ ::-webkit-scrollbar {
42
+ width: 4px;
43
+ }
44
+
45
+ ::-webkit-scrollbar-track {
46
+ @apply bg-transparent;
47
+ }
48
+
49
+ ::-webkit-scrollbar-thumb {
50
+ @apply bg-slate-800 rounded-full hover:bg-slate-700;
51
+ }
university-advisor-ui/src/main.tsx ADDED
@@ -0,0 +1,10 @@
 
 
 
 
 
 
 
 
 
 
 
1
+ import React from 'react'
2
+ import ReactDOM from 'react-dom/client'
3
+ import App from './App'
4
+ import './index.css'
5
+
6
+ ReactDOM.createRoot(document.getElementById('root')!).render(
7
+ <React.StrictMode>
8
+ <App />
9
+ </React.StrictMode>,
10
+ )
university-advisor-ui/src/services/api.ts ADDED
@@ -0,0 +1,63 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { WindowMemoryItem } from "../types"
2
+
3
+ const API_BASE = "http://localhost:8000"
4
+ const MOCK_MODE = false // Set to false when backend is running
5
+
6
+ export interface ChatRequest {
7
+ query: string
8
+ window_memory: WindowMemoryItem[]
9
+ }
10
+
11
+ export interface CheckpointData {
12
+ checkpoint: number
13
+ label: string
14
+ status: "pass" | "fail" | "skip"
15
+ detail: string
16
+ reasoning?: string
17
+ docs_graded?: number
18
+ docs_relevant?: number
19
+ web_search_triggered?: boolean
20
+ retries_used?: number
21
+ }
22
+
23
+ export interface ChatResponse {
24
+ answer: string
25
+ path: string
26
+ trace: CheckpointData[]
27
+ used_web_search: boolean
28
+ retry_count: number
29
+ }
30
+
31
+ export const sendMessage = async (request: ChatRequest): Promise<ChatResponse> => {
32
+ if (MOCK_MODE) {
33
+ await new Promise(resolve => setTimeout(resolve, 1500))
34
+ return {
35
+ answer: "Based on the university catalog, CS-301 Advanced Algorithms requires CS-201 Data Structures as a prerequisite. The course is offered every Fall semester and carries 3 credit hours. The instructor is Dr. Ahmad Khan from the CS department.",
36
+ path: "decide_retrieval β†’ retrieve β†’ grade_documents β†’ prepare_context β†’ generate β†’ check_hallucination β†’ END",
37
+ trace: [
38
+ { checkpoint: 1, label: "RETRIEVAL DECISION", status: "pass",
39
+ detail: "Needs Retrieval: True",
40
+ reasoning: "Query asks about specific course prerequisites requiring KB search." },
41
+ { checkpoint: 2, label: "RELEVANCE GRADING", status: "pass",
42
+ detail: "3/4 documents relevant", docs_graded: 4, docs_relevant: 3,
43
+ web_search_triggered: false },
44
+ { checkpoint: 3, label: "HALLUCINATION CHECK", status: "pass",
45
+ detail: "No hallucinations detected", retries_used: 0 }
46
+ ],
47
+ used_web_search: false,
48
+ retry_count: 0
49
+ }
50
+ }
51
+
52
+ const response = await fetch(`${API_BASE}/chat`, {
53
+ method: "POST",
54
+ headers: { "Content-Type": "application/json" },
55
+ body: JSON.stringify(request)
56
+ })
57
+
58
+ if (!response.ok) {
59
+ throw new Error(`API error: ${response.status}`)
60
+ }
61
+
62
+ return response.json()
63
+ }
university-advisor-ui/src/types.ts ADDED
@@ -0,0 +1,32 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ export type MessageRole = "user" | "assistant" | "system"
2
+
3
+ export type CheckpointStatus = "pass" | "fail" | "skip" | "pending"
4
+
5
+ export interface TraceStep {
6
+ id: string
7
+ checkpoint: number // 1, 2, or 3
8
+ label: string // e.g. "RETRIEVAL DECISION"
9
+ status: CheckpointStatus
10
+ detail: string // e.g. "Needs Retrieval: True"
11
+ reasoning?: string // The reasoning text from the agent
12
+ docs_graded?: number // How many docs were graded (checkpoint 2)
13
+ docs_relevant?: number // How many were relevant (checkpoint 2)
14
+ web_search_triggered?: boolean
15
+ retries_used?: number
16
+ timestamp: Date
17
+ }
18
+
19
+ export interface Message {
20
+ id: string
21
+ role: MessageRole
22
+ content: string
23
+ timestamp: Date
24
+ trace?: TraceStep[] // Only bot messages have traces
25
+ isLoading?: boolean // True while streaming
26
+ path?: string // e.g. "retrieve β†’ grade β†’ generate"
27
+ }
28
+
29
+ export interface WindowMemoryItem {
30
+ role: MessageRole
31
+ content: string
32
+ }
university-advisor-ui/tailwind.config.ts ADDED
@@ -0,0 +1,39 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import type { Config } from 'tailwindcss'
2
+
3
+ const config: Config = {
4
+ content: [
5
+ "./index.html",
6
+ "./src/**/*.{js,ts,jsx,tsx}",
7
+ ],
8
+ theme: {
9
+ extend: {
10
+ colors: {
11
+ brand: {
12
+ bg: "#0A0E1A", // Deep navy β€” main background
13
+ surface: "#111827", // Slightly lighter β€” card/panel background
14
+ border: "#1E2D45", // Subtle border color
15
+ accent: "#2563EB", // Electric blue β€” primary action color
16
+ accentHov: "#1D4ED8", // Darker blue on hover
17
+ gold: "#F59E0B", // Amber gold β€” university branding accent
18
+ goldLight: "#FDE68A", // Light gold β€” for subtle highlights
19
+ text: "#F1F5F9", // Near white β€” primary text
20
+ muted: "#94A3B8", // Slate gray β€” secondary text, timestamps
21
+ user: "#1E3A5F", // Dark blue β€” user message bubble background
22
+ bot: "#0F1F35", // Darker navy β€” advisor message bubble background
23
+ success: "#10B981", // Emerald β€” checkpoint pass indicators
24
+ warning: "#F59E0B", // Amber β€” hallucination detected indicators
25
+ danger: "#EF4444", // Red β€” error states
26
+ checkpoint:"#7C3AED", // Purple β€” checkpoint badge backgrounds
27
+ }
28
+ },
29
+ fontFamily: {
30
+ display: ["Syne", "sans-serif"],
31
+ body: ["Bricolage Grotesque", "sans-serif"],
32
+ mono: ["Azeret Mono", "monospace"],
33
+ },
34
+ },
35
+ },
36
+ plugins: [],
37
+ }
38
+
39
+ export default config
university-advisor-ui/tsconfig.json ADDED
@@ -0,0 +1,21 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "compilerOptions": {
3
+ "target": "ESNext",
4
+ "useDefineForClassFields": true,
5
+ "lib": ["DOM", "DOM.Iterable", "ESNext"],
6
+ "allowJs": false,
7
+ "skipLibCheck": true,
8
+ "esModuleInterop": false,
9
+ "allowSyntheticDefaultImports": true,
10
+ "strict": true,
11
+ "forceConsistentCasingInFileNames": true,
12
+ "module": "ESNext",
13
+ "moduleResolution": "Node",
14
+ "resolveJsonModule": true,
15
+ "isolatedModules": true,
16
+ "noEmit": true,
17
+ "jsx": "react-jsx"
18
+ },
19
+ "include": ["src"],
20
+ "references": [{ "path": "./tsconfig.node.json" }]
21
+ }
university-advisor-ui/tsconfig.node.json ADDED
@@ -0,0 +1,10 @@
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "compilerOptions": {
3
+ "composite": true,
4
+ "skipLibCheck": true,
5
+ "module": "ESNext",
6
+ "moduleResolution": "Node",
7
+ "allowSyntheticDefaultImports": true
8
+ },
9
+ "include": ["vite.config.ts"]
10
+ }
university-advisor-ui/vite.config.ts ADDED
@@ -0,0 +1,7 @@
 
 
 
 
 
 
 
 
1
+ import { defineConfig } from 'vite'
2
+ import react from '@vitejs/plugin-react'
3
+
4
+ // https://vitejs.dev/config/
5
+ export default defineConfig({
6
+ plugins: [react()],
7
+ })