Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 5 additions & 2 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -14,8 +14,11 @@ GITHUB_CLIENT_SECRET="github_client_secret_placeholder"
GOOGLE_CLIENT_ID="google_client_id_placeholder"
GOOGLE_CLIENT_SECRET="google_client_secret_placeholder"

# AI services API keys
ANTHROPIC_API_KEY="sk-ant-api53-..."
# AI services — OpenRouter (unified access to 200+ models)
OPENROUTER_API_KEY="sk-or-v1-..."
# Model selection (optional, defaults to google/gemini-2.0-flash-001)
# Options: google/gemini-2.0-flash-001, openai/gpt-4o-mini, anthropic/claude-sonnet-4-20250514, deepseek/deepseek-chat
LLM_MODEL="google/gemini-2.0-flash-001"

# Cloudflare R2 Storage credentials
R2_ACCOUNT_ID="r2_account_id_placeholder"
Expand Down
44 changes: 44 additions & 0 deletions apps/ai-service/app/config.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
"""Application configuration loaded from environment variables."""

import os
from functools import lru_cache


class Settings:
"""Settings loaded from environment / .env file."""

def __init__(self) -> None:
# OpenRouter provides unified access to 200+ models
self.openrouter_api_key: str = os.getenv("OPENROUTER_API_KEY", "")
self.openrouter_base_url: str = os.getenv(
"OPENROUTER_BASE_URL", "https://openrouter.ai/api/v1"
)
# Your site URL for OpenRouter rankings (set to your domain or leave blank)
self.openrouter_site_url: str = os.getenv("OPENROUTER_SITE_URL", "")
self.openrouter_app_name: str = os.getenv("OPENROUTER_APP_NAME", "UnVibe")

# Model selection — change this to switch providers
# Options: google/gemini-2.0-flash-001, openai/gpt-4o-mini,
# anthropic/claude-sonnet-4-20250514, meta-llama/llama-3.3-70b-instruct,
# deepseek/deepseek-chat, mistralai/mistral-large-2407
self.llm_model: str = os.getenv("LLM_MODEL", "google/gemini-2.0-flash-001")
self.max_tokens: int = int(os.getenv("LLM_MAX_TOKENS", "4096"))
self.ai_service_port: int = int(os.getenv("AI_SERVICE_PORT", "8000"))

@property
def has_llm_key(self) -> bool:
"""Check if a usable API key is configured."""
key = self.openrouter_api_key
return bool(key) and key != "" and not key.startswith("sk-or-v1-placeholder")

@property
def llm_key_preview(self) -> str:
"""Return masked key for logging (first 8 chars)."""
k = self.openrouter_api_key
return f"{k[:8]}...{k[-4:]}" if len(k) > 12 else "(invalid)"


@lru_cache()
def get_settings() -> Settings:
"""Return singleton settings instance."""
return Settings()
1 change: 1 addition & 0 deletions apps/ai-service/app/prompts/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
# Versioned Claude prompt templates for all AI features.
1 change: 1 addition & 0 deletions apps/ai-service/app/prompts/v1/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
# v1 prompt templates
22 changes: 22 additions & 0 deletions apps/ai-service/app/prompts/v1/code_generation.txt
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
You are a senior software engineer. Generate a production-grade solution for the following problem.

Problem: {problem_description}

Language: {language}
Difficulty: {difficulty}

Requirements:
- Write clean, well-commented code
- Handle edge cases (empty input, invalid values, boundary conditions)
- Follow {language} best practices and conventions
- Include type hints / type annotations where applicable
- Optimize for readability and maintainability
- DO NOT include any explanation or introductory text — return ONLY the code
- Return the code inside a single markdown code block with the language identifier

Example output format:
```python
def solution(input_data):
# implementation
pass
```
24 changes: 24 additions & 0 deletions apps/ai-service/app/prompts/v1/defend_evaluation.txt
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
You are evaluating a developer's answer in a Defend session. Determine whether their response demonstrates genuine understanding of their code.

Question asked: {question}
Developer's answer: {answer}

Full code context:
{code}

Evaluate on these criteria:
1. Accuracy — Is the answer technically correct?
2. Depth — Does it show understanding beyond surface level?
3. Specificity — Does it reference specific lines, variables, or decisions in the code?
4. Confidence — Does the answer sound certain or guess-like?

