Spaces:
Sleeping
Sleeping
Commit Β·
e266561
1
Parent(s): fb69b4b
init_code
Browse files- .gitignore +6 -0
- Dockerfile +23 -0
- README.md +0 -11
- agent/__init__.py +5 -0
- agent/db.py +79 -0
- agent/graph.py +90 -0
- agent/knowledge.py +72 -0
- agent/llm_factory.py +53 -0
- agent/memory.py +44 -0
- agent/models.py +95 -0
- agent/nodes.py +194 -0
- agent/nodes/__init__.py +17 -0
- agent/nodes/analyze_node.py +95 -0
- agent/nodes/classify_node.py +18 -0
- agent/nodes/gate_node.py +41 -0
- agent/nodes/hint_node.py +90 -0
- agent/nodes/solution_node.py +47 -0
- agent/nodes/validate_node.py +36 -0
- agent/prompts/__init__.py +8 -0
- agent/prompts/analyze_prompt.py +41 -0
- agent/prompts/classify_prompt.py +17 -0
- agent/prompts/hint_prompt.py +33 -0
- agent/prompts/solution_prompt.py +19 -0
- agent/sandbox.py +132 -0
- dsa_mentor.db +0 -0
- main.py +109 -0
- requirements.txt +13 -0
- tests/__init__.py +1 -0
- tests/test_nodes.py +182 -0
.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"
|