anris05 commited on
Commit
8a2dcce
·
1 Parent(s): ae7d63e
This view is limited to 50 files because it contains too many changes.   See raw diff
Files changed (50) hide show
  1. .gitignore +12 -0
  2. agents/__init__.py +0 -0
  3. agents/comparison.py +47 -0
  4. agents/followup.py +68 -0
  5. agents/intent_classifier.py +208 -0
  6. agents/router.py +173 -0
  7. agents/topic_categories.py +56 -0
  8. app.py +94 -0
  9. auth/__init__.py +0 -0
  10. auth/db.py +22 -0
  11. auth/models.py +41 -0
  12. auth/routes.py +87 -0
  13. chat_routes.py +82 -0
  14. chat_service.py +126 -0
  15. config.py +90 -0
  16. database/__init__.py +0 -0
  17. database/app.db +0 -0
  18. evaluation/__init__.py +0 -0
  19. evaluation/evaluate.py +222 -0
  20. evaluation/last_report.json +302 -0
  21. evaluation/questions.json +124 -0
  22. history/__init__.py +0 -0
  23. history/models.py +71 -0
  24. history/service.py +91 -0
  25. history/session_manager.py +46 -0
  26. llm/__init__.py +0 -0
  27. llm/generate.py +64 -0
  28. llm/groq_provider.py +82 -0
  29. llm/local_provider.py +98 -0
  30. llm/prompts.py +203 -0
  31. llm/provider.py +33 -0
  32. logs/__init__.py +0 -0
  33. logs/app.log +440 -0
  34. logs/logger.py +48 -0
  35. memory/__init__.py +0 -0
  36. memory/mem0_client.py +137 -0
  37. rag/__init__.py +0 -0
  38. rag/build_index.py +211 -0
  39. rag/generate.py +147 -0
  40. rag/knowledge_base/01_knapsack.txt +37 -0
  41. rag/knowledge_base/activity_selection_problem.txt +41 -0
  42. rag/knowledge_base/avl_tree.txt +63 -0
  43. rag/knowledge_base/backtracking_overview.txt +42 -0
  44. rag/knowledge_base/bellman-ford_algorithm.txt +49 -0
  45. rag/knowledge_base/bfs.txt +29 -0
  46. rag/knowledge_base/binary_search.txt +25 -0
  47. rag/knowledge_base/binary_search_tree.txt +35 -0
  48. rag/knowledge_base/binary_tree.txt +43 -0
  49. rag/knowledge_base/bit_manipulation_basics.txt +24 -0
  50. rag/knowledge_base/bubble_sort.txt +33 -0