Return ONLY a JSON object with no additional text:
{{
"passed": true,
"feedback": "Constructive feedback explaining what was right and what could be improved.",
"score": 85
}}

- passed: true if score >= 60, false otherwise
- feedback: 1-3 sentences with specific, actionable feedback referencing their answer
- score: 0-100 integer reflecting overall understanding
22 changes: 22 additions & 0 deletions apps/ai-service/app/prompts/v1/defend_question.txt
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
You are conducting a Socratic interview about a developer's code submission. The developer has rebuilt a solution from memory during a Defend session. Ask them a probing question to test whether they truly understand the code's design decisions.

Original problem: {problem_description}

Developer's submitted code:
{code}

Conversation so far:
{messages}

Ask ONE probing question that:
- Challenges a specific design choice in their code (data structure, algorithm, pattern)
- Tests understanding of time or space complexity tradeoffs
- Probes error handling or edge cases they may have missed
- Asks WHY they chose one approach over an alternative
- Pushes them to think about scalability or real-world implications

Rules:
- Do NOT ask yes/no questions — require a substantive answer
- Do NOT ask about obvious syntax — ask about design reasoning
- Do NOT repeat a question already asked (check conversation history)
- Return ONLY the question text, no explanations or prefixes
31 changes: 31 additions & 0 deletions apps/ai-service/app/prompts/v1/quiz_generation.txt
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
You are a technical quiz generator. You are given a piece of code and optional annotations explaining parts of it. Generate {count} multiple-choice questions that test deep understanding of the code.

Code:
{code}

