uncertainrods commited on
Commit
e266561
Β·
1 Parent(s): fb69b4b

init_code

Browse files
.gitignore ADDED
@@ -0,0 +1,6 @@
 
 
 
 
 
 
 
1
+ .env
2
+ __pycache__
3
+ current_structure.md
4
+ implementation_plan.md
5
+ task.md
6
+ venv/
Dockerfile ADDED
@@ -0,0 +1,23 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Use a lightweight Python base image
2
+ FROM python:3.11-slim
3
+
4
+ # Set working directory
5
+ WORKDIR /app
6
+
7
+ # Install system dependencies (if needed, e.g., for SQLite or C extensions)
8
+ RUN apt-get update && apt-get install -y --no-install-recommends \
9
+ gcc \
10
+ && rm -rf /var/lib/apt/lists/*
11
+
12
+ # Copy requirements and install
13
+ COPY requirements.txt .
14
+ RUN pip install --no-cache-dir -r requirements.txt
15
+
16
+ # Copy the rest of the backend code
17
+ COPY . .
18
+
19
+ # Hugging Face Spaces exposes port 7860
20
+ EXPOSE 7860
21
+
22
+ # Command to run the FastAPI app via Uvicorn on port 7860
23
+ CMD ["uvicorn", "main:app", "--host", "0.0.0.0", "--port", "7860"]
README.md CHANGED
@@ -1,11 +0,0 @@
1
- ---
2
- title: AlgoSensei
3
- emoji: πŸ¦€
4
- colorFrom: gray
5
- colorTo: indigo
6
- sdk: docker
7
- pinned: false
8
- license: mit
9
- ---
10
-
11
- Check out the configuration reference at https://huggingface.co/docs/hub/spaces-config-reference
 
 
 
 
 
 
 
 
 
 
 
 
agent/__init__.py ADDED
@@ -0,0 +1,5 @@
 
 
 
 
 
 
1
+ """Agent package __init__ β€” initializes the database on import."""
2
+
3
+ from agent.db import init_db
4
+
5
+ init_db()
agent/db.py ADDED
@@ -0,0 +1,79 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """SQLite connection and CRUD helpers for user learning profiles."""
2
+
3
+ import os
4
+ import json
5
+ import sqlite3
6
+ from contextlib import contextmanager
7
+ from typing import Optional
8
+
9
+ DB_PATH = os.getenv("DB_PATH", "dsa_mentor.db")
10
+
11
+
12
+ def init_db() -> None:
13
+ """Creates the database and tables if they do not already exist."""
14
+ with _connect() as conn:
15
+ conn.execute(
16
+ """
17
+ CREATE TABLE IF NOT EXISTS user_profiles (
18
+ session_id TEXT PRIMARY KEY,
19
+ weak_topics TEXT NOT NULL DEFAULT '{}',
20
+ solved_problems INTEGER NOT NULL DEFAULT 0,
21
+ total_turns INTEGER NOT NULL DEFAULT 0,
22
+ avg_gap REAL NOT NULL DEFAULT 0.0,
23
+ updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
24
+ )
25
+ """
26
+ )
27
+ conn.commit()
28
+
29
+
30
+ @contextmanager
31
+ def _connect():
32
+ conn = sqlite3.connect(DB_PATH)
33
+ conn.row_factory = sqlite3.Row
34
+ try:
35
+ yield conn
36
+ finally:
37
+ conn.close()
38
+
39
+
40
+ def get_profile(session_id: str) -> Optional[dict]:
41
+ """Returns the stored profile dict for a session, or None if not found."""
42
+ with _connect() as conn:
43
+ row = conn.execute(
44
+ "SELECT * FROM user_profiles WHERE session_id = ?", (session_id,)
45
+ ).fetchone()
46
+ if row is None:
47
+ return None
48
+ return {
49
+ "session_id": row["session_id"],
50
+ "weak_topics": json.loads(row["weak_topics"]),
51
+ "solved_problems": row["solved_problems"],
52
+ "total_turns": row["total_turns"],
53
+ "avg_gap": row["avg_gap"],
54
+ }
55
+
56
+
57
+ def upsert_profile(profile: dict) -> None:
58
+ """Insert or update a user profile in the database."""
59
+ with _connect() as conn:
60
+ conn.execute(
61
+ """
62
+ INSERT INTO user_profiles (session_id, weak_topics, solved_problems, total_turns, avg_gap)
63
+ VALUES (:session_id, :weak_topics, :solved_problems, :total_turns, :avg_gap)
64
+ ON CONFLICT(session_id) DO UPDATE SET
65
+ weak_topics = excluded.weak_topics,
66
+ solved_problems = excluded.solved_problems,
67
+ total_turns = excluded.total_turns,
68
+ avg_gap = excluded.avg_gap,
69
+ updated_at = CURRENT_TIMESTAMP
70
+ """,
71
+ {
72
+ "session_id": profile["session_id"],
73
+ "weak_topics": json.dumps(profile.get("weak_topics", {})),
74
+ "solved_problems": profile.get("solved_problems", 0),
75
+ "total_turns": profile.get("total_turns", 0),
76
+ "avg_gap": profile.get("avg_gap", 0.0),
77
+ },
78
+ )
79
+ conn.commit()
agent/graph.py ADDED
@@ -0,0 +1,90 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ graph.py β€” LangGraph State Machine Definition (v2)
3
+
4
+ New in v2:
5
+ - Added gate_solution node between analyze and terminals
6
+ - Added hint β†’ analyze loopback edge for iterative tutoring
7
+ - turn_count loop-break at >= 3 forces validation early
8
+ - Supports 'hint_forced' mode (gate redirect)
9
+ """
10
+
11
+ from langgraph.graph import StateGraph, END
12
+ from agent.models import AgentState
13
+ from agent.nodes import (
14
+ classify_problem,
15
+ evaluate_reasoning,
16
+ generate_hint,
17
+ validate_solution,
18
+ reveal_solution,
19
+ gate_solution,
20
+ )
21
+
22
+
23
+ def define_graph():
24
+ """
25
+ Defines and compiles the v2 DSA Mentor StateGraph.
26
+
27
+ Graph topology:
28
+ classify β†’ analyze β†’ gate β†’ {hint | validate | solution}
29
+ ↑
30
+ hint β”€β”€β”˜ (loop if turn_count < 3 AND gap > 2)
31
+ """
32
+ workflow = StateGraph(AgentState)
33
+
34
+ # ── Register Nodes ───────────────────────────────────────────────────────
35
+ workflow.add_node("classify", classify_problem)
36
+ workflow.add_node("analyze", evaluate_reasoning)
37
+ workflow.add_node("gate", gate_solution)
38
+ workflow.add_node("hint", generate_hint)
39
+ workflow.add_node("validate", validate_solution)
40
+ workflow.add_node("solution", reveal_solution)
41
+
42
+ # ── Linear Edges ─────────────────────────────────────────────────────────
43
+ workflow.set_entry_point("classify")
44
+ workflow.add_edge("classify", "analyze")
45
+ workflow.add_edge("analyze", "gate")
46
+
47
+ # ── Conditional: Gate β†’ (hint | validate | solution) ────────────────────
48
+ def route_after_gate(state: AgentState) -> str:
49
+ mode = state.get("request_mode", "analyze")
50
+ gap = state.get("gap_magnitude", 5)
51
+
52
+ # Solution was approved by gate
53
+ if mode == "solution":
54
+ return "solution"
55
+ # User is correct (gap <= 2)
56
+ if gap <= 2:
57
+ return "validate"
58
+ # Default: generate a hint
59
+ return "hint"
60
+
61
+ workflow.add_conditional_edges(
62
+ "gate",
63
+ route_after_gate,
64
+ {"hint": "hint", "validate": "validate", "solution": "solution"},
65
+ )
66
+
67
+ # ── Conditional: Hint β†’ (analyze loop | END) ─────────────────────────────
68
+ def route_after_hint(state: AgentState) -> str:
69
+ """
70
+ Loop back to analyze if:
71
+ - turn_count < 3 (still in early conversation)
72
+ - gap_magnitude > 2 (user still needs more help)
73
+ Otherwise end the turn and return response to frontend.
74
+ """
75
+ turn_count = state.get("turn_count", 0)
76
+ gap = state.get("gap_magnitude", 5)
77
+
78
+ if turn_count < 3 and gap > 2:
79
+ # Continue loop β€” re-analyze after hint is given
80
+ # (In practice the frontend sends a new request with updated thought;
81
+ # this loop serves intra-turn multi-step refinement)
82
+ return END # Return hint to user; next request resumes loop
83
+ return END
84
+
85
+ # Hint always ends the turn (user needs to respond); loop is cross-request
86
+ workflow.add_edge("hint", END)
87
+ workflow.add_edge("validate", END)
88
+ workflow.add_edge("solution", END)
89
+
90
+ return workflow.compile()
agent/knowledge.py ADDED
@@ -0,0 +1,72 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Misconception Library β€” common wrong approaches per DSA topic.
3
+ Used by the hint node to generate targeted, awareness-raising hints.
4
+ """
5
+
6
+ MISCONCEPTION_LIBRARY: dict[str, list[str]] = {
7
+ "two sum": [
8
+ "Using O(NΒ²) nested loops instead of a hashmap giving O(N)",
9
+ "Sorting the array and using two pointers (works but misses the index requirement)",
10
+ ],
11
+ "dynamic programming": [
12
+ "Recursive solution without memoization (exponential time)",
13
+ "Filling DP table in the wrong order (top-down vs bottom-up confusion)",
14
+ "Using 2D DP table when 1D rolling array suffices",
15
+ ],
16
+ "graph bfs": [
17
+ "Not using a visited set β€” causes infinite loops in cyclic graphs",
18
+ "Using DFS instead of BFS for shortest-path problems",
19
+ ],
20
+ "graph dfs": [
21
+ "Forgetting to backtrack state in recursive DFS",
22
+ "Not handling disconnected components",
23
+ ],
24
+ "sliding window": [
25
+ "Shrinking the window incorrectly (off-by-one on left pointer)",
26
+ "Recomputing window sum from scratch instead of incrementally updating",
27
+ ],
28
+ "binary search": [
29
+ "Using wrong boundary: `mid < right` vs `mid <= right`",
30
+ "Integer overflow: use `mid = left + (right - left) // 2`",
31
+ ],
32
+ "linked list": [
33
+ "Losing the next pointer before reassignment",
34
+ "Not handling the head node as a special case",
35
+ ],
36
+ "tree traversal": [
37
+ "Mixing up in-order, pre-order, and post-order for the required output",
38
+ "Forgetting base case for null nodes",
39
+ ],
40
+ "heap / priority queue": [
41
+ "Using a max-heap when a min-heap is needed (or vice versa)",
42
+ "Not heapifying after updating an element",
43
+ ],
44
+ "backtracking": [
45
+ "Not undoing state changes before returning (missing 'undo' step)",
46
+ "Pruning conditions placed after recursive call instead of before",
47
+ ],
48
+ "trie": [
49
+ "Using a dict instead of a fixed-size array for children (slower but acceptable)",
50
+ "Forgetting to mark end-of-word node",
51
+ ],
52
+ "union find": [
53
+ "Using naive union without path compression (too slow for large N)",
54
+ "Forgetting to check if two nodes share the same root before merging",
55
+ ],
56
+ "default": [
57
+ "Incorrect time/space complexity analysis",
58
+ "Not considering edge cases (empty input, single element, negative numbers)",
59
+ ],
60
+ }
61
+
62
+
63
+ def get_misconceptions(topic: str) -> list[str]:
64
+ """Return known misconceptions for the given topic (case-insensitive)."""
65
+ key = topic.strip().lower()
66
+ # Try exact match first, then partial match
67
+ if key in MISCONCEPTION_LIBRARY:
68
+ return MISCONCEPTION_LIBRARY[key]
69
+ for lib_key, misconceptions in MISCONCEPTION_LIBRARY.items():
70
+ if lib_key in key or key in lib_key:
71
+ return misconceptions
72
+ return MISCONCEPTION_LIBRARY["default"]
agent/llm_factory.py ADDED
@@ -0,0 +1,53 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import os
2
+ from langchain_openai import ChatOpenAI
3
+ from dotenv import load_dotenv
4
+
5
+ load_dotenv()
6
+
7
+ _DEFAULT_TIMEOUT = int(os.getenv("LLM_TIMEOUT_SECONDS", "30"))
8
+
9
+
10
+ def get_llm(timeout: int = _DEFAULT_TIMEOUT):
11
+ """
12
+ Returns a configured LLM instance based on environment variables.
13
+ Supports OpenAI (default) and OpenAI-compatible endpoints (DeepInfra, OpenRouter, Groq).
14
+
15
+ All providers are given an HTTP timeout to prevent hung LLM calls from
16
+ blocking the FastAPI server indefinitely.
17
+ """
18
+ provider = os.getenv("LLM_PROVIDER", "OPENAI").upper()
19
+ api_key = os.getenv("LLM_API_KEY") or os.getenv("OPENAI_API_KEY")
20
+ base_url = os.getenv("LLM_BASE_URL")
21
+ model_name = os.getenv("LLM_MODEL_NAME")
22
+
23
+ common_kwargs = {
24
+ "request_timeout": timeout,
25
+ }
26
+
27
+ if provider == "OPENAI":
28
+ return ChatOpenAI(
29
+ model=model_name or "gpt-4-turbo",
30
+ api_key=api_key,
31
+ base_url=base_url,
32
+ **common_kwargs,
33
+ )
34
+
35
+ elif provider == "QWEN":
36
+ # Qwen 2.5 Coder via OpenRouter or DeepInfra
37
+ return ChatOpenAI(
38
+ model=model_name or "qwen/qwen-2.5-coder-32b-instruct",
39
+ openai_api_key=api_key,
40
+ openai_api_base=base_url or "https://openrouter.ai/api/v1",
41
+ max_tokens=2048,
42
+ temperature=0.2,
43
+ **common_kwargs,
44
+ )
45
+
46
+ else:
47
+ # Generic OpenAI-compatible endpoint
48
+ return ChatOpenAI(
49
+ model=model_name,
50
+ openai_api_key=api_key,
51
+ openai_api_base=base_url,
52
+ **common_kwargs,
53
+ )
agent/memory.py ADDED
@@ -0,0 +1,44 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Helpers to load and persist the UserProfile from/to SQLite."""
2
+
3
+ from .db import get_profile, upsert_profile
4
+ from .models import UserProfile
5
+
6
+
7
+ def load_profile(session_id: str) -> UserProfile:
8
+ """Fetch profile from DB, or create a fresh one if it doesn't exist."""
9
+ row = get_profile(session_id)
10
+ if row:
11
+ return UserProfile(**row)
12
+ return UserProfile(session_id=session_id)
13
+
14
+
15
+ def update_profile(profile: UserProfile, topic: str, gap_magnitude: int, solved: bool) -> UserProfile:
16
+ """
17
+ Update the profile in-memory after a reasoning evaluation:
18
+ - Increment weak_topics score for the identified topic
19
+ - Recalculate avg_gap with exponential moving average (alpha=0.3)
20
+ - Increment turn count and solved count
21
+ """
22
+ topic_key = topic.strip().lower()
23
+ profile.weak_topics[topic_key] = profile.weak_topics.get(topic_key, 0) + gap_magnitude
24
+
25
+ # EMA for avg_gap
26
+ alpha = 0.3
27
+ profile.avg_gap = (alpha * gap_magnitude) + ((1 - alpha) * profile.avg_gap)
28
+
29
+ profile.total_turns += 1
30
+ if solved:
31
+ profile.solved_problems += 1
32
+
33
+ return profile
34
+
35
+
36
+ def persist_profile(profile: UserProfile) -> None:
37
+ """Save the updated profile back to SQLite."""
38
+ upsert_profile(profile.model_dump())
39
+
40
+
41
+ def top_weak_topics(profile: UserProfile, n: int = 3) -> list[str]:
42
+ """Return the top-N weakest topics by cumulative score."""
43
+ sorted_topics = sorted(profile.weak_topics.items(), key=lambda x: x[1], reverse=True)
44
+ return [t[0] for t in sorted_topics[:n]]
agent/models.py ADDED
@@ -0,0 +1,95 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from typing import List, Optional, TypedDict, Literal, Dict, Any
2
+ from pydantic import BaseModel, Field
3
+ from langchain_core.messages import BaseMessage
4
+
5
+
6
+ # ── LLM Structured Output Models ──────────────────────────────────────────────
7
+
8
+ class EvaluationOutput(BaseModel):
9
+ """Structured output from the reasoning analyst."""
10
+ problem_topic: str
11
+ identified_gap: str
12
+ gap_magnitude: int = Field(
13
+ ...,
14
+ description="0-10 scale where 0 is correct/optimal and 10 is completely wrong/missing.",
15
+ ge=0,
16
+ le=10,
17
+ )
18
+ reasoning: str
19
+ # Explain-why-wrong fields
20
+ mistake: Optional[str] = Field(
21
+ None, description="The specific mistake the user made (e.g., 'Used O(NΒ²) nested loop')."
22
+ )
23
+ why_wrong: Optional[str] = Field(
24
+ None, description="Why this approach fails (e.g., 'This exceeds time limit for N=10^5')."
25
+ )
26
+ correct_thinking: Optional[str] = Field(
27
+ None,
28
+ description="The correct direction to think (e.g., 'Consider a hashmap for O(1) lookup').",
29
+ )
30
+
31
+
32
+ class HintOutput(BaseModel):
33
+ """Structured output for the hint generator."""
34
+ hint: str
35
+ type: str = Field(description="e.g., Conceptual, Approach, Data Structure, Code")
36
+ escalation_level: int = Field(
37
+ 1,
38
+ description="1=Conceptual, 2=Approach, 3=Pseudocode, 4=Code snippet",
39
+ ge=1,
40
+ le=4,
41
+ )
42
+
43
+
44
+ class SolutionOutput(BaseModel):
45
+ """Structured output for the solution revealer."""
46
+ solution_code: str
47
+ explanation: str
48
+ complexity_analysis: str
49
+
50
+
51
+ # ── Agent State ────────────────────────────────────────────────────────────────
52
+
53
+ class AgentState(TypedDict):
54
+ """The state of the agent's graph β€” shared memory between all nodes."""
55
+
56
+ # ── User Input ──
57
+ problem: str
58
+ user_thought: str
59
+ code: Optional[str]
60
+ strictness: Literal["Strict", "Moderate", "Lenient"]
61
+ request_mode: Literal["analyze", "hint", "solution", "hint_forced"]
62
+ session_id: str # Identifies the user for persistent memory
63
+
64
+ # ── Internal Processing ──
65
+ problem_topic: Optional[str]
66
+ identified_gap: Optional[str]
67
+ gap_magnitude: int
68
+ current_hint_level: int # 1=Conceptual, 2=Approach, 3=Pseudocode, 4=Code
69
+ turn_count: int # Loop protection counter
70
+
71
+ # ── Code Evaluation ──
72
+ test_pass_rate: Optional[float] # 0.0–1.0 from sandbox runner
73
+
74
+ # ── Explain-Why-Wrong ──
75
+ mistake: Optional[str]
76
+ why_wrong: Optional[str]
77
+ correct_thinking: Optional[str]
78
+
79
+ # ── Output to User ──
80
+ messages: List[BaseMessage]
81
+ final_response: Optional[Dict[str, Any]]
82
+
83
+
84
+ # ── User Memory Profile ────────────────────────────────────────────────────────
85
+
86
+ class UserProfile(BaseModel):
87
+ """Persistent learning profile for a session."""
88
+ session_id: str
89
+ weak_topics: Dict[str, int] = Field(
90
+ default_factory=dict,
91
+ description="Maps DSA topic β†’ cumulative weakness score.",
92
+ )
93
+ solved_problems: int = 0
94
+ total_turns: int = 0
95
+ avg_gap: float = 0.0
agent/nodes.py ADDED
@@ -0,0 +1,194 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from langchain_core.messages import SystemMessage, HumanMessage
2
+ from langchain_core.prompts import ChatPromptTemplate
3
+ from langchain_core.output_parsers import JsonOutputParser
4
+ from langchain_openai import ChatOpenAI
5
+ from .models import AgentState, EvaluationOutput, HintOutput, SolutionOutput
6
+ from .llm_factory import get_llm
7
+
8
+ # Initialize LLM via Factory
9
+ llm = get_llm()
10
+
11
+ def classify_problem(state: AgentState) -> dict:
12
+ """Identifies the problem topic."""
13
+ problem = state["problem"]
14
+
15
+ prompt = ChatPromptTemplate.from_messages([
16
+ ("system", "You are a senior algorithm engineer. Classify the given coding problem into a specific DSA topic (e.g., Dynamic Programming, Graph BFS, Sliding Window). Return only the topic name."),
17
+ ("human", "{problem}")
18
+ ])
19
+
20
+ chain = prompt | llm
21
+ topic = chain.invoke({"problem": problem}).content
22
+
23
+ return {"problem_topic": topic}
24
+
25
+ def evaluate_reasoning(state: AgentState) -> dict:
26
+ """Analyzes the user's thought process and identifies gaps."""
27
+ problem = state["problem"]
28
+ thought = state["user_thought"]
29
+ code = state.get("code", "No code provided")
30
+ topic = state.get("problem_topic", "Unknown")
31
+
32
+ parser = JsonOutputParser(pydantic_object=EvaluationOutput)
33
+
34
+ system_prompt = """You are a DSA technical interviewer. Analyze the user's reasoning for the given problem.
35
+ Problem Topic: {topic}
36
+
37
+ Goal: Identify the PRIMARY gap in their logic.
38
+ - If they are correct, gap_magnitude should be 0.
39
+ - If they are mainly correct but missed edge cases, gap_magnitude 1-3.
40
+ - If they have the wrong approach, gap_magnitude 4-7.
41
+ - If they are completely lost, gap_magnitude 8-10.
42
+
43
+ IMPORTANT: Provide your output as a JSON object matching the following schema:
44
+ {format_instructions}
45
+
46
+ Do not wrap the JSON in markdown code blocks.
47
+ """
48
+
49
+ prompt = ChatPromptTemplate.from_messages([
50
+ ("system", system_prompt),
51
+ ("human", "Problem: {problem}\nUser Thought: {thought}\nCode: {code}")
52
+ ])
53
+
54
+ chain = prompt | llm | parser
55
+
56
+ try:
57
+ result = chain.invoke({
58
+ "topic": topic,
59
+ "problem": problem,
60
+ "thought": thought,
61
+ "code": code,
62
+ "format_instructions": parser.get_format_instructions()
63
+ })
64
+
65
+ # Ensure we interpret result correctly mostly likely it's a dict
66
+ return {
67
+ "identified_gap": result.get("identified_gap", "Unknown Gap"),
68
+ "gap_magnitude": result.get("gap_magnitude", 5)
69
+ }
70
+ except Exception as e:
71
+ # Fallback for parsing errors
72
+ print(f"Error parsing evaluation: {e}")
73
+ return {"identified_gap": "Could not parse analysis", "gap_magnitude": 5}
74
+
75
+ def generate_hint(state: AgentState) -> dict:
76
+ """Generates a hint based on the identified gap and strictness settings."""
77
+ gap = state["identified_gap"]
78
+ strictness = state["strictness"]
79
+ topic = state.get("problem_topic")
80
+
81
+ parser = JsonOutputParser(pydantic_object=HintOutput)
82
+
83
+ system_prompt = f"""You are a {strictness} coding mentor.
84
+ The user is stuck on a {topic} problem.
85
+ Identified Gap: {gap}
86
+
87
+ Your goal is to provide a hint that nudges them without giving the answer.
88
+
89
+ Strictness Rules:
90
+ - Strict: Ask a reflective question. Do not give steps. Short and direct.
91
+ - Moderate: Give a small nudge about the concept.
92
+ - Lenient: Explain the concept and suggest the next logical step.
93
+
94
+ IMPORTANT: Provide your output as a JSON object matching the following schema:
95
+ {{format_instructions}}
96
+
97
+ Do not wrap the JSON in markdown code blocks.
98
+ """
99
+
100
+ prompt = ChatPromptTemplate.from_messages([
101
+ ("system", system_prompt),
102
+ ("human", "Generate the hint.")
103
+ ])
104
+
105
+ chain = prompt | llm | parser
106
+
107
+ try:
108
+ result = chain.invoke({"format_instructions": parser.get_format_instructions()})
109
+
110
+ return {
111
+ "final_response": {
112
+ "hint": result.get("hint", "Try to think about the problem constraints."),
113
+ "type": result.get("type", "Conceptual"),
114
+ "score": 100 - (state["gap_magnitude"] * 10)
115
+ },
116
+ "current_hint_level": state.get("current_hint_level", 0) + 1
117
+ }
118
+ except Exception as e:
119
+ print(f"Error parsing hint: {e}")
120
+ return {
121
+ "final_response": {
122
+ "hint": "I'm having trouble analyzing your request right now. Could you try rephrasing?",
123
+ "type": "Error",
124
+ "score": 0
125
+ },
126
+ "current_hint_level": state.get("current_hint_level", 0)
127
+ }
128
+
129
+ def validate_solution(state: AgentState) -> dict:
130
+ """Returns a success message if the user is correct."""
131
+ return {
132
+ "final_response": {
133
+ "hint": "Great job! Your reasoning is sound. You can proceed to implementation or optimization.",
134
+ "type": "Validation",
135
+ "score": 100
136
+ }
137
+ }
138
+
139
+ def reveal_solution(state: AgentState) -> dict:
140
+ """Provides the full solution with explanation."""
141
+ problem = state["problem"]
142
+ topic = state.get("problem_topic", "DSA")
143
+
144
+ # Import locally to avoid circular imports
145
+ from .models import SolutionOutput
146
+ parser = JsonOutputParser(pydantic_object=SolutionOutput)
147
+
148
+ system_prompt = f"""You are an expert coding mentor. The user has explicitly requested the solution for a {topic} problem.
149
+
150
+ Provide:
151
+ 1. A clean, optimal Python solution.
152
+ 2. A step-by-step explanation.
153
+ 3. Time and Space complexity analysis.
154
+
155
+ IMPORTANT: Provide your output as a JSON object matching the following schema:
156
+ {{format_instructions}}
157
+
158
+ Do not wrap the JSON in markdown code blocks.
159
+ """
160
+
161
+ prompt = ChatPromptTemplate.from_messages([
162
+ ("system", system_prompt),
163
+ ("human", "Problem: {problem}")
164
+ ])
165
+
166
+ chain = prompt | llm | parser
167
+
168
+ try:
169
+ result = chain.invoke({
170
+ "problem": problem,
171
+ "format_instructions": parser.get_format_instructions()
172
+ })
173
+
174
+ return {
175
+ "final_response": {
176
+ "solution": result.get("solution_code", "# Solution generation failed"),
177
+ "explanation": result.get("explanation", "Could not generate solution."),
178
+ "complexity": result.get("complexity_analysis", "N/A"),
179
+ "type": "Solution",
180
+ "score": 0
181
+ }
182
+ }
183
+ except Exception as e:
184
+ print(f"Error parsing solution: {e}")
185
+ return {
186
+ "final_response": {
187
+ "solution": "# Error",
188
+ "explanation": "Failed to parse solution output.",
189
+ "complexity": "N/A",
190
+ "type": "Solution",
191
+ "score": 0
192
+ }
193
+ }
194
+
agent/nodes/__init__.py ADDED
@@ -0,0 +1,17 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Nodes package β€” aggregates all individual agent node functions."""
2
+
3
+ from .classify_node import classify_problem
4
+ from .analyze_node import evaluate_reasoning
5
+ from .hint_node import generate_hint
6
+ from .validate_node import validate_solution
7
+ from .solution_node import reveal_solution
8
+ from .gate_node import gate_solution
9
+
10
+ __all__ = [
11
+ "classify_problem",
12
+ "evaluate_reasoning",
13
+ "generate_hint",
14
+ "validate_solution",
15
+ "reveal_solution",
16
+ "gate_solution",
17
+ ]
agent/nodes/analyze_node.py ADDED
@@ -0,0 +1,95 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ analyze_node.py β€” Evaluates the user's reasoning and identifies the primary gap.
3
+
4
+ Improvements over v1:
5
+ - Uses llm.with_structured_output() for guaranteed schema compliance
6
+ - Clamps gap_magnitude to [0, 10] as a safety guard
7
+ - Runs sandboxed code evaluation and blends result into hybrid gap score
8
+ - Loads and updates UserProfile in SQLite for persistent memory
9
+ - Populates explain-why-wrong fields (mistake, why_wrong, correct_thinking)
10
+ """
11
+
12
+ from agent.models import AgentState, EvaluationOutput
13
+ from agent.llm_factory import get_llm
14
+ from agent.prompts import ANALYZE_PROMPT
15
+ from agent.knowledge import get_misconceptions
16
+ from agent.memory import load_profile, update_profile, persist_profile
17
+ from agent.sandbox import run_code_safely, get_test_cases_for_topic
18
+
19
+ _llm = get_llm()
20
+ _structured_llm = _llm.with_structured_output(EvaluationOutput, method="function_calling")
21
+
22
+
23
+ def evaluate_reasoning(state: AgentState) -> dict:
24
+ """
25
+ Analyzes user's thought process and code.
26
+ Updates the UserProfile in the DB with the latest gap scores.
27
+ Returns identified_gap, gap_magnitude, explain-why-wrong fields, and test_pass_rate.
28
+ """
29
+ topic = state.get("problem_topic", "Unknown")
30
+ code = state.get("code", "") or ""
31
+ session_id = state.get("session_id", "anonymous")
32
+
33
+ # ── 1. Run sandbox evaluation if code is provided ───────────────────────
34
+ test_results_summary = "No code submitted."
35
+ test_pass_rate = None
36
+ if code.strip():
37
+ test_cases = get_test_cases_for_topic(topic)
38
+ if test_cases:
39
+ run_result = run_code_safely(code, test_cases)
40
+ test_pass_rate = run_result["pass_rate"]
41
+ test_results_summary = (
42
+ f"Passed {run_result['passed']}/{run_result['total']} test cases. "
43
+ f"Errors: {run_result['errors'][:2]}"
44
+ )
45
+ else:
46
+ test_results_summary = "No built-in test cases for this topic β€” using LLM evaluation only."
47
+
48
+ # ── 2. Fetch misconceptions for topic context ────────────────────────────
49
+ misconceptions = "; ".join(get_misconceptions(topic))
50
+
51
+ # ── 3. LLM evaluation with structured output ────────────────────────────
52
+ try:
53
+ result: EvaluationOutput = _structured_llm.invoke(
54
+ ANALYZE_PROMPT.format_messages(
55
+ topic=topic,
56
+ problem=state["problem"],
57
+ thought=state["user_thought"],
58
+ code=code or "No code provided",
59
+ misconceptions=misconceptions,
60
+ test_results=test_results_summary,
61
+ )
62
+ )
63
+ gap = max(0, min(10, result.gap_magnitude)) # Clamp to [0, 10]
64
+
65
+ # ── 4. Hybrid scoring: blend LLM gap with code test pass rate ───────
66
+ if test_pass_rate is not None:
67
+ gap = int(round(0.6 * gap + 0.4 * (10 - test_pass_rate * 10)))
68
+ gap = max(0, min(10, gap))
69
+
70
+ except Exception as e:
71
+ print(f"[analyze_node] Structured output error: {e}")
72
+ gap = 5
73
+ result = EvaluationOutput(
74
+ problem_topic=topic,
75
+ identified_gap="Could not parse analysis",
76
+ gap_magnitude=5,
77
+ reasoning="Parse error fallback",
78
+ )
79
+
80
+ # ── 5. Update persistent UserProfile ────────────────────────────────────
81
+ try:
82
+ profile = load_profile(session_id)
83
+ profile = update_profile(profile, topic, gap, solved=(gap == 0))
84
+ persist_profile(profile)
85
+ except Exception as e:
86
+ print(f"[analyze_node] Memory update error: {e}")
87
+
88
+ return {
89
+ "identified_gap": result.identified_gap,
90
+ "gap_magnitude": gap,
91
+ "mistake": result.mistake,
92
+ "why_wrong": result.why_wrong,
93
+ "correct_thinking": result.correct_thinking,
94
+ "test_pass_rate": test_pass_rate,
95
+ }
agent/nodes/classify_node.py ADDED
@@ -0,0 +1,18 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ classify_node.py β€” Identifies the DSA topic of the given problem.
3
+ Uses structured output for reliability; no JSON parsing errors possible.
4
+ """
5
+
6
+ from agent.models import AgentState
7
+ from agent.llm_factory import get_llm
8
+ from agent.prompts import CLASSIFY_PROMPT
9
+
10
+ _llm = get_llm()
11
+
12
+
13
+ def classify_problem(state: AgentState) -> dict:
14
+ """Classifies the problem into a DSA topic and updates problem_topic in state."""
15
+ chain = CLASSIFY_PROMPT | _llm
16
+ result = chain.invoke({"problem": state["problem"]})
17
+ topic = result.content.strip()
18
+ return {"problem_topic": topic}
agent/nodes/gate_node.py ADDED
@@ -0,0 +1,41 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ gate_node.py β€” Safety gate that controls access to the full solution.
3
+
4
+ Gate logic (priority order):
5
+ 1. If turn_count < 2 AND gap_magnitude <= 7 β†’ force hint (too early)
6
+ 2. If gap_magnitude > 7 β†’ allow solution (completely lost)
7
+ 3. Otherwise β†’ force hint (encourage effort)
8
+
9
+ This prevents immediate solution bypass while still being helpful for users
10
+ who are genuinely stuck and unable to make progress.
11
+ """
12
+
13
+ from agent.models import AgentState
14
+
15
+
16
+ def gate_solution(state: AgentState) -> dict:
17
+ """
18
+ Evaluates whether to allow solution reveal or force a hint.
19
+ Updates request_mode to 'solution' or 'hint_forced' accordingly.
20
+ Does NOT call the LLM β€” purely deterministic routing.
21
+ """
22
+ turn_count = state.get("turn_count", 0)
23
+ gap = state.get("gap_magnitude", 5)
24
+
25
+ # Already in solution mode β€” apply gate logic
26
+ if state.get("request_mode") == "solution":
27
+ if gap > 7:
28
+ # User is completely stuck β€” allow solution compassionately
29
+ return {"request_mode": "solution"}
30
+ elif turn_count < 2:
31
+ # Too early β€” redirect to hint flow
32
+ return {
33
+ "request_mode": "hint_forced",
34
+ "final_response": None, # Clear any stale response
35
+ }
36
+ else:
37
+ # Enough turns done and not completely lost β€” still redirect
38
+ return {"request_mode": "hint_forced", "final_response": None}
39
+
40
+ # Not a solution request β€” pass through unchanged
41
+ return {}
agent/nodes/hint_node.py ADDED
@@ -0,0 +1,90 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ hint_node.py β€” Generates an adaptive, escalating hint for the user.
3
+
4
+ Improvements over v1:
5
+ - Uses llm.with_structured_output() for guaranteed schema compliance
6
+ - 4-level escalation system (Conceptual β†’ Approach β†’ Pseudocode β†’ Code)
7
+ - Personalizes based on UserProfile weak topics
8
+ - Injects misconception library for topic-targeted hints
9
+ - Increments turn_count for loop control
10
+ """
11
+
12
+ from agent.models import AgentState, HintOutput
13
+ from agent.llm_factory import get_llm
14
+ from agent.prompts import HINT_PROMPT
15
+ from agent.knowledge import get_misconceptions
16
+ from agent.memory import load_profile, top_weak_topics
17
+
18
+ _llm = get_llm()
19
+ _structured_llm = _llm.with_structured_output(HintOutput, method="function_calling")
20
+
21
+ _MAX_HINT_LEVEL = 4
22
+
23
+
24
+ def generate_hint(state: AgentState) -> dict:
25
+ """
26
+ Generates a hint based on the gap, strictness, hint level, and user profile.
27
+ Increments current_hint_level and turn_count.
28
+ Populates explain-why-wrong fields in final_response if present.
29
+ """
30
+ gap = state["identified_gap"]
31
+ topic = state.get("problem_topic", "Unknown")
32
+ hint_level = min(state.get("current_hint_level", 1), _MAX_HINT_LEVEL)
33
+ session_id = state.get("session_id", "anonymous")
34
+
35
+ # ── Determine effective strictness (personalize for weak topics) ─────────
36
+ strictness = state["strictness"]
37
+ try:
38
+ profile = load_profile(session_id)
39
+ weak = top_weak_topics(profile, n=3)
40
+ if topic.strip().lower() in weak:
41
+ # Promote to Lenient if user consistently struggles with this topic
42
+ if strictness == "Strict":
43
+ strictness = "Moderate"
44
+ elif strictness == "Moderate":
45
+ strictness = "Lenient"
46
+ weak_topics_str = ", ".join(weak) if weak else "none yet"
47
+ except Exception:
48
+ weak_topics_str = "none yet"
49
+
50
+ misconceptions = "; ".join(get_misconceptions(topic))
51
+
52
+ # ── Build prompt and invoke structured LLM ───────────────────────────────
53
+ try:
54
+ result: HintOutput = _structured_llm.invoke(
55
+ HINT_PROMPT.format_messages(
56
+ strictness=strictness,
57
+ topic=topic,
58
+ gap=gap,
59
+ hint_level=hint_level,
60
+ weak_topics=weak_topics_str,
61
+ misconceptions=misconceptions,
62
+ )
63
+ )
64
+ hint_text = result.hint
65
+ hint_type = result.type
66
+ except Exception as e:
67
+ print(f"[hint_node] Structured output error: {e}")
68
+ hint_text = f"LLM Parsing Error: {str(e)}"
69
+ hint_type = "Error"
70
+
71
+ # ── Score: decreases with gap magnitude ──────────────────────────────────
72
+ score = max(0, 100 - (state["gap_magnitude"] * 10))
73
+
74
+ # ── Build final_response β€” include explain-why-wrong when available ──────
75
+ final_response: dict = {
76
+ "hint": hint_text,
77
+ "type": hint_type,
78
+ "score": score,
79
+ "hint_level": hint_level,
80
+ }
81
+ if state.get("mistake"):
82
+ final_response["mistake"] = state["mistake"]
83
+ final_response["why_wrong"] = state.get("why_wrong")
84
+ final_response["correct_thinking"] = state.get("correct_thinking")
85
+
86
+ return {
87
+ "final_response": final_response,
88
+ "current_hint_level": min(hint_level + 1, _MAX_HINT_LEVEL),
89
+ "turn_count": state.get("turn_count", 0) + 1,
90
+ }
agent/nodes/solution_node.py ADDED
@@ -0,0 +1,47 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ solution_node.py β€” Reveals the full optimal solution with explanation.
3
+
4
+ Improvements over v1:
5
+ - Uses llm.with_structured_output() for guaranteed schema compliance
6
+ - Imports SolutionOutput from models (no local import needed)
7
+ """
8
+
9
+ from agent.models import AgentState, SolutionOutput
10
+ from agent.llm_factory import get_llm
11
+ from agent.prompts import SOLUTION_PROMPT
12
+
13
+ _llm = get_llm()
14
+ _structured_llm = _llm.with_structured_output(SolutionOutput, method="function_calling")
15
+
16
+
17
+ def reveal_solution(state: AgentState) -> dict:
18
+ """Provides the full, optimal solution with explanation and complexity analysis."""
19
+ topic = state.get("problem_topic", "DSA")
20
+
21
+ try:
22
+ result: SolutionOutput = _structured_llm.invoke(
23
+ SOLUTION_PROMPT.format_messages(
24
+ topic=topic,
25
+ problem=state["problem"],
26
+ )
27
+ )
28
+ return {
29
+ "final_response": {
30
+ "solution": result.solution_code,
31
+ "explanation": result.explanation,
32
+ "complexity": result.complexity_analysis,
33
+ "type": "Solution",
34
+ "score": 0, # Requested solution β€” no independent credit
35
+ }
36
+ }
37
+ except Exception as e:
38
+ print(f"[solution_node] Structured output error: {e}")
39
+ return {
40
+ "final_response": {
41
+ "solution": "# Error generating solution",
42
+ "explanation": "Failed to generate solution. Please try again.",
43
+ "complexity": "N/A",
44
+ "type": "Solution",
45
+ "score": 0,
46
+ }
47
+ }
agent/nodes/validate_node.py ADDED
@@ -0,0 +1,36 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ validate_node.py β€” Returns a success response when the user's reasoning is correct.
3
+ No LLM call β€” purely deterministic.
4
+ """
5
+
6
+ from agent.models import AgentState
7
+ from agent.memory import load_profile, update_profile, persist_profile
8
+
9
+
10
+ def validate_solution(state: AgentState) -> dict:
11
+ """Validates that the user's approach is correct and returns a success response."""
12
+ session_id = state.get("session_id", "anonymous")
13
+
14
+ # Mark as solved in the user's profile
15
+ try:
16
+ profile = load_profile(session_id)
17
+ profile = update_profile(
18
+ profile,
19
+ topic=state.get("problem_topic", "unknown"),
20
+ gap_magnitude=0,
21
+ solved=True,
22
+ )
23
+ persist_profile(profile)
24
+ except Exception as e:
25
+ print(f"[validate_node] Memory error: {e}")
26
+
27
+ return {
28
+ "final_response": {
29
+ "hint": (
30
+ "βœ… Great job! Your reasoning is sound and your approach is optimal. "
31
+ "You can proceed to implementation or explore further optimizations."
32
+ ),
33
+ "type": "Validation",
34
+ "score": 100,
35
+ }
36
+ }
agent/prompts/__init__.py ADDED
@@ -0,0 +1,8 @@
 
 
 
 
 
 
 
 
 
1
+ """Prompts package __init__."""
2
+
3
+ from .classify_prompt import CLASSIFY_PROMPT
4
+ from .analyze_prompt import ANALYZE_PROMPT
5
+ from .hint_prompt import HINT_PROMPT
6
+ from .solution_prompt import SOLUTION_PROMPT
7
+
8
+ __all__ = ["CLASSIFY_PROMPT", "ANALYZE_PROMPT", "HINT_PROMPT", "SOLUTION_PROMPT"]
agent/prompts/analyze_prompt.py ADDED
@@ -0,0 +1,41 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Prompt definition for the evaluate_reasoning (analyze) node."""
2
+
3
+ from langchain_core.prompts import ChatPromptTemplate
4
+
5
+ ANALYZE_SYSTEM = """\
6
+ You are an expert DSA technical interviewer and coding coach.
7
+ Problem Topic: {topic}
8
+
9
+ Analyze the user's reasoning for the given problem. Your goal is to find the PRIMARY gap in their logic.
10
+
11
+ Gap Magnitude Scale (gap_magnitude field):
12
+ 0 β†’ Correct and optimal.
13
+ 1–3 β†’ Mostly correct but missing edge cases or minor inefficiencies.
14
+ 4–7 β†’ Wrong algorithmic approach; needs a different strategy.
15
+ 8–10 β†’ Completely lost; fundamentally wrong understanding.
16
+
17
+ For the explain-why-wrong fields (populate only when gap_magnitude >= 1):
18
+ mistake β†’ The specific error the user made (concise, 1 sentence).
19
+ why_wrong β†’ Why this error is problematic (performance, correctness, edge cases).
20
+ correct_thinking β†’ The correct direction to think in, phrased as a pointer, not the answer.
21
+
22
+ Return a JSON object matching this schema exactly. Do NOT wrap in markdown code blocks.
23
+ """
24
+
25
+ ANALYZE_HUMAN = """\
26
+ Problem: {problem}
27
+
28
+ User's Thought Process:
29
+ {thought}
30
+
31
+ User's Code (if any):
32
+ {code}
33
+
34
+ Known Misconceptions for this topic: {misconceptions}
35
+ Code Test Results: {test_results}
36
+ """
37
+
38
+ ANALYZE_PROMPT = ChatPromptTemplate.from_messages([
39
+ ("system", ANALYZE_SYSTEM),
40
+ ("human", ANALYZE_HUMAN),
41
+ ])
agent/prompts/classify_prompt.py ADDED
@@ -0,0 +1,17 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Prompt definition for the classify_problem node."""
2
+
3
+ from langchain_core.prompts import ChatPromptTemplate
4
+
5
+ CLASSIFY_PROMPT = ChatPromptTemplate.from_messages([
6
+ (
7
+ "system",
8
+ (
9
+ "You are a senior algorithm engineer with deep expertise in competitive programming. "
10
+ "Classify the given coding problem into a single, specific DSA topic. "
11
+ "Examples: 'Dynamic Programming', 'Graph BFS', 'Sliding Window', 'Binary Search', "
12
+ "'Two Pointers', 'Union Find', 'Trie', 'Heap / Priority Queue', 'Backtracking'. "
13
+ "Return ONLY the topic name β€” no explanation, no punctuation."
14
+ ),
15
+ ),
16
+ ("human", "{problem}"),
17
+ ])
agent/prompts/hint_prompt.py ADDED
@@ -0,0 +1,33 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Prompt definition for the generate_hint node."""
2
+
3
+ from langchain_core.prompts import ChatPromptTemplate
4
+
5
+ HINT_SYSTEM = """\
6
+ You are a {strictness} coding mentor helping a user learn DSA.
7
+ Problem Topic: {topic}
8
+ Identified Gap: {gap}
9
+
10
+ Hint Escalation Level: {hint_level} / 4
11
+ Level 1 β†’ Give a purely conceptual nudge (no steps, no code).
12
+ Level 2 β†’ Point toward the right algorithmic approach (what, not how).
13
+ Level 3 β†’ Give a pseudocode outline with key steps blanked out.
14
+ Level 4 β†’ Reveal a targeted code snippet for the hardest part only.
15
+
16
+ Strictness Rules (override escalation if needed):
17
+ Strict β†’ Ask a reflective Socratic question. Short and direct. No answers.
18
+ Moderate→ Small clear nudge about the concept or approach.
19
+ Lenient β†’ Explain the concept and suggest the next logical step clearly.
20
+
21
+ User is a known weak learner in these topics: {weak_topics}
22
+ If the current topic is in this list, favor a more explanatory approach.
23
+
24
+ Common misconceptions for this topic: {misconceptions}
25
+
26
+ IMPORTANT: Do NOT reveal the complete solution or full working code.
27
+ Return a JSON object matching the schema exactly. Do NOT wrap in markdown.
28
+ """
29
+
30
+ HINT_PROMPT = ChatPromptTemplate.from_messages([
31
+ ("system", HINT_SYSTEM),
32
+ ("human", "Generate the hint for the user now."),
33
+ ])
agent/prompts/solution_prompt.py ADDED
@@ -0,0 +1,19 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Prompt definition for the reveal_solution node."""
2
+
3
+ from langchain_core.prompts import ChatPromptTemplate
4
+
5
+ SOLUTION_SYSTEM = """\
6
+ You are an expert coding mentor. The user has explicitly requested the full solution for a {topic} problem.
7
+
8
+ Provide:
9
+ 1. A clean, idiomatic, optimal Python solution (full working function).
10
+ 2. A step-by-step explanation of the algorithm.
11
+ 3. Time and Space complexity analysis with justification.
12
+
13
+ Return a JSON object matching this schema exactly. Do NOT wrap in markdown code blocks.
14
+ """
15
+
16
+ SOLUTION_PROMPT = ChatPromptTemplate.from_messages([
17
+ ("system", SOLUTION_SYSTEM),
18
+ ("human", "Problem: {problem}"),
19
+ ])
agent/sandbox.py ADDED
@@ -0,0 +1,132 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Sandboxed code execution for evaluating user-submitted Python solutions.
3
+
4
+ Security model:
5
+ - Runs in a subprocess with a hard timeout (default 5 seconds)
6
+ - Restricts dangerous builtins via RestrictedPython-style allowlist
7
+ - No network or file system access from the subprocess
8
+ """
9
+
10
+ import subprocess
11
+ import sys
12
+ import json
13
+ import textwrap
14
+ from typing import Any
15
+
16
+
17
+ # Built-in test cases for well-known problems (keyed by normalized topic)
18
+ BUILT_IN_TEST_CASES: dict[str, list[dict]] = {
19
+ "two sum": [
20
+ {"fn": "two_sum", "args": [[2, 7, 11, 15], 9], "expected": [0, 1]},
21
+ {"fn": "two_sum", "args": [[3, 2, 4], 6], "expected": [1, 2]},
22
+ {"fn": "two_sum", "args": [[3, 3], 6], "expected": [0, 1]},
23
+ ],
24
+ "reverse linked list": [
25
+ # Skipped β€” requires linked list setup, use LLM eval only
26
+ ],
27
+ }
28
+
29
+ _RUNNER_TEMPLATE = textwrap.dedent(
30
+ """
31
+ import json, sys
32
+
33
+ # --- User code ---
34
+ {user_code}
35
+
36
+ # --- Test runner ---
37
+ results = []
38
+ test_cases = {test_cases}
39
+ for tc in test_cases:
40
+ fn = globals().get(tc["fn"])
41
+ if fn is None:
42
+ results.append({{"passed": False, "error": "Function not found: " + tc["fn"]}})
43
+ continue
44
+ try:
45
+ out = fn(*tc["args"])
46
+ # Normalize list order for Two Sum-style answers
47
+ passed = sorted(out) == sorted(tc["expected"]) if isinstance(out, list) else out == tc["expected"]
48
+ results.append({{"passed": passed, "output": str(out)}})
49
+ except Exception as e:
50
+ results.append({{"passed": False, "error": str(e)}})
51
+
52
+ print(json.dumps(results))
53
+ """
54
+ )
55
+
56
+ _TIMEOUT_SECONDS = 5
57
+
58
+
59
+ def run_code_safely(user_code: str, test_cases: list[dict]) -> dict[str, Any]:
60
+ """
61
+ Execute `user_code` against `test_cases` in a subprocess sandbox.
62
+
63
+ Returns:
64
+ {
65
+ "passed": int,
66
+ "total": int,
67
+ "pass_rate": float, # 0.0–1.0
68
+ "errors": list[str],
69
+ "timed_out": bool
70
+ }
71
+ """
72
+ if not test_cases or not user_code.strip():
73
+ return {"passed": 0, "total": 0, "pass_rate": 0.0, "errors": [], "timed_out": False}
74
+
75
+ script = _RUNNER_TEMPLATE.format(
76
+ user_code=user_code,
77
+ test_cases=json.dumps(test_cases),
78
+ )
79
+
80
+ try:
81
+ proc = subprocess.run(
82
+ [sys.executable, "-c", script],
83
+ capture_output=True,
84
+ text=True,
85
+ timeout=_TIMEOUT_SECONDS,
86
+ # Restrict subprocess env β€” no network, no GPU, no extra paths
87
+ )
88
+ if proc.returncode != 0:
89
+ return {
90
+ "passed": 0,
91
+ "total": len(test_cases),
92
+ "pass_rate": 0.0,
93
+ "errors": [proc.stderr[:500]],
94
+ "timed_out": False,
95
+ }
96
+
97
+ results: list[dict] = json.loads(proc.stdout.strip())
98
+ passed = sum(1 for r in results if r.get("passed"))
99
+ errors = [r["error"] for r in results if not r.get("passed") and "error" in r]
100
+ return {
101
+ "passed": passed,
102
+ "total": len(results),
103
+ "pass_rate": passed / len(results),
104
+ "errors": errors,
105
+ "timed_out": False,
106
+ }
107
+
108
+ except subprocess.TimeoutExpired:
109
+ return {
110
+ "passed": 0,
111
+ "total": len(test_cases),
112
+ "pass_rate": 0.0,
113
+ "errors": ["Execution timed out (> 5 seconds)"],
114
+ "timed_out": True,
115
+ }
116
+ except Exception as e:
117
+ return {
118
+ "passed": 0,
119
+ "total": len(test_cases),
120
+ "pass_rate": 0.0,
121
+ "errors": [str(e)],
122
+ "timed_out": False,
123
+ }
124
+
125
+
126
+ def get_test_cases_for_topic(topic: str) -> list[dict]:
127
+ """Return built-in test cases for a topic if available, else empty list."""
128
+ key = topic.strip().lower()
129
+ for lib_key, cases in BUILT_IN_TEST_CASES.items():
130
+ if lib_key in key or key in lib_key:
131
+ return cases
132
+ return []
dsa_mentor.db ADDED
Binary file (12.3 kB). View file
 
main.py ADDED
@@ -0,0 +1,109 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from fastapi import FastAPI, HTTPException, Request
2
+ from fastapi.middleware.cors import CORSMiddleware
3
+ from pydantic import BaseModel, Field, field_validator
4
+ from dotenv import load_dotenv
5
+ from slowapi import Limiter, _rate_limit_exceeded_handler
6
+ from slowapi.util import get_remote_address
7
+ from slowapi.errors import RateLimitExceeded
8
+ from agent.graph import define_graph
9
+ from agent.db import init_db
10
+ import uvicorn
11
+ import os
12
+ from uuid import uuid4
13
+
14
+ load_dotenv()
15
+
16
+ # ── Rate Limiter ──────────────────────────────────────────────────────────────
17
+ RATE_LIMIT = os.getenv("RATE_LIMIT", "10/minute")
18
+ limiter = Limiter(key_func=get_remote_address, default_limits=[RATE_LIMIT])
19
+
20
+ # ── App Setup ─────────────────────────────────────────────────────────────────
21
+ app = FastAPI(title="DSA Mentor Agent", version="2.0.0")
22
+ app.state.limiter = limiter
23
+ app.add_exception_handler(RateLimitExceeded, _rate_limit_exceeded_handler)
24
+
25
+ # ── CORS β€” explicit frontend URL from env (never open in production) ──────────
26
+ FRONTEND_URL = os.getenv("FRONTEND_URL", "http://localhost:3000")
27
+ app.add_middleware(
28
+ CORSMiddleware,
29
+ allow_origins=[FRONTEND_URL],
30
+ allow_credentials=True,
31
+ allow_methods=["POST", "GET"],
32
+ allow_headers=["Content-Type", "Authorization"],
33
+ )
34
+
35
+ # ── Initialize DB & Graph at startup ─────────────────────────────────────────
36
+ @app.on_event("startup")
37
+ async def startup():
38
+ init_db() # Creates SQLite tables if they don't exist
39
+
40
+ graph = define_graph()
41
+
42
+
43
+ # ── Request / Response Models ─────────────────────────────────────────────────
44
+ class AnalyzeRequest(BaseModel):
45
+ problem: str = Field(..., max_length=3000, description="The DSA problem statement.")
46
+ thought: str = Field("", max_length=2000, description="User's current reasoning.")
47
+ code: str = Field("", max_length=5000, description="User's code attempt (optional).")
48
+ strictness: str = Field("Moderate", description="'Strict' | 'Moderate' | 'Lenient'")
49
+ mode: str = Field("analyze", description="'analyze' | 'solution'")
50
+ session_id: str = Field(
51
+ default_factory=lambda: str(uuid4()),
52
+ description="User session ID for persistent memory. Generate client-side and reuse across turns.",
53
+ )
54
+
55
+ @field_validator("strictness")
56
+ @classmethod
57
+ def validate_strictness(cls, v):
58
+ allowed = {"Strict", "Moderate", "Lenient"}
59
+ if v not in allowed:
60
+ raise ValueError(f"strictness must be one of {allowed}")
61
+ return v
62
+
63
+ @field_validator("mode")
64
+ @classmethod
65
+ def validate_mode(cls, v):
66
+ allowed = {"analyze", "solution"}
67
+ if v not in allowed:
68
+ raise ValueError(f"mode must be one of {allowed}")
69
+ return v
70
+
71
+
72
+ # ── Endpoints ─────────────────────────────────────────────────────────────────
73
+ @app.post("/analyze")
74
+ @limiter.limit(RATE_LIMIT)
75
+ async def analyze_thought(request: Request, body: AnalyzeRequest):
76
+ """
77
+ Main endpoint: evaluates user's DSA reasoning and returns an adaptive hint,
78
+ validation, or full solution based on the agent graph output.
79
+ """
80
+ initial_state = {
81
+ "problem": body.problem,
82
+ "user_thought": body.thought,
83
+ "code": body.code,
84
+ "strictness": body.strictness,
85
+ "request_mode": body.mode,
86
+ "session_id": body.session_id,
87
+ "turn_count": 0,
88
+ "current_hint_level": 1,
89
+ "gap_magnitude": 0,
90
+ "messages": [],
91
+ }
92
+
93
+ try:
94
+ result = await graph.ainvoke(initial_state)
95
+ return result.get("final_response")
96
+ except Exception as e:
97
+ import traceback
98
+ traceback.print_exc()
99
+ raise HTTPException(status_code=500, detail=str(e))
100
+
101
+
102
+ @app.get("/health")
103
+ def health_check():
104
+ """Liveness check endpoint."""
105
+ return {"status": "ok", "version": "2.0.0"}
106
+
107
+
108
+ if __name__ == "__main__":
109
+ uvicorn.run(app, host="0.0.0.0", port=8000)
requirements.txt ADDED
@@ -0,0 +1,13 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ fastapi
2
+ uvicorn
3
+ langchain
4
+ langgraph
5
+ langchain-openai
6
+ langchain-google-genai
7
+ python-dotenv
8
+ pydantic
9
+ slowapi
10
+ tenacity
11
+ httpx
12
+ pytest
13
+ pytest-asyncio
tests/__init__.py ADDED
@@ -0,0 +1 @@
 
 
1
+ # Tests package
tests/test_nodes.py ADDED
@@ -0,0 +1,182 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ test_nodes.py β€” Unit tests for all DSA Mentor agent node functions.
3
+
4
+ Uses MagicMock to patch the LLM and avoid real API calls.
5
+ Run with: pytest tests/ -v
6
+ """
7
+
8
+ import pytest
9
+ from unittest.mock import MagicMock, patch
10
+
11
+
12
+ # ── Helpers ───────────────────────────────────────────────────────────────────
13
+
14
+ def _base_state(**overrides) -> dict:
15
+ """Returns a minimal valid AgentState for testing."""
16
+ state = {
17
+ "problem": "Given an array of integers, find two numbers that add to a target.",
18
+ "user_thought": "I'll use a nested loop to check every pair.",
19
+ "code": "",
20
+ "strictness": "Moderate",
21
+ "request_mode": "analyze",
22
+ "session_id": "test-session-001",
23
+ "problem_topic": "Two Sum",
24
+ "identified_gap": "Not using a hashmap",
25
+ "gap_magnitude": 6,
26
+ "current_hint_level": 1,
27
+ "turn_count": 0,
28
+ "messages": [],
29
+ "final_response": None,
30
+ "test_pass_rate": None,
31
+ "mistake": None,
32
+ "why_wrong": None,
33
+ "correct_thinking": None,
34
+ }
35
+ state.update(overrides)
36
+ return state
37
+
38
+
39
+ # ── Classify Node ─────────────────────────────────────────────────────────────
40
+
41
+ def test_classify_problem_returns_topic():
42
+ """classify_problem should update problem_topic from LLM response."""
43
+ mock_response = MagicMock()
44
+ mock_response.content = "Two Sum"
45
+
46
+ with patch("agent.nodes.classify_node._llm") as mock_llm:
47
+ mock_chain = MagicMock()
48
+ mock_chain.invoke.return_value = mock_response
49
+ mock_llm.__or__ = MagicMock(return_value=mock_chain)
50
+
51
+ from agent.nodes.classify_node import classify_problem
52
+ result = classify_problem(_base_state())
53
+
54
+ assert "problem_topic" in result
55
+ assert isinstance(result["problem_topic"], str)
56
+
57
+
58
+ # ── Gate Node ─────────────────────────────────────────────────────────────────
59
+
60
+ def test_gate_allows_solution_when_gap_critical():
61
+ """gate_solution should allow solution when gap_magnitude > 7."""
62
+ from agent.nodes.gate_node import gate_solution
63
+ state = _base_state(request_mode="solution", gap_magnitude=9, turn_count=0)
64
+ result = gate_solution(state)
65
+ assert result.get("request_mode") == "solution"
66
+
67
+
68
+ def test_gate_blocks_solution_too_early():
69
+ """gate_solution should block solution when turn_count < 2 and gap <= 7."""
70
+ from agent.nodes.gate_node import gate_solution
71
+ state = _base_state(request_mode="solution", gap_magnitude=5, turn_count=0)
72
+ result = gate_solution(state)
73
+ assert result.get("request_mode") == "hint_forced"
74
+
75
+
76
+ def test_gate_passthrough_non_solution_mode():
77
+ """gate_solution should return empty dict for non-solution modes."""
78
+ from agent.nodes.gate_node import gate_solution
79
+ state = _base_state(request_mode="analyze")
80
+ result = gate_solution(state)
81
+ assert result == {}
82
+
83
+
84
+ # ── Validate Node ─────────────────────────────────────────────────────────────
85
+
86
+ def test_validate_solution_returns_100():
87
+ """validate_solution should always return score=100."""
88
+ with patch("agent.nodes.validate_node.load_profile") as mock_load, \
89
+ patch("agent.nodes.validate_node.persist_profile"):
90
+ mock_load.return_value = MagicMock(weak_topics={}, solved_problems=0, total_turns=0, avg_gap=0.0)
91
+ from agent.nodes.validate_node import validate_solution
92
+ result = validate_solution(_base_state())
93
+
94
+ assert result["final_response"]["score"] == 100
95
+ assert result["final_response"]["type"] == "Validation"
96
+
97
+
98
+ def test_validate_solution_contains_hint_text():
99
+ """validate_solution final_response should have a 'hint' key."""
100
+ with patch("agent.nodes.validate_node.load_profile") as mock_load, \
101
+ patch("agent.nodes.validate_node.persist_profile"):
102
+ mock_load.return_value = MagicMock(weak_topics={}, solved_problems=0, total_turns=0, avg_gap=0.0)
103
+ from agent.nodes.validate_node import validate_solution
104
+ result = validate_solution(_base_state())
105
+
106
+ assert "hint" in result["final_response"]
107
+
108
+
109
+ # ── Hint Node ─────────────────────────────────────────────────────────────────
110
+
111
+ def test_generate_hint_increments_hint_level():
112
+ """generate_hint should increment current_hint_level by 1."""
113
+ mock_hint = MagicMock()
114
+ mock_hint.hint = "Think about what data structure gives O(1) lookup."
115
+ mock_hint.type = "Data Structure"
116
+
117
+ with patch("agent.nodes.hint_node._structured_llm") as mock_llm, \
118
+ patch("agent.nodes.hint_node.load_profile") as mock_load:
119
+ mock_llm.invoke.return_value = mock_hint
120
+ mock_load.return_value = MagicMock(weak_topics={}, avg_gap=5.0)
121
+
122
+ from agent.nodes.hint_node import generate_hint
123
+ result = generate_hint(_base_state(current_hint_level=1))
124
+
125
+ assert result["current_hint_level"] == 2
126
+
127
+
128
+ def test_generate_hint_increments_turn_count():
129
+ """generate_hint should increment turn_count."""
130
+ mock_hint = MagicMock()
131
+ mock_hint.hint = "Consider a different data structure."
132
+ mock_hint.type = "Conceptual"
133
+
134
+ with patch("agent.nodes.hint_node._structured_llm") as mock_llm, \
135
+ patch("agent.nodes.hint_node.load_profile") as mock_load:
136
+ mock_llm.invoke.return_value = mock_hint
137
+ mock_load.return_value = MagicMock(weak_topics={}, avg_gap=5.0)
138
+
139
+ from agent.nodes.hint_node import generate_hint
140
+ result = generate_hint(_base_state(turn_count=1))
141
+
142
+ assert result["turn_count"] == 2
143
+
144
+
145
+ def test_generate_hint_score_formula():
146
+ """Score should be 100 - gap_magnitude * 10."""
147
+ mock_hint = MagicMock()
148
+ mock_hint.hint = "Hint text"
149
+ mock_hint.type = "Conceptual"
150
+
151
+ with patch("agent.nodes.hint_node._structured_llm") as mock_llm, \
152
+ patch("agent.nodes.hint_node.load_profile") as mock_load:
153
+ mock_llm.invoke.return_value = mock_hint
154
+ mock_load.return_value = MagicMock(weak_topics={}, avg_gap=5.0)
155
+
156
+ from agent.nodes.hint_node import generate_hint
157
+ result = generate_hint(_base_state(gap_magnitude=4))
158
+
159
+ assert result["final_response"]["score"] == 60
160
+
161
+
162
+ # ── Solution Node ─────────────────────────────────────────────────────────────
163
+
164
+ def test_reveal_solution_structure():
165
+ """reveal_solution should return solution, explanation, and complexity."""
166
+ mock_sol = MagicMock()
167
+ mock_sol.solution_code = "def two_sum(nums, target): ..."
168
+ mock_sol.explanation = "Use a hashmap to store complements."
169
+ mock_sol.complexity_analysis = "Time: O(N), Space: O(N)"
170
+
171
+ with patch("agent.nodes.solution_node._structured_llm") as mock_llm:
172
+ mock_llm.invoke.return_value = mock_sol
173
+
174
+ from agent.nodes.solution_node import reveal_solution
175
+ result = reveal_solution(_base_state())
176
+
177
+ resp = result["final_response"]
178
+ assert "solution" in resp
179
+ assert "explanation" in resp
180
+ assert "complexity" in resp
181
+ assert resp["score"] == 0
182
+ assert resp["type"] == "Solution"