.gitignore ADDED
@@ -0,0 +1,12 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # ChromaDB
2
+ rag/chroma_db/
3
+ memory/mem0_store/chroma_db/
4
+
5
+ # SQLite
6
+ *.sqlite3
7
+ *.bin
8
+
9
+ # Python
10
+ __pycache__/
11
+ .venv/
12
+ .env
agents/__init__.py ADDED
File without changes
agents/comparison.py ADDED
@@ -0,0 +1,47 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Comparison agent.
3
+
4
+ Handles queries like "difference between BFS and DFS" — retrieves chunks
5
+ per-topic (rather than pooled) so the prompt builder can present each
6
+ topic's relevant sections side by side.
7
+ """
8
+
9
+ from logs.logger import get_logger
10
+
11
+ logger = get_logger(__name__)
12
+
13
+
14
+ def gather_comparison_context(topics: list, retriever, top_k_per_topic: int = None) -> dict:
15
+ """
16
+ Args:
17
+ topics: canonical topic names, e.g. ["Breadth-First Search", "Depth-First Search"].
18
+ retriever: a rag.retriever.Retriever instance.
19
+ top_k_per_topic: chunks to retrieve per topic (defaults to retriever/config default).
20
+
21
+ Returns:
22
+ {
23
+ "Breadth-First Search": [chunk, chunk, ...],
24
+ "Depth-First Search": [chunk, chunk, ...],
25
+ }
26
+ A topic with no chunks clearing the similarity threshold maps to [].
27
+ """
28
+ context = retriever.retrieve_topics(topics, top_k_per_topic=top_k_per_topic)
29
+
30
+ for topic, chunks in context.items():
31
+ if not chunks:
32
+ logger.warning(
33
+ "Comparison agent: no chunks passed the similarity threshold for topic %r",
34
+ topic,
35
+ )
36
+
37
+ return context
38
+
39
+
40
+ def has_sufficient_context(comparison_context: dict) -> bool:
41
+ """
42
+ True only if every topic in the comparison has at least one retrieved
43
+ chunk. If any topic comes back empty, the caller should fall back to
44
+ the "insufficient information" response rather than comparing a topic
45
+ it knows nothing about against one it does.
46
+ """
47
+ return all(len(chunks) > 0 for chunks in comparison_context.values())
agents/followup.py ADDED
@@ -0,0 +1,68 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Follow-up agent.
3
+
4
+ Handles queries like "can you explain that again?" or "what about the
5
+ space complexity?" that only make sense in light of recent conversation.
6
+
7
+ Strategy: reuse recent conversation history as context by default, and
8
+ only fall back to a fresh embedding-based retrieval if the follow-up
9
+ mentions a topic that wasn't part of the recent conversation (e.g. user
10
+ pivots to a new topic with a pronoun-only phrasing).
11
+ """
12
+
13
+ from agents.intent_classifier import extract_topics,topics_in_recent_history
14
+ from logs.logger import get_logger
15
+
16
+ logger = get_logger(__name__)
17
+
18
+ def gather_followup_context(
19
+ query: str,
20
+ recent_messages: list,
21
+ retriever,
22
+ top_k: int = None,
23
+ ) -> dict:
24
+ """
25
+ Args:
26
+ query: the current follow-up question.
27
+ recent_messages: recent conversation turns, oldest -> newest
28
+ (see history/service.py for how these are fetched;
29
+ bounded by config.MAX_RECENT_HISTORY_MESSAGES).
30
+ retriever: a rag.retriever.Retriever instance.
31
+ top_k: chunks to retrieve if a fresh lookup is needed.
32
+
33
+ Returns:
34
+ {
35
+ "reused_topics": [...], # topics inferred from history
36
+ "additional_chunks": [...], # only populated if a new topic
37
+ # was mentioned in `query` itself
38
+ "recent_messages": recent_messages,
39
+ }
40
+ """
41
+ reused_topics = topics_in_recent_history(recent_messages)
42
+
43
+ # Does the follow-up query itself introduce a topic not seen recently?
44
+ query_topics = extract_topics(query)
45
+ new_topics = [t for t in query_topics if t not in reused_topics]
46
+
47
+ additional_chunks = []
48
+ if new_topics:
49
+ logger.info(
50
+ "Follow-up query introduces new topic(s) not in recent history: %s",
51
+ new_topics,
52
+ )
53
+ for topic in new_topics:
54
+ additional_chunks.extend(
55
+ retriever.retrieve(query=query, top_k=top_k, topic_filter=[topic])
56
+ )
57
+ else:
58
+ logger.info(
59
+ "Follow-up query reuses existing conversation context (topics=%s); "
60
+ "skipping fresh retrieval.",
61
+ reused_topics,
62
+ )
63
+
64
+ return {
65
+ "reused_topics": reused_topics,
66
+ "additional_chunks": additional_chunks,
67
+ "recent_messages": recent_messages,
68
+ }
agents/intent_classifier.py ADDED
@@ -0,0 +1,208 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Intent classification helpers used by the agent router.
3
+
4
+ This module is intentionally rule-based (regex + fuzzy topic-name matching)
5
+ rather than LLM-based, since the LLM provider abstraction (llm/) hasn't
6
+ been wired in yet. It's isolated behind plain functions so it can be
7
+ swapped for an LLM-based classifier later without changing router.py's
8
+ interface — router.py only calls extract_topics(), is_comparison_query(),
9
+ and is_followup_query().
10
+
11
+ No keyword-based *retrieval* happens here — this only decides ROUTING.
12
+ Actual knowledge retrieval is always embedding-based (see rag/retriever.py).
13
+ """
14
+
15
+ import difflib
16
+ import json
17
+ import re
18
+
19
+ import config
20
+
21
+ # ---------------------------------------------------------------------------
22
+ # Topic vocabulary (loaded once from topic_metadata.json)
23
+ # ---------------------------------------------------------------------------
24
+
25
+ _FUZZY_MATCH_CUTOFF = 0.85
26
+
27
+
28
+ def _load_known_topics() -> list:
29
+ """Returns the list of canonical topic names, e.g. ['Binary Search', ...]."""
30
+ with open(config.TOPIC_METADATA_PATH, "r", encoding="utf-8") as f:
31
+ metadata = json.load(f)
32
+ return sorted({entry["topic"] for entry in metadata.values() if entry.get("topic")})
33
+
34
+
35
+ _KNOWN_TOPICS = None
36
+
37
+
38
+ def get_known_topics() -> list:
39
+ global _KNOWN_TOPICS
40
+ if _KNOWN_TOPICS is None:
41
+ _KNOWN_TOPICS = _load_known_topics()
42
+ return _KNOWN_TOPICS
43
+
44
+
45
+ def refresh_known_topics() -> None:
46
+ """Call after topic_metadata.json changes (e.g. after a re-index) to
47
+ force reloading the topic vocabulary on next use."""
48
+ global _KNOWN_TOPICS
49
+ _KNOWN_TOPICS = None
50
+
51
+
52
+ # ---------------------------------------------------------------------------
53
+ # Comparison detection
54
+ # ---------------------------------------------------------------------------
55
+
56
+ _COMPARISON_PATTERNS = [
57
+ re.compile(r"\bdifference between\b", re.IGNORECASE),
58
+ re.compile(r"\bvs\.?\b", re.IGNORECASE),
59
+ re.compile(r"\bversus\b", re.IGNORECASE),
60
+ re.compile(r"\bcompare(d|s)?\b", re.IGNORECASE),
61
+ re.compile(r"\bwhich (one )?is (better|faster|more efficient)\b", re.IGNORECASE),
62
+ re.compile(r"\bpros and cons of\b", re.IGNORECASE),
63
+ ]
64
+
65
+
66
+ def is_comparison_query(query: str) -> bool:
67
+ return any(p.search(query) for p in _COMPARISON_PATTERNS)
68
+
69
+
70
+ # ---------------------------------------------------------------------------
71
+ # Follow-up detection
72
+ # ---------------------------------------------------------------------------
73
+
74
+ _FOLLOWUP_PATTERNS = [
75
+ re.compile(r"^\s*(explain|say|show)?\s*(that|this|it)\b.*again\b", re.IGNORECASE),
76
+ re.compile(r"^\s*what (do you mean|about (that|it|this))\b", re.IGNORECASE),
77
+ re.compile(r"^\s*(can you|could you)\s+(clarify|elaborate|expand)\b", re.IGNORECASE),
78
+ re.compile(r"^\s*(and|so)\b", re.IGNORECASE),
79
+ re.compile(r"^\s*(why|how) (is that|does that work)\b", re.IGNORECASE),
80
+ re.compile(r"\b(more|another) example\b", re.IGNORECASE),
81
+ re.compile(r"^\s*(simpler|simplify|dumb it down|eli5)\b", re.IGNORECASE),
82
+ ]
83
+
84
+ # Bare pronoun references with no topic name mentioned, e.g. "explain it",
85
+ # "why is that", "tell me more"
86
+ _PRONOUN_ONLY_PATTERN = re.compile(
87
+ r"\b(it|that|this|those|these)\b", re.IGNORECASE
88
+ )
89
+
90
+ _WORKED_EXAMPLE_PATTERN = re.compile(
91
+ r"\d+(?:\s*,\s*\d+){1,}.*\b(this|that|same)\b.*\b(search|sort|algorithm|method|approach|traversal)\b",
92
+ re.IGNORECASE,
93
+ )
94
+
95
+ def is_followup_query(query: str, has_conversation_history: bool) -> bool:
96
+ """
97
+ A query is treated as a follow-up when:
98
+ 1. There IS prior conversation to follow up on, AND
99
+ 2. Either it matches a known follow-up phrasing pattern, OR
100
+ it's short, contains a bare pronoun reference, and mentions no
101
+ known topic by name (i.e. it can't stand on its own).
102
+ """
103
+ if not has_conversation_history:
104
+ return False
105
+
106
+ if any(p.search(query) for p in _FOLLOWUP_PATTERNS):
107
+ return True
108
+
109
+ if _WORKED_EXAMPLE_PATTERN.search(query):
110
+ return True
111
+
112
+ word_count = len(query.strip().split())
113
+ has_pronoun = bool(_PRONOUN_ONLY_PATTERN.search(query))
114
+ mentions_topic = bool(extract_topics(query))
115
+
116
+ if has_pronoun and not mentions_topic and word_count <= 8:
117
+ return True
118
+
119
+ return False
120
+
121
+ _GENERIC_COMPARISON_PATTERN = re.compile(
122
+ r"\b(compare|comparison|vs\.?|versus|difference|different from|how does .* (differ|compare))\b.*"
123
+ r"\b(other|similar|related|different|alternative)\b",
124
+ re.IGNORECASE,
125
+ )
126
+
127
+
128
+ def is_generic_comparison_followup(query: str) -> bool:
129
+ """
130
+ Matches comparison phrasing that references *other* algorithms without
131
+ naming a second one explicitly, e.g. "compare with other graph
132
+ algorithms", "how does this differ from similar approaches".
133
+ """
134
+ return bool(_GENERIC_COMPARISON_PATTERN.search(query))
135
+
136
+ def topics_in_recent_history(recent_messages: list) -> list:
137
+ """
138
+ Scans recent conversation turns (both user and assistant messages) for
139
+ known topic mentions. Order preserved, most-recently-mentioned last.
140
+ """
141
+ topics = []
142
+ for message in recent_messages:
143
+ for topic in extract_topics(message.get("content", "")):
144
+ if topic not in topics:
145
+ topics.append(topic)
146
+ else:
147
+ topics.remove(topic)
148
+ topics.append(topic) # bump to most-recent position
149
+ return topics
150
+
151
+
152
+ # ---------------------------------------------------------------------------
153
+ # Topic extraction (fuzzy match against known topic names)
154
+ # ---------------------------------------------------------------------------
155
+
156
+ def extract_topics(query: str) -> list:
157
+ """
158
+ Returns the list of known topic names mentioned in the query, matched
159
+ via substring + fuzzy matching (handles typos/casing/partial names like
160
+ "binary search" or "BFS traversal").
161
+
162
+ This is NOT used for retrieval (retrieval stays purely embedding-based)
163
+ — it's only used for ROUTING decisions: deciding single-topic vs.
164
+ comparison, and picking which topics a comparison should retrieve.
165
+ """
166
+ query_lower = query.lower()
167
+ found = []
168
+
169
+ for topic in get_known_topics():
170
+ topic_lower = topic.lower()
171
+ # Strip a trailing parenthetical, e.g. "Kruskal's Algorithm (MST)"
172
+ # -> "kruskal's algorithm", since people ask using the plain name,
173
+ # not the parenthetical qualifier.
174
+ topic_core = re.sub(r"\s*\([^)]*\)\s*$", "", topic_lower).strip()
175
+
176
+ # Direct substring match (full name or core name without qualifier)
177
+ if topic_lower in query_lower or (topic_core and topic_core in query_lower):
178
+ found.append(topic)
179
+ continue
180
+
181
+ # Acronym match, e.g. "Depth-First Search" -> "DFS", "Breadth-First
182
+ # Search" -> "BFS". Common in how people actually ask DSA questions.
183
+ acronym = "".join(w[0] for w in re.split(r"[\s\-]+", topic) if w).lower()
184
+ if len(acronym) >= 3 and re.search(rf"\b{re.escape(acronym)}\b", query_lower):
185
+ found.append(topic)
186
+ continue
187
+
188
+ # Fuzzy match against each sliding n-gram window of the query,
189
+ # sized to the topic's word count, to catch typos/near-matches.
190
+ topic_words = topic_lower.split()
191
+ query_words = query_lower.split()
192
+ window = len(topic_words)
193
+ for i in range(len(query_words) - window + 1):
194
+ candidate = " ".join(query_words[i : i + window])
195
+ ratio = difflib.SequenceMatcher(None, candidate, topic_lower).ratio()
196
+ if ratio >= _FUZZY_MATCH_CUTOFF:
197
+ found.append(topic)
198
+ break
199
+
200
+ # De-duplicate while preserving order
201
+ seen = set()
202
+ deduped = []
203
+ for t in found:
204
+ if t not in seen:
205
+ seen.add(t)
206
+ deduped.append(t)
207
+ return deduped
208
+
agents/router.py ADDED
@@ -0,0 +1,173 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Agent router.
3
+
4
+ Decides how each incoming query should be processed and gathers the
5
+ context needed for the corresponding prompt builder (llm/prompts.py,
6
+ built in a later layer). This module does NOT call the LLM — it only
7
+ classifies + retrieves.
8
+
9
+ Routes:
10
+ SINGLE_TOPIC - e.g. "Explain Merge Sort" -> retrieve one topic
11
+ COMPARISON - e.g. "Difference between BFS/DFS" -> retrieve multiple topics
12
+ FOLLOWUP - e.g. "Can you explain that again?" -> reuse conversation context
13
+ OUT_OF_SCOPE - e.g. "Who won the World Cup?" -> reject politely
14
+ """
15
+
16
+ from dataclasses import dataclass, field
17
+ from enum import Enum
18
+
19
+ from agents.comparison import gather_comparison_context, has_sufficient_context
20
+ from agents.followup import gather_followup_context
21
+ from agents.intent_classifier import (
22
+ extract_topics,
23
+ is_comparison_query,
24
+ is_followup_query,
25
+ is_generic_comparison_followup,
26
+ topics_in_recent_history,
27
+ )
28
+ from agents.topic_categories import get_sibling_topics
29
+ from logs.logger import get_logger
30
+
31
+ logger = get_logger(__name__)
32
+
33
+
34
+ class RouteType(str, Enum):
35
+ SINGLE_TOPIC = "single_topic"
36
+ COMPARISON = "comparison"
37
+ FOLLOWUP = "followup"
38
+ OUT_OF_SCOPE = "out_of_scope"
39
+
40
+
41
+ @dataclass
42
+ class RouteDecision:
43
+ route_type: RouteType
44
+ query: str
45
+ topics: list = field(default_factory=list)
46
+ # Populated by gather_context() below, per route type:
47
+ single_chunks: list = field(default_factory=list)
48
+ comparison_context: dict = field(default_factory=dict)
49
+ followup_context: dict = field(default_factory=dict)
50
+
51
+ def to_log_dict(self) -> dict:
52
+ return {
53
+ "route_type": self.route_type.value,
54
+ "query": self.query,
55
+ "topics": self.topics,
56
+ }
57
+
58
+
59
+ def classify(query: str, has_conversation_history: bool = False, recent_topics: list = None) -> RouteDecision:
60
+ """
61
+ Pure classification step (no retrieval yet). Order matters:
62
+ 1. Follow-up check first — a short pronoun-based query with existing
63
+ history should be treated as a follow-up even if it superficially
64
+ contains comparison words.
65
+ 2. Comparison check — requires comparison phrasing AND 2+ topics.
66
+ 3. Single topic — exactly one recognized topic mentioned.
67
+ 4. Otherwise — out of scope.
68
+ """
69
+ recent_topics = recent_topics or []
70
+
71
+ if is_followup_query(query, has_conversation_history):
72
+ decision = RouteDecision(route_type=RouteType.FOLLOWUP, query=query)
73
+ logger.info("Routed to FOLLOWUP: %r", query)
74
+ return decision
75
+
76
+ topics = extract_topics(query)
77
+
78
+ if is_comparison_query(query) and len(topics) >= 2:
79
+ decision = RouteDecision(route_type=RouteType.COMPARISON, query=query, topics=topics)
80
+ logger.info("Routed to COMPARISON: %r | topics=%s", query, topics)
81
+ return decision
82
+
83
+ # NEW: comparison referencing the previous topic implicitly, e.g.
84
+ # "compare with other graph algorithms" after discussing BFS.
85
+ if is_generic_comparison_followup(query) and recent_topics:
86
+ anchor_topic = recent_topics[-1]
87
+ siblings = get_sibling_topics(anchor_topic, exclude=topics, limit=2)
88
+ combined = list(dict.fromkeys(topics + [anchor_topic] + siblings))
89
+ if len(combined) >= 2:
90
+ decision = RouteDecision(route_type=RouteType.COMPARISON, query=query, topics=combined)
91
+ logger.info(
92
+ "Routed to COMPARISON (category follow-up): %r | anchor=%s | topics=%s",
93
+ query, anchor_topic, combined,
94
+ )
95
+ return decision
96
+
97
+ if len(topics) >= 1:
98
+ decision = RouteDecision(route_type=RouteType.SINGLE_TOPIC, query=query, topics=topics[:1])
99
+ logger.info("Routed to SINGLE_TOPIC: %r | topic=%s", query, topics[0])
100
+ return decision
101
+
102
+ decision = RouteDecision(route_type=RouteType.OUT_OF_SCOPE, query=query)
103
+ logger.info("Routed to OUT_OF_SCOPE: %r", query)
104
+ return decision
105
+
106
+
107
+ def gather_context(
108
+ decision: RouteDecision,
109
+ retriever,
110
+ recent_messages: list = None,
111
+ ) -> RouteDecision:
112
+ """
113
+ Populates the appropriate context field on `decision` based on its
114
+ route_type. Mutates and returns the same decision object.
115
+
116
+ Args:
117
+ decision: output of classify().
118
+ retriever: a rag.retriever.Retriever instance.
119
+ recent_messages: required for FOLLOWUP routes — recent conversation
120
+ turns, oldest -> newest.
121
+ """
122
+ recent_messages = recent_messages or []
123
+
124
+ if decision.route_type == RouteType.SINGLE_TOPIC:
125
+ topic = decision.topics[0] if decision.topics else None
126
+ decision.single_chunks = retriever.retrieve(
127
+ query=decision.query,
128
+ topic_filter=[topic] if topic else None,
129
+ bypass_threshold_if_single_topic=True,
130
+ )
131
+ if not decision.single_chunks:
132
+ logger.warning(
133
+ "SINGLE_TOPIC query had no chunks pass the similarity "
134
+ "threshold: %r",
135
+ decision.query,
136
+ )
137
+
138
+ elif decision.route_type == RouteType.COMPARISON:
139
+ decision.comparison_context = gather_comparison_context(decision.topics, retriever)
140
+ if not has_sufficient_context(decision.comparison_context):
141
+ logger.warning(
142
+ "COMPARISON query missing sufficient context for one or "
143
+ "more topics: %s",
144
+ decision.topics,
145
+ )
146
+
147
+ elif decision.route_type == RouteType.FOLLOWUP:
148
+ decision.followup_context = gather_followup_context(
149
+ query=decision.query,
150
+ recent_messages=recent_messages,
151
+ retriever=retriever,
152
+ )
153
+
154
+ # OUT_OF_SCOPE needs no retrieval.
155
+
156
+ return decision
157
+
158
+ def route(query: str, retriever, recent_messages: list = None) -> RouteDecision:
159
+ """
160
+ Convenience entrypoint combining classify() + gather_context().
161
+
162
+ recent_messages: recent conversation turns (oldest -> newest). Presence
163
+ of any messages here signals "has_conversation_history=True" for
164
+ follow-up detection.
165
+ """
166
+ recent_messages = recent_messages or []
167
+ recent_topics = topics_in_recent_history(recent_messages)
168
+ decision = classify(
169
+ query,
170
+ has_conversation_history=len(recent_messages) > 0,
171
+ recent_topics=recent_topics,
172
+ )
173
+ return gather_context(decision, retriever, recent_messages=recent_messages)
agents/topic_categories.py ADDED
@@ -0,0 +1,56 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Category lookups for topic_metadata.json.
3
+
4
+ Used by the router to resolve "compare this with other X" style queries
5
+ that reference the previous topic implicitly, without naming a second
6
+ topic explicitly (e.g. "compare BFS with other graph algorithms").
7
+ """
8
+
9
+ import json
10
+ import os
11
+
12
+ import config
13
+ from logs.logger import get_logger
14
+
15
+ logger = get_logger(__name__)
16
+
17
+ _metadata_cache = None
18
+
19
+
20
+ def _load_metadata() -> dict:
21
+ global _metadata_cache
22
+ if _metadata_cache is None:
23
+ if not os.path.exists(config.TOPIC_METADATA_PATH):
24
+ logger.warning("topic_metadata.json not found; category lookups disabled.")
25
+ _metadata_cache = {}
26
+ else:
27
+ with open(config.TOPIC_METADATA_PATH, "r", encoding="utf-8") as f:
28
+ _metadata_cache = json.load(f)
29
+ return _metadata_cache
30
+
31
+
32
+ def get_category(topic_name: str) -> str | None:
33
+ """Returns the category string for a canonical topic name, or None."""
34
+ for entry in _load_metadata().values():
35
+ if entry.get("topic") == topic_name:
36
+ return entry.get("category")
37
+ return None
38
+
39
+
40
+ def get_sibling_topics(topic_name: str, exclude: list = None, limit: int = 2) -> list:
41
+ """
42
+ Returns up to `limit` other topic names sharing the same category as
43
+ `topic_name` (e.g. siblings of "Breadth-First Search" in category
44
+ "Graph Traversal" -> ["Depth-First Search", "Dijkstra's Algorithm"]).
45
+ """
46
+ exclude = set(exclude or []) | {topic_name}
47
+ category = get_category(topic_name)
48
+ if not category:
49
+ return []
50
+
51
+ siblings = [
52
+ entry["topic"]
53
+ for entry in _load_metadata().values()
54
+ if entry.get("category") == category and entry.get("topic") not in exclude
55
+ ]
56
+ return siblings[:limit]
app.py ADDED
@@ -0,0 +1,94 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Flask application entrypoint.
3
+
4
+ At this stage of the build (Layer 1), only the database and authentication
5
+ are wired up so this layer can be tested in isolation. Chat/RAG/agent
6
+ routes will be added in later layers.
7
+ """
8
+ # http://127.0.0.1:7860/
9
+
10
+
11
+ import os
12
+ import threading
13
+
14
+ # Some corporate proxies / VPNs set HTTP_PROXY / HTTPS_PROXY, which makes
15
+ # Gradio's internal "is localhost reachable" check fail even for 127.0.0.1.
16
+ os.environ["NO_PROXY"] = "127.0.0.1,localhost," + os.environ.get("NO_PROXY", "")
17
+ os.environ["no_proxy"] = os.environ["NO_PROXY"]
18
+
19
+ # --- Workaround for a gradio_client bug (gradio 4.44.1 + newer pydantic) ---
20
+ # When a schema (or a nested value like `additionalProperties`) is a plain
21
+ # bool (True/False) rather than a dict, gradio_client's schema walker either
22
+ # crashes with "TypeError: argument of type 'bool' is not iterable" or raises
23
+ # "APIInfoParseError: Cannot parse schema True". This happens while Gradio
24
+ # builds the page's embedded API info, unconditionally, on every request to
25
+ # "/" - so it can't be avoided via show_api=False. Patch both entry points.
26
+ import gradio_client.utils as _gc_utils
27
+
28
+ _orig_get_type = _gc_utils.get_type
29
+ _orig_json_schema_to_python_type = _gc_utils._json_schema_to_python_type
30
+
31
+
32
+ def _safe_get_type(schema):
33
+ if isinstance(schema, bool):
34
+ return "Any"
35
+ return _orig_get_type(schema)
36
+
37
+
38
+ def _safe_json_schema_to_python_type(schema, defs=None):
39
+ if isinstance(schema, bool):
40
+ return "Any"
41
+ return _orig_json_schema_to_python_type(schema, defs)
42
+
43
+
44
+ _gc_utils.get_type = _safe_get_type
45
+ _gc_utils._json_schema_to_python_type = _safe_json_schema_to_python_type
46
+ # --- end workaround ---
47
+
48
+ from flask import Flask
49
+
50
+ import config
51
+ from auth.db import init_db
52
+ from auth.routes import auth_bp
53
+ from chat_routes import chat_bp
54
+ from ui.layout import build_ui
55
+
56
+
57
+ def create_app():
58
+ app = Flask(__name__)
59
+
60
+ app.config["SECRET_KEY"] = config.SECRET_KEY
61
+ app.config["SQLALCHEMY_DATABASE_URI"] = config.SQLALCHEMY_DATABASE_URI
62
+ app.config["SQLALCHEMY_TRACK_MODIFICATIONS"] = config.SQLALCHEMY_TRACK_MODIFICATIONS
63
+
64
+ init_db(app)
65
+
66
+ app.register_blueprint(auth_bp)
67
+ app.register_blueprint(chat_bp)
68
+
69
+ @app.route("/health")
70
+ def health():
71
+ return {"status": "ok"}
72
+
73
+ return app
74
+
75
+
76
+ if __name__ == "__main__":
77
+ flask_app = create_app()
78
+
79
+ # Run the Flask REST API (auth + chat endpoints) on a background thread.
80
+ api_thread = threading.Thread(
81
+ target=lambda: flask_app.run(port=5000, debug=False, use_reloader=False),
82
+ daemon=True,
83
+ )
84
+ api_thread.start()
85
+
86
+ # Gradio UI is the main process. Its callbacks call into Flask
87
+ # in-process via app_context() (see ui/callbacks.py) rather than HTTP.
88
+ demo = build_ui(flask_app)
89
+ demo.queue().launch(
90
+ server_name="0.0.0.0",
91
+ server_port=7860,
92
+ show_api=False,
93
+ inbrowser=False,
94
+ )
auth/__init__.py ADDED
File without changes
auth/db.py ADDED
@@ -0,0 +1,22 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Shared SQLAlchemy instance.
3
+
4
+ Kept in its own module (rather than inside models.py or app.py) so that
5
+ every part of the application — auth, history, etc. — can import the same
6
+ `db` object without circular imports.
7
+ """
8
+
9
+ from flask_sqlalchemy import SQLAlchemy
10
+
11
+ db = SQLAlchemy()
12
+
13
+
14
+ def init_db(app):
15
+ """Bind SQLAlchemy to the Flask app and create tables if they don't exist."""
16
+ db.init_app(app)
17
+ with app.app_context():
18
+ # Import models here so they're registered on db.metadata before create_all()
19
+ from auth.models import User # noqa: F401
20
+ from history.models import ChatSession, ChatMessage # noqa: F401
21
+
22
+ db.create_all()
auth/models.py ADDED
@@ -0,0 +1,41 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ User model for email/password authentication.
3
+
4
+ No OAuth. No plaintext passwords — only Werkzeug password hashes are stored.
5
+ """
6
+
7
+ from datetime import datetime
8
+
9
+ from werkzeug.security import generate_password_hash, check_password_hash
10
+
11
+ from auth.db import db
12
+
13
+
14
+ class User(db.Model):
15
+ __tablename__ = "users"
16
+
17
+ id = db.Column(db.Integer, primary_key=True)
18
+ email = db.Column(db.String(255), unique=True, nullable=False, index=True)
19
+ password_hash = db.Column(db.String(255), nullable=False)
20
+ created_at = db.Column(db.DateTime, default=datetime.utcnow, nullable=False)
21
+
22
+ # Relationship to chat sessions (defined in history/models.py)
23
+ chat_sessions = db.relationship(
24
+ "ChatSession", backref="user", lazy=True, cascade="all, delete-orphan"
25
+ )
26
+
27
+ def set_password(self, raw_password: str) -> None:
28
+ self.password_hash = generate_password_hash(raw_password)
29
+
30
+ def check_password(self, raw_password: str) -> bool:
31
+ return check_password_hash(self.password_hash, raw_password)
32
+
33
+ def to_dict(self) -> dict:
34
+ return {
35
+ "id": self.id,
36
+ "email": self.email,
37
+ "created_at": self.created_at.isoformat(),
38
+ }
39
+
40
+ def __repr__(self) -> str:
41
+ return f"<User id={self.id} email={self.email}>"
auth/routes.py ADDED
@@ -0,0 +1,87 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Authentication REST endpoints.
3
+
4
+ Routes:
5
+ POST /signup -> create a new user account
6
+ POST /login -> start a session-cookie-based login
7
+ POST /logout -> clear the session
8
+ GET /me -> return the currently authenticated user, if any
9
+
10
+ Session-based auth only: Flask's signed session cookie stores the user id.
11
+ No tokens, no OAuth.
12
+ """
13
+
14
+ import re
15
+
16
+ from flask import Blueprint, request, jsonify, session
17
+
18
+ from auth.db import db
19
+ from auth.models import User
20
+
21
+ auth_bp = Blueprint("auth", __name__)
22
+
23
+ EMAIL_REGEX = re.compile(r"^[^@\s]+@[^@\s]+\.[^@\s]+$")
24
+ MIN_PASSWORD_LENGTH = 8
25
+
26
+
27
+ def _current_user():
28
+ user_id = session.get("user_id")
29
+ if user_id is None:
30
+ return None
31
+ return User.query.get(user_id)
32
+
33
+
34
+ @auth_bp.route("/signup", methods=["POST"])
35
+ def signup():
36
+ data = request.get_json(silent=True) or {}
37
+ email = (data.get("email") or "").strip().lower()
38
+ password = data.get("password") or ""
39
+
40
+ if not email or not EMAIL_REGEX.match(email):
41
+ return jsonify({"error": "A valid email is required."}), 400
42
+
43
+ if len(password) < MIN_PASSWORD_LENGTH:
44
+ return jsonify({
45
+ "error": f"Password must be at least {MIN_PASSWORD_LENGTH} characters."
46
+ }), 400
47
+
48
+ if User.query.filter_by(email=email).first() is not None:
49
+ return jsonify({"error": "An account with this email already exists."}), 409
50
+
51
+ user = User(email=email)
52
+ user.set_password(password)
53
+ db.session.add(user)
54
+ db.session.commit()
55
+
56
+ session["user_id"] = user.id
57
+
58
+ return jsonify({"message": "Account created.", "user": user.to_dict()}), 201
59
+
60
+
61
+ @auth_bp.route("/login", methods=["POST"])
62
+ def login():
63
+ data = request.get_json(silent=True) or {}
64
+ email = (data.get("email") or "").strip().lower()
65
+ password = data.get("password") or ""
66
+
67
+ user = User.query.filter_by(email=email).first()
68
+ if user is None or not user.check_password(password):
69
+ return jsonify({"error": "Invalid email or password."}), 401
70
+
71
+ session["user_id"] = user.id
72
+
73
+ return jsonify({"message": "Logged in.", "user": user.to_dict()}), 200
74
+
75
+
76
+ @auth_bp.route("/logout", methods=["POST"])
77
+ def logout():
78
+ session.pop("user_id", None)
79
+ return jsonify({"message": "Logged out."}), 200
80
+
81
+
82
+ @auth_bp.route("/me", methods=["GET"])
83
+ def me():
84
+ user = _current_user()
85
+ if user is None:
86
+ return jsonify({"user": None}), 200
87
+ return jsonify({"user": user.to_dict()}), 200
chat_routes.py ADDED
@@ -0,0 +1,82 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Chat + session-history REST endpoints.
3
+
4
+ Routes:
5
+ POST /chat -> send a message, get a response
6
+ GET /sessions -> list the logged-in user's chat sessions
7
+ GET /sessions/<id> -> full transcript of one session
8
+ DELETE /sessions/<id> -> delete a session
9
+
10
+ Anonymous users can POST /chat (no persistence, no session panel per spec)
11
+ but cannot access /sessions endpoints.
12
+ """
13
+
14
+ from flask import Blueprint, jsonify, request, session
15
+
16
+ from auth.models import User
17
+ from chat_service import handle_chat_message
18
+ from history import service as history_service
19
+
20
+ chat_bp = Blueprint("chat", __name__)
21
+
22
+
23
+ def _current_user():
24
+ user_id = session.get("user_id")
25
+ if user_id is None:
26
+ return None
27
+ return User.query.get(user_id)
28
+
29
+
30
+ @chat_bp.route("/chat", methods=["POST"])
31
+ def chat():
32
+ data = request.get_json(silent=True) or {}
33
+ query = (data.get("query") or "").strip()
34
+
35
+ if not query:
36
+ return jsonify({"error": "query is required."}), 400
37
+
38
+ user = _current_user()
39
+ session_id = data.get("session_id")
40
+ anonymous_history = data.get("history") if user is None else None
41
+
42
+ result = handle_chat_message(
43
+ query=query,
44
+ user=user,
45
+ session_id=session_id,
46
+ anonymous_history=anonymous_history,
47
+ )
48
+ return jsonify(result), 200
49
+
50
+
51
+ @chat_bp.route("/sessions", methods=["GET"])
52
+ def list_sessions():
53
+ user = _current_user()
54
+ if user is None:
55
+ return jsonify({"error": "Login required."}), 401
56
+ return jsonify({"sessions": history_service.list_sessions(user.id)}), 200
57
+
58
+
59
+ @chat_bp.route("/sessions/<int:session_id>", methods=["GET"])
60
+ def get_session_route(session_id):
61
+ user = _current_user()
62
+ if user is None:
63
+ return jsonify({"error": "Login required."}), 401
64
+
65
+ full_session = history_service.get_full_session(session_id, user.id)
66
+ if full_session is None:
67
+ return jsonify({"error": "Session not found."}), 404
68
+
69
+ return jsonify({"session": full_session}), 200
70
+
71
+
72
+ @chat_bp.route("/sessions/<int:session_id>", methods=["DELETE"])
73
+ def delete_session_route(session_id):
74
+ user = _current_user()
75
+ if user is None:
76
+ return jsonify({"error": "Login required."}), 401
77
+
78
+ deleted = history_service.delete_session(session_id, user.id)
79
+ if not deleted:
80
+ return jsonify({"error": "Session not found."}), 404
81
+
82
+ return jsonify({"message": "Session deleted."}), 200
chat_service.py ADDED
@@ -0,0 +1,126 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Chat orchestration service.
3
+
4
+ This is the single place that wires together everything built in the
5
+ previous layers for one chat turn:
6
+
7
+ agents.router -> classify + retrieve context
8
+ llm.prompts -> build the right prompt for the route
9
+ llm.generate -> call the configured LLM provider
10
+ memory.mem0_client -> pull/store long-term preference memory (logged-in only)
11
+ history.service -> persist the transcript (logged-in only)
12
+
13
+ Kept separate from app.py so Flask routes stay thin, and so the same logic
14
+ can be reused by the Gradio UI layer later without going through HTTP.
15
+ """
16
+
17
+ import config
18
+ from agents.router import RouteType, route
19
+ from history import service as history_service
20
+ from history.session_manager import resolve_session
21
+ from llm.generate import generate
22
+ from llm.prompts import (
23
+ build_comparison_prompt,
24
+ build_followup_prompt,
25
+ build_reject_prompt,
26
+ build_single_prompt,
27
+ )
28
+ from logs.logger import get_logger
29
+ from memory.mem0_client import get_mem0_client
30
+ from rag.retriever import get_retriever
31
+
32
+ logger = get_logger(__name__)
33
+
34
+
35
+ def _build_prompt(decision, mem0_context, recent_messages):
36
+ if decision.route_type == RouteType.SINGLE_TOPIC:
37
+ return build_single_prompt(
38
+ decision.single_chunks, mem0_context, recent_messages, decision.query
39
+ )
40
+ if decision.route_type == RouteType.COMPARISON:
41
+ return build_comparison_prompt(
42
+ decision.comparison_context, mem0_context, recent_messages, decision.query
43
+ )
44
+ if decision.route_type == RouteType.FOLLOWUP:
45
+ return build_followup_prompt(
46
+ decision.followup_context, mem0_context, recent_messages, decision.query
47
+ )
48
+ # OUT_OF_SCOPE
49
+ return build_reject_prompt(decision.query)
50
+
51
+
52
+ def handle_chat_message(
53
+ query: str,
54
+ user=None,
55
+ session_id: int = None,
56
+ anonymous_history: list = None,
57
+ ) -> dict:
58
+ """
59
+ Args:
60
+ query: the user's message.
61
+ user: the authenticated auth.models.User, or None if anonymous.
62
+ session_id: existing chat session id (logged-in users only, optional).
63
+ anonymous_history: client-managed recent history for anonymous users
64
+ (no server-side persistence — see spec: "when
65
+ not logged in, chat works normally, no
66
+ persistence, no history panel"). Ignored for
67
+ logged-in users, whose history is read from
68
+ SQLite instead.
69
+
70
+ Returns:
71
+ {
72
+ "response": "...",
73
+ "route_type": "single_topic" | "comparison" | "followup" | "out_of_scope",
74
+ "topics": [...],
75
+ "session_id": int | None, # None for anonymous users
76
+ }
77
+ """
78
+ is_logged_in = user is not None
79
+ session_obj = None
80
+
81
+ if is_logged_in:
82
+ session_obj = resolve_session(user.id, session_id, first_message=query)
83
+ recent_messages = history_service.get_recent_messages(session_obj.id)
84
+ else:
85
+ recent_messages = (anonymous_history or [])[-config.MAX_RECENT_HISTORY_MESSAGES :]
86
+
87
+ retriever = get_retriever()
88
+ decision = route(query, retriever, recent_messages=recent_messages)
89
+
90
+ mem0_context = []
91
+ if is_logged_in:
92
+ mem0_context = get_mem0_client().get_relevant_context(str(user.id), query)
93
+
94
+ prompt = _build_prompt(decision, mem0_context, recent_messages)
95
+ response_text = generate(prompt)
96
+
97
+ if is_logged_in:
98
+ history_service.add_message(
99
+ session_obj.id,
100
+ role="user",
101
+ content=query,
102
+ route_type=decision.route_type.value,
103
+ retrieved_topics=decision.topics,
104
+ )
105
+ history_service.add_message(
106
+ session_obj.id,
107
+ role="assistant",
108
+ content=response_text,
109
+ route_type=decision.route_type.value,
110
+ )
111
+ get_mem0_client().add_interaction(str(user.id), query, response_text)
112
+
113
+ logger.info(
114
+ "Chat turn complete | user_id=%s | route=%s | topics=%s | session_id=%s",
115
+ user.id if is_logged_in else None,
116
+ decision.route_type.value,
117
+ decision.topics,
118
+ session_obj.id if session_obj else None,
119
+ )
120
+
121
+ return {
122
+ "response": response_text,
123
+ "route_type": decision.route_type.value,
124
+ "topics": decision.topics,
125
+ "session_id": session_obj.id if session_obj else None,
126
+ }
config.py ADDED
@@ -0,0 +1,90 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Central configuration for the DSA RAG Chatbot.
3
+ No magic numbers should be hardcoded anywhere else in the codebase — import from here.
4
+ """
5
+
6
+ import os
7
+ from dotenv import load_dotenv
8
+
9
+ load_dotenv()
10
+
11
+ BASE_DIR = os.path.dirname(os.path.abspath(__file__))
12
+
13
+ # ---------------------------------------------------------------------------
14
+ # Flask / App
15
+ # ---------------------------------------------------------------------------
16
+ SECRET_KEY = os.getenv("SECRET_KEY", "dev-secret-key-change-me")
17
+ SESSION_COOKIE_NAME = "dsa_chatbot_session"
18
+ SESSION_LIFETIME_MINUTES = int(os.getenv("SESSION_LIFETIME_MINUTES", 60 * 24 * 7)) # 7 days
19
+
20
+ # ---------------------------------------------------------------------------
21
+ # Database (SQLite via SQLAlchemy)
22
+ # ---------------------------------------------------------------------------
23
+ DATABASE_PATH = os.path.join(BASE_DIR, "database", "app.db")
24
+ SQLALCHEMY_DATABASE_URI = f"sqlite:///{DATABASE_PATH}"
25
+ SQLALCHEMY_TRACK_MODIFICATIONS = False
26
+
27
+ # ---------------------------------------------------------------------------
28
+ # RAG / Knowledge base
29
+ # ---------------------------------------------------------------------------
30
+ KNOWLEDGE_BASE_DIR = os.path.join(BASE_DIR, "rag", "knowledge_base")
31
+ TOPIC_METADATA_PATH = os.path.join(BASE_DIR, "rag", "knowledge_base", "topic_metadata.json")
32
+ CHROMA_DB_DIR = os.path.join(BASE_DIR, "rag", "chroma_db")
33
+ CHROMA_COLLECTION_NAME = "dsa_knowledge_base"
34
+
35
+ EMBEDDING_MODEL_NAME = "all-MiniLM-L6-v2"
36
+
37
+ # Sections expected inside each knowledge-base .txt file. Used by the chunker
38
+ # to split on structure rather than arbitrary fixed-size windows.
39
+ KB_SECTION_HEADERS = [
40
+ "DEFINITION",
41
+ "TIME_COMPLEXITY",
42
+ "SPACE_COMPLEXITY",
43
+ "USE_WHEN",
44
+ "EXAMPLE",
45
+ ]
46
+
47
+ # ---------------------------------------------------------------------------
48
+ # Retrieval pipeline
49
+ # ---------------------------------------------------------------------------
50
+ INITIAL_RETRIEVAL_K = int(os.getenv("INITIAL_RETRIEVAL_K", 15))
51
+ TOP_K = int(os.getenv("TOP_K", 5))
52
+ SIMILARITY_THRESHOLD = float(os.getenv("SIMILARITY_THRESHOLD", 0.45))
53
+
54
+ # ---------------------------------------------------------------------------
55
+ # Memory (mem0)
56
+ # ---------------------------------------------------------------------------
57
+ USE_MEM0 = os.getenv("USE_MEM0", "True") == "True"
58
+ MEM0_LOCAL_STORAGE_DIR = os.path.join(BASE_DIR, "memory", "mem0_store")
59
+
60
+ # ---------------------------------------------------------------------------
61
+ # History / conversation
62
+ # ---------------------------------------------------------------------------
63
+ MAX_RECENT_HISTORY_MESSAGES = int(os.getenv("MAX_RECENT_HISTORY_MESSAGES", 6))
64
+
65
+ # ---------------------------------------------------------------------------
66
+ # LLM Provider
67
+ # ---------------------------------------------------------------------------
68
+ # "local" -> Mistral-7B-Instruct-v0.2 via transformers/bitsandbytes
69
+ # "groq" -> future Groq-hosted provider
70
+ # "openai"-> future OpenAI provider
71
+ # LLM_PROVIDER = os.getenv("LLM_PROVIDER", "local")
72
+ LLM_PROVIDER = os.getenv("LLM_PROVIDER", "groq")
73
+
74
+ LOCAL_MODEL_NAME = os.getenv("LOCAL_MODEL_NAME", "mistralai/Mistral-7B-Instruct-v0.2")
75
+ LOCAL_MODEL_MAX_NEW_TOKENS = int(os.getenv("LOCAL_MODEL_MAX_NEW_TOKENS", 512))
76
+ LOCAL_MODEL_TEMPERATURE = float(os.getenv("LOCAL_MODEL_TEMPERATURE", 0.3))
77
+ LOCAL_MODEL_LOAD_IN_4BIT = os.getenv("LOCAL_MODEL_LOAD_IN_4BIT", "True") == "True"
78
+
79
+ GROQ_API_KEY = os.getenv("GROQ_API_KEY")
80
+ GROQ_MODEL_NAME = os.getenv("GROQ_MODEL_NAME", "llama-3.3-70b-versatile")
81
+
82
+ OPENAI_API_KEY = os.getenv("OPENAI_API_KEY", "")
83
+ OPENAI_MODEL_NAME = os.getenv("OPENAI_MODEL_NAME", "")
84
+
85
+ # ---------------------------------------------------------------------------
86
+ # Logging
87
+ # ---------------------------------------------------------------------------
88
+ LOG_DIR = os.path.join(BASE_DIR, "logs")
89
+ LOG_FILE_PATH = os.path.join(LOG_DIR, "app.log")
90
+ LOG_LEVEL = os.getenv("LOG_LEVEL", "INFO")
database/__init__.py ADDED
File without changes
database/app.db ADDED
Binary file (41 kB). View file
 
evaluation/__init__.py ADDED
File without changes
evaluation/evaluate.py ADDED
@@ -0,0 +1,222 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Evaluation harness for the DSA RAG chatbot.
3
+
4
+ Runs the predefined question set (evaluation/questions.json) through the
5
+ agent router + retriever (and optionally the full LLM pipeline) and reports:
6
+
7
+ - routing accuracy (predicted route_type vs expected_route)
8
+ - retrieval topic recall (expected topics found among retrieved chunks)
9
+ - retrieval latency (avg / p95, ms)
10
+ - generation latency (avg / p95, ms) -- only with --with-llm
11
+
12
+ Usage:
13
+ python -m evaluation.evaluate
14
+ python -m evaluation.evaluate --with-llm
15
+ python -m evaluation.evaluate --questions evaluation/questions.json
16
+
17
+ Run from the project root (dsa-rag-chatbot/) so `config` and the app
18
+ packages resolve correctly.
19
+ """
20
+
21
+ import argparse
22
+ import json
23
+ import os
24
+ import statistics
25
+ import time
26
+
27
+ import config
28
+ from agents.followup import gather_followup_context
29
+ from agents.router import RouteType, classify, gather_context
30
+ from logs.logger import get_logger
31
+
32
+ logger = get_logger(__name__)
33
+
34
+ DEFAULT_QUESTIONS_PATH = os.path.join(os.path.dirname(__file__), "questions.json")
35
+
36
+ # A tiny bit of fake prior conversation so the follow-up route can actually
37
+ # be exercised (is_followup_query requires has_conversation_history=True).
38
+ _FAKE_HISTORY_FOR_FOLLOWUPS = [
39
+ {"role": "user", "content": "Explain merge sort"},
40
+ {"role": "assistant", "content": "Merge sort is a divide-and-conquer sorting algorithm..."},
41
+ ]
42
+
43
+
44
+ def _load_questions(path: str) -> list:
45
+ with open(path, "r", encoding="utf-8") as f:
46
+ return json.load(f)
47
+
48
+
49
+ def _percentile(values: list, pct: float) -> float:
50
+ if not values:
51
+ return 0.0
52
+ values = sorted(values)
53
+ k = (len(values) - 1) * (pct / 100)
54
+ f, c = int(k), min(int(k) + 1, len(values) - 1)
55
+ if f == c:
56
+ return values[f]
57
+ return values[f] + (k - f) * (values[c] - values[f])
58
+
59
+
60
+ def _retrieved_topics(chunks: list) -> set:
61
+ return {c.get("metadata", {}).get("topic") for c in chunks if c.get("metadata", {}).get("topic")}
62
+
63
+
64
+ def _topic_recall(expected_topics: list, retrieved: set) -> float:
65
+ if not expected_topics:
66
+ return 1.0 # nothing expected (e.g. out_of_scope) => trivially satisfied
67
+ hits = sum(1 for t in expected_topics if t in retrieved)
68
+ return hits / len(expected_topics)
69
+
70
+
71
+ def evaluate_one(item: dict, retriever, with_llm: bool) -> dict:
72
+ query = item["query"]
73
+ expected_route = item["expected_route"]
74
+ expected_topics = item.get("expected_topics", [])
75
+ recent_messages = _FAKE_HISTORY_FOR_FOLLOWUPS if item.get("requires_history") else []
76
+
77
+ t0 = time.perf_counter()
78
+ decision = classify(query, has_conversation_history=len(recent_messages) > 0)
79
+ decision = gather_context(decision, retriever, recent_messages=recent_messages)
80
+ retrieval_latency_ms = (time.perf_counter() - t0) * 1000
81
+
82
+ if decision.route_type == RouteType.SINGLE_TOPIC:
83
+ retrieved = _retrieved_topics(decision.single_chunks)
84
+ elif decision.route_type == RouteType.COMPARISON:
85
+ retrieved = set()
86
+ for chunks in decision.comparison_context.values():
87
+ retrieved |= _retrieved_topics(chunks)
88
+ elif decision.route_type == RouteType.FOLLOWUP:
89
+ retrieved = _retrieved_topics(decision.followup_context.get("additional_chunks", []))
90
+ else:
91
+ retrieved = set()
92
+
93
+ route_correct = decision.route_type.value == expected_route
94
+ topic_recall = _topic_recall(expected_topics, retrieved)
95
+
96
+ result = {
97
+ "id": item.get("id"),
98
+ "query": query,
99
+ "expected_route": expected_route,
100
+ "predicted_route": decision.route_type.value,
101
+ "route_correct": route_correct,
102
+ "expected_topics": expected_topics,
103
+ "retrieved_topics": sorted(t for t in retrieved if t),
104
+ "topic_recall": round(topic_recall, 3),
105
+ "retrieval_latency_ms": round(retrieval_latency_ms, 2),
106
+ }
107
+
108
+ if with_llm:
109
+ from llm.generate import generate
110
+ from llm.prompts import (
111
+ build_comparison_prompt,
112
+ build_followup_prompt,
113
+ build_reject_prompt,
114
+ build_single_prompt,
115
+ )
116
+
117
+ if decision.route_type == RouteType.SINGLE_TOPIC:
118
+ prompt = build_single_prompt(decision.single_chunks, [], recent_messages, query)
119
+ elif decision.route_type == RouteType.COMPARISON:
120
+ prompt = build_comparison_prompt(decision.comparison_context, [], recent_messages, query)
121
+ elif decision.route_type == RouteType.FOLLOWUP:
122
+ prompt = build_followup_prompt(decision.followup_context, [], recent_messages, query)
123
+ else:
124
+ prompt = build_reject_prompt(query)
125
+
126
+ t1 = time.perf_counter()
127
+ try:
128
+ response_text = generate(prompt)
129
+ gen_error = None
130
+ except Exception as exc: # keep the run going even if the LLM call fails
131
+ response_text = None
132
+ gen_error = str(exc)
133
+ generation_latency_ms = (time.perf_counter() - t1) * 1000
134
+
135
+ result["generation_latency_ms"] = round(generation_latency_ms, 2)
136
+ result["response_preview"] = (response_text or "")[:200]
137
+ if gen_error:
138
+ result["generation_error"] = gen_error
139
+
140
+ return result
141
+
142
+
143
+ def run(questions_path: str = DEFAULT_QUESTIONS_PATH, with_llm: bool = False) -> dict:
144
+ from rag.retriever import get_retriever
145
+
146
+ questions = _load_questions(questions_path)
147
+ retriever = get_retriever()
148
+
149
+ results = [evaluate_one(item, retriever, with_llm) for item in questions]
150
+
151
+ route_accuracy = sum(r["route_correct"] for r in results) / len(results)
152
+ avg_topic_recall = statistics.mean(r["topic_recall"] for r in results)
153
+ retrieval_latencies = [r["retrieval_latency_ms"] for r in results]
154
+
155
+ summary = {
156
+ "total_questions": len(results),
157
+ "route_accuracy": round(route_accuracy, 3),
158
+ "avg_topic_recall": round(avg_topic_recall, 3),
159
+ "retrieval_latency_ms_avg": round(statistics.mean(retrieval_latencies), 2),
160
+ "retrieval_latency_ms_p95": round(_percentile(retrieval_latencies, 95), 2),
161
+ }
162
+
163
+ if with_llm:
164
+ gen_latencies = [r["generation_latency_ms"] for r in results if "generation_latency_ms" in r]
165
+ if gen_latencies:
166
+ summary["generation_latency_ms_avg"] = round(statistics.mean(gen_latencies), 2)
167
+ summary["generation_latency_ms_p95"] = round(_percentile(gen_latencies, 95), 2)
168
+ summary["generation_errors"] = sum(1 for r in results if r.get("generation_error"))
169
+
170
+ report = {"summary": summary, "results": results}
171
+
172
+ logger.info("Evaluation complete: %s", summary)
173
+ return report
174
+
175
+
176
+ def _print_report(report: dict) -> None:
177
+ summary = report["summary"]
178
+ print("\n=== DSA RAG Chatbot — Evaluation Summary ===")
179
+ for key, value in summary.items():
180
+ print(f" {key}: {value}")
181
+
182
+ print("\n=== Per-question results ===")
183
+ header = f"{'id':<5} {'route (exp->got)':<28} {'recall':<8} {'ret_ms':<8} query"
184
+ print(header)
185
+ print("-" * len(header))
186
+ for r in report["results"]:
187
+ route_str = f"{r['expected_route']} -> {r['predicted_route']}"
188
+ mark = "OK" if r["route_correct"] else "MISS"
189
+ print(
190
+ f"{r['id']:<5} {route_str:<28} {r['topic_recall']:<8} "
191
+ f"{r['retrieval_latency_ms']:<8} [{mark}] {r['query']}"
192
+ )
193
+
194
+
195
+ def main():
196
+ parser = argparse.ArgumentParser(description="Evaluate DSA RAG chatbot retrieval + routing.")
197
+ parser.add_argument(
198
+ "--questions", default=DEFAULT_QUESTIONS_PATH, help="Path to questions.json"
199
+ )
200
+ parser.add_argument(
201
+ "--with-llm",
202
+ action="store_true",
203
+ help="Also call the configured LLM provider end-to-end (uses API quota).",
204
+ )
205
+ parser.add_argument(
206
+ "--out",
207
+ default=None,
208
+ help="Path to write the full JSON report (default: evaluation/last_report.json)",
209
+ )
210
+ args = parser.parse_args()
211
+
212
+ report = run(questions_path=args.questions, with_llm=args.with_llm)
213
+ _print_report(report)
214
+
215
+ out_path = args.out or os.path.join(os.path.dirname(__file__), "last_report.json")
216
+ with open(out_path, "w", encoding="utf-8") as f:
217
+ json.dump(report, f, indent=2)
218
+ print(f"\nFull report written to: {out_path}")
219
+
220
+
221
+ if __name__ == "__main__":
222
+ main()
evaluation/last_report.json ADDED
@@ -0,0 +1,302 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "summary": {
3
+ "total_questions": 20,
4
+ "route_accuracy": 1.0,
5
+ "avg_topic_recall": 1.0,
6
+ "retrieval_latency_ms_avg": 46.26,
7
+ "retrieval_latency_ms_p95": 101.1
8
+ },
9
+ "results": [
10
+ {
11
+ "id": "q01",
12
+ "query": "Explain how binary search works",
13
+ "expected_route": "single_topic",
14
+ "predicted_route": "single_topic",
15
+ "route_correct": true,
16
+ "expected_topics": [
17
+ "Binary Search"
18
+ ],
19
+ "retrieved_topics": [
20
+ "Binary Search"
21
+ ],
22
+ "topic_recall": 1.0,
23
+ "retrieval_latency_ms": 144.79
24
+ },
25
+ {
26
+ "id": "q02",
27
+ "query": "What is the time complexity of merge sort?",
28
+ "expected_route": "single_topic",
29
+ "predicted_route": "single_topic",
30
+ "route_correct": true,
31
+ "expected_topics": [
32
+ "Merge Sort"
33
+ ],
34
+ "retrieved_topics": [
35
+ "Merge Sort"
36
+ ],
37
+ "topic_recall": 1.0,
38
+ "retrieval_latency_ms": 30.63
39
+ },
40
+ {
41
+ "id": "q03",
42
+ "query": "Explain quick sort with an example",
43
+ "expected_route": "single_topic",
44
+ "predicted_route": "single_topic",
45
+ "route_correct": true,
46
+ "expected_topics": [
47
+ "Quick Sort"
48
+ ],
49
+ "retrieved_topics": [
50
+ "Quick Sort"
51
+ ],
52
+ "topic_recall": 1.0,
53
+ "retrieval_latency_ms": 33.24
54
+ },
55
+ {
56
+ "id": "q04",
57
+ "query": "What is a Trie used for?",
58
+ "expected_route": "single_topic",
59
+ "predicted_route": "single_topic",
60
+ "route_correct": true,
61
+ "expected_topics": [
62
+ "Trie"
63
+ ],
64
+ "retrieved_topics": [
65
+ "Trie"
66
+ ],
67
+ "topic_recall": 1.0,
68
+ "retrieval_latency_ms": 31.99
69
+ },
70
+ {
71
+ "id": "q05",
72
+ "query": "What is a doubly linked list?",
73
+ "expected_route": "single_topic",
74
+ "predicted_route": "single_topic",
75
+ "route_correct": true,
76
+ "expected_topics": [
77
+ "Doubly Linked List"
78
+ ],
79
+ "retrieved_topics": [
80
+ "Doubly Linked List"
81
+ ],
82
+ "topic_recall": 1.0,
83
+ "retrieval_latency_ms": 34.44
84
+ },
85
+ {
86
+ "id": "q06",
87
+ "query": "Explain how a stack works",
88
+ "expected_route": "single_topic",
89
+ "predicted_route": "single_topic",
90
+ "route_correct": true,
91
+ "expected_topics": [
92
+ "Stack"
93
+ ],
94
+ "retrieved_topics": [
95
+ "Stack"
96
+ ],
97
+ "topic_recall": 1.0,
98
+ "retrieval_latency_ms": 28.98
99
+ },
100
+ {
101
+ "id": "q07",
102
+ "query": "What is topological sort used for?",
103
+ "expected_route": "single_topic",
104
+ "predicted_route": "single_topic",
105
+ "route_correct": true,
106
+ "expected_topics": [
107
+ "Topological Sort"
108
+ ],
109
+ "retrieved_topics": [
110
+ "Topological Sort"
111
+ ],
112
+ "topic_recall": 1.0,
113
+ "retrieval_latency_ms": 33.71
114
+ },
115
+ {
116
+ "id": "q08",
117
+ "query": "Explain Dijkstra's Algorithm",
118
+ "expected_route": "single_topic",
119
+ "predicted_route": "single_topic",
120
+ "route_correct": true,
121
+ "expected_topics": [
122
+ "Dijkstra's Algorithm"
123
+ ],
124
+ "retrieved_topics": [
125
+ "Dijkstra's Algorithm"
126
+ ],
127
+ "topic_recall": 1.0,
128
+ "retrieval_latency_ms": 27.78
129
+ },
130
+ {
131
+ "id": "q09",
132
+ "query": "What is a segment tree?",
133
+ "expected_route": "single_topic",
134
+ "predicted_route": "single_topic",
135
+ "route_correct": true,
136
+ "expected_topics": [
137
+ "Segment Tree"
138
+ ],
139
+ "retrieved_topics": [
140
+ "Segment Tree"
141
+ ],
142
+ "topic_recall": 1.0,
143
+ "retrieval_latency_ms": 35.57
144
+ },
145
+ {
146
+ "id": "q10",
147
+ "query": "Explain the sliding window technique",
148
+ "expected_route": "single_topic",
149
+ "predicted_route": "single_topic",
150
+ "route_correct": true,
151
+ "expected_topics": [
152
+ "Sliding Window Technique"
153
+ ],
154
+ "retrieved_topics": [
155
+ "Sliding Window Technique"
156
+ ],
157
+ "topic_recall": 1.0,
158
+ "retrieval_latency_ms": 34.67
159
+ },
160
+ {
161
+ "id": "q11",
162
+ "query": "What is the difference between BFS and DFS?",
163
+ "expected_route": "comparison",
164
+ "predicted_route": "comparison",
165
+ "route_correct": true,
166
+ "expected_topics": [
167
+ "Breadth-First Search",
168
+ "Depth-First Search"
169
+ ],
170
+ "retrieved_topics": [
171
+ "Breadth-First Search",
172
+ "Depth-First Search"
173
+ ],
174
+ "topic_recall": 1.0,
175
+ "retrieval_latency_ms": 51.73
176
+ },
177
+ {
178
+ "id": "q12",
179
+ "query": "Compare merge sort vs quick sort",
180
+ "expected_route": "comparison",
181
+ "predicted_route": "comparison",
182
+ "route_correct": true,
183
+ "expected_topics": [
184
+ "Merge Sort",
185
+ "Quick Sort"
186
+ ],
187
+ "retrieved_topics": [
188
+ "Merge Sort",
189
+ "Quick Sort"
190
+ ],
191
+ "topic_recall": 1.0,
192
+ "retrieval_latency_ms": 63.2
193
+ },
194
+ {
195
+ "id": "q13",
196
+ "query": "Difference between a stack and a queue",
197
+ "expected_route": "comparison",
198
+ "predicted_route": "comparison",
199
+ "route_correct": true,
200
+ "expected_topics": [
201
+ "Stack",
202
+ "Queue"
203
+ ],
204
+ "retrieved_topics": [
205
+ "Queue",
206
+ "Stack"
207
+ ],
208
+ "topic_recall": 1.0,
209
+ "retrieval_latency_ms": 66.79
210
+ },
211
+ {
212
+ "id": "q14",
213
+ "query": "Which one is better, Prim's Algorithm or Kruskal's Algorithm, for MST?",
214
+ "expected_route": "comparison",
215
+ "predicted_route": "comparison",
216
+ "route_correct": true,
217
+ "expected_topics": [
218
+ "Prim's Algorithm (MST)",
219
+ "Kruskal's Algorithm (MST)"
220
+ ],
221
+ "retrieved_topics": [
222
+ "Kruskal's Algorithm (MST)",
223
+ "Prim's Algorithm (MST)"
224
+ ],
225
+ "topic_recall": 1.0,
226
+ "retrieval_latency_ms": 98.8
227
+ },
228
+ {
229
+ "id": "q15",
230
+ "query": "Compare AVL Tree and Binary Search Tree",
231
+ "expected_route": "comparison",
232
+ "predicted_route": "comparison",
233
+ "route_correct": true,
234
+ "expected_topics": [
235
+ "AVL Tree",
236
+ "Binary Search Tree"
237
+ ],
238
+ "retrieved_topics": [
239
+ "AVL Tree",
240
+ "Binary Search",
241
+ "Binary Search Tree"
242
+ ],
243
+ "topic_recall": 1.0,
244
+ "retrieval_latency_ms": 84.79
245
+ },
246
+ {
247
+ "id": "q16",
248
+ "query": "Who won the football World Cup in 2022?",
249
+ "expected_route": "out_of_scope",
250
+ "predicted_route": "out_of_scope",
251
+ "route_correct": true,
252
+ "expected_topics": [],
253
+ "retrieved_topics": [],
254
+ "topic_recall": 1.0,
255
+ "retrieval_latency_ms": 23.85
256
+ },
257
+ {
258
+ "id": "q17",
259
+ "query": "What's a good recipe for chocolate cake?",
260
+ "expected_route": "out_of_scope",
261
+ "predicted_route": "out_of_scope",
262
+ "route_correct": true,
263
+ "expected_topics": [],
264
+ "retrieved_topics": [],
265
+ "topic_recall": 1.0,
266
+ "retrieval_latency_ms": 16.91
267
+ },
268
+ {
269
+ "id": "q18",
270
+ "query": "What is the capital of France?",
271
+ "expected_route": "out_of_scope",
272
+ "predicted_route": "out_of_scope",
273
+ "route_correct": true,
274
+ "expected_topics": [],
275
+ "retrieved_topics": [],
276
+ "topic_recall": 1.0,
277
+ "retrieval_latency_ms": 14.83
278
+ },
279
+ {
280
+ "id": "q19",
281
+ "query": "Can you explain that again?",
282
+ "expected_route": "followup",
283
+ "predicted_route": "followup",
284
+ "route_correct": true,
285
+ "expected_topics": [],
286
+ "retrieved_topics": [],
287
+ "topic_recall": 1.0,
288
+ "retrieval_latency_ms": 44.24
289
+ },
290
+ {
291
+ "id": "q20",
292
+ "query": "Give me another example",
293
+ "expected_route": "followup",
294
+ "predicted_route": "followup",
295
+ "route_correct": true,
296
+ "expected_topics": [],
297
+ "retrieved_topics": [],
298
+ "topic_recall": 1.0,
299
+ "retrieval_latency_ms": 24.32
300
+ }
301
+ ]
302
+ }
evaluation/questions.json ADDED
@@ -0,0 +1,124 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ [
2
+ {
3
+ "id": "q01",
4
+ "query": "Explain how binary search works",
5
+ "expected_route": "single_topic",
6
+ "expected_topics": ["Binary Search"]
7
+ },
8
+ {
9
+ "id": "q02",
10
+ "query": "What is the time complexity of merge sort?",
11
+ "expected_route": "single_topic",
12
+ "expected_topics": ["Merge Sort"]
13
+ },
14
+ {
15
+ "id": "q03",
16
+ "query": "Explain quick sort with an example",
17
+ "expected_route": "single_topic",
18
+ "expected_topics": ["Quick Sort"]
19
+ },
20
+ {
21
+ "id": "q04",
22
+ "query": "What is a Trie used for?",
23
+ "expected_route": "single_topic",
24
+ "expected_topics": ["Trie"]
25
+ },
26
+ {
27
+ "id": "q05",
28
+ "query": "What is a doubly linked list?",
29
+ "expected_route": "single_topic",
30
+ "expected_topics": ["Doubly Linked List"]
31
+ },
32
+ {
33
+ "id": "q06",
34
+ "query": "Explain how a stack works",
35
+ "expected_route": "single_topic",
36
+ "expected_topics": ["Stack"]
37
+ },
38
+ {
39
+ "id": "q07",
40
+ "query": "What is topological sort used for?",
41
+ "expected_route": "single_topic",
42
+ "expected_topics": ["Topological Sort"]
43
+ },
44
+ {
45
+ "id": "q08",
46
+ "query": "Explain Dijkstra's Algorithm",
47
+ "expected_route": "single_topic",
48
+ "expected_topics": ["Dijkstra's Algorithm"]
49
+ },
50
+ {
51
+ "id": "q09",
52
+ "query": "What is a segment tree?",
53
+ "expected_route": "single_topic",
54
+ "expected_topics": ["Segment Tree"]
55
+ },
56
+ {
57
+ "id": "q10",
58
+ "query": "Explain the sliding window technique",
59
+ "expected_route": "single_topic",
60
+ "expected_topics": ["Sliding Window Technique"]
61
+ },
62
+ {
63
+ "id": "q11",
64
+ "query": "What is the difference between BFS and DFS?",
65
+ "expected_route": "comparison",
66
+ "expected_topics": ["Breadth-First Search", "Depth-First Search"]
67
+ },
68
+ {
69
+ "id": "q12",
70
+ "query": "Compare merge sort vs quick sort",
71
+ "expected_route": "comparison",
72
+ "expected_topics": ["Merge Sort", "Quick Sort"]
73
+ },
74
+ {
75
+ "id": "q13",
76
+ "query": "Difference between a stack and a queue",
77
+ "expected_route": "comparison",
78
+ "expected_topics": ["Stack", "Queue"]
79
+ },
80
+ {
81
+ "id": "q14",
82
+ "query": "Which one is better, Prim's Algorithm or Kruskal's Algorithm, for MST?",
83
+ "expected_route": "comparison",
84
+ "expected_topics": ["Prim's Algorithm (MST)", "Kruskal's Algorithm (MST)"]
85
+ },
86
+ {
87
+ "id": "q15",
88
+ "query": "Compare AVL Tree and Binary Search Tree",
89
+ "expected_route": "comparison",
90
+ "expected_topics": ["AVL Tree", "Binary Search Tree"]
91
+ },
92
+ {
93
+ "id": "q16",
94
+ "query": "Who won the football World Cup in 2022?",
95
+ "expected_route": "out_of_scope",
96
+ "expected_topics": []
97
+ },
98
+ {
99
+ "id": "q17",
100
+ "query": "What's a good recipe for chocolate cake?",
101
+ "expected_route": "out_of_scope",
102
+ "expected_topics": []
103
+ },
104
+ {
105
+ "id": "q18",
106
+ "query": "What is the capital of France?",
107
+ "expected_route": "out_of_scope",
108
+ "expected_topics": []
109
+ },
110
+ {
111
+ "id": "q19",
112
+ "query": "Can you explain that again?",
113
+ "expected_route": "followup",
114
+ "expected_topics": [],
115
+ "requires_history": true
116
+ },
117
+ {
118
+ "id": "q20",
119
+ "query": "Give me another example",
120
+ "expected_route": "followup",
121
+ "expected_topics": [],
122
+ "requires_history": true
123
+ }
124
+ ]
history/__init__.py ADDED
File without changes
history/models.py ADDED
@@ -0,0 +1,71 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Chat history models.
3
+
4
+ SQLite here is used ONLY for exact transcript storage (raw messages,
5
+ timestamps, sessions). It must never store embeddings (that's ChromaDB)
6
+ or user preference/long-term memory (that's mem0).
7
+ """
8
+
9
+ from datetime import datetime
10
+
11
+ from auth.db import db
12
+
13
+
14
+ class ChatSession(db.Model):
15
+ __tablename__ = "chat_sessions"
16
+
17
+ id = db.Column(db.Integer, primary_key=True)
18
+ user_id = db.Column(db.Integer, db.ForeignKey("users.id"), nullable=False, index=True)
19
+ title = db.Column(db.String(255), nullable=True) # e.g. first user message, truncated
20
+ created_at = db.Column(db.DateTime, default=datetime.utcnow, nullable=False)
21
+ updated_at = db.Column(
22
+ db.DateTime, default=datetime.utcnow, onupdate=datetime.utcnow, nullable=False
23
+ )
24
+
25
+ messages = db.relationship(
26
+ "ChatMessage",
27
+ backref="chat_session",
28
+ lazy=True,
29
+ order_by="ChatMessage.created_at",
30
+ cascade="all, delete-orphan",
31
+ )
32
+
33
+ def to_dict(self, include_messages: bool = False) -> dict:
34
+ result = {
35
+ "id": self.id,
36
+ "user_id": self.user_id,
37
+ "title": self.title,
38
+ "created_at": self.created_at.isoformat(),
39
+ "updated_at": self.updated_at.isoformat(),
40
+ }
41
+ if include_messages:
42
+ result["messages"] = [m.to_dict() for m in self.messages]
43
+ return result
44
+
45
+
46
+ class ChatMessage(db.Model):
47
+ __tablename__ = "chat_messages"
48
+
49
+ id = db.Column(db.Integer, primary_key=True)
50
+ session_id = db.Column(
51
+ db.Integer, db.ForeignKey("chat_sessions.id"), nullable=False, index=True
52
+ )
53
+ role = db.Column(db.String(20), nullable=False) # "user" or "assistant"
54
+ content = db.Column(db.Text, nullable=False)
55
+
56
+ # Optional debugging/eval metadata about how this response was produced.
57
+ route_type = db.Column(db.String(50), nullable=True) # single/comparison/followup/reject
58
+ retrieved_topics = db.Column(db.Text, nullable=True) # JSON-encoded list
59
+
60
+ created_at = db.Column(db.DateTime, default=datetime.utcnow, nullable=False)
61
+
62
+ def to_dict(self) -> dict:
63
+ return {
64
+ "id": self.id,
65
+ "session_id": self.session_id,
66
+ "role": self.role,
67
+ "content": self.content,
68
+ "route_type": self.route_type,
69
+ "retrieved_topics": self.retrieved_topics,
70
+ "created_at": self.created_at.isoformat(),
71
+ }
history/service.py ADDED
@@ -0,0 +1,91 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Chat history service.
3
+
4
+ The only module that reads/writes history.models (ChatSession, ChatMessage).
5
+ Callers (chat_service.py, chat_routes.py, the Gradio UI layer) go through
6
+ these functions instead of touching the ORM directly, so persistence
7
+ behavior stays in one place.
8
+
9
+ Reminder of the scope boundary (see history/models.py): SQLite here is for
10
+ exact transcript storage only — no embeddings, no long-term preference
11
+ memory (that's ChromaDB / mem0 respectively).
12
+ """
13
+
14
+ import json
15
+
16
+ from auth.db import db
17
+ from history.models import ChatMessage, ChatSession
18
+ from logs.logger import get_logger
19
+
20
+ logger = get_logger(__name__)
21
+
22
+
23
+ def add_message(
24
+ session_id: int,
25
+ role: str,
26
+ content: str,
27
+ route_type: str = None,
28
+ retrieved_topics: list = None,
29
+ ) -> ChatMessage:
30
+ """Append one message (user or assistant) to a session's transcript."""
31
+ message = ChatMessage(
32
+ session_id=session_id,
33
+ role=role,
34
+ content=content,
35
+ route_type=route_type,
36
+ retrieved_topics=json.dumps(retrieved_topics) if retrieved_topics else None,
37
+ )
38
+ db.session.add(message)
39
+ db.session.commit()
40
+ return message
41
+
42
+
43
+ def get_recent_messages(session_id: int, limit: int = None) -> list:
44
+ """
45
+ Returns the most recent messages for a session, oldest -> newest, as
46
+ plain dicts ({"role": ..., "content": ...}) — the shape llm/prompts.py
47
+ and the follow-up agent expect.
48
+ """
49
+ import config
50
+
51
+ limit = limit or config.MAX_RECENT_HISTORY_MESSAGES
52
+
53
+ messages = (
54
+ ChatMessage.query.filter_by(session_id=session_id)
55
+ .order_by(ChatMessage.created_at.desc())
56
+ .limit(limit)
57
+ .all()
58
+ )
59
+ messages.reverse()
60
+ return [{"role": m.role, "content": m.content} for m in messages]
61
+
62
+
63
+ def list_sessions(user_id: int) -> list:
64
+ """Returns a summary (no messages) of every chat session for a user,
65
+ most recently updated first — used for the history panel."""
66
+ sessions = (
67
+ ChatSession.query.filter_by(user_id=user_id)
68
+ .order_by(ChatSession.updated_at.desc())
69
+ .all()
70
+ )
71
+ return [s.to_dict(include_messages=False) for s in sessions]
72
+
73
+
74
+ def get_full_session(session_id: int, user_id: int) -> dict:
75
+ """Returns the full transcript of one session, or None if it doesn't
76
+ exist or doesn't belong to this user."""
77
+ session_obj = ChatSession.query.filter_by(id=session_id, user_id=user_id).first()
78
+ if session_obj is None:
79
+ return None
80
+ return session_obj.to_dict(include_messages=True)
81
+
82
+
83
+ def delete_session(session_id: int, user_id: int) -> bool:
84
+ """Deletes a session (and its messages, via cascade) if it belongs to
85
+ this user. Returns True if something was deleted."""
86
+ session_obj = ChatSession.query.filter_by(id=session_id, user_id=user_id).first()
87
+ if session_obj is None:
88
+ return False
89
+ db.session.delete(session_obj)
90
+ db.session.commit()
91
+ return True
history/session_manager.py ADDED
@@ -0,0 +1,46 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Session resolution helper.
3
+
4
+ Given a logged-in user and an optional client-supplied session_id, decides
5
+ whether to reuse an existing ChatSession or create a new one. Kept separate
6
+ from history/service.py so chat_service.py has one obvious entrypoint for
7
+ "which session am I writing to?" without duplicating that logic per caller.
8
+ """
9
+
10
+ from auth.db import db
11
+ from history.models import ChatSession
12
+
13
+ TITLE_MAX_LENGTH = 60
14
+
15
+
16
+ def _make_title(first_message: str) -> str:
17
+ first_message = (first_message or "").strip()
18
+ if not first_message:
19
+ return "New chat"
20
+ if len(first_message) <= TITLE_MAX_LENGTH:
21
+ return first_message
22
+ return first_message[: TITLE_MAX_LENGTH - 1].rstrip() + "…"
23
+
24
+
25
+ def resolve_session(user_id: int, session_id: int = None, first_message: str = "") -> ChatSession:
26
+ """
27
+ Args:
28
+ user_id: the authenticated user's id.
29
+ session_id: an existing session id the client wants to continue.
30
+ Ignored (falls back to creating a new session) if it
31
+ doesn't exist or doesn't belong to this user.
32
+ first_message: used as the auto-generated title when a new session
33
+ is created.
34
+
35
+ Returns:
36
+ The ChatSession to append messages to (already committed, has .id).
37
+ """
38
+ if session_id is not None:
39
+ existing = ChatSession.query.filter_by(id=session_id, user_id=user_id).first()
40
+ if existing is not None:
41
+ return existing
42
+
43
+ session_obj = ChatSession(user_id=user_id, title=_make_title(first_message))
44
+ db.session.add(session_obj)
45
+ db.session.commit()
46
+ return session_obj
llm/__init__.py ADDED
File without changes
llm/generate.py ADDED
@@ -0,0 +1,64 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Provider factory.
3
+
4
+ This is the ONLY module the rest of the application should import to talk
5
+ to an LLM. It picks the concrete provider based on config.LLM_PROVIDER and
6
+ exposes a single generate(prompt) function. Swapping providers is a
7
+ config change only.
8
+ """
9
+
10
+ import config
11
+ from logs.logger import get_logger
12
+
13
+ logger = get_logger(__name__)
14
+
15
+ _PROVIDER_SINGLETON = None
16
+
17
+
18
+ def _build_provider():
19
+ provider_name = config.LLM_PROVIDER.lower()
20
+
21
+ if provider_name == "local":
22
+ from llm.local_provider import LocalMistralProvider
23
+
24
+ return LocalMistralProvider()
25
+
26
+ if provider_name == "groq":
27
+ from llm.groq_provider import GroqProvider
28
+
29
+ return GroqProvider()
30
+
31
+ if provider_name == "openai":
32
+ from llm.openai_provider import OpenAIProvider
33
+
34
+ return OpenAIProvider()
35
+
36
+ raise ValueError(
37
+ f"Unknown LLM_PROVIDER={provider_name!r} in config.py. "
38
+ "Expected one of: 'local', 'groq', 'openai'."
39
+ )
40
+
41
+
42
+ def get_provider():
43
+ """Module-level singleton so the local model (if used) loads only once."""
44
+ global _PROVIDER_SINGLETON
45
+ if _PROVIDER_SINGLETON is None:
46
+ _PROVIDER_SINGLETON = _build_provider()
47
+ logger.info("LLM provider initialized: %s", _PROVIDER_SINGLETON.name())
48
+ return _PROVIDER_SINGLETON
49
+
50
+
51
+ def generate(prompt: str, stream: bool = False):
52
+ """
53
+ The single entrypoint the rest of the app (Flask routes, agents,
54
+ evaluation) should call. Never import a provider class directly outside
55
+ of this module.
56
+ """
57
+ provider = get_provider()
58
+ return provider.generate(prompt, stream=stream)
59
+
60
+
61
+ def reset_provider():
62
+ """Mainly for tests: force re-reading config.LLM_PROVIDER on next call."""
63
+ global _PROVIDER_SINGLETON
64
+ _PROVIDER_SINGLETON = None
llm/groq_provider.py ADDED
@@ -0,0 +1,82 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Groq-hosted LLM provider.
3
+
4
+ Implements the same generate(prompt, stream=False) interface as
5
+ LocalMistralProvider, using Groq's OpenAI-compatible chat completions API.
6
+ Nothing outside llm/generate.py should import this class directly — always
7
+ go through llm.generate.generate().
8
+ """
9
+
10
+ import config
11
+ from llm.provider import LLMProvider
12
+ from logs.logger import get_logger
13
+
14
+ logger = get_logger(__name__)
15
+
16
+
17
+ class GroqProvider(LLMProvider):
18
+ def __init__(self):
19
+ if not config.GROQ_API_KEY:
20
+ raise ValueError(
21
+ "GROQ_API_KEY is not set in config/.env. Set it before using "
22
+ "LLM_PROVIDER=groq."
23
+ )
24
+
25
+ from groq import Groq
26
+
27
+ self._client = Groq(api_key=config.GROQ_API_KEY)
28
+ self._model = config.GROQ_MODEL_NAME
29
+
30
+ def generate(self, prompt: str, stream: bool = False):
31
+ """
32
+ Args:
33
+ prompt: the fully-constructed prompt string from llm/prompts.py.
34
+ stream: if True, returns a generator yielding text chunks as
35
+ Groq streams them back. If False, returns the full
36
+ response as a single string.
37
+ """
38
+ messages = [{"role": "user", "content": prompt}]
39
+
40
+ if stream:
41
+ return self._generate_stream(messages)
42
+ return self._generate_sync(messages)
43
+
44
+ def _generate_sync(self, messages) -> str:
45
+ try:
46
+ response = self._client.chat.completions.create(
47
+ model=self._model,
48
+ messages=messages,
49
+ temperature=config.LOCAL_MODEL_TEMPERATURE,
50
+ max_tokens=config.LOCAL_MODEL_MAX_NEW_TOKENS,
51
+ stream=False,
52
+ )
53
+ except Exception:
54
+ logger.exception("Groq API call failed (model=%s)", self._model)
55
+ return (
56
+ "Sorry, I couldn't reach the language model service right now. "
57
+ "Please try again in a moment."
58
+ )
59
+
60
+ return response.choices[0].message.content.strip()
61
+
62
+ def _generate_stream(self, messages):
63
+ try:
64
+ stream = self._client.chat.completions.create(
65
+ model=self._model,
66
+ messages=messages,
67
+ temperature=config.LOCAL_MODEL_TEMPERATURE,
68
+ max_tokens=config.LOCAL_MODEL_MAX_NEW_TOKENS,
69
+ stream=True,
70
+ )
71
+ except Exception:
72
+ logger.exception("Groq streaming API call failed (model=%s)", self._model)
73
+ yield (
74
+ "Sorry, I couldn't reach the language model service right now. "
75
+ "Please try again in a moment."
76
+ )
77
+ return
78
+
79
+ for chunk in stream:
80
+ delta = chunk.choices[0].delta.content if chunk.choices else None
81
+ if delta:
82
+ yield delta
llm/local_provider.py ADDED
@@ -0,0 +1,98 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Local Mistral-7B-Instruct-v0.2 provider.
3
+
4
+ Loads the model lazily (on first generate() call, not at import time) so
5
+ importing this module never requires a GPU or downloaded weights — this
6
+ keeps the rest of the codebase importable/testable without the model
7
+ present.
8
+
9
+ Requires: transformers, bitsandbytes, torch, accelerate (see requirements.txt)
10
+ """
11
+
12
+ import config
13
+ from llm.provider import LLMProvider
14
+ from logs.logger import get_logger
15
+
16
+ logger = get_logger(__name__)
17
+
18
+
19
+ class LocalMistralProvider(LLMProvider):
20
+ def __init__(self):
21
+ self._model = None
22
+ self._tokenizer = None
23
+
24
+ def _load(self):
25
+ """Lazy-load the model + tokenizer on first use."""
26
+ if self._model is not None:
27
+ return
28
+
29
+ logger.info("Loading local model %s ...", config.LOCAL_MODEL_NAME)
30
+
31
+ import torch
32
+ from transformers import AutoModelForCausalLM, AutoTokenizer, BitsAndBytesConfig
33
+
34
+ quantization_config = None
35
+ if config.LOCAL_MODEL_LOAD_IN_4BIT:
36
+ quantization_config = BitsAndBytesConfig(
37
+ load_in_4bit=True,
38
+ bnb_4bit_quant_type="nf4",
39
+ bnb_4bit_compute_dtype=torch.float16,
40
+ bnb_4bit_use_double_quant=True,
41
+ )
42
+
43
+ self._tokenizer = AutoTokenizer.from_pretrained(config.LOCAL_MODEL_NAME)
44
+ self._model = AutoModelForCausalLM.from_pretrained(
45
+ config.LOCAL_MODEL_NAME,
46
+ quantization_config=quantization_config,
47
+ device_map="auto",
48
+ )
49
+
50
+ logger.info("Local model loaded.")
51
+
52
+ def generate(self, prompt: str, stream: bool = False):
53
+ self._load()
54
+
55
+ import torch
56
+
57
+ inputs = self._tokenizer(prompt, return_tensors="pt").to(self._model.device)
58
+
59
+ if not stream:
60
+ with torch.no_grad():
61
+ output_ids = self._model.generate(
62
+ **inputs,
63
+ max_new_tokens=config.LOCAL_MODEL_MAX_NEW_TOKENS,
64
+ temperature=config.LOCAL_MODEL_TEMPERATURE,
65
+ do_sample=config.LOCAL_MODEL_TEMPERATURE > 0,
66
+ )
67
+ full_text = self._tokenizer.decode(
68
+ output_ids[0][inputs["input_ids"].shape[1]:], skip_special_tokens=True
69
+ )
70
+ return full_text.strip()
71
+
72
+ return self._generate_streaming(inputs)
73
+
74
+ def _generate_streaming(self, inputs):
75
+ import threading
76
+
77
+ import torch
78
+ from transformers import TextIteratorStreamer
79
+
80
+ streamer = TextIteratorStreamer(
81
+ self._tokenizer, skip_prompt=True, skip_special_tokens=True
82
+ )
83
+
84
+ generation_kwargs = dict(
85
+ **inputs,
86
+ max_new_tokens=config.LOCAL_MODEL_MAX_NEW_TOKENS,
87
+ temperature=config.LOCAL_MODEL_TEMPERATURE,
88
+ do_sample=config.LOCAL_MODEL_TEMPERATURE > 0,
89
+ streamer=streamer,
90
+ )
91
+
92
+ thread = threading.Thread(target=self._model.generate, kwargs=generation_kwargs)
93
+ thread.start()
94
+
95
+ for text_chunk in streamer:
96
+ yield text_chunk
97
+
98
+ thread.join()
llm/prompts.py ADDED
@@ -0,0 +1,203 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Prompt builders.
3
+
4
+ Every prompt follows this fixed order:
5
+
6
+ System Prompt
7
+ -> Retrieved Context
8
+ -> Relevant mem0 Context
9
+ -> Recent Conversation History
10
+ -> Current User Question
11
+
12
+ One builder per route type, so each route's prompt shape can evolve
13
+ independently:
14
+
15
+ build_single_prompt()
16
+ build_comparison_prompt()
17
+ build_followup_prompt()
18
+ build_direct_prompt()
19
+ build_reject_prompt()
20
+
21
+ These builders only assemble strings — they never call the LLM themselves
22
+ (see llm/generate.py for that).
23
+ """
24
+
25
+ SYSTEM_PROMPT = (
26
+ "You are a patient, precise Data Structures and Algorithms (DSA) tutor. "
27
+ "Answer using ONLY the information provided in the retrieved context below. "
28
+ "If the retrieved context does not contain enough information to answer "
29
+ "confidently, say so explicitly rather than guessing or inventing details. "
30
+ "Explain concepts clearly, use the time/space complexity and examples given "
31
+ "when relevant, and adapt your explanation style to any user preferences "
32
+ "provided in the memory context."
33
+ )
34
+
35
+
36
+ # ---------------------------------------------------------------------------
37
+ # Shared formatting helpers
38
+ # ---------------------------------------------------------------------------
39
+
40
+ def _format_chunks(chunks: list, heading: str = None) -> str:
41
+ if not chunks:
42
+ return ""
43
+ lines = []
44
+ if heading:
45
+ lines.append(f"### {heading}")
46
+ for chunk in chunks:
47
+ meta = chunk.get("metadata", {})
48
+ section = meta.get("section", "")
49
+ topic = meta.get("topic", "")
50
+ lines.append(f"[{topic} - {section}]\n{chunk.get('text', '')}")
51
+ return "\n\n".join(lines)
52
+
53
+
54
+ def _format_mem0_context(mem0_context) -> str:
55
+ if not mem0_context:
56
+ return ""
57
+ if isinstance(mem0_context, str):
58
+ body = mem0_context
59
+ else:
60
+ # list of preference strings/facts returned by memory/mem0_client.py
61
+ body = "\n".join(f"- {item}" for item in mem0_context)
62
+ return f"### User Preferences / Memory\n{body}"
63
+
64
+
65
+ def _format_history(recent_messages: list) -> str:
66
+ if not recent_messages:
67
+ return ""
68
+ lines = ["### Recent Conversation"]
69
+ for message in recent_messages:
70
+ role = message.get("role", "user").capitalize()
71
+ content = message.get("content", "")
72
+ lines.append(f"{role}: {content}")
73
+ return "\n".join(lines)
74
+
75
+
76
+ def _assemble(*sections: str) -> str:
77
+ """Joins non-empty sections with blank lines, preserving fixed order."""
78
+ return "\n\n".join(s for s in sections if s)
79
+
80
+
81
+ # ---------------------------------------------------------------------------
82
+ # Per-route builders
83
+ # ---------------------------------------------------------------------------
84
+
85
+ def build_single_prompt(
86
+ chunks: list,
87
+ mem0_context=None,
88
+ recent_history: list = None,
89
+ query: str = "",
90
+ ) -> str:
91
+ """Single-topic route, e.g. 'Explain Merge Sort'."""
92
+ retrieved = _format_chunks(chunks, heading="Retrieved Context")
93
+ mem0_section = _format_mem0_context(mem0_context)
94
+ history_section = _format_history(recent_history)
95
+ question_section = f"### Current Question\n{query}"
96
+
97
+ return _assemble(
98
+ SYSTEM_PROMPT, retrieved, mem0_section, history_section, question_section
99
+ )
100
+
101
+
102
+ def build_comparison_prompt(
103
+ comparison_context: dict,
104
+ mem0_context=None,
105
+ recent_history: list = None,
106
+ query: str = "",
107
+ ) -> str:
108
+ """Comparison route, e.g. 'Difference between BFS and DFS'."""
109
+ retrieved_sections = []
110
+ for topic, chunks in comparison_context.items():
111
+ retrieved_sections.append(_format_chunks(chunks, heading=f"Retrieved Context: {topic}"))
112
+ retrieved = "\n\n".join(s for s in retrieved_sections if s)
113
+
114
+ mem0_section = _format_mem0_context(mem0_context)
115
+ history_section = _format_history(recent_history)
116
+ question_section = f"### Current Question\n{query}"
117
+
118
+ instruction = (
119
+ "Compare the topics above directly: highlight the key differences in "
120
+ "definition, time/space complexity, and when to use each."
121
+ )
122
+
123
+ return _assemble(
124
+ SYSTEM_PROMPT, instruction, retrieved, mem0_section, history_section, question_section
125
+ )
126
+
127
+
128
+ def build_followup_prompt(
129
+ followup_context: dict,
130
+ mem0_context=None,
131
+ recent_history: list = None,
132
+ query: str = "",
133
+ ) -> str:
134
+ """
135
+ Follow-up route, e.g. 'Can you explain that again?'. Reuses recent
136
+ conversation as primary context; only includes `additional_chunks` if
137
+ the follow-up agent decided a fresh retrieval was necessary.
138
+ """
139
+ additional_chunks = followup_context.get("additional_chunks", [])
140
+ retrieved = _format_chunks(additional_chunks, heading="Additional Retrieved Context")
141
+
142
+ mem0_section = _format_mem0_context(mem0_context)
143
+ # Prefer the followup_context's own recent_messages if the caller didn't
144
+ # pass a separate recent_history override.
145
+ history_section = _format_history(
146
+ recent_history or followup_context.get("recent_messages", [])
147
+ )
148
+ question_section = f"### Current Question\n{query}"
149
+
150
+ instruction = (
151
+ "This is a follow-up question. Use the recent conversation above to "
152
+ "understand what the user is referring to before answering."
153
+ )
154
+
155
+ WORKED_EXAMPLE_INSTRUCTION = (
156
+ "If the user provides their own concrete data (a list, array, or set of "
157
+ "numbers) and asks you to apply a search/sort/algorithm to it, walk "
158
+ "through the exact steps of the algorithm from the retrieved context "
159
+ "on their data. Do not substitute a different algorithm than the one "
160
+ "being discussed, even if their data doesn't perfectly fit it (e.g. "
161
+ "note if the list isn't sorted, but still apply the same algorithm's "
162
+ "logic unless the user explicitly asks for a different one)."
163
+ )
164
+
165
+ return _assemble(
166
+ SYSTEM_PROMPT, instruction, WORKED_EXAMPLE_INSTRUCTION, retrieved, mem0_section, history_section, question_section,
167
+ )
168
+
169
+
170
+ def build_direct_prompt(
171
+ mem0_context=None,
172
+ recent_history: list = None,
173
+ query: str = "",
174
+ ) -> str:
175
+ """
176
+ Fallback builder for cases with no retrieved KB context at all (e.g. a
177
+ general greeting, or a follow-up so light it needs no context beyond
178
+ conversation history). Still follows the same section ordering, simply
179
+ omitting the "Retrieved Context" section.
180
+ """
181
+ mem0_section = _format_mem0_context(mem0_context)
182
+ history_section = _format_history(recent_history)
183
+ question_section = f"### Current Question\n{query}"
184
+
185
+ return _assemble(SYSTEM_PROMPT, mem0_section, history_section, question_section)
186
+
187
+
188
+ def build_reject_prompt(query: str = "") -> str:
189
+ """
190
+ Out-of-scope route, e.g. 'Who won the World Cup?'. Kept deliberately
191
+ simple/static — no retrieved context, no memory, no history — since the
192
+ goal is just a polite, on-brand refusal, not a reasoned answer.
193
+ """
194
+ instruction = (
195
+ "The user's question is outside the scope of this DSA tutor "
196
+ "(it isn't a data structures / algorithms question, or the knowledge "
197
+ "base has no relevant information). Politely explain that you can "
198
+ "only help with Data Structures and Algorithms topics, and invite "
199
+ "them to ask a DSA-related question instead. Keep it brief and friendly."
200
+ )
201
+ question_section = f"### User's Message\n{query}"
202
+
203
+ return _assemble(SYSTEM_PROMPT, instruction, question_section)
llm/provider.py ADDED
@@ -0,0 +1,33 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ LLM provider interface.
3
+
4
+ The rest of the application (router, chat endpoints, evaluation) must
5
+ NEVER call a specific model directly. Everything goes through
6
+ generate(prompt) on whatever provider is configured via
7
+ config.LLM_PROVIDER. Switching from local Mistral to Groq or OpenAI later
8
+ should require changing config only — no changes to callers.
9
+ """
10
+
11
+ from abc import ABC, abstractmethod
12
+
13
+
14
+ class LLMProvider(ABC):
15
+ """Base interface every provider (local, groq, openai, ...) must implement."""
16
+
17
+ @abstractmethod
18
+ def generate(self, prompt: str, stream: bool = False):
19
+ """
20
+ Args:
21
+ prompt: the fully-constructed prompt string (see llm/prompts.py).
22
+ stream: if True, returns a generator yielding text chunks as
23
+ they're produced, instead of a single string. Providers
24
+ that can't stream should fall back to yielding the full
25
+ response once.
26
+
27
+ Returns:
28
+ str if stream=False, otherwise a generator[str].
29
+ """
30
+ raise NotImplementedError
31
+
32
+ def name(self) -> str:
33
+ return self.__class__.__name__
logs/__init__.py ADDED
File without changes
logs/app.log ADDED
@@ -0,0 +1,440 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ 2026-08-02 13:43:39,527 | INFO | dsa_chatbot.__main__ | Loading embedding model: all-MiniLM-L6-v2
2
+ 2026-08-02 13:44:07,549 | WARNING | dsa_chatbot.__main__ | The following KB files have no topic_metadata.json entry and will be skipped: ['01_knapsack.txt', 'activity_selection_problem.txt', 'backtracking_overview.txt', 'bit_manipulation_basics.txt', 'coin_change_problem.txt', 'dijkstras_algorithm.txt', 'dynamic_programming_overview.txt', 'edit_distance.txt', 'fractional_knapsack.txt', 'greedy_algorithms_overview.txt', 'hash_set_vs_hash_map.txt', 'hash_table.txt', 'kruskals_algorithm_mst.txt', 'longest_common_subsequence.txt', 'longest_increasing_subsequence.txt', 'n-queens_problem.txt', 'prims_algorithm_mst.txt', 'recursion_overview.txt', 'subset_sum_power_set_generation.txt', 'xor_tricks.txt']
3
+ 2026-08-02 13:44:08,102 | INFO | dsa_chatbot.__main__ | Built 98 chunks from 54 knowledge-base files.
4
+ 2026-08-02 13:44:08,102 | INFO | dsa_chatbot.__main__ | Embedding 98 chunks...
5
+ 2026-08-02 13:44:13,562 | INFO | dsa_chatbot.__main__ | Index build complete. Collection 'dsa_knowledge_base' now has 98 chunks.
6
+ 2026-08-02 13:45:19,505 | INFO | dsa_chatbot.agents.router | Routed to SINGLE_TOPIC: 'Explain how binary search works' | topic=Binary Search
7
+ 2026-08-02 13:45:19,648 | INFO | dsa_chatbot.rag.retriever | Query='Explain how binary search works' | candidates=1 | passed_threshold=1 | threshold=0.45
8
+ 2026-08-02 13:45:19,658 | INFO | dsa_chatbot.agents.router | Routed to SINGLE_TOPIC: 'What is the time complexity of merge sort?' | topic=Insertion Sort
9
+ 2026-08-02 13:45:19,680 | INFO | dsa_chatbot.rag.retriever | Query='What is the time complexity of merge sort?' | candidates=1 | passed_threshold=0 | threshold=0.45
10
+ 2026-08-02 13:45:19,681 | WARNING | dsa_chatbot.agents.router | SINGLE_TOPIC query had no chunks pass the similarity threshold: 'What is the time complexity of merge sort?'
11
+ 2026-08-02 13:45:19,688 | INFO | dsa_chatbot.agents.router | Routed to SINGLE_TOPIC: 'Explain quick sort with an example' | topic=Bucket Sort
12
+ 2026-08-02 13:45:19,718 | INFO | dsa_chatbot.rag.retriever | Query='Explain quick sort with an example' | candidates=1 | passed_threshold=1 | threshold=0.45
13
+ 2026-08-02 13:45:19,727 | INFO | dsa_chatbot.agents.router | Routed to SINGLE_TOPIC: 'What is a Trie used for?' | topic=Insertion Sort
14
+ 2026-08-02 13:45:19,752 | INFO | dsa_chatbot.rag.retriever | Query='What is a Trie used for?' | candidates=1 | passed_threshold=0 | threshold=0.45
15
+ 2026-08-02 13:45:19,753 | WARNING | dsa_chatbot.agents.router | SINGLE_TOPIC query had no chunks pass the similarity threshold: 'What is a Trie used for?'
16
+ 2026-08-02 13:45:19,763 | INFO | dsa_chatbot.agents.router | Routed to SINGLE_TOPIC: 'What is a doubly linked list?' | topic=Doubly Linked List
17
+ 2026-08-02 13:45:19,791 | INFO | dsa_chatbot.rag.retriever | Query='What is a doubly linked list?' | candidates=1 | passed_threshold=1 | threshold=0.45
18
+ 2026-08-02 13:45:19,796 | INFO | dsa_chatbot.agents.router | Routed to SINGLE_TOPIC: 'Explain how a stack works' | topic=Stack
19
+ 2026-08-02 13:45:19,817 | INFO | dsa_chatbot.rag.retriever | Query='Explain how a stack works' | candidates=1 | passed_threshold=1 | threshold=0.45
20
+ 2026-08-02 13:45:19,825 | INFO | dsa_chatbot.agents.router | Routed to SINGLE_TOPIC: 'What is topological sort used for?' | topic=Insertion Sort
21
+ 2026-08-02 13:45:19,848 | INFO | dsa_chatbot.rag.retriever | Query='What is topological sort used for?' | candidates=1 | passed_threshold=1 | threshold=0.45
22
+ 2026-08-02 13:45:19,852 | INFO | dsa_chatbot.agents.router | Routed to SINGLE_TOPIC: "Explain Dijkstra's Algorithm" | topic=Dijkstra's Algorithm
23
+ 2026-08-02 13:45:19,899 | INFO | dsa_chatbot.rag.retriever | Query="Explain Dijkstra's Algorithm" | candidates=0 | passed_threshold=0 | threshold=0.45
24
+ 2026-08-02 13:45:19,899 | WARNING | dsa_chatbot.agents.router | SINGLE_TOPIC query had no chunks pass the similarity threshold: "Explain Dijkstra's Algorithm"
25
+ 2026-08-02 13:45:19,905 | INFO | dsa_chatbot.agents.router | Routed to SINGLE_TOPIC: 'What is a segment tree?' | topic=Insertion Sort
26
+ 2026-08-02 13:45:19,929 | INFO | dsa_chatbot.rag.retriever | Query='What is a segment tree?' | candidates=1 | passed_threshold=0 | threshold=0.45
27
+ 2026-08-02 13:45:19,929 | WARNING | dsa_chatbot.agents.router | SINGLE_TOPIC query had no chunks pass the similarity threshold: 'What is a segment tree?'
28
+ 2026-08-02 13:45:19,935 | INFO | dsa_chatbot.agents.router | Routed to SINGLE_TOPIC: 'Explain the sliding window technique' | topic=Sliding Window Technique
29
+ 2026-08-02 13:45:19,958 | INFO | dsa_chatbot.rag.retriever | Query='Explain the sliding window technique' | candidates=1 | passed_threshold=1 | threshold=0.45
30
+ 2026-08-02 13:45:19,966 | INFO | dsa_chatbot.agents.router | Routed to COMPARISON: 'What is the difference between BFS and DFS?' | topics=['Breadth-First Search', 'Depth-First Search', 'Insertion Sort']
31
+ 2026-08-02 13:45:19,995 | INFO | dsa_chatbot.rag.retriever | Query='Breadth-First Search' | candidates=5 | passed_threshold=5 | threshold=0.45
32
+ 2026-08-02 13:45:20,013 | INFO | dsa_chatbot.rag.retriever | Query='Depth-First Search' | candidates=1 | passed_threshold=1 | threshold=0.45
33
+ 2026-08-02 13:45:20,033 | INFO | dsa_chatbot.rag.retriever | Query='Insertion Sort' | candidates=1 | passed_threshold=1 | threshold=0.45
34
+ 2026-08-02 13:45:20,042 | INFO | dsa_chatbot.agents.router | Routed to COMPARISON: 'Compare merge sort vs quick sort' | topics=['Bucket Sort', 'Merge Sort', 'Quick Sort']
35
+ 2026-08-02 13:45:20,062 | INFO | dsa_chatbot.rag.retriever | Query='Bucket Sort' | candidates=1 | passed_threshold=1 | threshold=0.45
36
+ 2026-08-02 13:45:20,087 | INFO | dsa_chatbot.rag.retriever | Query='Merge Sort' | candidates=1 | passed_threshold=1 | threshold=0.45
37
+ 2026-08-02 13:45:20,107 | INFO | dsa_chatbot.rag.retriever | Query='Quick Sort' | candidates=1 | passed_threshold=1 | threshold=0.45
38
+ 2026-08-02 13:45:20,115 | INFO | dsa_chatbot.agents.router | Routed to COMPARISON: 'Difference between a stack and a queue' | topics=['Queue', 'Stack']
39
+ 2026-08-02 13:45:20,168 | INFO | dsa_chatbot.rag.retriever | Query='Queue' | candidates=5 | passed_threshold=5 | threshold=0.45
40
+ 2026-08-02 13:45:20,190 | INFO | dsa_chatbot.rag.retriever | Query='Stack' | candidates=1 | passed_threshold=1 | threshold=0.45
41
+ 2026-08-02 13:45:20,215 | INFO | dsa_chatbot.agents.router | Routed to COMPARISON: "Which one is better, Prim's Algorithm or Kruskal's Algorithm, for MST?" | topics=["Dijkstra's Algorithm", 'Insertion Sort', "Kruskal's Algorithm (MST)", "Prim's Algorithm (MST)"]
42
+ 2026-08-02 13:45:20,245 | INFO | dsa_chatbot.rag.retriever | Query="Dijkstra's Algorithm" | candidates=0 | passed_threshold=0 | threshold=0.45
43
+ 2026-08-02 13:45:20,271 | INFO | dsa_chatbot.rag.retriever | Query='Insertion Sort' | candidates=1 | passed_threshold=1 | threshold=0.45
44
+ 2026-08-02 13:45:20,305 | INFO | dsa_chatbot.rag.retriever | Query="Kruskal's Algorithm (MST)" | candidates=0 | passed_threshold=0 | threshold=0.45
45
+ 2026-08-02 13:45:20,330 | INFO | dsa_chatbot.rag.retriever | Query="Prim's Algorithm (MST)" | candidates=0 | passed_threshold=0 | threshold=0.45
46
+ 2026-08-02 13:45:20,330 | WARNING | dsa_chatbot.agents.comparison | Comparison agent: no chunks passed the similarity threshold for topic "Dijkstra's Algorithm"
47
+ 2026-08-02 13:45:20,330 | WARNING | dsa_chatbot.agents.comparison | Comparison agent: no chunks passed the similarity threshold for topic "Kruskal's Algorithm (MST)"
48
+ 2026-08-02 13:45:20,330 | WARNING | dsa_chatbot.agents.comparison | Comparison agent: no chunks passed the similarity threshold for topic "Prim's Algorithm (MST)"
49
+ 2026-08-02 13:45:20,331 | WARNING | dsa_chatbot.agents.router | COMPARISON query missing sufficient context for one or more topics: ["Dijkstra's Algorithm", 'Insertion Sort', "Kruskal's Algorithm (MST)", "Prim's Algorithm (MST)"]
50
+ 2026-08-02 13:45:20,341 | INFO | dsa_chatbot.agents.router | Routed to COMPARISON: 'Compare AVL Tree and Binary Search Tree' | topics=['AVL Tree', 'Binary Search', 'Binary Search Tree', 'Linear Search', 'Trie']
51
+ 2026-08-02 13:45:20,362 | INFO | dsa_chatbot.rag.retriever | Query='AVL Tree' | candidates=5 | passed_threshold=5 | threshold=0.45
52
+ 2026-08-02 13:45:20,380 | INFO | dsa_chatbot.rag.retriever | Query='Binary Search' | candidates=1 | passed_threshold=1 | threshold=0.45
53
+ 2026-08-02 13:45:20,403 | INFO | dsa_chatbot.rag.retriever | Query='Binary Search Tree' | candidates=1 | passed_threshold=1 | threshold=0.45
54
+ 2026-08-02 13:45:20,426 | INFO | dsa_chatbot.rag.retriever | Query='Linear Search' | candidates=1 | passed_threshold=1 | threshold=0.45
55
+ 2026-08-02 13:45:20,448 | INFO | dsa_chatbot.rag.retriever | Query='Trie' | candidates=5 | passed_threshold=4 | threshold=0.45
56
+ 2026-08-02 13:45:20,459 | INFO | dsa_chatbot.agents.router | Routed to OUT_OF_SCOPE: 'Who won the football World Cup in 2022?'
57
+ 2026-08-02 13:45:20,466 | INFO | dsa_chatbot.agents.router | Routed to OUT_OF_SCOPE: "What's a good recipe for chocolate cake?"
58
+ 2026-08-02 13:45:20,474 | INFO | dsa_chatbot.agents.router | Routed to SINGLE_TOPIC: 'What is the capital of France?' | topic=Insertion Sort
59
+ 2026-08-02 13:45:20,495 | INFO | dsa_chatbot.rag.retriever | Query='What is the capital of France?' | candidates=1 | passed_threshold=0 | threshold=0.45
60
+ 2026-08-02 13:45:20,495 | WARNING | dsa_chatbot.agents.router | SINGLE_TOPIC query had no chunks pass the similarity threshold: 'What is the capital of France?'
61
+ 2026-08-02 13:45:20,499 | INFO | dsa_chatbot.agents.router | Routed to FOLLOWUP: 'Can you explain that again?'
62
+ 2026-08-02 13:45:20,517 | INFO | dsa_chatbot.agents.followup | Follow-up query reuses existing conversation context (topics=['Merge Sort', 'Insertion Sort']); skipping fresh retrieval.
63
+ 2026-08-02 13:45:20,530 | INFO | dsa_chatbot.agents.router | Routed to OUT_OF_SCOPE: 'Give me another example'
64
+ 2026-08-02 13:45:20,531 | INFO | dsa_chatbot.__main__ | Evaluation complete: {'total_questions': 20, 'route_accuracy': 0.9, 'avg_topic_recall': 0.65, 'retrieval_latency_ms_avg': 51.74, 'retrieval_latency_ms_p95': 140.89}
65
+ 2026-08-02 13:59:25,086 | INFO | dsa_chatbot.__main__ | Loading embedding model: all-MiniLM-L6-v2
66
+ 2026-08-02 13:59:31,186 | INFO | dsa_chatbot.__main__ | Existing collection 'dsa_knowledge_base' deleted.
67
+ 2026-08-02 13:59:31,554 | INFO | dsa_chatbot.__main__ | Built 150 chunks from 54 knowledge-base files.
68
+ 2026-08-02 13:59:31,555 | INFO | dsa_chatbot.__main__ | Embedding 150 chunks...
69
+ 2026-08-02 13:59:39,737 | INFO | dsa_chatbot.__main__ | Index build complete. Collection 'dsa_knowledge_base' now has 150 chunks.
70
+ 2026-08-02 14:00:37,066 | INFO | dsa_chatbot.agents.router | Routed to SINGLE_TOPIC: 'Explain how binary search works' | topic=Binary Search
71
+ 2026-08-02 14:00:37,234 | INFO | dsa_chatbot.rag.retriever | Query='Explain how binary search works' | candidates=1 | passed_threshold=1 | threshold=0.45
72
+ 2026-08-02 14:00:37,248 | INFO | dsa_chatbot.agents.router | Routed to SINGLE_TOPIC: 'What is the time complexity of merge sort?' | topic=Insertion Sort
73
+ 2026-08-02 14:00:37,273 | INFO | dsa_chatbot.rag.retriever | Query='What is the time complexity of merge sort?' | candidates=1 | passed_threshold=0 | threshold=0.45
74
+ 2026-08-02 14:00:37,274 | WARNING | dsa_chatbot.agents.router | SINGLE_TOPIC query had no chunks pass the similarity threshold: 'What is the time complexity of merge sort?'
75
+ 2026-08-02 14:00:37,288 | INFO | dsa_chatbot.agents.router | Routed to SINGLE_TOPIC: 'Explain quick sort with an example' | topic=Bucket Sort
76
+ 2026-08-02 14:00:37,310 | INFO | dsa_chatbot.rag.retriever | Query='Explain quick sort with an example' | candidates=1 | passed_threshold=1 | threshold=0.45
77
+ 2026-08-02 14:00:37,321 | INFO | dsa_chatbot.agents.router | Routed to SINGLE_TOPIC: 'What is a Trie used for?' | topic=Insertion Sort
78
+ 2026-08-02 14:00:37,345 | INFO | dsa_chatbot.rag.retriever | Query='What is a Trie used for?' | candidates=1 | passed_threshold=0 | threshold=0.45
79
+ 2026-08-02 14:00:37,346 | WARNING | dsa_chatbot.agents.router | SINGLE_TOPIC query had no chunks pass the similarity threshold: 'What is a Trie used for?'
80
+ 2026-08-02 14:00:37,357 | INFO | dsa_chatbot.agents.router | Routed to SINGLE_TOPIC: 'What is a doubly linked list?' | topic=Doubly Linked List
81
+ 2026-08-02 14:00:37,378 | INFO | dsa_chatbot.rag.retriever | Query='What is a doubly linked list?' | candidates=1 | passed_threshold=1 | threshold=0.45
82
+ 2026-08-02 14:00:37,386 | INFO | dsa_chatbot.agents.router | Routed to SINGLE_TOPIC: 'Explain how a stack works' | topic=Stack
83
+ 2026-08-02 14:00:37,405 | INFO | dsa_chatbot.rag.retriever | Query='Explain how a stack works' | candidates=1 | passed_threshold=1 | threshold=0.45
84
+ 2026-08-02 14:00:37,415 | INFO | dsa_chatbot.agents.router | Routed to SINGLE_TOPIC: 'What is topological sort used for?' | topic=Insertion Sort
85
+ 2026-08-02 14:00:37,433 | INFO | dsa_chatbot.rag.retriever | Query='What is topological sort used for?' | candidates=1 | passed_threshold=1 | threshold=0.45
86
+ 2026-08-02 14:00:37,439 | INFO | dsa_chatbot.agents.router | Routed to SINGLE_TOPIC: "Explain Dijkstra's Algorithm" | topic=Dijkstra's Algorithm
87
+ 2026-08-02 14:00:37,463 | INFO | dsa_chatbot.rag.retriever | Query="Explain Dijkstra's Algorithm" | candidates=1 | passed_threshold=1 | threshold=0.45
88
+ 2026-08-02 14:00:37,471 | INFO | dsa_chatbot.agents.router | Routed to SINGLE_TOPIC: 'What is a segment tree?' | topic=Insertion Sort
89
+ 2026-08-02 14:00:37,493 | INFO | dsa_chatbot.rag.retriever | Query='What is a segment tree?' | candidates=1 | passed_threshold=0 | threshold=0.45
90
+ 2026-08-02 14:00:37,494 | WARNING | dsa_chatbot.agents.router | SINGLE_TOPIC query had no chunks pass the similarity threshold: 'What is a segment tree?'
91
+ 2026-08-02 14:00:37,501 | INFO | dsa_chatbot.agents.router | Routed to SINGLE_TOPIC: 'Explain the sliding window technique' | topic=Sliding Window Technique
92
+ 2026-08-02 14:00:37,521 | INFO | dsa_chatbot.rag.retriever | Query='Explain the sliding window technique' | candidates=1 | passed_threshold=1 | threshold=0.45
93
+ 2026-08-02 14:00:37,539 | INFO | dsa_chatbot.agents.router | Routed to COMPARISON: 'What is the difference between BFS and DFS?' | topics=['Breadth-First Search', 'Depth-First Search', 'Insertion Sort']
94
+ 2026-08-02 14:00:37,597 | INFO | dsa_chatbot.rag.retriever | Query='Breadth-First Search' | candidates=5 | passed_threshold=5 | threshold=0.45
95
+ 2026-08-02 14:00:37,624 | INFO | dsa_chatbot.rag.retriever | Query='Depth-First Search' | candidates=1 | passed_threshold=1 | threshold=0.45
96
+ 2026-08-02 14:00:37,647 | INFO | dsa_chatbot.rag.retriever | Query='Insertion Sort' | candidates=1 | passed_threshold=1 | threshold=0.45
97
+ 2026-08-02 14:00:37,666 | INFO | dsa_chatbot.agents.router | Routed to COMPARISON: 'Compare merge sort vs quick sort' | topics=['Bucket Sort', 'Merge Sort', 'Quick Sort']
98
+ 2026-08-02 14:00:37,699 | INFO | dsa_chatbot.rag.retriever | Query='Bucket Sort' | candidates=1 | passed_threshold=1 | threshold=0.45
99
+ 2026-08-02 14:00:37,725 | INFO | dsa_chatbot.rag.retriever | Query='Merge Sort' | candidates=1 | passed_threshold=1 | threshold=0.45
100
+ 2026-08-02 14:00:37,751 | INFO | dsa_chatbot.rag.retriever | Query='Quick Sort' | candidates=1 | passed_threshold=1 | threshold=0.45
101
+ 2026-08-02 14:00:37,767 | INFO | dsa_chatbot.agents.router | Routed to COMPARISON: 'Difference between a stack and a queue' | topics=['Queue', 'Stack']
102
+ 2026-08-02 14:00:37,821 | INFO | dsa_chatbot.rag.retriever | Query='Queue' | candidates=5 | passed_threshold=5 | threshold=0.45
103
+ 2026-08-02 14:00:37,851 | INFO | dsa_chatbot.rag.retriever | Query='Stack' | candidates=1 | passed_threshold=1 | threshold=0.45
104
+ 2026-08-02 14:00:37,892 | INFO | dsa_chatbot.agents.router | Routed to COMPARISON: "Which one is better, Prim's Algorithm or Kruskal's Algorithm, for MST?" | topics=["Dijkstra's Algorithm", 'Insertion Sort', "Kruskal's Algorithm (MST)", "Prim's Algorithm (MST)"]
105
+ 2026-08-02 14:00:37,919 | INFO | dsa_chatbot.rag.retriever | Query="Dijkstra's Algorithm" | candidates=1 | passed_threshold=1 | threshold=0.45
106
+ 2026-08-02 14:00:37,944 | INFO | dsa_chatbot.rag.retriever | Query='Insertion Sort' | candidates=1 | passed_threshold=1 | threshold=0.45
107
+ 2026-08-02 14:00:37,977 | INFO | dsa_chatbot.rag.retriever | Query="Kruskal's Algorithm (MST)" | candidates=5 | passed_threshold=5 | threshold=0.45
108
+ 2026-08-02 14:00:38,005 | INFO | dsa_chatbot.rag.retriever | Query="Prim's Algorithm (MST)" | candidates=5 | passed_threshold=5 | threshold=0.45
109
+ 2026-08-02 14:00:38,022 | INFO | dsa_chatbot.agents.router | Routed to COMPARISON: 'Compare AVL Tree and Binary Search Tree' | topics=['AVL Tree', 'Binary Search', 'Binary Search Tree', 'Linear Search', 'Trie']
110
+ 2026-08-02 14:00:38,043 | INFO | dsa_chatbot.rag.retriever | Query='AVL Tree' | candidates=5 | passed_threshold=5 | threshold=0.45
111
+ 2026-08-02 14:00:38,058 | INFO | dsa_chatbot.rag.retriever | Query='Binary Search' | candidates=1 | passed_threshold=1 | threshold=0.45
112
+ 2026-08-02 14:00:38,081 | INFO | dsa_chatbot.rag.retriever | Query='Binary Search Tree' | candidates=1 | passed_threshold=1 | threshold=0.45
113
+ 2026-08-02 14:00:38,109 | INFO | dsa_chatbot.rag.retriever | Query='Linear Search' | candidates=1 | passed_threshold=1 | threshold=0.45
114
+ 2026-08-02 14:00:38,126 | INFO | dsa_chatbot.rag.retriever | Query='Trie' | candidates=5 | passed_threshold=4 | threshold=0.45
115
+ 2026-08-02 14:00:38,138 | INFO | dsa_chatbot.agents.router | Routed to OUT_OF_SCOPE: 'Who won the football World Cup in 2022?'
116
+ 2026-08-02 14:00:38,152 | INFO | dsa_chatbot.agents.router | Routed to OUT_OF_SCOPE: "What's a good recipe for chocolate cake?"
117
+ 2026-08-02 14:00:38,164 | INFO | dsa_chatbot.agents.router | Routed to SINGLE_TOPIC: 'What is the capital of France?' | topic=Insertion Sort
118
+ 2026-08-02 14:00:38,186 | INFO | dsa_chatbot.rag.retriever | Query='What is the capital of France?' | candidates=1 | passed_threshold=0 | threshold=0.45
119
+ 2026-08-02 14:00:38,186 | WARNING | dsa_chatbot.agents.router | SINGLE_TOPIC query had no chunks pass the similarity threshold: 'What is the capital of France?'
120
+ 2026-08-02 14:00:38,195 | INFO | dsa_chatbot.agents.router | Routed to FOLLOWUP: 'Can you explain that again?'
121
+ 2026-08-02 14:00:38,226 | INFO | dsa_chatbot.agents.followup | Follow-up query reuses existing conversation context (topics=['Merge Sort', 'Insertion Sort']); skipping fresh retrieval.
122
+ 2026-08-02 14:00:38,236 | INFO | dsa_chatbot.agents.router | Routed to OUT_OF_SCOPE: 'Give me another example'
123
+ 2026-08-02 14:00:38,237 | INFO | dsa_chatbot.__main__ | Evaluation complete: {'total_questions': 20, 'route_accuracy': 0.9, 'avg_topic_recall': 0.75, 'retrieval_latency_ms_avg': 59.26, 'retrieval_latency_ms_p95': 155.61}
124
+ 2026-08-02 14:03:52,099 | INFO | dsa_chatbot.agents.router | Routed to SINGLE_TOPIC: 'Explain how binary search works' | topic=Binary Search
125
+ 2026-08-02 14:03:52,274 | INFO | dsa_chatbot.rag.retriever | Query='Explain how binary search works' | candidates=1 | passed_threshold=1 | threshold=0.45
126
+ 2026-08-02 14:03:52,288 | INFO | dsa_chatbot.agents.router | Routed to SINGLE_TOPIC: 'What is the time complexity of merge sort?' | topic=Merge Sort
127
+ 2026-08-02 14:03:52,311 | INFO | dsa_chatbot.rag.retriever | Query='What is the time complexity of merge sort?' | candidates=1 | passed_threshold=1 | threshold=0.45
128
+ 2026-08-02 14:03:52,324 | INFO | dsa_chatbot.agents.router | Routed to SINGLE_TOPIC: 'Explain quick sort with an example' | topic=Quick Sort
129
+ 2026-08-02 14:03:52,350 | INFO | dsa_chatbot.rag.retriever | Query='Explain quick sort with an example' | candidates=1 | passed_threshold=1 | threshold=0.45
130
+ 2026-08-02 14:03:52,362 | INFO | dsa_chatbot.agents.router | Routed to SINGLE_TOPIC: 'What is a Trie used for?' | topic=Trie
131
+ 2026-08-02 14:03:52,384 | INFO | dsa_chatbot.rag.retriever | Query='What is a Trie used for?' | candidates=5 | passed_threshold=4 | threshold=0.45
132
+ 2026-08-02 14:03:52,398 | INFO | dsa_chatbot.agents.router | Routed to SINGLE_TOPIC: 'What is a doubly linked list?' | topic=Doubly Linked List
133
+ 2026-08-02 14:03:52,419 | INFO | dsa_chatbot.rag.retriever | Query='What is a doubly linked list?' | candidates=1 | passed_threshold=1 | threshold=0.45
134
+ 2026-08-02 14:03:52,430 | INFO | dsa_chatbot.agents.router | Routed to SINGLE_TOPIC: 'Explain how a stack works' | topic=Stack
135
+ 2026-08-02 14:03:52,450 | INFO | dsa_chatbot.rag.retriever | Query='Explain how a stack works' | candidates=1 | passed_threshold=1 | threshold=0.45
136
+ 2026-08-02 14:03:52,462 | INFO | dsa_chatbot.agents.router | Routed to SINGLE_TOPIC: 'What is topological sort used for?' | topic=Topological Sort
137
+ 2026-08-02 14:03:52,483 | INFO | dsa_chatbot.rag.retriever | Query='What is topological sort used for?' | candidates=5 | passed_threshold=5 | threshold=0.45
138
+ 2026-08-02 14:03:52,490 | INFO | dsa_chatbot.agents.router | Routed to SINGLE_TOPIC: "Explain Dijkstra's Algorithm" | topic=Dijkstra's Algorithm
139
+ 2026-08-02 14:03:52,516 | INFO | dsa_chatbot.rag.retriever | Query="Explain Dijkstra's Algorithm" | candidates=1 | passed_threshold=1 | threshold=0.45
140
+ 2026-08-02 14:03:52,522 | INFO | dsa_chatbot.agents.router | Routed to SINGLE_TOPIC: 'What is a segment tree?' | topic=Segment Tree
141
+ 2026-08-02 14:03:52,547 | INFO | dsa_chatbot.rag.retriever | Query='What is a segment tree?' | candidates=1 | passed_threshold=1 | threshold=0.45
142
+ 2026-08-02 14:03:52,561 | INFO | dsa_chatbot.agents.router | Routed to SINGLE_TOPIC: 'Explain the sliding window technique' | topic=Sliding Window Technique
143
+ 2026-08-02 14:03:52,582 | INFO | dsa_chatbot.rag.retriever | Query='Explain the sliding window technique' | candidates=1 | passed_threshold=1 | threshold=0.45
144
+ 2026-08-02 14:03:52,605 | INFO | dsa_chatbot.agents.router | Routed to COMPARISON: 'What is the difference between BFS and DFS?' | topics=['Breadth-First Search', 'Depth-First Search']
145
+ 2026-08-02 14:03:52,627 | INFO | dsa_chatbot.rag.retriever | Query='Breadth-First Search' | candidates=5 | passed_threshold=5 | threshold=0.45
146
+ 2026-08-02 14:03:52,649 | INFO | dsa_chatbot.rag.retriever | Query='Depth-First Search' | candidates=1 | passed_threshold=1 | threshold=0.45
147
+ 2026-08-02 14:03:52,662 | INFO | dsa_chatbot.agents.router | Routed to COMPARISON: 'Compare merge sort vs quick sort' | topics=['Merge Sort', 'Quick Sort']
148
+ 2026-08-02 14:03:52,685 | INFO | dsa_chatbot.rag.retriever | Query='Merge Sort' | candidates=1 | passed_threshold=1 | threshold=0.45
149
+ 2026-08-02 14:03:52,706 | INFO | dsa_chatbot.rag.retriever | Query='Quick Sort' | candidates=1 | passed_threshold=1 | threshold=0.45
150
+ 2026-08-02 14:03:52,722 | INFO | dsa_chatbot.agents.router | Routed to COMPARISON: 'Difference between a stack and a queue' | topics=['Queue', 'Stack']
151
+ 2026-08-02 14:03:52,749 | INFO | dsa_chatbot.rag.retriever | Query='Queue' | candidates=5 | passed_threshold=5 | threshold=0.45
152
+ 2026-08-02 14:03:52,767 | INFO | dsa_chatbot.rag.retriever | Query='Stack' | candidates=1 | passed_threshold=1 | threshold=0.45
153
+ 2026-08-02 14:03:52,793 | INFO | dsa_chatbot.agents.router | Routed to OUT_OF_SCOPE: "Which one is better, Prim's Algorithm or Kruskal's Algorithm, for MST?"
154
+ 2026-08-02 14:03:52,814 | INFO | dsa_chatbot.agents.router | Routed to COMPARISON: 'Compare AVL Tree and Binary Search Tree' | topics=['AVL Tree', 'Binary Search', 'Binary Search Tree']
155
+ 2026-08-02 14:03:52,836 | INFO | dsa_chatbot.rag.retriever | Query='AVL Tree' | candidates=5 | passed_threshold=5 | threshold=0.45
156
+ 2026-08-02 14:03:52,852 | INFO | dsa_chatbot.rag.retriever | Query='Binary Search' | candidates=1 | passed_threshold=1 | threshold=0.45
157
+ 2026-08-02 14:03:52,873 | INFO | dsa_chatbot.rag.retriever | Query='Binary Search Tree' | candidates=1 | passed_threshold=1 | threshold=0.45
158
+ 2026-08-02 14:03:52,886 | INFO | dsa_chatbot.agents.router | Routed to OUT_OF_SCOPE: 'Who won the football World Cup in 2022?'
159
+ 2026-08-02 14:03:52,904 | INFO | dsa_chatbot.agents.router | Routed to OUT_OF_SCOPE: "What's a good recipe for chocolate cake?"
160
+ 2026-08-02 14:03:52,917 | INFO | dsa_chatbot.agents.router | Routed to OUT_OF_SCOPE: 'What is the capital of France?'
161
+ 2026-08-02 14:03:52,924 | INFO | dsa_chatbot.agents.router | Routed to FOLLOWUP: 'Can you explain that again?'
162
+ 2026-08-02 14:03:52,949 | INFO | dsa_chatbot.agents.followup | Follow-up query reuses existing conversation context (topics=['Merge Sort']); skipping fresh retrieval.
163
+ 2026-08-02 14:03:52,950 | INFO | dsa_chatbot.agents.router | Routed to FOLLOWUP: 'Give me another example'
164
+ 2026-08-02 14:03:52,973 | INFO | dsa_chatbot.agents.followup | Follow-up query reuses existing conversation context (topics=['Merge Sort']); skipping fresh retrieval.
165
+ 2026-08-02 14:03:52,974 | INFO | dsa_chatbot.__main__ | Evaluation complete: {'total_questions': 20, 'route_accuracy': 0.95, 'avg_topic_recall': 0.95, 'retrieval_latency_ms_avg': 44.4, 'retrieval_latency_ms_p95': 85.11}
166
+ 2026-08-02 14:06:23,677 | INFO | dsa_chatbot.agents.router | Routed to SINGLE_TOPIC: 'Explain how binary search works' | topic=Binary Search
167
+ 2026-08-02 14:06:23,808 | INFO | dsa_chatbot.rag.retriever | Query='Explain how binary search works' | candidates=1 | passed_threshold=1 | threshold=0.45
168
+ 2026-08-02 14:06:23,820 | INFO | dsa_chatbot.agents.router | Routed to SINGLE_TOPIC: 'What is the time complexity of merge sort?' | topic=Merge Sort
169
+ 2026-08-02 14:06:23,838 | INFO | dsa_chatbot.rag.retriever | Query='What is the time complexity of merge sort?' | candidates=1 | passed_threshold=1 | threshold=0.45
170
+ 2026-08-02 14:06:23,848 | INFO | dsa_chatbot.agents.router | Routed to SINGLE_TOPIC: 'Explain quick sort with an example' | topic=Quick Sort
171
+ 2026-08-02 14:06:23,872 | INFO | dsa_chatbot.rag.retriever | Query='Explain quick sort with an example' | candidates=1 | passed_threshold=1 | threshold=0.45
172
+ 2026-08-02 14:06:23,881 | INFO | dsa_chatbot.agents.router | Routed to SINGLE_TOPIC: 'What is a Trie used for?' | topic=Trie
173
+ 2026-08-02 14:06:23,904 | INFO | dsa_chatbot.rag.retriever | Query='What is a Trie used for?' | candidates=5 | passed_threshold=4 | threshold=0.45
174
+ 2026-08-02 14:06:23,917 | INFO | dsa_chatbot.agents.router | Routed to SINGLE_TOPIC: 'What is a doubly linked list?' | topic=Doubly Linked List
175
+ 2026-08-02 14:06:23,938 | INFO | dsa_chatbot.rag.retriever | Query='What is a doubly linked list?' | candidates=1 | passed_threshold=1 | threshold=0.45
176
+ 2026-08-02 14:06:23,946 | INFO | dsa_chatbot.agents.router | Routed to SINGLE_TOPIC: 'Explain how a stack works' | topic=Stack
177
+ 2026-08-02 14:06:23,968 | INFO | dsa_chatbot.rag.retriever | Query='Explain how a stack works' | candidates=1 | passed_threshold=1 | threshold=0.45
178
+ 2026-08-02 14:06:23,980 | INFO | dsa_chatbot.agents.router | Routed to SINGLE_TOPIC: 'What is topological sort used for?' | topic=Topological Sort
179
+ 2026-08-02 14:06:24,001 | INFO | dsa_chatbot.rag.retriever | Query='What is topological sort used for?' | candidates=5 | passed_threshold=5 | threshold=0.45
180
+ 2026-08-02 14:06:24,007 | INFO | dsa_chatbot.agents.router | Routed to SINGLE_TOPIC: "Explain Dijkstra's Algorithm" | topic=Dijkstra's Algorithm
181
+ 2026-08-02 14:06:24,029 | INFO | dsa_chatbot.rag.retriever | Query="Explain Dijkstra's Algorithm" | candidates=1 | passed_threshold=1 | threshold=0.45
182
+ 2026-08-02 14:06:24,038 | INFO | dsa_chatbot.agents.router | Routed to SINGLE_TOPIC: 'What is a segment tree?' | topic=Segment Tree
183
+ 2026-08-02 14:06:24,065 | INFO | dsa_chatbot.rag.retriever | Query='What is a segment tree?' | candidates=1 | passed_threshold=1 | threshold=0.45
184
+ 2026-08-02 14:06:24,076 | INFO | dsa_chatbot.agents.router | Routed to SINGLE_TOPIC: 'Explain the sliding window technique' | topic=Sliding Window Technique
185
+ 2026-08-02 14:06:24,099 | INFO | dsa_chatbot.rag.retriever | Query='Explain the sliding window technique' | candidates=1 | passed_threshold=1 | threshold=0.45
186
+ 2026-08-02 14:06:24,113 | INFO | dsa_chatbot.agents.router | Routed to COMPARISON: 'What is the difference between BFS and DFS?' | topics=['Breadth-First Search', 'Depth-First Search']
187
+ 2026-08-02 14:06:24,134 | INFO | dsa_chatbot.rag.retriever | Query='Breadth-First Search' | candidates=5 | passed_threshold=5 | threshold=0.45
188
+ 2026-08-02 14:06:24,151 | INFO | dsa_chatbot.rag.retriever | Query='Depth-First Search' | candidates=1 | passed_threshold=1 | threshold=0.45
189
+ 2026-08-02 14:06:24,166 | INFO | dsa_chatbot.agents.router | Routed to COMPARISON: 'Compare merge sort vs quick sort' | topics=['Merge Sort', 'Quick Sort']
190
+ 2026-08-02 14:06:24,190 | INFO | dsa_chatbot.rag.retriever | Query='Merge Sort' | candidates=1 | passed_threshold=1 | threshold=0.45
191
+ 2026-08-02 14:06:24,215 | INFO | dsa_chatbot.rag.retriever | Query='Quick Sort' | candidates=1 | passed_threshold=1 | threshold=0.45
192
+ 2026-08-02 14:06:24,229 | INFO | dsa_chatbot.agents.router | Routed to COMPARISON: 'Difference between a stack and a queue' | topics=['Queue', 'Stack']
193
+ 2026-08-02 14:06:24,253 | INFO | dsa_chatbot.rag.retriever | Query='Queue' | candidates=5 | passed_threshold=5 | threshold=0.45
194
+ 2026-08-02 14:06:24,281 | INFO | dsa_chatbot.rag.retriever | Query='Stack' | candidates=1 | passed_threshold=1 | threshold=0.45
195
+ 2026-08-02 14:06:24,320 | INFO | dsa_chatbot.agents.router | Routed to COMPARISON: "Which one is better, Prim's Algorithm or Kruskal's Algorithm, for MST?" | topics=["Kruskal's Algorithm (MST)", "Prim's Algorithm (MST)"]
196
+ 2026-08-02 14:06:24,354 | INFO | dsa_chatbot.rag.retriever | Query="Kruskal's Algorithm (MST)" | candidates=5 | passed_threshold=5 | threshold=0.45
197
+ 2026-08-02 14:06:24,380 | INFO | dsa_chatbot.rag.retriever | Query="Prim's Algorithm (MST)" | candidates=5 | passed_threshold=5 | threshold=0.45
198
+ 2026-08-02 14:06:24,395 | INFO | dsa_chatbot.agents.router | Routed to COMPARISON: 'Compare AVL Tree and Binary Search Tree' | topics=['AVL Tree', 'Binary Search', 'Binary Search Tree']
199
+ 2026-08-02 14:06:24,419 | INFO | dsa_chatbot.rag.retriever | Query='AVL Tree' | candidates=5 | passed_threshold=5 | threshold=0.45
200
+ 2026-08-02 14:06:24,444 | INFO | dsa_chatbot.rag.retriever | Query='Binary Search' | candidates=1 | passed_threshold=1 | threshold=0.45
201
+ 2026-08-02 14:06:24,465 | INFO | dsa_chatbot.rag.retriever | Query='Binary Search Tree' | candidates=1 | passed_threshold=1 | threshold=0.45
202
+ 2026-08-02 14:06:24,489 | INFO | dsa_chatbot.agents.router | Routed to OUT_OF_SCOPE: 'Who won the football World Cup in 2022?'
203
+ 2026-08-02 14:06:24,503 | INFO | dsa_chatbot.agents.router | Routed to OUT_OF_SCOPE: "What's a good recipe for chocolate cake?"
204
+ 2026-08-02 14:06:24,521 | INFO | dsa_chatbot.agents.router | Routed to OUT_OF_SCOPE: 'What is the capital of France?'
205
+ 2026-08-02 14:06:24,533 | INFO | dsa_chatbot.agents.router | Routed to FOLLOWUP: 'Can you explain that again?'
206
+ 2026-08-02 14:06:24,565 | INFO | dsa_chatbot.agents.followup | Follow-up query reuses existing conversation context (topics=['Merge Sort']); skipping fresh retrieval.
207
+ 2026-08-02 14:06:24,566 | INFO | dsa_chatbot.agents.router | Routed to FOLLOWUP: 'Give me another example'
208
+ 2026-08-02 14:06:24,590 | INFO | dsa_chatbot.agents.followup | Follow-up query reuses existing conversation context (topics=['Merge Sort']); skipping fresh retrieval.
209
+ 2026-08-02 14:06:24,590 | INFO | dsa_chatbot.__main__ | Evaluation complete: {'total_questions': 20, 'route_accuracy': 1.0, 'avg_topic_recall': 1.0, 'retrieval_latency_ms_avg': 46.26, 'retrieval_latency_ms_p95': 101.1}
210
+ 2026-08-02 16:00:13,397 | INFO | dsa_chatbot.agents.router | Routed to SINGLE_TOPIC: 'explain binary search' | topic=Binary Search
211
+ 2026-08-02 16:00:14,003 | INFO | dsa_chatbot.rag.retriever | Query='explain binary search' | candidates=1 | passed_threshold=1 | threshold=0.45
212
+ 2026-08-02 16:00:15,631 | INFO | dsa_chatbot.llm.generate | LLM provider initialized: GroqProvider
213
+ 2026-08-02 16:00:17,326 | INFO | dsa_chatbot.chat_service | Chat turn complete | user_id=None | route=single_topic | topics=['Binary Search'] | session_id=None
214
+ 2026-08-02 16:00:56,629 | INFO | dsa_chatbot.agents.router | Routed to OUT_OF_SCOPE: '2,3,1,6,5,4 find 5 this using this search step by step'
215
+ 2026-08-02 16:00:57,493 | INFO | dsa_chatbot.chat_service | Chat turn complete | user_id=None | route=out_of_scope | topics=[] | session_id=None
216
+ 2026-08-02 16:07:37,423 | INFO | dsa_chatbot.agents.router | Routed to SINGLE_TOPIC: 'explain binary search' | topic=Binary Search
217
+ 2026-08-02 16:07:37,480 | INFO | dsa_chatbot.rag.retriever | Query='explain binary search' | candidates=1 | passed_threshold=1 | threshold=0.45
218
+ 2026-08-02 16:07:41,129 | INFO | dsa_chatbot.memory.mem0_client | Initializing mem0 (local chroma + huggingface embedder)...
219
+ 2026-08-02 16:07:51,945 | ERROR | dsa_chatbot.memory.mem0_client | mem0 get_relevant_context failed for user_id=1
220
+ Traceback (most recent call last):
221
+ File "D:\scratch\chatbot\chatbot\memory\mem0_client.py", line 110, in get_relevant_context
222
+ results = self._get_memory().search(query, user_id=user_id, limit=limit)
223
+ ~~~~~~~~~~~~~~~~^^
224
+ File "D:\scratch\chatbot\chatbot\memory\mem0_client.py", line 72, in _get_memory
225
+ self._memory = Memory.from_config(_build_config_dict())
226
+ ~~~~~~~~~~~~~~~~~~^^^^^^^^^^^^^^^^^^^^^^
227
+ File "D:\scratch\chatbot\chatbot\venv\Lib\site-packages\mem0\memory\main.py", line 59, in from_config
228
+ return cls(config)
229
+ File "D:\scratch\chatbot\chatbot\venv\Lib\site-packages\mem0\memory\main.py", line 37, in __init__
230
+ self.llm = LlmFactory.create(self.config.llm.provider, self.config.llm.config)
231
+ ~~~~~~~~~~~~~~~~~^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
232
+ File "D:\scratch\chatbot\chatbot\venv\Lib\site-packages\mem0\utils\factory.py", line 34, in create
233
+ return llm_instance(base_config)
234
+ File "D:\scratch\chatbot\chatbot\venv\Lib\site-packages\mem0\llms\openai.py", line 28, in __init__
235
+ self.client = OpenAI(api_key=api_key, base_url=base_url)
236
+ ~~~~~~^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
237
+ File "D:\scratch\chatbot\chatbot\venv\Lib\site-packages\openai\_client.py", line 135, in __init__
238
+ raise OpenAIError(
239
+ "The api_key client option must be set either by passing api_key to the client or by setting the OPENAI_API_KEY environment variable"
240
+ )
241
+ openai.OpenAIError: The api_key client option must be set either by passing api_key to the client or by setting the OPENAI_API_KEY environment variable
242
+ 2026-08-02 16:07:53,458 | INFO | dsa_chatbot.memory.mem0_client | Initializing mem0 (local chroma + huggingface embedder)...
243
+ 2026-08-02 16:07:57,115 | ERROR | dsa_chatbot.memory.mem0_client | mem0 add_interaction failed for user_id=1
244
+ Traceback (most recent call last):
245
+ File "D:\scratch\chatbot\chatbot\memory\mem0_client.py", line 93, in add_interaction
246
+ self._get_memory().add(messages, user_id=user_id, infer=self.infer)
247
+ ~~~~~~~~~~~~~~~~^^
248
+ File "D:\scratch\chatbot\chatbot\memory\mem0_client.py", line 72, in _get_memory
249
+ self._memory = Memory.from_config(_build_config_dict())
250
+ ~~~~~~~~~~~~~~~~~~^^^^^^^^^^^^^^^^^^^^^^
251
+ File "D:\scratch\chatbot\chatbot\venv\Lib\site-packages\mem0\memory\main.py", line 59, in from_config
252
+ return cls(config)
253
+ File "D:\scratch\chatbot\chatbot\venv\Lib\site-packages\mem0\memory\main.py", line 37, in __init__
254
+ self.llm = LlmFactory.create(self.config.llm.provider, self.config.llm.config)
255
+ ~~~~~~~~~~~~~~~~~^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
256
+ File "D:\scratch\chatbot\chatbot\venv\Lib\site-packages\mem0\utils\factory.py", line 34, in create
257
+ return llm_instance(base_config)
258
+ File "D:\scratch\chatbot\chatbot\venv\Lib\site-packages\mem0\llms\openai.py", line 28, in __init__
259
+ self.client = OpenAI(api_key=api_key, base_url=base_url)
260
+ ~~~~~~^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
261
+ File "D:\scratch\chatbot\chatbot\venv\Lib\site-packages\openai\_client.py", line 135, in __init__
262
+ raise OpenAIError(
263
+ "The api_key client option must be set either by passing api_key to the client or by setting the OPENAI_API_KEY environment variable"
264
+ )
265
+ openai.OpenAIError: The api_key client option must be set either by passing api_key to the client or by setting the OPENAI_API_KEY environment variable
266
+ 2026-08-02 16:07:57,120 | INFO | dsa_chatbot.chat_service | Chat turn complete | user_id=1 | route=single_topic | topics=['Binary Search'] | session_id=1
267
+ 2026-08-02 16:31:21,411 | INFO | dsa_chatbot.agents.router | Routed to SINGLE_TOPIC: 'explain binary search' | topic=Binary Search
268
+ 2026-08-02 16:31:21,670 | INFO | dsa_chatbot.rag.retriever | Query='explain binary search' | candidates=1 | passed_threshold=1 | threshold=-1.00
269
+ 2026-08-02 16:31:24,341 | INFO | dsa_chatbot.memory.mem0_client | Initializing mem0 (local chroma + huggingface embedder)...
270
+ 2026-08-02 16:31:29,511 | ERROR | dsa_chatbot.memory.mem0_client | mem0 get_relevant_context failed for user_id=1
271
+ Traceback (most recent call last):
272
+ File "D:\scratch\chatbot\chatbot\memory\mem0_client.py", line 110, in get_relevant_context
273
+ results = self._get_memory().search(query, user_id=user_id, limit=limit)
274
+ ~~~~~~~~~~~~~~~~^^
275
+ File "D:\scratch\chatbot\chatbot\memory\mem0_client.py", line 72, in _get_memory
276
+ self._memory = Memory.from_config(_build_config_dict())
277
+ ~~~~~~~~~~~~~~~~~~^^^^^^^^^^^^^^^^^^^^^^
278
+ File "D:\scratch\chatbot\chatbot\venv\Lib\site-packages\mem0\memory\main.py", line 59, in from_config
279
+ return cls(config)
280
+ File "D:\scratch\chatbot\chatbot\venv\Lib\site-packages\mem0\memory\main.py", line 37, in __init__
281
+ self.llm = LlmFactory.create(self.config.llm.provider, self.config.llm.config)
282
+ ~~~~~~~~~~~~~~~~~^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
283
+ File "D:\scratch\chatbot\chatbot\venv\Lib\site-packages\mem0\utils\factory.py", line 34, in create
284
+ return llm_instance(base_config)
285
+ File "D:\scratch\chatbot\chatbot\venv\Lib\site-packages\mem0\llms\openai.py", line 28, in __init__
286
+ self.client = OpenAI(api_key=api_key, base_url=base_url)
287
+ ~~~~~~^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
288
+ File "D:\scratch\chatbot\chatbot\venv\Lib\site-packages\openai\_client.py", line 135, in __init__
289
+ raise OpenAIError(
290
+ "The api_key client option must be set either by passing api_key to the client or by setting the OPENAI_API_KEY environment variable"
291
+ )
292
+ openai.OpenAIError: The api_key client option must be set either by passing api_key to the client or by setting the OPENAI_API_KEY environment variable
293
+ 2026-08-02 16:31:30,363 | INFO | dsa_chatbot.llm.generate | LLM provider initialized: GroqProvider
294
+ 2026-08-02 16:31:31,945 | INFO | dsa_chatbot.memory.mem0_client | Initializing mem0 (local chroma + huggingface embedder)...
295
+ 2026-08-02 16:31:35,540 | ERROR | dsa_chatbot.memory.mem0_client | mem0 add_interaction failed for user_id=1
296
+ Traceback (most recent call last):
297
+ File "D:\scratch\chatbot\chatbot\memory\mem0_client.py", line 93, in add_interaction
298
+ self._get_memory().add(messages, user_id=user_id, infer=self.infer)
299
+ ~~~~~~~~~~~~~~~~^^
300
+ File "D:\scratch\chatbot\chatbot\memory\mem0_client.py", line 72, in _get_memory
301
+ self._memory = Memory.from_config(_build_config_dict())
302
+ ~~~~~~~~~~~~~~~~~~^^^^^^^^^^^^^^^^^^^^^^
303
+ File "D:\scratch\chatbot\chatbot\venv\Lib\site-packages\mem0\memory\main.py", line 59, in from_config
304
+ return cls(config)
305
+ File "D:\scratch\chatbot\chatbot\venv\Lib\site-packages\mem0\memory\main.py", line 37, in __init__
306
+ self.llm = LlmFactory.create(self.config.llm.provider, self.config.llm.config)
307
+ ~~~~~~~~~~~~~~~~~^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
308
+ File "D:\scratch\chatbot\chatbot\venv\Lib\site-packages\mem0\utils\factory.py", line 34, in create
309
+ return llm_instance(base_config)
310
+ File "D:\scratch\chatbot\chatbot\venv\Lib\site-packages\mem0\llms\openai.py", line 28, in __init__
311
+ self.client = OpenAI(api_key=api_key, base_url=base_url)
312
+ ~~~~~~^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
313
+ File "D:\scratch\chatbot\chatbot\venv\Lib\site-packages\openai\_client.py", line 135, in __init__
314
+ raise OpenAIError(
315
+ "The api_key client option must be set either by passing api_key to the client or by setting the OPENAI_API_KEY environment variable"
316
+ )
317
+ openai.OpenAIError: The api_key client option must be set either by passing api_key to the client or by setting the OPENAI_API_KEY environment variable
318
+ 2026-08-02 16:31:35,545 | INFO | dsa_chatbot.chat_service | Chat turn complete | user_id=1 | route=single_topic | topics=['Binary Search'] | session_id=2
319
+ 2026-08-02 16:32:22,442 | INFO | dsa_chatbot.agents.router | Routed to FOLLOWUP: 'in this array [65,32,43,23,78,96] how will you find 23 using this search'
320
+ 2026-08-02 16:32:24,207 | INFO | dsa_chatbot.agents.followup | Follow-up query reuses existing conversation context (topics=['Binary Search', 'Linear Search']); skipping fresh retrieval.
321
+ 2026-08-02 16:32:24,208 | INFO | dsa_chatbot.memory.mem0_client | Initializing mem0 (local chroma + huggingface embedder)...
322
+ 2026-08-02 16:32:27,835 | ERROR | dsa_chatbot.memory.mem0_client | mem0 get_relevant_context failed for user_id=1
323
+ Traceback (most recent call last):
324
+ File "D:\scratch\chatbot\chatbot\memory\mem0_client.py", line 110, in get_relevant_context
325
+ results = self._get_memory().search(query, user_id=user_id, limit=limit)
326
+ ~~~~~~~~~~~~~~~~^^
327
+ File "D:\scratch\chatbot\chatbot\memory\mem0_client.py", line 72, in _get_memory
328
+ self._memory = Memory.from_config(_build_config_dict())
329
+ ~~~~~~~~~~~~~~~~~~^^^^^^^^^^^^^^^^^^^^^^
330
+ File "D:\scratch\chatbot\chatbot\venv\Lib\site-packages\mem0\memory\main.py", line 59, in from_config
331
+ return cls(config)
332
+ File "D:\scratch\chatbot\chatbot\venv\Lib\site-packages\mem0\memory\main.py", line 37, in __init__
333
+ self.llm = LlmFactory.create(self.config.llm.provider, self.config.llm.config)
334
+ ~~~~~~~~~~~~~~~~~^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
335
+ File "D:\scratch\chatbot\chatbot\venv\Lib\site-packages\mem0\utils\factory.py", line 34, in create
336
+ return llm_instance(base_config)
337
+ File "D:\scratch\chatbot\chatbot\venv\Lib\site-packages\mem0\llms\openai.py", line 28, in __init__
338
+ self.client = OpenAI(api_key=api_key, base_url=base_url)
339
+ ~~~~~~^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
340
+ File "D:\scratch\chatbot\chatbot\venv\Lib\site-packages\openai\_client.py", line 135, in __init__
341
+ raise OpenAIError(
342
+ "The api_key client option must be set either by passing api_key to the client or by setting the OPENAI_API_KEY environment variable"
343
+ )
344
+ openai.OpenAIError: The api_key client option must be set either by passing api_key to the client or by setting the OPENAI_API_KEY environment variable
345
+ 2026-08-02 16:32:29,805 | INFO | dsa_chatbot.memory.mem0_client | Initializing mem0 (local chroma + huggingface embedder)...
346
+ 2026-08-02 16:32:33,863 | ERROR | dsa_chatbot.memory.mem0_client | mem0 add_interaction failed for user_id=1
347
+ Traceback (most recent call last):
348
+ File "D:\scratch\chatbot\chatbot\memory\mem0_client.py", line 93, in add_interaction
349
+ self._get_memory().add(messages, user_id=user_id, infer=self.infer)
350
+ ~~~~~~~~~~~~~~~~^^
351
+ File "D:\scratch\chatbot\chatbot\memory\mem0_client.py", line 72, in _get_memory
352
+ self._memory = Memory.from_config(_build_config_dict())
353
+ ~~~~~~~~~~~~~~~~~~^^^^^^^^^^^^^^^^^^^^^^
354
+ File "D:\scratch\chatbot\chatbot\venv\Lib\site-packages\mem0\memory\main.py", line 59, in from_config
355
+ return cls(config)
356
+ File "D:\scratch\chatbot\chatbot\venv\Lib\site-packages\mem0\memory\main.py", line 37, in __init__
357
+ self.llm = LlmFactory.create(self.config.llm.provider, self.config.llm.config)
358
+ ~~~~~~~~~~~~~~~~~^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
359
+ File "D:\scratch\chatbot\chatbot\venv\Lib\site-packages\mem0\utils\factory.py", line 34, in create
360
+ return llm_instance(base_config)
361
+ File "D:\scratch\chatbot\chatbot\venv\Lib\site-packages\mem0\llms\openai.py", line 28, in __init__
362
+ self.client = OpenAI(api_key=api_key, base_url=base_url)
363
+ ~~~~~~^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
364
+ File "D:\scratch\chatbot\chatbot\venv\Lib\site-packages\openai\_client.py", line 135, in __init__
365
+ raise OpenAIError(
366
+ "The api_key client option must be set either by passing api_key to the client or by setting the OPENAI_API_KEY environment variable"
367
+ )
368
+ openai.OpenAIError: The api_key client option must be set either by passing api_key to the client or by setting the OPENAI_API_KEY environment variable
369
+ 2026-08-02 16:32:33,869 | INFO | dsa_chatbot.chat_service | Chat turn complete | user_id=1 | route=followup | topics=[] | session_id=2
370
+ 2026-08-02 16:33:48,648 | INFO | dsa_chatbot.agents.router | Routed to SINGLE_TOPIC: 'compare this search with linear search' | topic=Linear Search
371
+ 2026-08-02 16:33:48,715 | INFO | dsa_chatbot.rag.retriever | Query='compare this search with linear search' | candidates=1 | passed_threshold=1 | threshold=-1.00
372
+ 2026-08-02 16:33:48,716 | INFO | dsa_chatbot.memory.mem0_client | Initializing mem0 (local chroma + huggingface embedder)...
373
+ 2026-08-02 16:33:52,390 | ERROR | dsa_chatbot.memory.mem0_client | mem0 get_relevant_context failed for user_id=1
374
+ Traceback (most recent call last):
375
+ File "D:\scratch\chatbot\chatbot\memory\mem0_client.py", line 110, in get_relevant_context
376
+ results = self._get_memory().search(query, user_id=user_id, limit=limit)
377
+ ~~~~~~~~~~~~~~~~^^
378
+ File "D:\scratch\chatbot\chatbot\memory\mem0_client.py", line 72, in _get_memory
379
+ self._memory = Memory.from_config(_build_config_dict())
380
+ ~~~~~~~~~~~~~~~~~~^^^^^^^^^^^^^^^^^^^^^^
381
+ File "D:\scratch\chatbot\chatbot\venv\Lib\site-packages\mem0\memory\main.py", line 59, in from_config
382
+ return cls(config)
383
+ File "D:\scratch\chatbot\chatbot\venv\Lib\site-packages\mem0\memory\main.py", line 37, in __init__
384
+ self.llm = LlmFactory.create(self.config.llm.provider, self.config.llm.config)
385
+ ~~~~~~~~~~~~~~~~~^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
386
+ File "D:\scratch\chatbot\chatbot\venv\Lib\site-packages\mem0\utils\factory.py", line 34, in create
387
+ return llm_instance(base_config)
388
+ File "D:\scratch\chatbot\chatbot\venv\Lib\site-packages\mem0\llms\openai.py", line 28, in __init__
389
+ self.client = OpenAI(api_key=api_key, base_url=base_url)
390
+ ~~~~~~^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
391
+ File "D:\scratch\chatbot\chatbot\venv\Lib\site-packages\openai\_client.py", line 135, in __init__
392
+ raise OpenAIError(
393
+ "The api_key client option must be set either by passing api_key to the client or by setting the OPENAI_API_KEY environment variable"
394
+ )
395
+ openai.OpenAIError: The api_key client option must be set either by passing api_key to the client or by setting the OPENAI_API_KEY environment variable
396
+ 2026-08-02 16:33:54,095 | INFO | dsa_chatbot.memory.mem0_client | Initializing mem0 (local chroma + huggingface embedder)...
397
+ 2026-08-02 16:33:58,206 | ERROR | dsa_chatbot.memory.mem0_client | mem0 add_interaction failed for user_id=1
398
+ Traceback (most recent call last):
399
+ File "D:\scratch\chatbot\chatbot\memory\mem0_client.py", line 93, in add_interaction
400
+ self._get_memory().add(messages, user_id=user_id, infer=self.infer)
401
+ ~~~~~~~~~~~~~~~~^^
402
+ File "D:\scratch\chatbot\chatbot\memory\mem0_client.py", line 72, in _get_memory
403
+ self._memory = Memory.from_config(_build_config_dict())
404
+ ~~~~~~~~~~~~~~~~~~^^^^^^^^^^^^^^^^^^^^^^
405
+ File "D:\scratch\chatbot\chatbot\venv\Lib\site-packages\mem0\memory\main.py", line 59, in from_config
406
+ return cls(config)
407
+ File "D:\scratch\chatbot\chatbot\venv\Lib\site-packages\mem0\memory\main.py", line 37, in __init__
408
+ self.llm = LlmFactory.create(self.config.llm.provider, self.config.llm.config)
409
+ ~~~~~~~~~~~~~~~~~^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
410
+ File "D:\scratch\chatbot\chatbot\venv\Lib\site-packages\mem0\utils\factory.py", line 34, in create
411
+ return llm_instance(base_config)
412
+ File "D:\scratch\chatbot\chatbot\venv\Lib\site-packages\mem0\llms\openai.py", line 28, in __init__
413
+ self.client = OpenAI(api_key=api_key, base_url=base_url)
414
+ ~~~~~~^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
415
+ File "D:\scratch\chatbot\chatbot\venv\Lib\site-packages\openai\_client.py", line 135, in __init__
416
+ raise OpenAIError(
417
+ "The api_key client option must be set either by passing api_key to the client or by setting the OPENAI_API_KEY environment variable"
418
+ )
419
+ openai.OpenAIError: The api_key client option must be set either by passing api_key to the client or by setting the OPENAI_API_KEY environment variable
420
+ 2026-08-02 16:33:58,212 | INFO | dsa_chatbot.chat_service | Chat turn complete | user_id=1 | route=single_topic | topics=['Linear Search'] | session_id=2
421
+ 2026-08-02 19:53:48,973 | INFO | dsa_chatbot.agents.router | Routed to OUT_OF_SCOPE: 'how are you'
422
+ 2026-08-02 19:53:49,894 | INFO | dsa_chatbot.llm.generate | LLM provider initialized: GroqProvider
423
+ 2026-08-02 19:53:51,450 | INFO | dsa_chatbot.chat_service | Chat turn complete | user_id=None | route=out_of_scope | topics=[] | session_id=None
424
+ 2026-08-02 22:34:22,716 | INFO | dsa_chatbot.agents.router | Routed to SINGLE_TOPIC: "explain dijikstra's algorithm" | topic=Dijkstra's Algorithm
425
+ 2026-08-02 22:34:23,350 | INFO | dsa_chatbot.rag.retriever | Query="explain dijikstra's algorithm" | candidates=1 | passed_threshold=1 | threshold=-1.00
426
+ 2026-08-02 22:34:24,785 | INFO | dsa_chatbot.llm.generate | LLM provider initialized: GroqProvider
427
+ 2026-08-02 22:34:26,608 | INFO | dsa_chatbot.chat_service | Chat turn complete | user_id=None | route=single_topic | topics=["Dijkstra's Algorithm"] | session_id=None
428
+ 2026-08-02 22:41:13,334 | INFO | dsa_chatbot.agents.router | Routed to SINGLE_TOPIC: 'explain bfs' | topic=Breadth-First Search
429
+ 2026-08-02 22:41:13,572 | INFO | dsa_chatbot.rag.retriever | Query='explain bfs' | candidates=5 | passed_threshold=5 | threshold=-1.00
430
+ 2026-08-02 22:41:14,419 | INFO | dsa_chatbot.llm.generate | LLM provider initialized: GroqProvider
431
+ 2026-08-02 22:41:15,798 | INFO | dsa_chatbot.chat_service | Chat turn complete | user_id=None | route=single_topic | topics=['Breadth-First Search'] | session_id=None
432
+ 2026-08-02 22:41:56,906 | INFO | dsa_chatbot.agents.router | Routed to OUT_OF_SCOPE: 'compare with other graph parsing algorithms'
433
+ 2026-08-02 22:41:57,470 | INFO | dsa_chatbot.chat_service | Chat turn complete | user_id=None | route=out_of_scope | topics=[] | session_id=None
434
+ 2026-08-02 22:51:55,471 | INFO | dsa_chatbot.agents.router | Routed to SINGLE_TOPIC: 'explain dfs' | topic=Depth-First Search
435
+ 2026-08-02 22:51:55,687 | INFO | dsa_chatbot.rag.retriever | Query='explain dfs' | candidates=1 | passed_threshold=1 | threshold=-1.00
436
+ 2026-08-02 22:51:56,559 | INFO | dsa_chatbot.llm.generate | LLM provider initialized: GroqProvider
437
+ 2026-08-02 22:51:57,814 | INFO | dsa_chatbot.chat_service | Chat turn complete | user_id=None | route=single_topic | topics=['Depth-First Search'] | session_id=None
438
+ 2026-08-02 22:52:27,161 | INFO | dsa_chatbot.agents.router | Routed to FOLLOWUP: 'compare it with other graph traversal algorithms'
439
+ 2026-08-02 22:52:28,250 | INFO | dsa_chatbot.agents.followup | Follow-up query reuses existing conversation context (topics=['Depth-First Search', 'Stack']); skipping fresh retrieval.
440
+ 2026-08-02 22:52:29,862 | INFO | dsa_chatbot.chat_service | Chat turn complete | user_id=None | route=followup | topics=[] | session_id=None
logs/logger.py ADDED
@@ -0,0 +1,48 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Centralized logging setup.
3
+
4
+ Every module does `from logs.logger import get_logger` and
5
+ `logger = get_logger(__name__)` rather than configuring logging itself.
6
+ Writes to both console and logs/app.log (paths from config.py).
7
+ """
8
+
9
+ import logging
10
+ import os
11
+ import sys
12
+
13
+ import config
14
+
15
+ _CONFIGURED = False
16
+
17
+
18
+ def _configure_root():
19
+ global _CONFIGURED
20
+ if _CONFIGURED:
21
+ return
22
+
23
+ os.makedirs(config.LOG_DIR, exist_ok=True)
24
+
25
+ root = logging.getLogger("dsa_chatbot")
26
+ root.setLevel(config.LOG_LEVEL)
27
+ root.propagate = False
28
+
29
+ formatter = logging.Formatter(
30
+ "%(asctime)s | %(levelname)-8s | %(name)s | %(message)s"
31
+ )
32
+
33
+ if not root.handlers:
34
+ stream_handler = logging.StreamHandler(sys.stdout)
35
+ stream_handler.setFormatter(formatter)
36
+ root.addHandler(stream_handler)
37
+
38
+ file_handler = logging.FileHandler(config.LOG_FILE_PATH, encoding="utf-8")
39
+ file_handler.setFormatter(formatter)
40
+ root.addHandler(file_handler)
41
+
42
+ _CONFIGURED = True
43
+
44
+
45
+ def get_logger(name: str) -> logging.Logger:
46
+ """Returns a named logger nested under the shared 'dsa_chatbot' root."""
47
+ _configure_root()
48
+ return logging.getLogger(f"dsa_chatbot.{name}")
memory/__init__.py ADDED
File without changes
memory/mem0_client.py ADDED
@@ -0,0 +1,137 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ mem0 client wrapper.
3
+
4
+ Responsibility boundary (do not blur this):
5
+ - mem0 -> user preferences + long-term conversational memory
6
+ (e.g. "prefers beginner explanations", "likes step-by-step examples")
7
+ - ChromaDB -> DSA knowledge embeddings (rag/retriever.py) — NEVER touched here
8
+ - SQLite -> raw message/session transcripts (history/) — NEVER touched here
9
+
10
+ mem0 runs in local/open-source mode: a local Chroma vector store (separate
11
+ collection from the knowledge base) + a local sentence-transformers
12
+ embedder (same model as the KB for consistency, configurable independently
13
+ if needed).
14
+
15
+ Fact extraction (infer=True) would use an LLM to summarize what to
16
+ remember. To avoid depending on the (expensive, 4-bit-quantized) local
17
+ Mistral model just to log a preference, this wrapper uses infer=False by
18
+ default — messages are stored as-is and semantic search over them still
19
+ works for retrieval. Set infer=True if you want mem0 to run LLM-based
20
+ fact extraction using the same provider configured in llm/generate.py.
21
+ """
22
+
23
+ import os
24
+
25
+ import config
26
+ from logs.logger import get_logger
27
+
28
+ logger = get_logger(__name__)
29
+
30
+ _MEM0_COLLECTION_NAME = "user_preferences_memory"
31
+
32
+
33
+ def _build_config_dict() -> dict:
34
+ os.makedirs(config.MEM0_LOCAL_STORAGE_DIR, exist_ok=True)
35
+ return {
36
+ "vector_store": {
37
+ "provider": "chroma",
38
+ "config": {
39
+ "collection_name": _MEM0_COLLECTION_NAME,
40
+ "path": os.path.join(config.MEM0_LOCAL_STORAGE_DIR, "chroma_db"),
41
+ },
42
+ },
43
+ "embedder": {
44
+ "provider": "huggingface",
45
+ "config": {
46
+ "model": f"sentence-transformers/{config.EMBEDDING_MODEL_NAME}",
47
+ },
48
+ },
49
+ "history_db_path": os.path.join(config.MEM0_LOCAL_STORAGE_DIR, "mem0_history.db"),
50
+ }
51
+
52
+
53
+ class Mem0Client:
54
+ """
55
+ Thin wrapper around mem0.Memory scoped to a single responsibility:
56
+ user preferences and long-term conversational memory.
57
+ """
58
+
59
+ def __init__(self, infer: bool = False):
60
+ self.infer = infer
61
+ self._memory = None
62
+ self._enabled = config.USE_MEM0
63
+
64
+ if not self._enabled:
65
+ logger.info("USE_MEM0 is False — Mem0Client will be a no-op.")
66
+
67
+ def _get_memory(self):
68
+ if self._memory is None:
69
+ from mem0 import Memory
70
+
71
+ logger.info("Initializing mem0 (local chroma + huggingface embedder)...")
72
+ self._memory = Memory.from_config(_build_config_dict())
73
+ return self._memory
74
+
75
+ def add_interaction(self, user_id: str, user_message: str, assistant_message: str = None) -> None:
76
+ """
77
+ Store a turn of conversation for long-term memory purposes (NOT
78
+ transcript storage — that's history/service.py + SQLite).
79
+
80
+ Only call this for things worth remembering long-term (preferences,
81
+ recurring topics of interest, stated skill level) — not every raw
82
+ message. The router/chat layer decides when this is worth calling;
83
+ this wrapper doesn't filter content itself.
84
+ """
85
+ if not self._enabled:
86
+ return
87
+
88
+ messages = [{"role": "user", "content": user_message}]
89
+ if assistant_message:
90
+ messages.append({"role": "assistant", "content": assistant_message})
91
+
92
+ try:
93
+ self._get_memory().add(messages, user_id=user_id, infer=self.infer)
94
+ except Exception:
95
+ logger.exception("mem0 add_interaction failed for user_id=%s", user_id)
96
+
97
+ def get_relevant_context(self, user_id: str, query: str, limit: int = 5) -> list:
98
+ """
99
+ Returns a list of plain-text memory strings relevant to `query`,
100
+ ready to hand to llm/prompts.py's mem0_context parameter.
101
+
102
+ Returns [] if mem0 is disabled, the user has no memories yet, or a
103
+ lookup error occurs — callers should treat that as "no memory
104
+ context available", not as an error condition.
105
+ """
106
+ if not self._enabled:
107
+ return []
108
+
109
+ try:
110
+ results = self._get_memory().search(query, user_id=user_id, limit=limit)
111
+ except Exception:
112
+ logger.exception("mem0 get_relevant_context failed for user_id=%s", user_id)
113
+ return []
114
+
115
+ # mem0's search() returns {"results": [{"memory": "...", "score": ...}, ...]}
116
+ # in v2, or a plain list in some versions — handle both.
117
+ items = results.get("results", results) if isinstance(results, dict) else results
118
+ return [item.get("memory", "") for item in items if item.get("memory")]
119
+
120
+ def delete_all_for_user(self, user_id: str) -> None:
121
+ """Useful for account deletion / a 'forget me' feature."""
122
+ if not self._enabled:
123
+ return
124
+ try:
125
+ self._get_memory().delete_all(user_id=user_id)
126
+ except Exception:
127
+ logger.exception("mem0 delete_all_for_user failed for user_id=%s", user_id)
128
+
129
+
130
+ _CLIENT_SINGLETON = None
131
+
132
+
133
+ def get_mem0_client() -> Mem0Client:
134
+ global _CLIENT_SINGLETON
135
+ if _CLIENT_SINGLETON is None:
136
+ _CLIENT_SINGLETON = Mem0Client()
137
+ return _CLIENT_SINGLETON
rag/__init__.py ADDED
File without changes
rag/build_index.py ADDED
@@ -0,0 +1,211 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ RAG index builder.
3
+
4
+ Reads every knowledge-base .txt file, splits it into semantic chunks based
5
+ on the existing structural headings (DEFINITION, TIME_COMPLEXITY,
6
+ SPACE_COMPLEXITY, USE_WHEN, EXAMPLE) rather than arbitrary fixed-size
7
+ windows, attaches per-topic metadata from topic_metadata.json, embeds each
8
+ chunk with sentence-transformers, and stores everything in a persistent
9
+ ChromaDB collection.
10
+
11
+ This script NEVER modifies the source .txt files. They are treated as
12
+ immutable. Run it whenever the knowledge base or topic_metadata.json
13
+ changes:
14
+
15
+ python -m rag.build_index
16
+ """
17
+
18
+ import json
19
+ import os
20
+ import re
21
+ import sys
22
+
23
+ import chromadb
24
+ from sentence_transformers import SentenceTransformer
25
+
26
+ # Allow running as a standalone script (python rag/build_index.py) as well
27
+ # as a module (python -m rag.build_index).
28
+ sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
29
+
30
+ import config
31
+ from logs.logger import get_logger
32
+
33
+ logger = get_logger(__name__)
34
+
35
+ # Matches a section header line, e.g. "DEFINITION:" or "TIME_COMPLEXITY:"
36
+ _HEADER_PATTERN = re.compile(
37
+ r"^(" + "|".join(config.KB_SECTION_HEADERS) + r"):\s*$", re.MULTILINE
38
+ )
39
+
40
+ _TOPIC_LINE_PATTERN = re.compile(r"^TOPIC:\s*(.+)$", re.MULTILINE)
41
+
42
+
43
+ def _parse_sections(file_text: str) -> dict:
44
+ """
45
+ Split a KB file's text into {section_name: section_body} using the
46
+ structural headers defined in config.KB_SECTION_HEADERS.
47
+
48
+ Any text before the first recognized header (e.g. a "TOPIC:" line) is
49
+ ignored here — the topic name is expected to come from
50
+ topic_metadata.json, not parsed out of the file body.
51
+ """
52
+ matches = list(_HEADER_PATTERN.finditer(file_text))
53
+ sections = {}
54
+
55
+ for i, match in enumerate(matches):
56
+ header = match.group(1)
57
+ start = match.end()
58
+ end = matches[i + 1].start() if i + 1 < len(matches) else len(file_text)
59
+ body = file_text[start:end].strip()
60
+ if body:
61
+ sections[header] = body
62
+
63
+ return sections
64
+
65
+
66
+ def _load_topic_metadata() -> dict:
67
+ if not os.path.exists(config.TOPIC_METADATA_PATH):
68
+ raise FileNotFoundError(
69
+ f"topic_metadata.json not found at {config.TOPIC_METADATA_PATH}. "
70
+ "Every knowledge-base file must have a matching entry."
71
+ )
72
+ with open(config.TOPIC_METADATA_PATH, "r", encoding="utf-8") as f:
73
+ return json.load(f)
74
+
75
+
76
+ def _list_kb_files() -> list:
77
+ if not os.path.isdir(config.KNOWLEDGE_BASE_DIR):
78
+ raise FileNotFoundError(
79
+ f"Knowledge base directory not found: {config.KNOWLEDGE_BASE_DIR}"
80
+ )
81
+ return sorted(
82
+ f
83
+ for f in os.listdir(config.KNOWLEDGE_BASE_DIR)
84
+ if f.endswith(".txt")
85
+ )
86
+
87
+
88
+ def build_chunks() -> list:
89
+ """
90
+ Returns a list of dicts, each representing one chunk ready for embedding:
91
+ {
92
+ "id": "binary_search.txt::DEFINITION",
93
+ "text": "...",
94
+ "metadata": {
95
+ "topic": "...",
96
+ "category": "...",
97
+ "section": "...",
98
+ "difficulty": "...",
99
+ "source": "binary_search.txt",
100
+ "chunk_id": 0
101
+ }
102
+ }
103
+ """
104
+ topic_metadata = _load_topic_metadata()
105
+ kb_files = _list_kb_files()
106
+
107
+ missing_metadata = [f for f in kb_files if f not in topic_metadata]
108
+ if missing_metadata:
109
+ logger.warning(
110
+ "The following KB files have no topic_metadata.json entry and "
111
+ "will be skipped: %s",
112
+ missing_metadata,
113
+ )
114
+
115
+ chunks = []
116
+ chunk_counter = 0
117
+
118
+ for filename in kb_files:
119
+ if filename not in topic_metadata:
120
+ continue
121
+
122
+ file_path = os.path.join(config.KNOWLEDGE_BASE_DIR, filename)
123
+ with open(file_path, "r", encoding="utf-8") as f:
124
+ file_text = f.read()
125
+
126
+ sections = _parse_sections(file_text)
127
+ if not sections:
128
+ logger.warning(
129
+ "No recognized section headers (%s) found in %s. Skipping.",
130
+ config.KB_SECTION_HEADERS,
131
+ filename,
132
+ )
133
+ continue
134
+
135
+ meta = topic_metadata[filename]
136
+
137
+ for section_name, section_body in sections.items():
138
+ chunk = {
139
+ "id": f"{filename}::{section_name}",
140
+ "text": f"{meta.get('topic', filename)} - {section_name}: {section_body}",
141
+ "metadata": {
142
+ "topic": meta.get("topic", ""),
143
+ "category": meta.get("category", ""),
144
+ "section": section_name,
145
+ "difficulty": meta.get("difficulty", ""),
146
+ "source": filename,
147
+ "prerequisites": ", ".join(meta.get("prerequisites", [])),
148
+ "chunk_id": chunk_counter,
149
+ },
150
+ }
151
+ chunks.append(chunk)
152
+ chunk_counter += 1
153
+
154
+ logger.info("Built %d chunks from %d knowledge-base files.", len(chunks), len(kb_files))
155
+ return chunks
156
+
157
+
158
+ def build_index(reset: bool = True) -> None:
159
+ """
160
+ Embed all chunks and persist them into the ChromaDB collection.
161
+
162
+ Args:
163
+ reset: if True, drops and recreates the collection so the index is
164
+ rebuilt from scratch (safe default whenever the KB or
165
+ metadata changes). If False, upserts into the existing
166
+ collection.
167
+ """
168
+ os.makedirs(config.CHROMA_DB_DIR, exist_ok=True)
169
+
170
+ logger.info("Loading embedding model: %s", config.EMBEDDING_MODEL_NAME)
171
+ embedder = SentenceTransformer(config.EMBEDDING_MODEL_NAME)
172
+
173
+ client = chromadb.PersistentClient(path=config.CHROMA_DB_DIR)
174
+
175
+ if reset:
176
+ try:
177
+ client.delete_collection(config.CHROMA_COLLECTION_NAME)
178
+ logger.info("Existing collection '%s' deleted.", config.CHROMA_COLLECTION_NAME)
179
+ except Exception:
180
+ pass # collection didn't exist yet
181
+
182
+ collection = client.get_or_create_collection(name=config.CHROMA_COLLECTION_NAME)
183
+
184
+ chunks = build_chunks()
185
+ if not chunks:
186
+ logger.warning("No chunks produced — index will be empty.")
187
+ return
188
+
189
+ texts = [c["text"] for c in chunks]
190
+ ids = [c["id"] for c in chunks]
191
+ metadatas = [c["metadata"] for c in chunks]
192
+
193
+ logger.info("Embedding %d chunks...", len(texts))
194
+ embeddings = embedder.encode(texts, show_progress_bar=False).tolist()
195
+
196
+ collection.upsert(
197
+ ids=ids,
198
+ embeddings=embeddings,
199
+ documents=texts,
200
+ metadatas=metadatas,
201
+ )
202
+
203
+ logger.info(
204
+ "Index build complete. Collection '%s' now has %d chunks.",
205
+ config.CHROMA_COLLECTION_NAME,
206
+ collection.count(),
207
+ )
208
+
209
+
210
+ if __name__ == "__main__":
211
+ build_index(reset=True)
rag/generate.py ADDED
@@ -0,0 +1,147 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Batch-generates DSA knowledge base .txt files using the Groq API.
3
+
4
+ Setup:
5
+ pip install groq python-dotenv
6
+ Create a .env file with: GROQ_API_KEY=your_key_here
7
+
8
+ Run:
9
+ python generate_knowledge_base.py
10
+
11
+ Output:
12
+ Files are written to ./knowledge_base/<topic_snake_case>.txt
13
+ """
14
+
15
+ import os
16
+ import re
17
+ import time
18
+ from dotenv import load_dotenv
19
+ from groq import Groq
20
+
21
+ load_dotenv()
22
+
23
+ client = Groq(api_key=os.environ.get("GROQ_API_KEY"))
24
+ MODEL = "llama-3.3-70b-versatile" # swap for another Groq-hosted model if you prefer
25
+ OUTPUT_DIR = "knowledge_base"
26
+ DELAY_BETWEEN_CALLS = 1.5 # seconds, be nice to rate limits
27
+
28
+ os.makedirs(OUTPUT_DIR, exist_ok=True)
29
+
30
+ # ── PROMPT TEMPLATE ─────────────────────────────────────────────────────────
31
+ PROMPT_TEMPLATE = """You are writing a knowledge base entry for a DSA (Data Structures & Algorithms)
32
+ tutoring RAG chatbot. Write in your own original words — do NOT copy verbatim from
33
+ GeeksforGeeks, textbooks, or any other source. Paraphrase and synthesize instead.
34
+ Keep explanations beginner-friendly but technically accurate.
35
+
36
+ Generate the entry for this topic: {topic}
37
+
38
+ Output ONLY in the exact following plain text structure, no markdown formatting,
39
+ no extra commentary before or after:
40
+
41
+ TOPIC: {topic}
42
+
43
+ DEFINITION:
44
+ <2-3 sentence clear definition of what it is and what problem it solves>
45
+
46
+ TIME_COMPLEXITY:
47
+ <Best/Average/Worst case if they differ, otherwise a single Big-O with brief justification>
48
+
49
+ SPACE_COMPLEXITY:
50
+ <Big-O with brief note on what's using the space>
51
+
52
+ USE_WHEN:
53
+ <1-2 sentences: scenarios/conditions where this is the right tool>
54
+
55
+ AVOID_WHEN:
56
+ <1-2 sentences: scenarios where this is a poor choice, and what to use instead>
57
+
58
+ EXAMPLE:
59
+ <A small, concrete worked example (3-6 elements/nodes max), shown as a step-by-step
60
+ walkthrough with intermediate states, ending in a clear result marked with a checkmark.
61
+ Use simple ASCII/text formatting (arrows, brackets, indentation) — no images.>
62
+
63
+ REAL_WORLD_ANALOGY:
64
+ <1-2 sentence relatable comparison to something non-technical>
65
+
66
+ SOURCE_NOTE:
67
+ <Optional: e.g. "Concepts referenced from CLRS Introduction to Algorithms" or
68
+ "GeeksforGeeks - {topic} (paraphrased, no verbatim text used)". Leave blank if not applicable.>
69
+ """
70
+
71
+ # ── TOPIC LIST ───────────────────────────────────────────────────────────────
72
+ TOPICS = [
73
+ # Arrays & Searching
74
+ "Linear Search", "Binary Search", "Two Pointer Technique", "Sliding Window Technique",
75
+ # Sorting
76
+ "Bubble Sort", "Selection Sort", "Insertion Sort", "Merge Sort", "Quick Sort",
77
+ "Heap Sort", "Counting Sort","radix sort","bucket sort",
78
+ # Linked Lists
79
+ "Singly Linked List", "Doubly Linked List", "Circular Linked List",
80
+ # Stacks & Queues
81
+ "Stack", "Queue", "Circular Queue", "Deque", "Monotonic Stack",
82
+ # Trees
83
+ "Binary Tree", "Binary Search Tree", "AVL Tree", "Heap (Min/Max)", "Trie",
84
+ "Segment Tree", "Fenwick Tree (Binary Indexed Tree)",
85
+ # Graphs
86
+ "BFS", "DFS", "Dijkstra's Algorithm", "Bellman-Ford Algorithm",
87
+ "Floyd-Warshall Algorithm", "Kruskal's Algorithm (MST)", "Prim's Algorithm (MST)",
88
+ "Topological Sort", "Union-Find (Disjoint Set)",
89
+ # Dynamic Programming
90
+ "Dynamic Programming Overview", "0/1 Knapsack", "Longest Common Subsequence",
91
+ "Longest Increasing Subsequence", "Coin Change Problem", "Edit Distance",
92
+ # Greedy
93
+ "Greedy Algorithms Overview", "Activity Selection Problem", "Fractional Knapsack",
94
+ # Hashing
95
+ "Hash Table", "Hash Set vs Hash Map",
96
+ # Recursion & Backtracking
97
+ "Recursion Overview", "Backtracking Overview", "N-Queens Problem",
98
+ "Subset Sum / Power Set Generation",
99
+ # Bit Manipulation
100
+ "Bit Manipulation Basics", "XOR Tricks",
101
+ ]
102
+
103
+
104
+ def to_snake_case(topic: str) -> str:
105
+ """Converts a topic name into a safe snake_case filename."""
106
+ cleaned = re.sub(r"[^\w\s-]", "", topic) # strip punctuation like ()/,'
107
+ cleaned = re.sub(r"\s+", "_", cleaned.strip()) # spaces -> underscores
108
+ return cleaned.lower()
109
+
110
+
111
+ def generate_entry(topic: str) -> str:
112
+ prompt = PROMPT_TEMPLATE.format(topic=topic)
113
+ response = client.chat.completions.create(
114
+ model=MODEL,
115
+ messages=[{"role": "user", "content": prompt}],
116
+ temperature=0.4,
117
+ max_tokens=1024,
118
+ )
119
+ return response.choices[0].message.content.strip()
120
+
121
+
122
+ def main():
123
+ print(f"Generating {len(TOPICS)} knowledge base files into ./{OUTPUT_DIR}/\n")
124
+
125
+ for i, topic in enumerate(TOPICS, start=1):
126
+ filename = to_snake_case(topic) + ".txt"
127
+ filepath = os.path.join(OUTPUT_DIR, filename)
128
+
129
+ if os.path.exists(filepath):
130
+ print(f"[{i}/{len(TOPICS)}] SKIP (already exists): {filename}")
131
+ continue
132
+
133
+ print(f"[{i}/{len(TOPICS)}] Generating: {topic} -> {filename}")
134
+ try:
135
+ content = generate_entry(topic)
136
+ with open(filepath, "w", encoding="utf-8") as f:
137
+ f.write(content)
138
+ except Exception as e:
139
+ print(f" ERROR generating '{topic}': {e}")
140
+
141
+ time.sleep(DELAY_BETWEEN_CALLS)
142
+
143
+ print("\nDone. Review files in ./knowledge_base/ before indexing into ChromaDB.")
144
+
145
+
146
+ if __name__ == "__main__":
147
+ main()
rag/knowledge_base/01_knapsack.txt ADDED
@@ -0,0 +1,37 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ TOPIC: 0/1 Knapsack
2
+
3
+ DEFINITION:
4
+ The 0/1 Knapsack problem is a classic problem in computer science where you have a set of items, each with a weight and a value, and you need to determine the subset of items to include in a knapsack of limited capacity such that the total value is maximized. This problem solves the issue of optimizing the selection of items when resources are limited. The "0/1" refers to the fact that each item is either included or excluded from the knapsack, with no partial inclusions allowed.
5
+
6
+ TIME_COMPLEXITY:
7
+ The time complexity of the 0/1 Knapsack problem is O(nW), where n is the number of items and W is the capacity of the knapsack. This is because the problem is typically solved using dynamic programming, which involves filling up a 2D table of size n x W.
8
+
9
+ SPACE_COMPLEXITY:
10
+ The space complexity of the 0/1 Knapsack problem is O(nW), which is used to store the 2D table that keeps track of the maximum value that can be obtained with a given capacity and number of items.
11
+
12
+ USE_WHEN:
13
+ The 0/1 Knapsack problem is useful when you need to make a selection from a set of items, each with its own value and cost, and you have limited resources. This problem is particularly relevant in scenarios such as budgeting, resource allocation, and investment decisions.
14
+
15
+ AVOID_WHEN:
16
+ You should avoid using the 0/1 Knapsack problem when the items can be divided or when the cost of an item is not directly proportional to its value, in which case other problems like the Fractional Knapsack problem may be more suitable.
17
+
18
+ EXAMPLE:
19
+ Suppose we have a knapsack with a capacity of 5 units and the following items:
20
+ - Item A: weight 2, value 10
21
+ - Item B: weight 3, value 8
22
+ - Item C: weight 1, value 5
23
+ We can solve this problem using dynamic programming as follows:
24
+ - Initialize a table with zeros:
25
+ [0, 0, 0, 0, 0, 0]
26
+ - Fill up the table:
27
+ - For Item A: [0, 0, 10, 10, 10, 10]
28
+ - For Item B: [0, 0, 10, 10, 10, 18]
29
+ - For Item C: [0, 5, 10, 15, 15, 18]
30
+ The maximum value we can get is 18, which is obtained by including Item B and Item C:
31
+
32
+ CHECKMARK:
33
+
34
+ REAL_WORLD_ANALOGY:
35
+ The 0/1 Knapsack problem is similar to going on a trip and having to pack a suitcase with limited space, where you need to decide which items to bring and which to leave behind in order to maximize your overall satisfaction.
36
+
37
+ SOURCE_NOTE:
rag/knowledge_base/activity_selection_problem.txt ADDED
@@ -0,0 +1,41 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ TOPIC: Activity Selection Problem
2
+
3
+ DEFINITION: The Activity Selection Problem is a classic problem in computer science and operations research that involves selecting the maximum number of activities that can be performed by a single person or resource, given a set of activities with start and end times. The goal is to choose the activities that do not conflict with each other, meaning that the end time of one activity is less than or equal to the start time of the next activity. This problem is useful in a variety of real-world scenarios, such as scheduling meetings or tasks.
4
+
5
+ TIME_COMPLEXITY: O(n log n) due to the sorting of activities by their end times, which is necessary to apply the greedy algorithm that solves the problem.
6
+
7
+ SPACE_COMPLEXITY: O(n) for storing the list of activities, where n is the number of activities.
8
+
9
+ USE_WHEN: This problem is the right tool when you need to schedule a set of tasks or activities that have specific start and end times, and you want to maximize the number of activities that can be performed without any conflicts. It is particularly useful when the activities are mutually exclusive, meaning that only one activity can be performed at a time.
10
+
11
+ AVOID_WHEN: This problem is a poor choice when the activities are not mutually exclusive, or when there are additional constraints that need to be considered, such as resource availability or priority levels. In such cases, more advanced scheduling algorithms or techniques, such as dynamic programming or integer programming, may be more suitable.
12
+
13
+ EXAMPLE:
14
+ Consider the following set of activities with their start and end times:
15
+ [1] (1, 4)
16
+ [2] (3, 5)
17
+ [3] (0, 6)
18
+ [4] (5, 7)
19
+ [5] (3, 8)
20
+ [6] (5, 9)
21
+ [7] (6, 10)
22
+ First, sort the activities by their end times:
23
+ [1] (1, 4)
24
+ [2] (3, 5)
25
+ [4] (5, 7)
26
+ [7] (6, 10)
27
+ [5] (3, 8)
28
+ [6] (5, 9)
29
+ [3] (0, 6)
30
+ Then, apply the greedy algorithm:
31
+ Select [1] (1, 4)
32
+ Select [4] (5, 7)
33
+ Select [7] (6, 10) is not selected because it conflicts with [4]
34
+ The final selected activities are:
35
+ [1] (1, 4)
36
+ [4] (5, 7)
37
+ Checkmark: The maximum number of non-conflicting activities is 2.
38
+
39
+ REAL_WORLD_ANALOGY: The Activity Selection Problem is similar to scheduling meetings in a conference room, where you want to maximize the number of meetings that can be held without any conflicts or overlaps.
40
+
41
+ SOURCE_NOTE: Concepts referenced from general knowledge of greedy algorithms and scheduling problems.
rag/knowledge_base/avl_tree.txt ADDED
@@ -0,0 +1,63 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ TOPIC: AVL Tree
2
+
3
+ DEFINITION:
4
+ An AVL tree is a self-balancing binary search tree that ensures the height of the tree remains relatively small by rotating nodes when the balance factor becomes too large, thus solving the problem of maintaining efficient search, insertion, and deletion operations. This balance is crucial for preventing the tree from becoming skewed, which would lead to reduced performance.
5
+
6
+ TIME_COMPLEXITY:
7
+ The time complexity for search, insertion, and deletion in an AVL tree is O(log n) in all cases, as the tree's height is kept logarithmic by the self-balancing mechanism.
8
+
9
+ SPACE_COMPLEXITY:
10
+ The space complexity of an AVL tree is O(n), where n is the number of nodes in the tree, as each node requires a constant amount of space to store its value, child pointers, and balance factor.
11
+
12
+ USE_WHEN:
13
+ Use an AVL tree when you need to frequently insert, delete, and search for elements in a dataset while maintaining an efficient order, such as in database indexing or file systems. This data structure is particularly useful when the dataset is constantly changing.
14
+
15
+ AVOID_WHEN:
16
+ Avoid using an AVL tree when the dataset is mostly static or when memory is extremely limited, as the overhead of maintaining the balance factor and performing rotations can be unnecessary or too costly; in such cases, a simpler binary search tree or even an array might be more suitable.
17
+
18
+ EXAMPLE:
19
+ Let's insert elements 5, 2, 8, 3, 9 into an AVL tree step by step:
20
+ - Start with an empty tree.
21
+ - Insert 5: [5]
22
+ - Insert 2: [5]
23
+ /
24
+ [2]
25
+ - Insert 8: [5]
26
+ / \
27
+ [2] [8]
28
+ - Insert 3: Since 3 is between 2 and 5, we insert it as the right child of 2.
29
+ [5]
30
+ / \
31
+ [2] [8]
32
+ \
33
+ [3]
34
+ - Insert 9: The tree becomes unbalanced, so we rotate:
35
+ [5]
36
+ / \
37
+ [2] [8]
38
+ \ \
39
+ [3] [9]
40
+ After rotation:
41
+ [5]
42
+ / \
43
+ [2] [8]
44
+ \ / \
45
+ [3] [9]
46
+ Final balanced tree:
47
+ [5]
48
+ / \
49
+ [2] [8]
50
+ \ \
51
+ [3] [9]
52
+ Result:
53
+ The final balanced AVL tree is:
54
+ [5]
55
+ / \
56
+ [2] [8]
57
+ \ \
58
+ [3] [9]
59
+
60
+ REAL_WORLD_ANALOGY:
61
+ An AVL tree can be thought of like a librarian who constantly rearranges books on shelves to ensure that the shelves remain balanced and it's easy to find any book, similar to how the tree balances itself to maintain efficient data retrieval.
62
+
63
+ SOURCE_NOTE:
rag/knowledge_base/backtracking_overview.txt ADDED
@@ -0,0 +1,42 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ TOPIC: Backtracking Overview
2
+
3
+ DEFINITION:
4
+ Backtracking is a problem-solving strategy used in algorithms to find a solution by exploring all possible options in a systematic way. It involves recursively trying different paths until a valid solution is found, and if a path does not lead to a solution, it backtracks to the previous step and tries another path. This approach is particularly useful for solving constraint satisfaction problems.
5
+
6
+ TIME_COMPLEXITY:
7
+ The time complexity of backtracking algorithms can vary, but in the worst case, it is typically O(n!), where n is the number of options to choose from, because it may need to explore all possible combinations. The average and best cases depend on the specific problem and the order in which options are explored.
8
+
9
+ SPACE_COMPLEXITY:
10
+ The space complexity of backtracking algorithms is usually O(n), where n is the maximum depth of the recursion tree, because that's how much space is needed to store the current path being explored.
11
+
12
+ USE_WHEN:
13
+ Backtracking is the right tool to use when dealing with problems that have multiple possible solutions and require exploring all options in a systematic way, such as puzzles, scheduling, or resource allocation problems. It's particularly useful when the problem can be broken down into smaller sub-problems and solved recursively.
14
+
15
+ AVOID_WHEN:
16
+ Backtracking is a poor choice for problems that have a very large solution space or require finding the optimal solution quickly, because it can be slow and inefficient. In such cases, other algorithms like dynamic programming or greedy algorithms may be more suitable.
17
+
18
+ EXAMPLE:
19
+ Suppose we want to find all possible ways to arrange the letters A, B, and C. We can use backtracking to solve this problem:
20
+ Start with an empty string: []
21
+ Try adding A: [A]
22
+ Try adding B: [A, B]
23
+ Try adding C: [A, B, C]
24
+ Try adding C: [A, C]
25
+ Try adding B: [A, C, B]
26
+ Try adding B: [B]
27
+ Try adding A: [B, A]
28
+ Try adding C: [B, A, C]
29
+ Try adding C: [B, C]
30
+ Try adding A: [B, C, A]
31
+ Try adding C: [C]
32
+ Try adding A: [C, A]
33
+ Try adding B: [C, A, B]
34
+ Try adding B: [C, B]
35
+ Try adding A: [C, B, A]
36
+ The final solutions are: [A, B, C] , [A, C, B] , [B, A, C] , [B, C, A] , [C, A, B] , [C, B, A]
37
+
38
+ REAL_WORLD_ANALOGY:
39
+ Backtracking is similar to trying different routes to reach a destination, where you explore each path until you find the one that leads you to your goal, and if a path doesn't work out, you go back to the previous intersection and try another route.
40
+
41
+ SOURCE_NOTE:
42
+ Concepts referenced from general knowledge of algorithms and problem-solving strategies.
rag/knowledge_base/bellman-ford_algorithm.txt ADDED
@@ -0,0 +1,49 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ TOPIC: Bellman-Ford Algorithm
2
+
3
+ DEFINITION: The Bellman-Ford algorithm is a graph search algorithm that finds the shortest path from a source vertex to all other vertices in a weighted graph. It can handle negative weight edges and can detect negative weight cycles, making it a versatile tool for solving various graph-related problems. This algorithm is particularly useful for finding the shortest path in graphs where the edge weights can be negative.
4
+
5
+ TIME_COMPLEXITY: The time complexity of the Bellman-Ford algorithm is O(VE), where V is the number of vertices and E is the number of edges, because it relaxes all edges V-1 times.
6
+
7
+ SPACE_COMPLEXITY: The space complexity is O(V), as the algorithm needs to store the distance to each vertex from the source vertex.
8
+
9
+ USE_WHEN: The Bellman-Ford algorithm is the right tool when you need to find the shortest path in a graph with negative weight edges, or when you need to detect negative weight cycles. It's particularly useful in scenarios where other algorithms like Dijkstra's may fail due to negative weights.
10
+
11
+ AVOID_WHEN: You should avoid using the Bellman-Ford algorithm when the graph is very large and dense, as its O(VE) time complexity can be slow, and in such cases, more efficient algorithms like Dijkstra's or A* may be more suitable for graphs with non-negative weights.
12
+
13
+ EXAMPLE:
14
+ Let's consider a graph with 4 vertices (A, B, C, D) and 5 edges:
15
+ A -> B (weight: -1)
16
+ A -> C (weight: 4)
17
+ B -> C (weight: 3)
18
+ B -> D (weight: 2)
19
+ C -> D (weight: 2)
20
+ Initially, the distance to all vertices is infinity, except for A which is 0.
21
+ A: 0
22
+ B: inf
23
+ C: inf
24
+ D: inf
25
+ After the first iteration:
26
+ A: 0
27
+ B: -1
28
+ C: inf
29
+ D: inf
30
+ After the second iteration:
31
+ A: 0
32
+ B: -1
33
+ C: 2
34
+ D: inf
35
+ After the third iteration:
36
+ A: 0
37
+ B: -1
38
+ C: 2
39
+ D: 1
40
+ After the fourth iteration, no changes occur, so we stop.
41
+ The shortest distances are:
42
+ A to B: -1
43
+ A to C: 2
44
+ A to D: 1
45
+ Checkmark: The shortest path from A to D is A -> B -> D with a total weight of 1.
46
+
47
+ REAL_WORLD_ANALOGY: The Bellman-Ford algorithm can be thought of as finding the cheapest route to travel between cities, where the cost of traveling between cities can sometimes be negative, such as getting a refund for a part of the journey.
48
+
49
+ SOURCE_NOTE: Concepts referenced from general knowledge of graph algorithms and data structures.
rag/knowledge_base/bfs.txt ADDED
@@ -0,0 +1,29 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ TOPIC: BFS
2
+
3
+ DEFINITION:
4
+ Breadth-First Search (BFS) is a graph traversal algorithm that explores all the nodes at a given depth level before moving on to the next level, solving the problem of finding the shortest path between two nodes in an unweighted graph. It's particularly useful for searching graphs or trees level by level, starting from a given source node. This approach ensures that the algorithm visits all the nodes closest to the source before moving further away.
5
+
6
+ TIME_COMPLEXITY:
7
+ The time complexity of BFS is O(V + E), where V represents the number of vertices (nodes) and E represents the number of edges, because in the worst case, the algorithm visits every node and edge once.
8
+
9
+ SPACE_COMPLEXITY:
10
+ The space complexity of BFS is O(V), as the algorithm uses a queue to store nodes to be visited, and in the worst case, the queue can hold all the nodes at the deepest level.
11
+
12
+ USE_WHEN:
13
+ BFS is the right tool when you need to find the shortest path in an unweighted graph or when you want to traverse a graph level by level, such as in web crawlers or social network friend suggestions. It's particularly useful when the graph is very large and you want to avoid the overhead of more complex algorithms.
14
+
15
+ AVOID_WHEN:
16
+ BFS is a poor choice when dealing with very deep graphs or trees, as it can be slow and memory-intensive, and in such cases, a depth-first search (DFS) or more specialized algorithms like Dijkstra's or A* might be more suitable.
17
+
18
+ EXAMPLE:
19
+ Consider a graph with nodes A, B, C, D, and E, where A is connected to B and C, B is connected to D, and C is connected to E. A BFS traversal starting from A would visit the nodes in the following order:
20
+ A
21
+ -> B, C
22
+ -> D, E
23
+ The final result is a visited set {A, B, C, D, E}
24
+
25
+ REAL_WORLD_ANALOGY:
26
+ BFS can be thought of as exploring a city by first visiting all the neighboring houses, then moving on to the next block, and so on, similar to how a fire department might search a neighborhood.
27
+
28
+ SOURCE_NOTE:
29
+ Concepts referenced from general knowledge of graph traversal algorithms.
rag/knowledge_base/binary_search.txt ADDED
@@ -0,0 +1,25 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ TOPIC: Binary Search
2
+
3
+ DEFINITION: Binary search is an efficient algorithm for finding an item from a sorted list of items. It works by repeatedly dividing in half the portion of the list that could contain the item, until you've narrowed the possible locations to just one. This approach solves the problem of quickly locating a specific element within a large sorted dataset.
4
+
5
+ TIME_COMPLEXITY: The time complexity of binary search is O(log n), where n is the number of items in the list. This is because with each comparison, the algorithm effectively halves the search space, leading to a logarithmic number of steps.
6
+
7
+ SPACE_COMPLEXITY: The space complexity is O(1), as binary search only requires a constant amount of additional space to store the indices and the target value, regardless of the size of the input list.
8
+
9
+ USE_WHEN: Use binary search when you need to find an item in a large sorted list, and speed is crucial. This is particularly useful in scenarios where the list is too large to search linearly, and the data is already sorted or can be sorted efficiently.
10
+
11
+ AVOID_WHEN: Avoid using binary search when the list is unsorted, as the algorithm relies on the data being sorted to work correctly. In such cases, a linear search or a different sorting algorithm followed by binary search might be more appropriate.
12
+
13
+ EXAMPLE:
14
+ Suppose we have a sorted list [2, 5, 8, 12, 16, 23] and we're looking for the number 12.
15
+ Start with the entire list: [2, 5, 8, 12, 16, 23]
16
+ -> Compare the middle element (8) with the target (12). Since 12 > 8, we know 12 must be in the right half.
17
+ Narrow down to the right half: [12, 16, 23]
18
+ -> Compare the middle element (16) with the target (12). Since 12 < 16, we know 12 must be in the left half of this subset.
19
+ Narrow down further: [12]
20
+ -> The only element left is 12, which matches our target:
21
+
22
+
23
+ REAL_WORLD_ANALOGY: Binary search is similar to finding a specific book in a library where the books are arranged alphabetically by title. You start by looking at the middle shelf, then move to the left or right half depending on whether the book you're looking for comes before or after the middle book alphabetically, repeating this process until you find the book.
24
+
25
+ SOURCE_NOTE:
rag/knowledge_base/binary_search_tree.txt ADDED
@@ -0,0 +1,35 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ TOPIC: Binary Search Tree
2
+
3
+ DEFINITION: A Binary Search Tree (BST) is a data structure in which each node has at most two children (i.e., left child and right child) and each node represents a value. This structure allows for efficient storage and retrieval of data by ensuring that all values to the left of a node are less than the node's value, and all values to the right are greater. This property enables fast searching, inserting, and deleting of nodes.
4
+
5
+ TIME_COMPLEXITY: The time complexity of a Binary Search Tree is O(log n) for best and average cases, and O(n) for the worst case, which occurs when the tree becomes a skewed tree (essentially a linked list).
6
+
7
+ SPACE_COMPLEXITY: The space complexity of a Binary Search Tree is O(n), where n is the number of nodes in the tree, as each node requires a constant amount of space to store its value and references to its children.
8
+
9
+ USE_WHEN: Use a Binary Search Tree when you need to store and retrieve large amounts of data efficiently, and the data needs to be ordered or searchable. This is particularly useful in scenarios where data is constantly being inserted or deleted.
10
+
11
+ AVOID_WHEN: Avoid using a Binary Search Tree when the data is mostly static or when the tree may become heavily unbalanced, as this can lead to poor performance; in such cases, a self-balancing binary search tree like an AVL tree or a Red-Black tree may be more suitable.
12
+
13
+ EXAMPLE:
14
+ Suppose we have a BST with the following nodes:
15
+ 5
16
+ / \
17
+ 3 7
18
+ / \ \
19
+ 2 4 8
20
+ We want to insert the value 6.
21
+ First, we start at the root node (5) and compare it to the value we want to insert (6).
22
+ Since 6 is greater than 5, we move to the right child (7).
23
+ Then, we compare 6 to 7; since 6 is less than 7, we move to the left child (which doesn't exist),
24
+ so we create a new node with the value 6 as the left child of 7.
25
+ The resulting tree is:
26
+ 5
27
+ / \
28
+ 3 7
29
+ / \ / \
30
+ 2 4 6 8
31
+ The insertion is successful
32
+
33
+ REAL_WORLD_ANALOGY: A Binary Search Tree can be thought of as a phonebook where each entry is a person's name, and the names are arranged alphabetically; when you look up a name, you can quickly find it by comparing it to the names on either side of the current page.
34
+
35
+ SOURCE_NOTE: Concepts referenced from general knowledge of data structures and algorithms.
rag/knowledge_base/binary_tree.txt ADDED
@@ -0,0 +1,43 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ TOPIC: Binary Tree
2
+
3
+ DEFINITION:
4
+ A binary tree is a data structure in which each node has at most two children, referred to as the left child and the right child. This structure allows for efficient storage and retrieval of data, particularly when the data has a hierarchical relationship. It solves the problem of organizing and searching large amounts of data by providing a systematic way to traverse and access the data.
5
+
6
+ TIME_COMPLEXITY:
7
+ The time complexity of binary tree operations such as search, insert, and delete can vary: O(1) for the best case when the tree is balanced and the operation is performed at the root, O(log n) for the average case when the tree is balanced, and O(n) for the worst case when the tree is skewed.
8
+
9
+ SPACE_COMPLEXITY:
10
+ The space complexity of a binary tree is O(n), where n is the number of nodes in the tree, since each node requires a constant amount of space to store its value and references to its children.
11
+
12
+ USE_WHEN:
13
+ Use a binary tree when you need to store and retrieve data efficiently, especially when the data has a natural hierarchical structure, such as a file system or a database index. It's also useful when you need to perform operations like insertion, deletion, and search frequently.
14
+
15
+ AVOID_WHEN:
16
+ Avoid using a binary tree when the data is too large to fit into memory or when the tree becomes severely unbalanced, leading to poor performance. In such cases, consider using other data structures like hash tables or self-balancing trees like AVL or Red-Black trees.
17
+
18
+ EXAMPLE:
19
+ Suppose we have a binary tree with the following nodes:
20
+ 4
21
+ / \
22
+ 2 6
23
+ / \ \
24
+ 1 3 5
25
+ To insert a new node with value 7, we start at the root (4) and move to the right child (6), then to the right child again (which doesn't exist), so we create a new node (7) as the right child of 6:
26
+ 4
27
+ / \
28
+ 2 6
29
+ / \ / \
30
+ 1 3 5 7
31
+ The resulting tree is now:
32
+ 4
33
+ / \
34
+ 2 6
35
+ / \ / \
36
+ 1 3 5 7
37
+ Result:
38
+
39
+ REAL_WORLD_ANALOGY:
40
+ A binary tree can be thought of as a family tree, where each person (node) has at most two children, and the relationships between family members are organized in a hierarchical structure.
41
+
42
+ SOURCE_NOTE:
43
+ Concepts referenced from general knowledge of data structures and algorithms.
rag/knowledge_base/bit_manipulation_basics.txt ADDED
@@ -0,0 +1,24 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ TOPIC: Bit Manipulation Basics
2
+
3
+ DEFINITION: Bit manipulation is a technique used to perform operations on the binary representation of numbers, allowing for efficient and low-level control over data. It solves problems that require direct access and modification of individual bits or groups of bits within a binary number. This is particularly useful in situations where memory or computational resources are limited.
4
+
5
+ TIME_COMPLEXITY: O(1) - Bit manipulation operations are typically constant time because they involve simple bitwise operations that do not depend on the size of the input.
6
+
7
+ SPACE_COMPLEXITY: O(1) - The space complexity is constant because bit manipulation operations usually only require a small, fixed amount of space to store the input and output values.
8
+
9
+ USE_WHEN: Bit manipulation is the right tool when working with low-level data representations, such as flags, masks, or bit fields, and when performing operations like setting, clearing, or toggling individual bits. It is also useful when optimizing code for performance or memory usage.
10
+
11
+ AVOID_WHEN: Bit manipulation is a poor choice when working with high-level abstractions or complex data structures, and when readability and maintainability are more important than performance. In such cases, using higher-level constructs and libraries may be more appropriate.
12
+
13
+ EXAMPLE:
14
+ Start with the binary number: 1010
15
+ Apply a bitwise AND operation with the mask: 1100
16
+ 1010
17
+ & 1100
18
+ ----
19
+ 1000
20
+ The result is: 1000
21
+
22
+ REAL_WORLD_ANALOGY: Bit manipulation is similar to working with a combination lock, where each bit represents a specific switch that can be turned on or off to achieve a desired outcome.
23
+
24
+ SOURCE_NOTE: Concepts referenced from general knowledge of computer science and programming principles.
rag/knowledge_base/bubble_sort.txt ADDED
@@ -0,0 +1,33 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ TOPIC: Bubble Sort
2
+
3
+ DEFINITION:
4
+ Bubble sort is a simple sorting algorithm that repeatedly steps through the list, compares adjacent elements, and swaps them if they are in the wrong order. This process is repeated until the list is sorted, solving the problem of arranging elements in ascending or descending order. It works by iteratively "bubbling" the largest or smallest element to the end or beginning of the list.
5
+
6
+ TIME_COMPLEXITY:
7
+ The time complexity of bubble sort is O(n) in the best case, O(n^2) on average, and O(n^2) in the worst case, because in the best case the list is already sorted and only one pass is needed, while in the average and worst cases, the algorithm needs to make multiple passes through the list to sort it.
8
+
9
+ SPACE_COMPLEXITY:
10
+ The space complexity of bubble sort is O(1), because it only uses a constant amount of additional space to store temporary swaps, and does not require any extra space that scales with the size of the input list.
11
+
12
+ USE_WHEN:
13
+ Bubble sort can be useful when the list is small or nearly sorted, as it has a simple implementation and can be efficient in these cases. It can also be a good teaching tool for introducing the concept of sorting algorithms.
14
+
15
+ AVOID_WHEN:
16
+ Bubble sort is a poor choice for large lists or lists that are in reverse order, as its average and worst-case time complexities are high, and other algorithms like quicksort or mergesort are generally more efficient and should be used instead.
17
+
18
+ EXAMPLE:
19
+ Suppose we have the list [5, 2, 8, 3, 1] and we want to sort it in ascending order using bubble sort.
20
+ Initial list: [5, 2, 8, 3, 1]
21
+ Compare 5 and 2, swap: [2, 5, 8, 3, 1]
22
+ Compare 5 and 8, no swap: [2, 5, 8, 3, 1]
23
+ Compare 8 and 3, swap: [2, 5, 3, 8, 1]
24
+ Compare 8 and 1, swap: [2, 5, 3, 1, 8]
25
+ Repeat the process until the list is sorted:
26
+ [2, 5, 3, 1, 8] -> [2, 3, 5, 1, 8] -> [2, 3, 1, 5, 8] -> [2, 1, 3, 5, 8] -> [1, 2, 3, 5, 8]
27
+ Final sorted list: [1, 2, 3, 5, 8]
28
+
29
+ REAL_WORLD_ANALOGY:
30
+ Bubble sort is similar to rearranging a set of books on a shelf, where you compare each book to the one next to it and swap them if they are in the wrong order, repeating the process until the books are in the correct order.
31
+
32
+ SOURCE_NOTE:
33
+ Concepts referenced from general knowledge of sorting algorithms and data structures.