Annotations (student's explanations of code sections):
{annotations}

Instructions for each question:
1. Focus on understanding WHY the code works, not just WHAT it does
2. Include exactly 4 options labeled A through D
3. Exactly one option must be correct
4. Include at least one distractor that reflects a common misconception
5. Vary difficulty — some surface-level, some requiring deep reasoning
6. Each question should have a brief explanation of why the correct answer is right

Return ONLY a valid JSON object with no additional text. Use this exact format:
{{
"title": "Comprehension Check: {topic}",
"questions": [
{{
"id": "q-1",
"question": "Why does the code do X on line Y?",
"options": ["Option A text", "Option B text", "Option C text", "Option D text"],
"correct_option": 0,
"explanation": "Brief explanation of why A is correct."
}}
]
}}

Generate exactly {count} questions.
205 changes: 188 additions & 17 deletions apps/ai-service/app/routes/defend.py
Original file line number Diff line number Diff line change
@@ -1,35 +1,206 @@
"""Defend session Q&A endpoint — Socratic questioning via Claude."""

import json
from typing import Optional

from fastapi import APIRouter, HTTPException
from pydantic import BaseModel
from typing import List, Dict, Any
from loguru import logger

from app.config import get_settings
from app.services.llm_client import llm, LLMClientError
from app.services.prompt_manager import render_prompt, strip_markdown_fence

router = APIRouter(prefix="/defend", tags=["defend"])

MAX_QUESTIONS = 5 # Maximum questions before auto-evaluation


class DefendMessage(BaseModel):
role: str # user or assistant
"""A single message in the defend conversation."""
role: str # "user" or "assistant"
content: str


class DefendSessionRequest(BaseModel):
"""Request to ask a question or evaluate in a defend session."""
session_id: str
messages: List[DefendMessage]
code: str
problem_description: str
messages: list[DefendMessage]


class DefendResponse(BaseModel):
next_question: str
passed: bool
feedback: str | None = None
"""Response from the defend endpoint."""
next_question: Optional[str] = None
passed: bool = False
feedback: Optional[str] = None
score: Optional[int] = None


@router.post("/respond", response_model=DefendResponse)
async def respond_defend(req: DefendSessionRequest):
logger.info(f"Processing defend response for session: {req.session_id}")
# Simple defense evaluation mockup
if len(req.messages) >= 3:
return DefendResponse(
next_question="Defense completed.",
passed=True,
feedback="Great work defending your solution! You demonstrated strong conceptual understanding."
async def respond_defend(req: DefendSessionRequest) -> DefendResponse:
"""
Process a defend session interaction.

Two modes based on conversation state:
1. **Ask mode** (default) — Generates the next Socratic question using Claude,
based on the code, problem, and conversation history.
2. **Evaluate mode** — Triggered when the conversation reaches MAX_QUESTIONS
exchanges. Claude evaluates the user's overall understanding and returns
a pass/fail verdict with feedback.

The endpoint determines which mode to use based on the number of
assistant messages (questions asked so far) in the conversation.
"""
settings = get_settings()

if not settings.has_llm_key:
raise HTTPException(
status_code=503,
detail="AI Service unavailable: OPENROUTER_API_KEY not configured.",
)
return DefendResponse(
next_question="Why did you choose this specific data structure here?",
passed=False

# Count how many questions have been asked so far
questions_asked = sum(1 for m in req.messages if m.role == "assistant")
user_answers = sum(1 for m in req.messages if m.role == "user")

logger.info(
"Defend session request",
session_id=req.session_id,
questions_asked=questions_asked,
user_answers=user_answers,
total_messages=len(req.messages),
)

# Determine mode: if we've reached the limit, evaluate
if questions_asked >= MAX_QUESTIONS:
return await _evaluate_answer(req)
else:
return await _ask_question(req)


async def _ask_question(req: DefendSessionRequest) -> DefendResponse:
"""Generate a Socratic question using Claude."""
try:
# Format conversation history for the prompt
messages_text = _format_conversation(req.messages)

prompt = render_prompt(
"defend_question",
problem_description=req.problem_description,
code=req.code,
messages=messages_text,
)

question = await llm.generate_async(prompt=prompt)

return DefendResponse(
next_question=question.strip(),
passed=False,
feedback=None,
score=None,
)

except LLMClientError as exc:
logger.error(f"Defend question generation failed: {exc}")
raise HTTPException(
status_code=502,
detail=f"Failed to generate defend question: {exc}",
) from exc


async def _evaluate_answer(req: DefendSessionRequest) -> DefendResponse:
"""
Evaluate the user's last answer and overall performance.

Uses Claude to assess the last user response against the code context,
then returns pass/fail with detailed feedback and a score.
"""
try:
# Get the last user message
last_answer = ""
for msg in reversed(req.messages):
if msg.role == "user":
last_answer = msg.content
break

# Get the question that was asked before that answer
last_question = ""
for msg in reversed(req.messages):
if msg.role == "assistant":
last_question = msg.content
break

prompt = render_prompt(
"defend_evaluation",
question=last_question,
answer=last_answer,
code=req.code,
)

text = await llm.generate_async(prompt=prompt)

# Parse JSON response
result = _parse_evaluation(text)

passed = result.get("passed", False)
feedback = result.get("feedback", "No feedback provided.")
score = result.get("score", 0)

logger.info(
"Defend evaluation complete",
session_id=req.session_id,
passed=passed,
score=score,
)

return DefendResponse(
next_question=None,
passed=passed,
feedback=feedback,
score=score,
)

except LLMClientError as exc:
logger.error(f"Defend evaluation failed: {exc}")
raise HTTPException(
status_code=502,
detail=f"Failed to evaluate defend answer: {exc}",
) from exc
except (json.JSONDecodeError, KeyError, ValueError) as exc:
logger.error(f"Failed to parse evaluation from Claude: {exc}")
raise HTTPException(
status_code=502,
detail="Defend evaluation returned an invalid response format.",
) from exc


def _format_conversation(messages: list[DefendMessage]) -> str:
"""Format conversation history for the prompt template."""
if not messages:
return "No previous conversation."

lines = []
for m in messages:
role_label = "Interviewer" if m.role == "assistant" else "Developer"
lines.append(f"{role_label}: {m.content}")
return "\n\n".join(lines)


def _parse_evaluation(text: str) -> dict:
"""Parse Claude's JSON evaluation response."""
# Strip markdown code fences if present
text = strip_markdown_fence(text)

data = json.loads(text)

# Validate expected fields
if "passed" not in data:
raise ValueError("Missing 'passed' field in evaluation response")

return {
"passed": bool(data["passed"]),
"feedback": str(data.get("feedback", "")),
"score": max(0, min(100, int(data.get("score", 0)))),
}
Loading