Version 1.0
This commit is contained in:
+181
@@ -0,0 +1,181 @@
|
||||
from fastapi import FastAPI, HTTPException
|
||||
from pydantic import BaseModel
|
||||
from fastapi.middleware.cors import CORSMiddleware
|
||||
|
||||
from src.core.loader import ChallengeLoader
|
||||
from src.core.executor import executor
|
||||
from src.core.linter import linter
|
||||
from src.core.prompts import get_socratic_prompt
|
||||
|
||||
app = FastAPI(title="Socratic Tutor API")
|
||||
|
||||
app.add_middleware(
|
||||
CORSMiddleware,
|
||||
allow_origins=["*"],
|
||||
allow_credentials=True,
|
||||
allow_methods=["*"],
|
||||
allow_headers=["*"],
|
||||
)
|
||||
|
||||
loader = ChallengeLoader()
|
||||
|
||||
|
||||
class LintRequest(BaseModel):
|
||||
language: str
|
||||
code: str
|
||||
|
||||
|
||||
from typing import Optional
|
||||
|
||||
|
||||
class GuideRequest(BaseModel):
|
||||
challenge_id: str
|
||||
language: str
|
||||
code: str
|
||||
question: Optional[str] = ""
|
||||
|
||||
|
||||
class RunRequest(BaseModel):
|
||||
language: str
|
||||
code: str
|
||||
stdin: Optional[str] = ""
|
||||
|
||||
|
||||
@app.get("/challenges")
|
||||
def list_challenges():
|
||||
"""Returns a list of all challenge details."""
|
||||
challenges = loader.load_all()
|
||||
return {"challenges": [c.to_dict(challenge_id=k) for k, c in sorted(challenges.items())]}
|
||||
|
||||
|
||||
@app.get("/challenges/{challenge_id}")
|
||||
def get_challenge(challenge_id: str):
|
||||
"""Returns details of a single challenge."""
|
||||
challenges = loader.load_all()
|
||||
if challenge_id not in challenges:
|
||||
raise HTTPException(status_code=404, detail=f"Challenge '{challenge_id}' not found.")
|
||||
return challenges[challenge_id].to_dict(challenge_id=challenge_id)
|
||||
|
||||
|
||||
@app.post("/lint")
|
||||
def lint_code(req: LintRequest):
|
||||
"""Runs linting on user code using Core Linter."""
|
||||
res = linter.lint(req.language, req.code)
|
||||
if res.get("stderr") == f"Language '{req.language}' is not supported.":
|
||||
raise HTTPException(status_code=404, detail=res["stderr"])
|
||||
return res
|
||||
|
||||
|
||||
@app.post("/run")
|
||||
def run_code(req: RunRequest):
|
||||
"""Executes user code using Core Executor."""
|
||||
res = executor.run(req.language, req.code, stdin=req.stdin or "")
|
||||
if res.get("stderr") == f"Language '{req.language}' is not supported.":
|
||||
raise HTTPException(status_code=404, detail=res["stderr"])
|
||||
return res
|
||||
|
||||
|
||||
from src.core.mentor import mentor
|
||||
from src.core.generator import generator
|
||||
|
||||
|
||||
class GenerateChallengeRequest(BaseModel):
|
||||
prompt: str
|
||||
difficulty: Optional[str] = "Medium"
|
||||
language: Optional[str] = "Python"
|
||||
subject: Optional[str] = "General"
|
||||
|
||||
|
||||
class SaveChallengeRequest(BaseModel):
|
||||
filename: str
|
||||
markdown: str
|
||||
|
||||
|
||||
@app.post("/guide")
|
||||
async def guide_code(req: GuideRequest):
|
||||
"""Provides mentor guidance based on user's code and question for a challenge."""
|
||||
challenges = loader.load_all()
|
||||
if req.challenge_id not in challenges:
|
||||
raise HTTPException(status_code=404, detail=f"Challenge '{req.challenge_id}' not found.")
|
||||
|
||||
challenge = challenges[req.challenge_id]
|
||||
guidance = await mentor.get_guidance_async(
|
||||
challenge, req.code, user_question=req.question or ""
|
||||
)
|
||||
|
||||
return {
|
||||
"challenge_id": req.challenge_id,
|
||||
"status": guidance.get("status"),
|
||||
"mentor_response": guidance.get("mentor_response"),
|
||||
}
|
||||
|
||||
|
||||
@app.post("/challenges/generate")
|
||||
async def generate_challenge_endpoint(req: GenerateChallengeRequest):
|
||||
"""Generates a new challenge markdown document via LLM."""
|
||||
res = await generator.generate_challenge_async(
|
||||
prompt_text=req.prompt,
|
||||
difficulty=req.difficulty or "Medium",
|
||||
language=req.language or "Python",
|
||||
subject=req.subject or "General",
|
||||
)
|
||||
if res.get("status") == "error":
|
||||
raise HTTPException(status_code=500, detail=res.get("message"))
|
||||
return res
|
||||
|
||||
|
||||
@app.post("/challenges/save")
|
||||
def save_challenge_endpoint(req: SaveChallengeRequest):
|
||||
"""Saves generated markdown challenge content to disk."""
|
||||
res = generator.save_challenge(req.filename, req.markdown)
|
||||
if res.get("status") == "error":
|
||||
raise HTTPException(status_code=500, detail=res.get("message"))
|
||||
return res
|
||||
|
||||
|
||||
from src.core.handbook import handbook_service
|
||||
|
||||
|
||||
class HandbookExampleRequest(BaseModel):
|
||||
language: str
|
||||
topic_id: str
|
||||
topic_title: str
|
||||
|
||||
|
||||
@app.get("/handbook/catalog/{language}")
|
||||
def get_handbook_catalog_endpoint(language: str):
|
||||
"""Returns W3Schools-style syntax handbook catalog (functions & subjects) for specified language."""
|
||||
catalog = handbook_service.get_catalog(language)
|
||||
return {"language": language, "catalog": catalog}
|
||||
|
||||
|
||||
@app.get("/handbook/topics/{language}")
|
||||
def get_handbook_topics_endpoint(language: str):
|
||||
"""Returns syntax handbook catalog for backwards compatibility."""
|
||||
catalog = handbook_service.get_catalog(language)
|
||||
return {"language": language, "topics": catalog.get("functions", []) + catalog.get("subjects", [])}
|
||||
|
||||
|
||||
|
||||
@app.post("/handbook/example")
|
||||
async def get_handbook_example_endpoint(req: HandbookExampleRequest):
|
||||
"""Generates an LLM code example for a handbook reference topic."""
|
||||
res = await handbook_service.generate_example_async(
|
||||
language=req.language,
|
||||
topic_id=req.topic_id,
|
||||
topic_title=req.topic_title,
|
||||
)
|
||||
if res.get("status") == "error":
|
||||
raise HTTPException(status_code=500, detail=res.get("message"))
|
||||
return res
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
import uvicorn
|
||||
from src.core.config import config
|
||||
uvicorn.run("src.api.main:app", host=config.web_host, port=config.web_port, reload=True)
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
# Challenge: Hello World
|
||||
**Difficulty:** Easy
|
||||
**Language:** Python
|
||||
**Subject:** Basic I/O
|
||||
|
||||
## Description
|
||||
Write a Python script that prints the phrase "Hello, World!" to the console.
|
||||
|
||||
## Requirements
|
||||
- The script must output exactly "Hello, World!".
|
||||
- The script should be named `hello.py`.
|
||||
|
||||
## Hints
|
||||
- Use the `print()` function in Python.
|
||||
- Ensure there are no extra spaces or characters in the string.
|
||||
|
||||
## Validation
|
||||
- **Check:** `python3 -c "import hello; print(hello.main())"`
|
||||
- **Expected Output:** Hello, World!
|
||||
@@ -0,0 +1,20 @@
|
||||
# Challenge: Variables and Assignment
|
||||
**Difficulty:** Easy
|
||||
**Language:** Python
|
||||
**Subject:** Variables & Types
|
||||
|
||||
## Description
|
||||
Create a variable named `score` and assign it the value 100. Then, print the message "The score is 100" to the console.
|
||||
|
||||
## Requirements
|
||||
- Create a variable named `score`.
|
||||
- Assign the integer value 100 to it.
|
||||
- Print "The score is 100".
|
||||
|
||||
## Hints
|
||||
- Use `score = 100` to create the variable.
|
||||
- Use `print("The score is 100")` to output the text.
|
||||
|
||||
## Validation
|
||||
- **Check:** `python3 -c "import main; print(main.score)"`
|
||||
- **Expected Output:** 100
|
||||
@@ -0,0 +1,24 @@
|
||||
# Challenge: For Loops
|
||||
**Difficulty:** Easy
|
||||
**Language:** Python
|
||||
**Subject:** Control Flow (Loops)
|
||||
|
||||
## Description
|
||||
Write a loop that prints the numbers from 1 to 5 (inclusive).
|
||||
|
||||
## Requirements
|
||||
- Use a `for` loop.
|
||||
- Print each number on a new line.
|
||||
|
||||
## Hints
|
||||
- The `range(1, 6)` function generates numbers from 1 to 5.
|
||||
- Use `print()` inside the loop body.
|
||||
|
||||
## Validation
|
||||
- **Check:** `python3 -c "import main; [print(i) for i in range(1, 6)]"`
|
||||
- **Expected Output:**
|
||||
1
|
||||
2
|
||||
3
|
||||
4
|
||||
5
|
||||
@@ -0,0 +1,20 @@
|
||||
# Challenge: Conditionals
|
||||
**Difficulty:** Easy
|
||||
**Language:** Python
|
||||
**Subject:** Control Flow (Conditionals)
|
||||
|
||||
## Description
|
||||
Write a program that checks if a number is positive, negative, or zero.
|
||||
|
||||
## Requirements
|
||||
- Create a variable `num` and assign it a value (e.g., -5).
|
||||
- Use `if`, `elif`, and `else` to check the value.
|
||||
- Print "Positive", "Negative", or "Zero" based on the value.
|
||||
|
||||
## Hints
|
||||
- Use `num > 0`, `num < 0`, and `num == 0` for the conditions.
|
||||
- Remember to use indentation for the code inside the blocks.
|
||||
|
||||
## Validation
|
||||
- **Check:** `python3 -c "import main; print(main.check_num(-5))"`
|
||||
- **Expected Output:** Negative
|
||||
@@ -0,0 +1,20 @@
|
||||
# Challenge: Functions
|
||||
**Difficulty:** Easy
|
||||
**Language:** Python
|
||||
**Subject:** Modular Programming (Functions)
|
||||
|
||||
## Description
|
||||
Write a function named `add_numbers` that takes two parameters, `a` and `b`, and returns their sum.
|
||||
|
||||
## Requirements
|
||||
- Define the function `add_numbers(a, b)`.
|
||||
- Use the `return` keyword.
|
||||
- Call the function and print the result of adding 5 and 10.
|
||||
|
||||
## Hints
|
||||
- `def add_numbers(a, b):` is the standard way to define a function.
|
||||
- Use `print(add_numbers(5, 10))` to see the result.
|
||||
|
||||
## Validation
|
||||
- **Check:** `python3 -c "import main; print(main.add_numbers(5, 10))"`
|
||||
- **Expected Output:** 15
|
||||
@@ -0,0 +1,20 @@
|
||||
# Challenge: List Basics
|
||||
**Difficulty:** Easy
|
||||
**Language:** Python
|
||||
**Subject:** Data Structures (Lists)
|
||||
|
||||
## Description
|
||||
Create a list of 3 fruits. Add a 4th fruit to the list and then print the final list.
|
||||
|
||||
## Requirements
|
||||
- Create a list named `fruits`.
|
||||
- Use `.append()` to add another item.
|
||||
- Print the final list.
|
||||
|
||||
## Hints
|
||||
- `fruits = ["apple", "banana", "cherry"]`
|
||||
- `fruits.append("date")`
|
||||
|
||||
## Validation
|
||||
- **Check:** `python3 -c "import main; print(main.fruits)"`
|
||||
- **Expected Output:** ['apple', 'banana', 'cherry', 'date']
|
||||
@@ -0,0 +1,44 @@
|
||||
# Challenge: LRU Cache Implementation
|
||||
**Difficulty:** Medium
|
||||
**Language:** Java
|
||||
**Subject:** Data Structures
|
||||
|
||||
## Description
|
||||
In high-performance software engineering, caching is a critical technique used to reduce data retrieval time by storing frequently accessed information in fast-access memory. One of the most common eviction policies is **LRU (Least Recently Used)**. This policy discards the least recently accessed items first when the cache reaches its capacity.
|
||||
|
||||
Your task is to design and implement a data structure for an LRU Cache. The cache must support two primary operations: `get` and `put`.
|
||||
- `get(key)`: Retrieve the value associated with the key. If the key exists, it should be marked as "recently used." If it doesn't exist, return -1.
|
||||
- `put(key, value)`: Insert or update the value for a given key. If the key already exists, update its value and mark it as "recently used." If the key is new and the cache is at full capacity, you must remove the least recently used item before inserting the new one.
|
||||
|
||||
The primary constraint is that both `get` and `put` operations must run in **O(1)** average time complexity.
|
||||
|
||||
## Requirements
|
||||
- Implement a class `LRUCache` that takes an integer `capacity` as a constructor argument.
|
||||
- Implement the `get(int key)` method:
|
||||
- Return the value if the key exists; otherwise, return -1.
|
||||
- Moving a key to the "most recently used" position must happen automatically upon access.
|
||||
- Implement the `put(int key, int value)` method:
|
||||
- If the key exists, update the value and move it to the "most recently used" position.
|
||||
- If the key is new, add it to the cache.
|
||||
- If the cache exceeds `capacity`, remove the entry that was accessed least recently.
|
||||
- **Performance Constraint:** You must achieve $O(1)$ time complexity for both operations. (Hint: Using only a HashMap or only a Linked List will not satisfy the time complexity requirements for both operations simultaneously).
|
||||
|
||||
## Hints
|
||||
- To achieve $O(1)$ lookup, a `HashMap` is essential. However, a standard `HashMap` does not maintain the order of access.
|
||||
- To achieve $O(1)$ removal and insertion at specific positions, a **Doubly Linked List** is the ideal companion to the HashMap.
|
||||
- The HashMap should store the key as the map key and the corresponding Node object (from your Doubly Linked List) as the map value. This allows you to jump directly to the node in the list to re-link it in constant time.
|
||||
|
||||
## Validation
|
||||
- **Check:** Initialize `LRUCache cache = new LRUCache(2)`.
|
||||
- **Sequence:**
|
||||
1. `cache.put(1, 1)`
|
||||
2. `cache.put(2, 2)`
|
||||
3. `cache.get(1)` (Should return 1, and 1 becomes most recent)
|
||||
4. `cache.put(3, 3)` (Capacity is 2, so the least recently used key '2' should be evicted)
|
||||
5. `cache.get(2)` (Should return -1)
|
||||
6. `cache.get(3)` (Should return 3)
|
||||
7. `cache.put(4, 4)` (Capacity is 2, so the least recently used key '1' should be evicted)
|
||||
8. `cache.get(1)` (Should return -1)
|
||||
9. `cache.get(3)` (Should return 3)
|
||||
10. `cache.get(4)` (Should return 4)
|
||||
- **Expected Output:** `[1, -1, 3, -1, 3, 4]`
|
||||
@@ -0,0 +1,120 @@
|
||||
"""Configuration loader for TactTerm."""
|
||||
import os
|
||||
import json
|
||||
from typing import Dict, Any
|
||||
|
||||
|
||||
class Config:
|
||||
"""Manages application configuration settings."""
|
||||
|
||||
DEFAULT_CONFIG = {
|
||||
"llm": {
|
||||
"base_url": "http://localhost:8080/v1",
|
||||
"model": "local-model",
|
||||
"api_key": "not-needed",
|
||||
"temperature": 0.7,
|
||||
"max_tokens": 512,
|
||||
"timeout_seconds": 5.0,
|
||||
},
|
||||
"web": {
|
||||
"host": "127.0.0.1",
|
||||
"port": 8000,
|
||||
"public": False,
|
||||
},
|
||||
}
|
||||
|
||||
def __init__(self, config_path: str = "config.json") -> None:
|
||||
self.config_path = config_path
|
||||
self._data = self.load_config()
|
||||
|
||||
def load_config(self) -> Dict[str, Any]:
|
||||
# Deep copy default configuration structure
|
||||
config = json.loads(json.dumps(self.DEFAULT_CONFIG))
|
||||
if os.path.exists(self.config_path):
|
||||
try:
|
||||
with open(self.config_path, "r", encoding="utf-8") as f:
|
||||
user_config = json.load(f)
|
||||
if "llm" in user_config and isinstance(user_config["llm"], dict):
|
||||
config["llm"].update(user_config["llm"])
|
||||
if "web" in user_config and isinstance(user_config["web"], dict):
|
||||
config["web"].update(user_config["web"])
|
||||
except Exception as e:
|
||||
print(f"Warning: Failed to load {self.config_path}: {e}")
|
||||
else:
|
||||
try:
|
||||
dirname = os.path.dirname(self.config_path)
|
||||
if dirname:
|
||||
os.makedirs(dirname, exist_ok=True)
|
||||
with open(self.config_path, "w", encoding="utf-8") as f:
|
||||
json.dump(config, f, indent=2)
|
||||
except Exception as e:
|
||||
print(f"Warning: Failed to create default {self.config_path}: {e}")
|
||||
|
||||
|
||||
# Allow environment variable overrides
|
||||
env_base_url = os.getenv("TACTTERM_LLM_BASE_URL")
|
||||
if env_base_url:
|
||||
config["llm"]["base_url"] = env_base_url
|
||||
|
||||
env_model = os.getenv("TACTTERM_LLM_MODEL")
|
||||
if env_model:
|
||||
config["llm"]["model"] = env_model
|
||||
|
||||
env_web_host = os.getenv("TACTTERM_WEB_HOST")
|
||||
if env_web_host:
|
||||
config["web"]["host"] = env_web_host
|
||||
|
||||
env_web_port = os.getenv("TACTTERM_WEB_PORT")
|
||||
if env_web_port:
|
||||
try:
|
||||
config["web"]["port"] = int(env_web_port)
|
||||
except ValueError:
|
||||
pass
|
||||
|
||||
env_web_public = os.getenv("TACTTERM_WEB_PUBLIC")
|
||||
if env_web_public is not None:
|
||||
config["web"]["public"] = env_web_public.lower() in ("true", "1", "yes")
|
||||
|
||||
return config
|
||||
|
||||
@property
|
||||
def llm_base_url(self) -> str:
|
||||
return self._data["llm"]["base_url"].rstrip("/")
|
||||
|
||||
@property
|
||||
def llm_model(self) -> str:
|
||||
return self._data["llm"]["model"]
|
||||
|
||||
@property
|
||||
def llm_api_key(self) -> str:
|
||||
return self._data["llm"]["api_key"]
|
||||
|
||||
@property
|
||||
def llm_temperature(self) -> float:
|
||||
return float(self._data["llm"]["temperature"])
|
||||
|
||||
@property
|
||||
def llm_max_tokens(self) -> int:
|
||||
return int(self._data["llm"]["max_tokens"])
|
||||
|
||||
@property
|
||||
def llm_timeout(self) -> float:
|
||||
return float(self._data["llm"]["timeout_seconds"])
|
||||
|
||||
@property
|
||||
def web_public(self) -> bool:
|
||||
return bool(self._data.get("web", {}).get("public", False))
|
||||
|
||||
@property
|
||||
def web_host(self) -> str:
|
||||
if self.web_public:
|
||||
return "0.0.0.0"
|
||||
return self._data.get("web", {}).get("host", "127.0.0.1")
|
||||
|
||||
@property
|
||||
def web_port(self) -> int:
|
||||
return int(self._data.get("web", {}).get("port", 8000))
|
||||
|
||||
|
||||
config = Config()
|
||||
|
||||
@@ -0,0 +1,215 @@
|
||||
"""Service for executing user code safely across 10 programming languages with stdin support."""
|
||||
import sys
|
||||
import os
|
||||
import tempfile
|
||||
import subprocess
|
||||
from typing import Dict, Any
|
||||
from src.core.registry import registry
|
||||
|
||||
|
||||
class CodeExecutor:
|
||||
"""Executes code for all 10 supported programming languages with stdin support."""
|
||||
|
||||
def run(self, language: str, code: str, stdin: str = "") -> Dict[str, Any]:
|
||||
config = registry.get_config(language)
|
||||
if not config:
|
||||
return {
|
||||
"language": language,
|
||||
"exit_code": 1,
|
||||
"stdout": "",
|
||||
"stderr": f"Language '{language}' is not supported.",
|
||||
}
|
||||
|
||||
lang_key = registry.canonical_name(language)
|
||||
ext = config.get("ext", ".txt")
|
||||
|
||||
with tempfile.TemporaryDirectory() as tmpdir:
|
||||
if lang_key == "java":
|
||||
temp_file_name = "Main.java"
|
||||
elif lang_key == "cpp":
|
||||
temp_file_name = "main.cpp"
|
||||
elif lang_key == "rust":
|
||||
temp_file_name = "main.rs"
|
||||
elif lang_key == "csharp":
|
||||
temp_file_name = "Program.cs"
|
||||
else:
|
||||
temp_file_name = f"main{ext}"
|
||||
|
||||
temp_file_path = os.path.join(tmpdir, temp_file_name)
|
||||
with open(temp_file_path, "w", encoding="utf-8") as f:
|
||||
f.write(code)
|
||||
|
||||
try:
|
||||
if lang_key == "python":
|
||||
cmd = [sys.executable, temp_file_path]
|
||||
result = subprocess.run(
|
||||
cmd,
|
||||
input=stdin,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
cwd=tmpdir,
|
||||
timeout=15,
|
||||
)
|
||||
elif lang_key == "html":
|
||||
return {
|
||||
"language": language,
|
||||
"exit_code": 0,
|
||||
"stdout": code,
|
||||
"stderr": "",
|
||||
}
|
||||
elif lang_key == "javascript":
|
||||
result = subprocess.run(
|
||||
["node", temp_file_path],
|
||||
input=stdin,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
cwd=tmpdir,
|
||||
timeout=15,
|
||||
)
|
||||
elif lang_key == "typescript":
|
||||
result = subprocess.run(
|
||||
["npx", "ts-node", temp_file_path],
|
||||
input=stdin,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
cwd=tmpdir,
|
||||
timeout=15,
|
||||
)
|
||||
elif lang_key == "go":
|
||||
result = subprocess.run(
|
||||
["go", "run", temp_file_path],
|
||||
input=stdin,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
cwd=tmpdir,
|
||||
timeout=15,
|
||||
)
|
||||
elif lang_key == "lua":
|
||||
result = subprocess.run(
|
||||
["lua", temp_file_path],
|
||||
input=stdin,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
cwd=tmpdir,
|
||||
timeout=15,
|
||||
)
|
||||
elif lang_key == "cpp":
|
||||
compile_res = subprocess.run(
|
||||
["g++", "-O2", temp_file_path, "-o", "main"],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
cwd=tmpdir,
|
||||
timeout=15,
|
||||
)
|
||||
if compile_res.returncode != 0:
|
||||
return {
|
||||
"language": language,
|
||||
"exit_code": compile_res.returncode,
|
||||
"stdout": compile_res.stdout,
|
||||
"stderr": f"Compilation Error:\n{compile_res.stderr}",
|
||||
}
|
||||
result = subprocess.run(
|
||||
["./main"],
|
||||
input=stdin,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
cwd=tmpdir,
|
||||
timeout=15,
|
||||
)
|
||||
elif lang_key == "rust":
|
||||
compile_res = subprocess.run(
|
||||
["rustc", temp_file_path, "-o", "main"],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
cwd=tmpdir,
|
||||
timeout=15,
|
||||
)
|
||||
if compile_res.returncode != 0:
|
||||
return {
|
||||
"language": language,
|
||||
"exit_code": compile_res.returncode,
|
||||
"stdout": compile_res.stdout,
|
||||
"stderr": f"Rustc Compilation Error:\n{compile_res.stderr}",
|
||||
}
|
||||
result = subprocess.run(
|
||||
["./main"],
|
||||
input=stdin,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
cwd=tmpdir,
|
||||
timeout=15,
|
||||
)
|
||||
elif lang_key == "java":
|
||||
compile_res = subprocess.run(
|
||||
["javac", temp_file_path],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
cwd=tmpdir,
|
||||
timeout=15,
|
||||
)
|
||||
if compile_res.returncode != 0:
|
||||
return {
|
||||
"language": language,
|
||||
"exit_code": compile_res.returncode,
|
||||
"stdout": compile_res.stdout,
|
||||
"stderr": f"Javac Compilation Error:\n{compile_res.stderr}",
|
||||
}
|
||||
result = subprocess.run(
|
||||
["java", "Main"],
|
||||
input=stdin,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
cwd=tmpdir,
|
||||
timeout=15,
|
||||
)
|
||||
elif lang_key == "csharp":
|
||||
result = subprocess.run(
|
||||
["dotnet", "run"],
|
||||
input=stdin,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
cwd=tmpdir,
|
||||
timeout=15,
|
||||
)
|
||||
else:
|
||||
exec_cmd = config["exec"]
|
||||
result = subprocess.run(
|
||||
f"{exec_cmd} {temp_file_path}",
|
||||
shell=True,
|
||||
input=stdin,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
cwd=tmpdir,
|
||||
timeout=15,
|
||||
)
|
||||
|
||||
return {
|
||||
"language": language,
|
||||
"exit_code": result.returncode,
|
||||
"stdout": result.stdout,
|
||||
"stderr": result.stderr,
|
||||
}
|
||||
except FileNotFoundError as fnf:
|
||||
return {
|
||||
"language": language,
|
||||
"exit_code": 127,
|
||||
"stdout": "",
|
||||
"stderr": f"Compiler/Interpreter binary not found for {language}: {fnf}",
|
||||
}
|
||||
except subprocess.TimeoutExpired:
|
||||
return {
|
||||
"language": language,
|
||||
"exit_code": 124,
|
||||
"stdout": "",
|
||||
"stderr": "Execution timed out (exceeded 15 seconds).",
|
||||
}
|
||||
except Exception as e:
|
||||
return {
|
||||
"language": language,
|
||||
"exit_code": 1,
|
||||
"stdout": "",
|
||||
"stderr": str(e),
|
||||
}
|
||||
|
||||
|
||||
executor = CodeExecutor()
|
||||
@@ -0,0 +1,143 @@
|
||||
"""Service for generating and saving new programming challenges via LLM backend."""
|
||||
import os
|
||||
import re
|
||||
import httpx
|
||||
from typing import Dict, Any
|
||||
from src.core.config import config
|
||||
from src.core.mentor import get_chat_completions_url
|
||||
|
||||
SYSTEM_PROMPT_GENERATOR = """You are a Master Software Engineering Challenge Creator for TactiTerm.
|
||||
Your task is to generate a complete, high-quality coding challenge formatted strictly as a Markdown document according to the specification below.
|
||||
|
||||
### STRICT MARKDOWN FORMAT SPECIFICATION:
|
||||
```markdown
|
||||
# Challenge: <Short Clear Title>
|
||||
**Difficulty:** <Easy|Medium|Hard|Advanced>
|
||||
**Language:** <Python|C#|C++|Java|JavaScript|TypeScript|Rust|Lua|HTML|Go>
|
||||
**Subject:** <Specific Subject/Topic>
|
||||
|
||||
## Description
|
||||
<Clear multi-paragraph explanation of the problem, background context, and what the student needs to build.>
|
||||
|
||||
## Requirements
|
||||
- <Clear, objective requirement 1>
|
||||
- <Clear, objective requirement 2>
|
||||
- <Clear, objective requirement 3>
|
||||
|
||||
## Hints
|
||||
- <Helpful conceptual hint 1>
|
||||
- <Helpful conceptual hint 2>
|
||||
|
||||
## Validation
|
||||
- **Check:** <Validation command or test description>
|
||||
- **Expected Output:** <Expected output or return value>
|
||||
```
|
||||
|
||||
### RULES:
|
||||
1. ONLY return the valid Markdown content starting with `# Challenge: `.
|
||||
2. Do NOT enclose the entire Markdown output in outer markdown triple backticks.
|
||||
3. Ensure the Language is one of: Python, C#, C++, Java, JavaScript, TypeScript, Rust, Lua, HTML, Go.
|
||||
4. Ensure Difficulty is one of: Easy, Medium, Hard, Advanced.
|
||||
5. Create realistic, engaging, and pedagogically sound requirements and hints.
|
||||
"""
|
||||
|
||||
|
||||
class ChallengeGenerator:
|
||||
"""Generates new challenge markdown files using the configured LLM backend."""
|
||||
|
||||
def __init__(self, challenges_dir: str = "src/challenges/"):
|
||||
self.challenges_dir = challenges_dir
|
||||
|
||||
async def generate_challenge_async(
|
||||
self, prompt_text: str, difficulty: str = "Medium", language: str = "Python", subject: str = "General"
|
||||
) -> Dict[str, Any]:
|
||||
endpoint = get_chat_completions_url(config.llm_base_url)
|
||||
|
||||
user_content = (
|
||||
f"Generate a {difficulty} difficulty challenge in {language} focusing on the subject '{subject}'.\n"
|
||||
f"User Prompt Details: {prompt_text}"
|
||||
)
|
||||
|
||||
payload = {
|
||||
"model": config.llm_model,
|
||||
"messages": [
|
||||
{"role": "system", "content": SYSTEM_PROMPT_GENERATOR},
|
||||
{"role": "user", "content": user_content},
|
||||
],
|
||||
"temperature": 0.7,
|
||||
"max_tokens": config.llm_max_tokens,
|
||||
}
|
||||
|
||||
headers = {"Content-Type": "application/json"}
|
||||
if config.llm_api_key and config.llm_api_key != "not-needed":
|
||||
headers["Authorization"] = f"Bearer {config.llm_api_key}"
|
||||
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=config.llm_timeout, follow_redirects=True) as client:
|
||||
response = await client.post(endpoint, json=payload, headers=headers)
|
||||
response.raise_for_status()
|
||||
data = response.json()
|
||||
|
||||
raw_md = (
|
||||
data.get("choices", [{}])[0]
|
||||
.get("message", {})
|
||||
.get("content", "")
|
||||
.strip()
|
||||
)
|
||||
|
||||
# Clean up outer markdown wrapper if present
|
||||
if raw_md.startswith("```markdown"):
|
||||
raw_md = raw_md.replace("```markdown", "", 1).rstrip("` \n")
|
||||
elif raw_md.startswith("```"):
|
||||
raw_md = raw_md.replace("```", "", 1).rstrip("` \n")
|
||||
|
||||
if raw_md:
|
||||
# Generate filename slug from title
|
||||
title_match = re.search(r"# Challenge: (.*)", raw_md)
|
||||
title = title_match.group(1).strip() if title_match else "new-challenge"
|
||||
slug = re.sub(r"[^\w\s-]", "", title.lower()).strip().replace(" ", "-")
|
||||
filename = f"{slug}.md"
|
||||
|
||||
return {
|
||||
"status": "success",
|
||||
"filename": filename,
|
||||
"markdown": raw_md,
|
||||
"title": title,
|
||||
}
|
||||
else:
|
||||
return {
|
||||
"status": "error",
|
||||
"message": "Received empty response from LLM server.",
|
||||
}
|
||||
except Exception as e:
|
||||
return {
|
||||
"status": "error",
|
||||
"message": f"Failed to generate challenge via LLM: {e}",
|
||||
}
|
||||
|
||||
def save_challenge(self, filename: str, markdown_content: str) -> Dict[str, Any]:
|
||||
"""Save a generated markdown challenge file into src/challenges/."""
|
||||
if not filename.endswith(".md"):
|
||||
filename = f"{filename}.md"
|
||||
|
||||
# Sanitize filename
|
||||
safe_filename = os.path.basename(filename)
|
||||
save_path = os.path.join(self.challenges_dir, safe_filename)
|
||||
|
||||
os.makedirs(self.challenges_dir, exist_ok=True)
|
||||
try:
|
||||
with open(save_path, "w", encoding="utf-8") as f:
|
||||
f.write(markdown_content)
|
||||
return {
|
||||
"status": "success",
|
||||
"path": save_path,
|
||||
"filename": safe_filename,
|
||||
}
|
||||
except Exception as e:
|
||||
return {
|
||||
"status": "error",
|
||||
"message": f"Failed to save challenge to {save_path}: {e}",
|
||||
}
|
||||
|
||||
|
||||
generator = ChallengeGenerator()
|
||||
@@ -0,0 +1,266 @@
|
||||
"""W3Schools-Style Language Reference Handbook service for built-in functions, methods, primitive types, and topics."""
|
||||
import httpx
|
||||
from typing import Dict, List, Any
|
||||
from src.core.config import config
|
||||
from src.core.mentor import get_chat_completions_url
|
||||
from src.core.registry import registry
|
||||
|
||||
HANDBOOK_CATALOG: Dict[str, Dict[str, List[Dict[str, str]]]] = {
|
||||
"python": {
|
||||
"functions": [
|
||||
{"id": "type_int", "title": "int", "desc": "Integer numerical data type (e.g. 42, -7)."},
|
||||
{"id": "type_float", "title": "float", "desc": "Floating-point real numerical data type (e.g. 3.14159)."},
|
||||
{"id": "type_bool", "title": "bool", "desc": "Boolean truth value type (True or False)."},
|
||||
{"id": "type_str", "title": "str", "desc": "Immutable text string data type."},
|
||||
{"id": "type_list", "title": "list", "desc": "Mutable ordered sequence collection type."},
|
||||
{"id": "type_dict", "title": "dict", "desc": "Key-value dictionary mapping collection type."},
|
||||
{"id": "type_set", "title": "set", "desc": "Unordered collection of unique items."},
|
||||
{"id": "print", "title": "print()", "desc": "Prints specified objects to standard output."},
|
||||
{"id": "len", "title": "len()", "desc": "Returns the number of items in a container."},
|
||||
{"id": "range", "title": "range()", "desc": "Generates a sequence of numbers from start to stop."},
|
||||
{"id": "enumerate", "title": "enumerate()", "desc": "Yields index and value tuples from an iterable."},
|
||||
{"id": "zip", "title": "zip()", "desc": "Iterates over multiple iterables in parallel."},
|
||||
{"id": "map", "title": "map()", "desc": "Applies function to all items in an iterable."},
|
||||
{"id": "filter", "title": "filter()", "desc": "Filters elements of iterable where predicate is True."},
|
||||
{"id": "sorted", "title": "sorted()", "desc": "Returns new sorted list from items in iterable."},
|
||||
{"id": "isinstance", "title": "isinstance()", "desc": "Checks if object is an instance of a class."},
|
||||
{"id": "type_func", "title": "type()", "desc": "Returns the type of an object."},
|
||||
{"id": "open", "title": "open()", "desc": "Opens a file and returns file object."},
|
||||
{"id": "input", "title": "input()", "desc": "Reads line from standard input as string."},
|
||||
{"id": "str_split", "title": "str.split()", "desc": "Splits string into list using delimiter."},
|
||||
{"id": "str_join", "title": "str.join()", "desc": "Concatenates string elements with separator."},
|
||||
{"id": "dict_get", "title": "dict.get()", "desc": "Returns value for key or default if missing."},
|
||||
{"id": "list_append", "title": "list.append()", "desc": "Appends item to end of list."},
|
||||
{"id": "sum", "title": "sum()", "desc": "Sums items of an iterable."},
|
||||
{"id": "min_max", "title": "min() / max()", "desc": "Returns smallest or largest item."},
|
||||
{"id": "abs", "title": "abs()", "desc": "Returns absolute value of number."},
|
||||
],
|
||||
"subjects": [
|
||||
{"id": "vars", "title": "Variables & Primitive Types", "desc": "int, float, bool, str, and dynamic typing."},
|
||||
{"id": "control", "title": "Control Flow & Loops", "desc": "if, elif, else, for, while, break, continue."},
|
||||
{"id": "funcs", "title": "Functions & Type Hints", "desc": "def, return, lambda, *args, **kwargs."},
|
||||
{"id": "collections", "title": "Lists, Dicts & Sets", "desc": "Comprehensions, operations, set math."},
|
||||
{"id": "errors", "title": "Exception Handling", "desc": "try, except, else, finally, raise."},
|
||||
{"id": "oop", "title": "Classes & OOP", "desc": "class, __init__, self, inheritance."},
|
||||
],
|
||||
},
|
||||
"rust": {
|
||||
"functions": [
|
||||
{"id": "type_bool", "title": "bool", "desc": "Boolean primitive type (true or false)."},
|
||||
{"id": "type_i32", "title": "i32 / i64", "desc": "Signed integer types (32-bit or 64-bit)."},
|
||||
{"id": "type_u32", "title": "u32 / u64", "desc": "Unsigned integer types (32-bit or 64-bit)."},
|
||||
{"id": "type_f64", "title": "f32 / f64", "desc": "Floating-point primitive types."},
|
||||
{"id": "type_str", "title": "str / String", "desc": "String slice (&str) and owned String type."},
|
||||
{"id": "type_vec", "title": "Vec<T>", "desc": "Growable heap-allocated vector collection."},
|
||||
{"id": "type_option", "title": "Option<T>", "desc": "Type representing optional value (Some or None)."},
|
||||
{"id": "type_result", "title": "Result<T, E>", "desc": "Type representing outcome (Ok or Err)."},
|
||||
{"id": "println", "title": "println!()", "desc": "Prints formatted text to stdout with newline."},
|
||||
{"id": "format", "title": "format!()", "desc": "Constructs formatted String using macro interpolation."},
|
||||
{"id": "vec_macro", "title": "vec![]", "desc": "Creates vector containing given elements."},
|
||||
{"id": "vec_push", "title": "Vec::push()", "desc": "Appends element to back of vector."},
|
||||
{"id": "string_from", "title": "String::from()", "desc": "Creates owned String from string literal."},
|
||||
{"id": "option_unwrap", "title": "Option::unwrap()", "desc": "Returns contained Some value or panics."},
|
||||
{"id": "result_expect", "title": "Result::expect()", "desc": "Returns contained Ok value or panics with message."},
|
||||
{"id": "iter_collect", "title": "Iterator::collect()", "desc": "Transforms iterator into collection."},
|
||||
{"id": "box_new", "title": "Box::new()", "desc": "Allocates memory on heap."},
|
||||
],
|
||||
"subjects": [
|
||||
{"id": "vars", "title": "Variables & Primitives", "desc": "let, let mut, bool, i32, f64, char, str."},
|
||||
{"id": "ownership", "title": "Ownership & Borrowing", "desc": "Move semantics, references (&), mutable (&mut)."},
|
||||
{"id": "control", "title": "Control Flow & Match", "desc": "if, loop, while, for, match pattern matching."},
|
||||
{"id": "structs", "title": "Structs & Enums", "desc": "struct, impl, enum, Option, Result."},
|
||||
{"id": "traits", "title": "Traits & Generics", "desc": "trait definition, generic type signatures."},
|
||||
],
|
||||
},
|
||||
"cpp": {
|
||||
"functions": [
|
||||
{"id": "type_int", "title": "int", "desc": "Signed 32-bit integer primitive type."},
|
||||
{"id": "type_bool", "title": "bool", "desc": "Boolean primitive type (true or false)."},
|
||||
{"id": "type_double", "title": "float / double", "desc": "Single or double precision floating point."},
|
||||
{"id": "type_string", "title": "std::string", "desc": "Standard library string object."},
|
||||
{"id": "type_vector", "title": "std::vector<T>", "desc": "Dynamic array sequence container."},
|
||||
{"id": "std_cout", "title": "std::cout / std::cin", "desc": "Standard input and output streams."},
|
||||
{"id": "vec_push_back", "title": "std::vector::push_back()", "desc": "Adds element to end of vector."},
|
||||
{"id": "std_sort", "title": "std::sort()", "desc": "Sorts range [first, last) in ascending order."},
|
||||
{"id": "make_unique", "title": "std::make_unique()", "desc": "Constructs unique pointer object."},
|
||||
{"id": "make_shared", "title": "std::make_shared()", "desc": "Constructs shared pointer object."},
|
||||
],
|
||||
"subjects": [
|
||||
{"id": "vars", "title": "Primitive Data Types", "desc": "int, float, double, bool, char, void, const, auto."},
|
||||
{"id": "control", "title": "Control Flow & Loops", "desc": "if, else, for, while, do while, switch."},
|
||||
{"id": "pointers", "title": "Pointers & References", "desc": "Raw pointers (*), references (&), nullptr."},
|
||||
{"id": "classes", "title": "Classes & OOP", "desc": "class, struct, public, private, constructors."},
|
||||
],
|
||||
},
|
||||
"go": {
|
||||
"functions": [
|
||||
{"id": "type_int", "title": "int / int64", "desc": "Signed integer numerical primitive types."},
|
||||
{"id": "type_bool", "title": "bool", "desc": "Boolean primitive type (true or false)."},
|
||||
{"id": "type_string", "title": "string", "desc": "Immutable sequence of bytes / UTF-8 text."},
|
||||
{"id": "type_error", "title": "error", "desc": "Built-in interface type for error handling."},
|
||||
{"id": "fmt_println", "title": "fmt.Println() / Printf()", "desc": "Formatted write to standard output."},
|
||||
{"id": "make", "title": "make()", "desc": "Allocates and initializes slice, map, or chan."},
|
||||
{"id": "append", "title": "append()", "desc": "Appends elements to slice."},
|
||||
{"id": "len", "title": "len() / cap()", "desc": "Returns length or capacity of collection."},
|
||||
],
|
||||
"subjects": [
|
||||
{"id": "vars", "title": "Variables & Primitives", "desc": "var, :=, int, float64, bool, string, const."},
|
||||
{"id": "control", "title": "Control Flow & Switch", "desc": "if with init, for loops, switch, select."},
|
||||
{"id": "structs", "title": "Structs & Interfaces", "desc": "type struct, methods, interface."},
|
||||
{"id": "goroutines", "title": "Goroutines & Channels", "desc": "go func(), chan, select statement."},
|
||||
],
|
||||
},
|
||||
"javascript": {
|
||||
"functions": [
|
||||
{"id": "type_boolean", "title": "boolean / bool", "desc": "Boolean primitive type (true or false)."},
|
||||
{"id": "type_number", "title": "number", "desc": "IEEE 754 floating-point numerical type."},
|
||||
{"id": "type_string", "title": "string", "desc": "Textual string primitive data type."},
|
||||
{"id": "console_log", "title": "console.log()", "desc": "Outputs message to debugging console."},
|
||||
{"id": "arr_map", "title": "Array.prototype.map()", "desc": "Creates new array with mapped elements."},
|
||||
{"id": "arr_filter", "title": "Array.prototype.filter()", "desc": "Filters elements satisfying predicate."},
|
||||
{"id": "arr_reduce", "title": "Array.prototype.reduce()", "desc": "Executes reducer function on elements."},
|
||||
{"id": "obj_keys", "title": "Object.keys()", "desc": "Returns array of object key names."},
|
||||
{"id": "json_parse", "title": "JSON.parse()", "desc": "Parses JSON string into object."},
|
||||
{"id": "fetch", "title": "fetch()", "desc": "Asynchronously fetches network resource."},
|
||||
],
|
||||
"subjects": [
|
||||
{"id": "vars", "title": "Variables & Primitives", "desc": "const, let, var, number, string, boolean, null."},
|
||||
{"id": "control", "title": "Control Flow & Logic", "desc": "if, else, ternary, for...of, for...in."},
|
||||
{"id": "funcs", "title": "Arrow Functions & Async", "desc": "function, =>, async, await, Promises."},
|
||||
],
|
||||
},
|
||||
"typescript": {
|
||||
"functions": [
|
||||
{"id": "type_boolean", "title": "boolean / bool", "desc": "Boolean type annotation (true or false)."},
|
||||
{"id": "type_number", "title": "number / int", "desc": "Numerical primitive type annotation."},
|
||||
{"id": "type_string", "title": "string", "desc": "Textual string type annotation."},
|
||||
{"id": "type_any", "title": "any / unknown", "desc": "Escape hatch any or type-safe unknown."},
|
||||
{"id": "record", "title": "Record<K, V>", "desc": "Constructs object type with keys K and value V."},
|
||||
{"id": "partial", "title": "Partial<T>", "desc": "Constructs type with all properties optional."},
|
||||
{"id": "readonly", "title": "Readonly<T>", "desc": "Constructs type with all properties read-only."},
|
||||
],
|
||||
"subjects": [
|
||||
{"id": "types", "title": "Type Annotations & Primitive Types", "desc": "number, string, boolean, interface, type."},
|
||||
{"id": "unions", "title": "Unions & Narrowing", "desc": "Type A | Type B, typeof, instanceof."},
|
||||
],
|
||||
},
|
||||
"java": {
|
||||
"functions": [
|
||||
{"id": "type_int", "title": "int / long", "desc": "Primitive integer data types."},
|
||||
{"id": "type_boolean", "title": "boolean / bool", "desc": "Primitive boolean true or false."},
|
||||
{"id": "type_double", "title": "float / double", "desc": "Primitive floating point data types."},
|
||||
{"id": "type_string", "title": "String", "desc": "Java String object class."},
|
||||
{"id": "sys_out", "title": "System.out.println()", "desc": "Writes formatted text to standard output."},
|
||||
{"id": "list_add", "title": "List.add()", "desc": "Appends element to Java List collection."},
|
||||
],
|
||||
"subjects": [
|
||||
{"id": "vars", "title": "Variables & Primitives", "desc": "int, double, boolean, String, char, float."},
|
||||
{"id": "control", "title": "Control Flow", "desc": "if, else, for, while, switch."},
|
||||
],
|
||||
},
|
||||
"csharp": {
|
||||
"functions": [
|
||||
{"id": "type_int", "title": "int / long", "desc": "Signed integer primitive types."},
|
||||
{"id": "type_bool", "title": "bool", "desc": "Boolean primitive type (true or false)."},
|
||||
{"id": "type_string", "title": "string", "desc": "UTF-16 text string type."},
|
||||
{"id": "console_write", "title": "Console.WriteLine()", "desc": "Writes line terminator to stdout."},
|
||||
{"id": "list_add", "title": "List<T>.Add()", "desc": "Adds item to List collection."},
|
||||
],
|
||||
"subjects": [
|
||||
{"id": "vars", "title": "Variables & Types", "desc": "int, bool, string, double, float, var."},
|
||||
{"id": "control", "title": "Control Flow & OOP", "desc": "if, else, foreach, class, struct, record."},
|
||||
],
|
||||
},
|
||||
"lua": {
|
||||
"functions": [
|
||||
{"id": "type_number", "title": "number / int", "desc": "Numerical data type."},
|
||||
{"id": "type_boolean", "title": "boolean / bool", "desc": "Boolean truth value (true or false)."},
|
||||
{"id": "type_string", "title": "string", "desc": "Byte sequence string type."},
|
||||
{"id": "print", "title": "print()", "desc": "Prints values to standard output."},
|
||||
],
|
||||
"subjects": [
|
||||
{"id": "vars", "title": "Variables & Types", "desc": "local, number, string, boolean, nil, table."},
|
||||
],
|
||||
},
|
||||
"html": {
|
||||
"functions": [
|
||||
{"id": "elem_doctype", "title": "<!DOCTYPE html>", "desc": "Declares document type as HTML5."},
|
||||
{"id": "elem_html", "title": "<html>", "desc": "Root element of HTML document."},
|
||||
{"id": "elem_body", "title": "<body>", "desc": "Container for all visible web page contents."},
|
||||
{"id": "elem_div", "title": "<div> / <span>", "desc": "Block or inline layout container elements."},
|
||||
],
|
||||
"subjects": [
|
||||
{"id": "struct", "title": "Document Structure & Elements", "desc": "DOCTYPE, html, head, body, div, p, a, input."},
|
||||
],
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
class HandbookService:
|
||||
"""W3Schools-Style Language Reference Handbook service."""
|
||||
|
||||
def get_catalog(self, language: str) -> Dict[str, List[Dict[str, str]]]:
|
||||
canonical = registry.canonical_name(language)
|
||||
return HANDBOOK_CATALOG.get(canonical, HANDBOOK_CATALOG["python"])
|
||||
|
||||
async def generate_example_async(self, language: str, topic_id: str, topic_title: str) -> Dict[str, Any]:
|
||||
endpoint = get_chat_completions_url(config.llm_base_url)
|
||||
canonical = registry.canonical_name(language)
|
||||
config_info = registry.get_config(canonical)
|
||||
lang_name = config_info.get("name", language) if config_info else language
|
||||
|
||||
prompt = (
|
||||
f"Generate a W3Schools-Style Reference Card for '{topic_title}' in {lang_name}.\n"
|
||||
f"Structure your response strictly into these four Markdown sections:\n"
|
||||
f"1. **Syntax / Signature:** Clear representation of how to invoke or write it.\n"
|
||||
f"2. **Description & Parameters:** Brief summary of parameter types and return value.\n"
|
||||
f"3. **Try It Yourself (Executable Code):** A clean, self-contained, working code snippet in ```{canonical} code blocks.\n"
|
||||
f"4. **Key Notes & Best Practices:** 2 bullet points on common pitfalls or best practices."
|
||||
)
|
||||
|
||||
sys_prompt = (
|
||||
"You are an expert W3Schools-Style Documentation Assistant for TactiTerm.\n"
|
||||
"Provide clean, educational reference cards. Ensure the code example is realistic, complete, and copy-pasteable."
|
||||
)
|
||||
|
||||
payload = {
|
||||
"model": config.llm_model,
|
||||
"messages": [
|
||||
{"role": "system", "content": sys_prompt},
|
||||
{"role": "user", "content": prompt},
|
||||
],
|
||||
"temperature": 0.2,
|
||||
"max_tokens": 900,
|
||||
}
|
||||
|
||||
headers = {"Content-Type": "application/json"}
|
||||
if config.llm_api_key and config.llm_api_key != "not-needed":
|
||||
headers["Authorization"] = f"Bearer {config.llm_api_key}"
|
||||
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=config.llm_timeout, follow_redirects=True) as client:
|
||||
response = await client.post(endpoint, json=payload, headers=headers)
|
||||
response.raise_for_status()
|
||||
data = response.json()
|
||||
|
||||
example_md = (
|
||||
data.get("choices", [{}])[0]
|
||||
.get("message", {})
|
||||
.get("content", "")
|
||||
.strip()
|
||||
)
|
||||
|
||||
return {
|
||||
"status": "success",
|
||||
"language": lang_name,
|
||||
"topic_title": topic_title,
|
||||
"example_markdown": example_md,
|
||||
}
|
||||
except Exception as e:
|
||||
return {
|
||||
"status": "error",
|
||||
"message": f"Failed to generate W3Schools reference card via LLM: {e}",
|
||||
}
|
||||
|
||||
|
||||
handbook_service = HandbookService()
|
||||
@@ -0,0 +1,95 @@
|
||||
"""Service for linting user code across 10 programming languages."""
|
||||
import sys
|
||||
import os
|
||||
import tempfile
|
||||
import subprocess
|
||||
from typing import Dict, Any
|
||||
from src.core.registry import registry
|
||||
|
||||
|
||||
class CodeLinter:
|
||||
"""Lints user code for syntax and style issues for 10 languages."""
|
||||
|
||||
def lint(self, language: str, code: str) -> Dict[str, Any]:
|
||||
config = registry.get_config(language)
|
||||
if not config:
|
||||
return {
|
||||
"language": language,
|
||||
"exit_code": 1,
|
||||
"stdout": "",
|
||||
"stderr": f"Language '{language}' is not supported.",
|
||||
}
|
||||
|
||||
lang_key = registry.canonical_name(language)
|
||||
ext = config.get("ext", ".txt")
|
||||
|
||||
with tempfile.TemporaryDirectory() as tmpdir:
|
||||
if lang_key == "java":
|
||||
temp_file_name = "Main.java"
|
||||
elif lang_key == "cpp":
|
||||
temp_file_name = "main.cpp"
|
||||
elif lang_key == "rust":
|
||||
temp_file_name = "main.rs"
|
||||
elif lang_key == "csharp":
|
||||
temp_file_name = "Program.cs"
|
||||
else:
|
||||
temp_file_name = f"main{ext}"
|
||||
|
||||
temp_path = os.path.join(tmpdir, temp_file_name)
|
||||
with open(temp_path, "w", encoding="utf-8") as f:
|
||||
f.write(code)
|
||||
|
||||
try:
|
||||
if lang_key == "python":
|
||||
cmd = [sys.executable, "-m", "flake8", temp_path]
|
||||
result = subprocess.run(cmd, capture_output=True, text=True, cwd=tmpdir, timeout=15)
|
||||
elif lang_key == "javascript":
|
||||
result = subprocess.run(["node", "--check", temp_path], capture_output=True, text=True, cwd=tmpdir, timeout=15)
|
||||
elif lang_key == "typescript":
|
||||
result = subprocess.run(["npx", "tsc", "--noEmit", temp_path], capture_output=True, text=True, cwd=tmpdir, timeout=15)
|
||||
elif lang_key == "lua":
|
||||
result = subprocess.run(["luac", "-p", temp_path], capture_output=True, text=True, cwd=tmpdir, timeout=15)
|
||||
elif lang_key == "go":
|
||||
result = subprocess.run(["go", "vet", temp_path], capture_output=True, text=True, cwd=tmpdir, timeout=15)
|
||||
elif lang_key == "cpp":
|
||||
result = subprocess.run(["g++", "-fsyntax-only", temp_path], capture_output=True, text=True, cwd=tmpdir, timeout=15)
|
||||
elif lang_key == "rust":
|
||||
result = subprocess.run(["rustc", "--emit=metadata", temp_path], capture_output=True, text=True, cwd=tmpdir, timeout=15)
|
||||
elif lang_key == "java":
|
||||
result = subprocess.run(["javac", temp_path], capture_output=True, text=True, cwd=tmpdir, timeout=15)
|
||||
elif lang_key == "html":
|
||||
result = subprocess.run(["python3", "-c", f"import html.parser; html.parser.HTMLParser().feed(open('{temp_path}').read())"], capture_output=True, text=True, cwd=tmpdir, timeout=15)
|
||||
else:
|
||||
lint_cmd = config["lint"]
|
||||
result = subprocess.run(f"{lint_cmd} {temp_path}", shell=True, capture_output=True, text=True, cwd=tmpdir, timeout=15)
|
||||
|
||||
return {
|
||||
"language": language,
|
||||
"exit_code": result.returncode,
|
||||
"stdout": result.stdout,
|
||||
"stderr": result.stderr,
|
||||
}
|
||||
except FileNotFoundError as fnf:
|
||||
return {
|
||||
"language": language,
|
||||
"exit_code": 0,
|
||||
"stdout": f"✓ Syntax check skipped (linter tool not installed for {language}).",
|
||||
"stderr": "",
|
||||
}
|
||||
except subprocess.TimeoutExpired:
|
||||
return {
|
||||
"language": language,
|
||||
"exit_code": 124,
|
||||
"stdout": "",
|
||||
"stderr": "Linting timed out.",
|
||||
}
|
||||
except Exception as e:
|
||||
return {
|
||||
"language": language,
|
||||
"exit_code": 1,
|
||||
"stdout": "",
|
||||
"stderr": str(e),
|
||||
}
|
||||
|
||||
|
||||
linter = CodeLinter()
|
||||
@@ -0,0 +1,107 @@
|
||||
import re
|
||||
import os
|
||||
from typing import List, Dict, Optional
|
||||
from src.core.models import Challenge
|
||||
|
||||
|
||||
class ChallengeLoader:
|
||||
def __init__(self, challenges_dir: str = "src/challenges/"):
|
||||
self.challenges_dir = challenges_dir
|
||||
|
||||
def load_all(self) -> Dict[str, Challenge]:
|
||||
challenges = {}
|
||||
if not os.path.exists(self.challenges_dir):
|
||||
return challenges
|
||||
|
||||
for filename in os.listdir(self.challenges_dir):
|
||||
if filename.endswith(".md"):
|
||||
path = os.path.join(self.challenges_dir, filename)
|
||||
challenge = self.parse_file(path)
|
||||
if challenge:
|
||||
key = filename.replace(".md", "")
|
||||
challenges[key] = challenge
|
||||
return challenges
|
||||
|
||||
def parse_file(self, path: str) -> Optional[Challenge]:
|
||||
with open(path, "r", encoding="utf-8") as f:
|
||||
content = f.read()
|
||||
|
||||
# Parse YAML frontmatter if present
|
||||
fm_data = {}
|
||||
if content.startswith("---"):
|
||||
parts = content.split("---", 2)
|
||||
if len(parts) >= 3:
|
||||
fm_raw = parts[1]
|
||||
content = parts[2]
|
||||
for line in fm_raw.splitlines():
|
||||
if ":" in line:
|
||||
k, v = line.split(":", 1)
|
||||
fm_data[k.strip().lower()] = v.strip()
|
||||
|
||||
name = fm_data.get("name")
|
||||
difficulty = fm_data.get("difficulty")
|
||||
language = fm_data.get("language")
|
||||
subject = fm_data.get("subject") or fm_data.get("topic")
|
||||
|
||||
# Regex extraction fallback for markdown headers
|
||||
if not name:
|
||||
name_match = re.search(r"# (?:Challenge: )?(.*)", content)
|
||||
name = name_match.group(1).strip() if name_match else None
|
||||
|
||||
if not difficulty:
|
||||
diff_match = re.search(r"\*\*Difficulty:\*\* (.*)", content)
|
||||
difficulty = diff_match.group(1).strip() if diff_match else "Medium"
|
||||
|
||||
if not language:
|
||||
lang_match = re.search(r"\*\*Language:\*\* (.*)", content)
|
||||
language = lang_match.group(1).strip() if lang_match else "Python"
|
||||
|
||||
if not subject:
|
||||
subj_match = re.search(r"\*\*(?:Subject|Topic):\*\* (.*)", content)
|
||||
subject = subj_match.group(1).strip() if subj_match else "General Concepts"
|
||||
|
||||
desc_match = re.search(r"## Description\n(.*?)(?=\n##|$)", content, re.DOTALL)
|
||||
req_match = re.search(r"## Requirements\n(.*?)(?=\n##|$)", content, re.DOTALL)
|
||||
hint_match = re.search(r"## Hints\n(.*?)(?=\n##|$)", content, re.DOTALL)
|
||||
valid_match = re.search(r"## Validation\n-\s*\*\*Check:\*\* (.*)", content)
|
||||
exp_match = re.search(r"\*\*Expected Output:\*\* (.*)", content)
|
||||
|
||||
if not name:
|
||||
return None
|
||||
|
||||
description = desc_match.group(1).strip() if desc_match else ""
|
||||
|
||||
requirements = (
|
||||
[
|
||||
r.strip("- ").strip()
|
||||
for r in req_match.group(1).strip().split("\n")
|
||||
if r.strip("- ")
|
||||
]
|
||||
if req_match
|
||||
else []
|
||||
)
|
||||
|
||||
hints = (
|
||||
[
|
||||
h.strip("- ").strip()
|
||||
for h in hint_match.group(1).strip().split("\n")
|
||||
if h.strip("- ")
|
||||
]
|
||||
if hint_match
|
||||
else []
|
||||
)
|
||||
|
||||
validation_check = valid_match.group(1).strip() if valid_match else ""
|
||||
expected_output = exp_match.group(1).strip() if exp_match else ""
|
||||
|
||||
return Challenge(
|
||||
name=name,
|
||||
difficulty=difficulty,
|
||||
language=language,
|
||||
subject=subject,
|
||||
description=description,
|
||||
requirements=requirements,
|
||||
hints=hints,
|
||||
validation_check=validation_check,
|
||||
expected_output=expected_output,
|
||||
)
|
||||
@@ -0,0 +1,120 @@
|
||||
"""Mentor LLM client communicating with llama.cpp server (OpenAI API compatible)."""
|
||||
import httpx
|
||||
from typing import Dict, Any, Optional
|
||||
from src.core.config import config
|
||||
from src.core.prompts import get_mentor_prompt, get_socratic_prompt
|
||||
|
||||
|
||||
def get_chat_completions_url(base_url: str) -> str:
|
||||
url = base_url.strip().rstrip("/")
|
||||
if url.endswith("/chat/completions"):
|
||||
return url
|
||||
if url.endswith("/v1"):
|
||||
return f"{url}/chat/completions"
|
||||
return f"{url}/v1/chat/completions"
|
||||
|
||||
|
||||
class MentorClient:
|
||||
"""LLM client for software engineering tutoring via OpenAI-compatible endpoints."""
|
||||
|
||||
def __init__(self) -> None:
|
||||
self.config = config
|
||||
|
||||
async def get_guidance_async(
|
||||
self, challenge: object, user_code: str, user_question: str = ""
|
||||
) -> Dict[str, Any]:
|
||||
"""Fetch mentor guidance asynchronously from the configured LLM endpoint."""
|
||||
prompt = get_mentor_prompt(challenge, user_code, user_question=user_question)
|
||||
endpoint = get_chat_completions_url(self.config.llm_base_url)
|
||||
|
||||
user_content = (
|
||||
f"Question for Mentor: {user_question}\n\nPlease evaluate my code or answer my question for this challenge."
|
||||
if user_question.strip()
|
||||
else "Please review my code structure and provide guidance for my current step."
|
||||
)
|
||||
|
||||
payload = {
|
||||
"model": self.config.llm_model,
|
||||
"messages": [
|
||||
{"role": "system", "content": prompt},
|
||||
{"role": "user", "content": user_content},
|
||||
],
|
||||
"temperature": self.config.llm_temperature,
|
||||
"max_tokens": self.config.llm_max_tokens,
|
||||
}
|
||||
|
||||
headers = {"Content-Type": "application/json"}
|
||||
if self.config.llm_api_key and self.config.llm_api_key != "not-needed":
|
||||
headers["Authorization"] = f"Bearer {self.config.llm_api_key}"
|
||||
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=self.config.llm_timeout, follow_redirects=True) as client:
|
||||
response = await client.post(endpoint, json=payload, headers=headers)
|
||||
response.raise_for_status()
|
||||
data = response.json()
|
||||
|
||||
content = (
|
||||
data.get("choices", [{}])[0]
|
||||
.get("message", {})
|
||||
.get("content", "")
|
||||
.strip()
|
||||
)
|
||||
|
||||
if content:
|
||||
return {
|
||||
"status": "success",
|
||||
"mentor_response": content,
|
||||
"offline": False,
|
||||
}
|
||||
else:
|
||||
return {
|
||||
"status": "error",
|
||||
"mentor_response": "Received empty response from LLM server.",
|
||||
"offline": False,
|
||||
}
|
||||
except (httpx.ConnectError, httpx.TimeoutException, httpx.TransportError):
|
||||
return {
|
||||
"status": "offline",
|
||||
"mentor_response": (
|
||||
f"⚠️ LLM Server offline or unreachable at {self.config.llm_base_url}.\n"
|
||||
"Start your llama.cpp server with:\n"
|
||||
" ./llama-server -m <your-model.gguf> --port 8080\n\n"
|
||||
"💡 Offline Hint:\n"
|
||||
"How is your code structuring the initial state before entering your main logic loop?"
|
||||
),
|
||||
"offline": True,
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
return {
|
||||
"status": "error",
|
||||
"mentor_response": f"⚠️ Error consulting LLM mentor: {e}",
|
||||
"offline": False,
|
||||
}
|
||||
|
||||
def get_guidance(
|
||||
self, challenge: object, user_code: str, user_question: str = ""
|
||||
) -> Dict[str, Any]:
|
||||
"""Synchronous wrapper for fetching mentor guidance."""
|
||||
import asyncio
|
||||
|
||||
try:
|
||||
loop = asyncio.get_event_loop()
|
||||
if loop.is_running():
|
||||
import nest_asyncio
|
||||
nest_asyncio.apply()
|
||||
return loop.run_until_complete(
|
||||
self.get_guidance_async(challenge, user_code, user_question=user_question)
|
||||
)
|
||||
else:
|
||||
return asyncio.run(
|
||||
self.get_guidance_async(challenge, user_code, user_question=user_question)
|
||||
)
|
||||
except Exception:
|
||||
return asyncio.run(
|
||||
self.get_guidance_async(challenge, user_code, user_question=user_question)
|
||||
)
|
||||
|
||||
|
||||
SocraticMentor = MentorClient
|
||||
mentor = MentorClient()
|
||||
@@ -0,0 +1,43 @@
|
||||
from typing import List, Dict, Any
|
||||
|
||||
|
||||
class Challenge:
|
||||
def __init__(
|
||||
self,
|
||||
name: str,
|
||||
difficulty: str,
|
||||
language: str,
|
||||
subject: str,
|
||||
description: str,
|
||||
requirements: List[str],
|
||||
hints: List[str],
|
||||
validation_check: str,
|
||||
expected_output: str,
|
||||
):
|
||||
self.name = name
|
||||
self.difficulty = difficulty
|
||||
self.language = language
|
||||
self.subject = subject if subject else "General Software Engineering"
|
||||
self.description = description
|
||||
self.requirements = requirements
|
||||
self.hints = hints
|
||||
self.validation_check = validation_check
|
||||
self.expected_output = expected_output
|
||||
|
||||
def __repr__(self) -> str:
|
||||
return f"<Challenge: {self.name} [{self.subject}] ({self.language})>"
|
||||
|
||||
def to_dict(self, challenge_id: str = "") -> Dict[str, Any]:
|
||||
return {
|
||||
"id": challenge_id,
|
||||
"challenge_id": challenge_id,
|
||||
"name": self.name,
|
||||
"difficulty": self.difficulty,
|
||||
"language": self.language,
|
||||
"subject": self.subject,
|
||||
"description": self.description,
|
||||
"requirements": self.requirements,
|
||||
"hints": self.hints,
|
||||
"validation_check": self.validation_check,
|
||||
"expected_output": self.expected_output,
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
"""Mentor system prompt and prompt generation utilities."""
|
||||
|
||||
SYSTEM_PROMPT_MENTOR = """You are a Software Engineering Mentor.
|
||||
Your ultimate mission is to build the user's confidence in designing and constructing complex software programs from scratch.
|
||||
|
||||
### YOUR CORE RESPONSIBILITIES:
|
||||
1. **Upfront Program Structure:** Guide the user on how to structure their software cleanly from the start — decomposing complex problems into small modular functions, defining clear data flow, and choosing appropriate data structures.
|
||||
2. **Roadblock Breaking & Solution Evaluation:** When the user asks a question, encounters bugs, or asks to check their answer, evaluate their code against all challenge requirements, illuminate root causes, and provide clear constructive feedback.
|
||||
3. **Illustrative Code Examples:** You MAY provide abstract code templates, conceptual code snippets, and syntax examples (e.g. showing how to define a function `def function_name(param):`, how to use `try/except`, or how to structure a loop) to help the user learn programming constructs.
|
||||
|
||||
### STRICT GUARDRAILS (NEVER BREAK THESE):
|
||||
- **NEVER** write or provide the specific solution code, completed logic, or full answer for the active challenge.
|
||||
- **NEVER** solve the challenge's core problem for the user.
|
||||
- Abstract syntax snippets and structural templates ARE allowed as long as they demonstrate general programming concepts rather than giving away the specific challenge solution.
|
||||
- If the user explicitly asks for the answer to the challenge, politely decline in-character, provide a generic syntax example if applicable, and ask a question to guide their next step.
|
||||
|
||||
### CONTEXT FOR THIS SESSION:
|
||||
- Challenge Title: {challenge_name}
|
||||
- Language: {language}
|
||||
- Difficulty: {difficulty}
|
||||
- Description: {challenge_description}
|
||||
- Requirements:
|
||||
{requirements}
|
||||
- Hints:
|
||||
{hints}
|
||||
|
||||
### USER'S CURRENT CODE:
|
||||
```
|
||||
{user_code}
|
||||
```
|
||||
|
||||
### USER'S QUESTION FOR THE MENTOR:
|
||||
{user_question}
|
||||
|
||||
### RESPONSE FORMAT:
|
||||
1. **Direct Answer & Feedback:** Address the user's question or solution evaluation directly (using illustrative syntax/code templates where helpful).
|
||||
2. **Structural & Logic Guidance:** Provide structural or architectural suggestions on how to break down the program cleanly.
|
||||
3. **Probing Question:** Ask a focused question to guide their next implementation or debugging step.
|
||||
"""
|
||||
|
||||
SYSTEM_PROMPT_SOCRATIC = SYSTEM_PROMPT_MENTOR
|
||||
|
||||
|
||||
def get_mentor_prompt(challenge: object, user_code: str, user_question: str = "") -> str:
|
||||
"""Returns the formatted system prompt for the LLM mentor."""
|
||||
reqs = "\n".join(f"- {r}" for r in getattr(challenge, "requirements", []))
|
||||
hints = "\n".join(f"- {h}" for h in getattr(challenge, "hints", []))
|
||||
|
||||
q_text = (
|
||||
user_question.strip()
|
||||
if user_question.strip()
|
||||
else "How should I structure my program and overcome my current blocker for this challenge?"
|
||||
)
|
||||
|
||||
return SYSTEM_PROMPT_MENTOR.format(
|
||||
challenge_name=getattr(challenge, "name", "Coding Challenge"),
|
||||
language=getattr(challenge, "language", "Python"),
|
||||
difficulty=getattr(challenge, "difficulty", "Medium"),
|
||||
challenge_description=getattr(challenge, "description", ""),
|
||||
requirements=reqs if reqs else "- Follow standard problem specifications",
|
||||
hints=hints if hints else "- Think through edge cases",
|
||||
user_code=user_code if user_code.strip() else "# No code written yet",
|
||||
user_question=q_text,
|
||||
)
|
||||
|
||||
|
||||
get_socratic_prompt = get_mentor_prompt
|
||||
@@ -0,0 +1,97 @@
|
||||
from typing import Dict, Optional, List
|
||||
|
||||
|
||||
class LanguageRegistry:
|
||||
"""Registry mapping programming languages to execution, linting, and extension settings."""
|
||||
|
||||
def __init__(self) -> None:
|
||||
self._languages: Dict[str, Dict[str, str]] = {
|
||||
"python": {
|
||||
"name": "Python",
|
||||
"ext": ".py",
|
||||
"exec": "python3",
|
||||
"lint": "flake8",
|
||||
},
|
||||
"csharp": {
|
||||
"name": "C#",
|
||||
"ext": ".cs",
|
||||
"exec": "dotnet run",
|
||||
"lint": "dotnet build",
|
||||
},
|
||||
"cpp": {
|
||||
"name": "C++",
|
||||
"ext": ".cpp",
|
||||
"exec": "g++ -O2 -o main main.cpp && ./main",
|
||||
"lint": "g++ -fsyntax-only main.cpp",
|
||||
},
|
||||
"java": {
|
||||
"name": "Java",
|
||||
"ext": ".java",
|
||||
"exec": "javac Main.java && java Main",
|
||||
"lint": "javac Main.java",
|
||||
},
|
||||
"javascript": {
|
||||
"name": "JavaScript",
|
||||
"ext": ".js",
|
||||
"exec": "node",
|
||||
"lint": "node --check",
|
||||
},
|
||||
"typescript": {
|
||||
"name": "TypeScript",
|
||||
"ext": ".ts",
|
||||
"exec": "npx ts-node",
|
||||
"lint": "npx tsc --noEmit",
|
||||
},
|
||||
"rust": {
|
||||
"name": "Rust",
|
||||
"ext": ".rs",
|
||||
"exec": "rustc main.rs -o main && ./main",
|
||||
"lint": "rustc --emit=metadata main.rs",
|
||||
},
|
||||
"lua": {
|
||||
"name": "Lua",
|
||||
"ext": ".lua",
|
||||
"exec": "lua",
|
||||
"lint": "luac -p",
|
||||
},
|
||||
"html": {
|
||||
"name": "HTML",
|
||||
"ext": ".html",
|
||||
"exec": "cat",
|
||||
"lint": "tidy -e -q",
|
||||
},
|
||||
"go": {
|
||||
"name": "Go",
|
||||
"ext": ".go",
|
||||
"exec": "go run",
|
||||
"lint": "go vet",
|
||||
},
|
||||
}
|
||||
|
||||
# Language aliases mapping
|
||||
self._aliases: Dict[str, str] = {
|
||||
"py": "python",
|
||||
"c#": "csharp",
|
||||
"cs": "csharp",
|
||||
"c++": "cpp",
|
||||
"cxx": "cpp",
|
||||
"js": "javascript",
|
||||
"node": "javascript",
|
||||
"ts": "typescript",
|
||||
"rs": "rust",
|
||||
"golang": "go",
|
||||
}
|
||||
|
||||
def canonical_name(self, language: str) -> str:
|
||||
lang_lower = language.lower().strip()
|
||||
return self._aliases.get(lang_lower, lang_lower)
|
||||
|
||||
def get_config(self, language: str) -> Optional[Dict[str, str]]:
|
||||
key = self.canonical_name(language)
|
||||
return self._languages.get(key)
|
||||
|
||||
def list_languages(self) -> List[str]:
|
||||
return list(self._languages.keys())
|
||||
|
||||
|
||||
registry = LanguageRegistry()
|
||||
@@ -0,0 +1,18 @@
|
||||
# Challenge: [Name]
|
||||
**Difficulty:** [Easy/Med/Hard]
|
||||
**Language:** [Language]
|
||||
|
||||
## Description
|
||||
[Description of the problem]
|
||||
|
||||
## Requirements
|
||||
- [Requirement 1]
|
||||
- [Requirement 2]
|
||||
|
||||
## Hints
|
||||
- [Hint 1]
|
||||
- [Hint 2]
|
||||
|
||||
## Validation
|
||||
- **Check:** [Command to run, e.g., pytest or eslint]
|
||||
- **Expected Output:** [Description]
|
||||
+845
@@ -0,0 +1,845 @@
|
||||
"""TactiTerm TUI — Coding Tutor Terminal User Interface with W3Schools-style Handbook & Expandable Output Terminal."""
|
||||
import argparse
|
||||
import logging
|
||||
from typing import List, Dict, Any
|
||||
|
||||
from textual.app import App, ComposeResult
|
||||
from textual.containers import Container, Horizontal, VerticalScroll
|
||||
from textual.widgets import Header, Footer, Static, OptionList, Button, Markdown, TextArea, Input
|
||||
from textual.widgets.option_list import Option
|
||||
from textual.binding import Binding
|
||||
|
||||
from src.core.loader import ChallengeLoader
|
||||
from src.core.executor import executor
|
||||
from src.core.linter import linter
|
||||
from src.core.mentor import mentor
|
||||
from src.core.handbook import handbook_service
|
||||
from src.core.prompts import get_mentor_prompt
|
||||
from src.tui.widgets import CodeEditor, MentorInputTextArea, StdinInput, TUI_COMPLETIONS
|
||||
|
||||
|
||||
class TactiTermTUI(App):
|
||||
"""A coding tutor TUI with direct core service integration, Mentor, Handbook, and Expandable Stdin Output Terminal."""
|
||||
|
||||
TITLE = "TactiTerm"
|
||||
|
||||
CSS = """
|
||||
Screen {
|
||||
layout: vertical;
|
||||
}
|
||||
|
||||
#main-container {
|
||||
layout: horizontal;
|
||||
height: 1fr;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
/* Left column: challenge list & details */
|
||||
#challenge-panel {
|
||||
width: 32%;
|
||||
min-width: 25;
|
||||
height: 100%;
|
||||
border-right: heavy $primary;
|
||||
padding: 0 1;
|
||||
}
|
||||
|
||||
/* Middle column: code editor & execution output */
|
||||
#editor-panel {
|
||||
width: 68%;
|
||||
height: 100%;
|
||||
padding: 0 1;
|
||||
}
|
||||
|
||||
/* Right columns: Mentor & Handbook sidebars (hidden by default) */
|
||||
#mentor-sidebar, #handbook-sidebar {
|
||||
width: 32%;
|
||||
min-width: 26;
|
||||
height: 100%;
|
||||
padding: 0 1;
|
||||
display: none;
|
||||
}
|
||||
|
||||
/* Adjusted column widths when a sidebar is toggled open */
|
||||
#main-container.sidebar-open #challenge-panel {
|
||||
width: 26%;
|
||||
}
|
||||
|
||||
#main-container.sidebar-open #editor-panel {
|
||||
width: 44%;
|
||||
border-right: heavy $primary;
|
||||
}
|
||||
|
||||
#main-container.sidebar-mentor-open #mentor-sidebar {
|
||||
display: block;
|
||||
width: 30%;
|
||||
}
|
||||
|
||||
#main-container.sidebar-handbook-open #handbook-sidebar {
|
||||
display: block;
|
||||
width: 30%;
|
||||
}
|
||||
|
||||
#challenge-header, #mentor-header, #handbook-header {
|
||||
height: 3;
|
||||
background: $primary;
|
||||
color: white;
|
||||
content-align: center middle;
|
||||
text-style: bold;
|
||||
margin-bottom: 1;
|
||||
}
|
||||
|
||||
#challenge_list {
|
||||
height: 8;
|
||||
border: solid $secondary;
|
||||
margin-bottom: 1;
|
||||
}
|
||||
|
||||
#handbook-search-input {
|
||||
margin-bottom: 1;
|
||||
}
|
||||
|
||||
#btn-handbook-mode {
|
||||
width: 100%;
|
||||
margin-bottom: 1;
|
||||
}
|
||||
|
||||
#handbook_topic_list {
|
||||
height: 8;
|
||||
border: solid $secondary;
|
||||
margin-bottom: 1;
|
||||
}
|
||||
|
||||
#challenge-details-container {
|
||||
height: 1fr;
|
||||
border: solid $primary-darken-2;
|
||||
padding: 1;
|
||||
background: $surface;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
/* Sleek compact action bar with reduced height */
|
||||
#action-bar {
|
||||
height: 1;
|
||||
margin-bottom: 1;
|
||||
}
|
||||
|
||||
#action-bar Button {
|
||||
height: 1;
|
||||
min-width: 7;
|
||||
padding: 0 1;
|
||||
margin-right: 1;
|
||||
border: none;
|
||||
}
|
||||
|
||||
/* Editor & Auto-Complete Popup Overlay styling */
|
||||
CodeEditor {
|
||||
height: 1fr;
|
||||
min-height: 8;
|
||||
border: solid $accent;
|
||||
margin-bottom: 1;
|
||||
}
|
||||
|
||||
CodeEditor:focus {
|
||||
border: heavy $primary;
|
||||
}
|
||||
|
||||
#completion-popup {
|
||||
display: none;
|
||||
height: 8;
|
||||
background: $surface-darken-1;
|
||||
border: heavy $warning;
|
||||
margin-bottom: 1;
|
||||
padding: 0 1;
|
||||
}
|
||||
|
||||
#main-container.show-completion #completion-popup {
|
||||
display: block;
|
||||
}
|
||||
|
||||
#completion-title {
|
||||
height: 1;
|
||||
text-style: bold;
|
||||
color: $warning;
|
||||
}
|
||||
|
||||
#completion_list_popup {
|
||||
height: 5;
|
||||
border: solid $warning-darken-2;
|
||||
}
|
||||
|
||||
/* Output Console Header & Expandable Terminal */
|
||||
#output-header-bar {
|
||||
height: 1;
|
||||
margin-bottom: 1;
|
||||
}
|
||||
|
||||
#output-title {
|
||||
height: 1;
|
||||
text-style: bold;
|
||||
color: $accent;
|
||||
width: 1fr;
|
||||
}
|
||||
|
||||
#btn-expand-output {
|
||||
height: 1;
|
||||
min-width: 16;
|
||||
padding: 0 1;
|
||||
border: none;
|
||||
}
|
||||
|
||||
#output-scroll-container {
|
||||
height: 7;
|
||||
background: $surface;
|
||||
border: solid $accent;
|
||||
padding: 1;
|
||||
overflow-y: auto;
|
||||
margin-bottom: 1;
|
||||
}
|
||||
|
||||
#editor-panel.output-expanded #output-scroll-container {
|
||||
height: 15;
|
||||
}
|
||||
|
||||
#expanded-input-bar {
|
||||
display: none;
|
||||
height: 3;
|
||||
layout: horizontal;
|
||||
margin-bottom: 1;
|
||||
}
|
||||
|
||||
#editor-panel.output-expanded #expanded-input-bar {
|
||||
display: block;
|
||||
}
|
||||
|
||||
#program-stdin-input {
|
||||
width: 1fr;
|
||||
margin-right: 1;
|
||||
}
|
||||
|
||||
#btn-send-stdin {
|
||||
width: 18;
|
||||
}
|
||||
|
||||
/* Mentor & Handbook Sidebar Elements */
|
||||
#mentor-response-scroll, #handbook-example-scroll {
|
||||
height: 1fr;
|
||||
background: $surface;
|
||||
border: solid $warning-darken-1;
|
||||
padding: 1;
|
||||
margin-bottom: 1;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
#mentor-input-title {
|
||||
height: 1;
|
||||
text-style: bold;
|
||||
color: $warning;
|
||||
}
|
||||
|
||||
#mentor-question-input {
|
||||
height: 4;
|
||||
border: solid $warning;
|
||||
margin-bottom: 1;
|
||||
}
|
||||
|
||||
#btn-send-mentor {
|
||||
width: 100%;
|
||||
margin-bottom: 1;
|
||||
}
|
||||
|
||||
/* Status bar */
|
||||
#status-bar {
|
||||
height: 1;
|
||||
background: $primary;
|
||||
color: white;
|
||||
padding: 0 1;
|
||||
text-style: bold;
|
||||
}
|
||||
"""
|
||||
|
||||
BINDINGS = [
|
||||
Binding("ctrl+r", "run_code", "Run"),
|
||||
Binding("ctrl+l", "lint_code", "Lint"),
|
||||
Binding("ctrl+e,f4", "toggle_expand_output", "Expand Output (Ctrl+E / F4)"),
|
||||
Binding("f2", "check_answer", "Check", show=False),
|
||||
Binding("f1", "toggle_mentor_sidebar", "Mentor", show=False),
|
||||
Binding("f3", "toggle_handbook_sidebar", "Handbook", show=False),
|
||||
Binding("ctrl+f", "refresh_challenges", "Refresh"),
|
||||
Binding("alt+w", "scroll_output_up", "Output Up", show=False),
|
||||
Binding("alt+s", "scroll_output_down", "Output Down", show=False),
|
||||
Binding("ctrl+up", "scroll_mentor_up", "Sidebar Up", show=False),
|
||||
Binding("ctrl+down", "scroll_mentor_down", "Sidebar Down", show=False),
|
||||
Binding("alt+up", "scroll_details_up", "Details Up", show=False),
|
||||
Binding("alt+down", "scroll_details_down", "Details Down", show=False),
|
||||
Binding("escape", "unfocus_editor", "Exit Focus"),
|
||||
Binding("f6", "toggle_panel_focus", "Switch Focus"),
|
||||
Binding("ctrl+q", "quit", "Quit"),
|
||||
]
|
||||
|
||||
def __init__(self, debug: bool = False) -> None:
|
||||
super().__init__()
|
||||
self.loader = ChallengeLoader()
|
||||
self.active_challenge_id = None
|
||||
self.challenges_dict = {}
|
||||
self.catalog_functions: List[Dict[str, str]] = []
|
||||
self.catalog_subjects: List[Dict[str, str]] = []
|
||||
self.active_handbook_mode = "all"
|
||||
self.active_completion_prefix = ""
|
||||
self.debug_mode = debug
|
||||
|
||||
def compose(self) -> ComposeResult:
|
||||
yield Header()
|
||||
|
||||
with Horizontal(id="main-container"):
|
||||
# Left column: Challenges & Details
|
||||
with Container(id="challenge-panel"):
|
||||
yield Static("TactiTerm Workspace", id="challenge-header")
|
||||
yield OptionList(id="challenge_list")
|
||||
with VerticalScroll(id="challenge-details-container"):
|
||||
yield Markdown("Select a challenge from the list above to view details...", id="challenge-details")
|
||||
|
||||
# Middle column: Code Editor, Completion Dropdown Popup & Expandable Output Terminal
|
||||
with Container(id="editor-panel"):
|
||||
with Horizontal(id="action-bar"):
|
||||
yield Button("▶ Run (Ctrl+R)", id="btn-run", variant="success")
|
||||
yield Button("🔍 Lint (Ctrl+L)", id="btn-lint", variant="primary")
|
||||
yield Button("✅ Check (F2)", id="btn-check", variant="success")
|
||||
yield Button("💡 Mentor (F1)", id="btn-guide", variant="warning")
|
||||
yield Button("📖 Handbook (F3)", id="btn-handbook", variant="primary")
|
||||
yield Button("🔄 Refresh", id="btn-refresh", variant="default")
|
||||
|
||||
yield CodeEditor(id="editor")
|
||||
|
||||
# Auto-Completion Dropdown Popup Overlay
|
||||
with Container(id="completion-popup"):
|
||||
yield Static("💡 Auto-Complete Suggestions (↑/↓ to navigate, Enter/Tab to insert):", id="completion-title")
|
||||
yield OptionList(id="completion_list_popup")
|
||||
|
||||
with Horizontal(id="output-header-bar"):
|
||||
yield Static("Output Console (Alt+W/S: Scroll)", id="output-title")
|
||||
yield Button("⤢ Expand / Input (Ctrl+E)", id="btn-expand-output", variant="primary")
|
||||
|
||||
with VerticalScroll(id="output-scroll-container"):
|
||||
yield Static("Execution and lint output will appear here...", id="output-container")
|
||||
|
||||
with Horizontal(id="expanded-input-bar"):
|
||||
yield StdinInput(placeholder="Type stdin input here and press Enter to send to program...", id="program-stdin-input")
|
||||
yield Button("Send Input", id="btn-send-stdin", variant="success")
|
||||
|
||||
# Right column 1: Mentor Sidebar
|
||||
with Container(id="mentor-sidebar"):
|
||||
yield Static("💡 Mentor", id="mentor-header")
|
||||
with VerticalScroll(id="mentor-response-scroll"):
|
||||
yield Markdown("Ask the mentor a question or click Check Answer...", id="mentor-response-display")
|
||||
|
||||
yield Static("Ask Question (Enter: Send | Shift+Enter: Newline):", id="mentor-input-title")
|
||||
yield MentorInputTextArea(id="mentor-question-input")
|
||||
yield Button("💡 Ask Mentor (Enter)", id="btn-send-mentor", variant="warning")
|
||||
|
||||
# Right column 2: W3Schools-Style Handbook Sidebar
|
||||
with Container(id="handbook-sidebar"):
|
||||
yield Static("📖 W3Schools Handbook", id="handbook-header")
|
||||
yield Input(placeholder="🔍 Search built-ins or topics...", id="handbook-search-input")
|
||||
yield Button("Filter: All Items (Click to Switch)", id="btn-handbook-mode", variant="default")
|
||||
yield OptionList(id="handbook_topic_list")
|
||||
with VerticalScroll(id="handbook-example-scroll"):
|
||||
yield Markdown("Select a function or topic above to view W3Schools reference card...", id="handbook-example-display")
|
||||
|
||||
yield Static("Ready", id="status-bar")
|
||||
yield Footer()
|
||||
|
||||
# ── Events & Lifecycle ────────────────────────────────────
|
||||
|
||||
def action_toggle_expand_output(self) -> None:
|
||||
"""Keybinding handler for Ctrl+E and F4."""
|
||||
self.toggle_expand_output()
|
||||
|
||||
def on_mount(self) -> None:
|
||||
self.call_after_refresh(self.load_challenges)
|
||||
|
||||
def load_challenges(self) -> None:
|
||||
self.challenges_dict = self.loader.load_all()
|
||||
|
||||
try:
|
||||
option_list = self.query_one("#challenge_list", OptionList)
|
||||
option_list.clear_options()
|
||||
|
||||
for cid, challenge in sorted(self.challenges_dict.items()):
|
||||
option_list.add_option(
|
||||
Option(f"{challenge.name} ({challenge.difficulty})", id=cid)
|
||||
)
|
||||
|
||||
count = len(self.challenges_dict)
|
||||
if count > 0:
|
||||
option_list.highlighted = 0
|
||||
first_opt = option_list.get_option_at_index(0)
|
||||
self.on_option_list_option_highlighted(
|
||||
OptionList.OptionHighlighted(option_list, first_opt, 0)
|
||||
)
|
||||
self.query_one("#status-bar", Static).update(
|
||||
f"Loaded {count} challenges — Hovering '{first_opt.prompt}' (Hit Enter to edit)"
|
||||
)
|
||||
else:
|
||||
self.query_one("#status-bar", Static).update("No challenges found in src/challenges/.")
|
||||
except Exception as e:
|
||||
if self.debug_mode:
|
||||
print(f"DEBUG: Exception in load_challenges: {e}")
|
||||
|
||||
def load_handbook_catalog(self, language: str) -> None:
|
||||
catalog = handbook_service.get_catalog(language)
|
||||
self.catalog_functions = catalog.get("functions", [])
|
||||
self.catalog_subjects = catalog.get("subjects", [])
|
||||
self.filter_handbook_list()
|
||||
|
||||
def filter_handbook_list(self) -> None:
|
||||
search_term = ""
|
||||
try:
|
||||
search_input = self.query_one("#handbook-search-input", Input)
|
||||
search_term = search_input.value.strip().lower()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
items: List[Dict[str, str]] = []
|
||||
if self.active_handbook_mode == "functions":
|
||||
items = [dict(t, icon="⚡") for t in self.catalog_functions]
|
||||
elif self.active_handbook_mode == "subjects":
|
||||
items = [dict(t, icon="📚") for t in self.catalog_subjects]
|
||||
else:
|
||||
items = [dict(t, icon="⚡") for t in self.catalog_functions] + [dict(t, icon="📚") for t in self.catalog_subjects]
|
||||
|
||||
if search_term:
|
||||
items = [
|
||||
i for i in items
|
||||
if search_term in i["title"].lower() or search_term in i["desc"].lower()
|
||||
]
|
||||
|
||||
topic_list = self.query_one("#handbook_topic_list", OptionList)
|
||||
topic_list.clear_options()
|
||||
|
||||
for item in items:
|
||||
topic_list.add_option(Option(f"{item['icon']} {item['title']}", id=item["id"]))
|
||||
|
||||
def on_input_changed(self, event: Input.Changed) -> None:
|
||||
if event.input.id == "handbook-search-input":
|
||||
self.filter_handbook_list()
|
||||
|
||||
def on_input_submitted(self, event: Input.Submitted) -> None:
|
||||
if event.input.id == "program-stdin-input":
|
||||
self.send_program_stdin()
|
||||
|
||||
def on_button_pressed(self, event: Button.Pressed) -> None:
|
||||
button_id = event.button.id
|
||||
if button_id == "btn-run":
|
||||
self.action_run_code()
|
||||
elif button_id == "btn-lint":
|
||||
self.action_lint_code()
|
||||
elif button_id == "btn-check":
|
||||
self.action_check_answer()
|
||||
elif button_id == "btn-guide":
|
||||
self.action_toggle_mentor_sidebar()
|
||||
elif button_id == "btn-handbook":
|
||||
self.action_toggle_handbook_sidebar()
|
||||
elif button_id == "btn-handbook-mode":
|
||||
self.toggle_handbook_mode()
|
||||
elif button_id == "btn-expand-output":
|
||||
self.toggle_expand_output()
|
||||
elif button_id == "btn-send-stdin":
|
||||
self.send_program_stdin()
|
||||
elif button_id == "btn-send-mentor":
|
||||
self.submit_mentor_question()
|
||||
elif button_id == "btn-refresh":
|
||||
self.action_refresh_challenges()
|
||||
|
||||
def toggle_expand_output(self) -> None:
|
||||
"""Toggle output terminal expansion and stdin input bar."""
|
||||
panel = self.query_one("#editor-panel")
|
||||
btn = self.query_one("#btn-expand-output", Button)
|
||||
if panel.has_class("output-expanded"):
|
||||
panel.remove_class("output-expanded")
|
||||
btn.label = "⤢ Expand / Input (Ctrl+E)"
|
||||
self.query_one("#editor", CodeEditor).focus()
|
||||
self.query_one("#status-bar", Static).update("Output console collapsed.")
|
||||
else:
|
||||
panel.add_class("output-expanded")
|
||||
btn.label = "⤡ Collapse (Ctrl+E)"
|
||||
self.query_one("#program-stdin-input", StdinInput).focus()
|
||||
self.query_one("#status-bar", Static).update(
|
||||
"Output console expanded. Type stdin input below and hit Enter to run program with input."
|
||||
)
|
||||
|
||||
def send_program_stdin(self) -> None:
|
||||
"""Send input text from #program-stdin-input to running code."""
|
||||
stdin_input = self.query_one("#program-stdin-input", StdinInput)
|
||||
user_stdin = stdin_input.value
|
||||
stdin_input.value = ""
|
||||
self.action_run_code(stdin=user_stdin)
|
||||
|
||||
def toggle_handbook_mode(self) -> None:
|
||||
if self.active_handbook_mode == "all":
|
||||
self.active_handbook_mode = "functions"
|
||||
label = "Filter: ⚡ Built-in Functions"
|
||||
elif self.active_handbook_mode == "functions":
|
||||
self.active_handbook_mode = "subjects"
|
||||
label = "Filter: 📚 Broad Subjects"
|
||||
else:
|
||||
self.active_handbook_mode = "all"
|
||||
label = "Filter: All Items"
|
||||
|
||||
self.query_one("#btn-handbook-mode", Button).label = label
|
||||
self.filter_handbook_list()
|
||||
|
||||
# ── Sidebar & Focus Management ───────────────────────────
|
||||
|
||||
def action_toggle_mentor_sidebar(self) -> None:
|
||||
main_box = self.query_one("#main-container")
|
||||
q_input = self.query_one("#mentor-question-input", MentorInputTextArea)
|
||||
|
||||
if main_box.has_class("sidebar-mentor-open"):
|
||||
main_box.remove_class("sidebar-mentor-open")
|
||||
main_box.remove_class("sidebar-open")
|
||||
self.query_one("#editor", CodeEditor).focus()
|
||||
self.query_one("#status-bar", Static).update("Mentor sidebar closed.")
|
||||
else:
|
||||
main_box.remove_class("sidebar-handbook-open")
|
||||
main_box.add_class("sidebar-open")
|
||||
main_box.add_class("sidebar-mentor-open")
|
||||
q_input.focus()
|
||||
self.query_one("#status-bar", Static).update("Mentor sidebar open.")
|
||||
|
||||
def action_toggle_handbook_sidebar(self) -> None:
|
||||
main_box = self.query_one("#main-container")
|
||||
|
||||
if main_box.has_class("sidebar-handbook-open"):
|
||||
main_box.remove_class("sidebar-handbook-open")
|
||||
main_box.remove_class("sidebar-open")
|
||||
self.query_one("#editor", CodeEditor).focus()
|
||||
self.query_one("#status-bar", Static).update("Handbook sidebar closed.")
|
||||
else:
|
||||
main_box.remove_class("sidebar-mentor-open")
|
||||
main_box.add_class("sidebar-open")
|
||||
main_box.add_class("sidebar-handbook-open")
|
||||
|
||||
challenge = self.challenges_dict.get(self.active_challenge_id) if self.active_challenge_id else None
|
||||
lang = challenge.language if challenge else "Python"
|
||||
self.load_handbook_catalog(lang)
|
||||
|
||||
self.query_one("#handbook_topic_list", OptionList).focus()
|
||||
self.query_one("#status-bar", Static).update(f"W3Schools Handbook open for {lang}.")
|
||||
|
||||
def on_mentor_input_text_area_submit_question(
|
||||
self, event: MentorInputTextArea.SubmitQuestion
|
||||
) -> None:
|
||||
self.submit_mentor_question(event.question)
|
||||
|
||||
def submit_mentor_question(self, question: str | None = None) -> None:
|
||||
if not self.active_challenge_id:
|
||||
self.query_one("#status-bar", Static).update("Please select a challenge first.")
|
||||
return
|
||||
|
||||
challenge = self.challenges_dict.get(self.active_challenge_id)
|
||||
if not challenge:
|
||||
return
|
||||
|
||||
q_input = self.query_one("#mentor-question-input", MentorInputTextArea)
|
||||
user_question = q_input.text.strip() if question is None else question.strip()
|
||||
code = self.query_one("#editor", CodeEditor).text
|
||||
|
||||
main_box = self.query_one("#main-container")
|
||||
main_box.remove_class("sidebar-handbook-open")
|
||||
main_box.add_class("sidebar-open")
|
||||
main_box.add_class("sidebar-mentor-open")
|
||||
|
||||
q_input.text = ""
|
||||
self.query_one("#mentor-response-display", Markdown).update(
|
||||
"⏳ **Mentor is thinking and generating guidance...**\n\n*Analyzing your code, task requirements, and question...*"
|
||||
)
|
||||
self.query_one("#status-bar", Static).update(
|
||||
f"⏳ Consulting Mentor at {mentor.config.llm_base_url}..."
|
||||
)
|
||||
self.run_worker(self._mentor_worker(challenge, code, user_question))
|
||||
|
||||
def action_check_answer(self) -> None:
|
||||
if not self.active_challenge_id:
|
||||
self.query_one("#status-bar", Static).update("Please select a challenge first.")
|
||||
return
|
||||
|
||||
challenge = self.challenges_dict.get(self.active_challenge_id)
|
||||
if not challenge:
|
||||
return
|
||||
|
||||
code = self.query_one("#editor", CodeEditor).text
|
||||
if not code.strip():
|
||||
self.query_one("#status-bar", Static).update("Editor is empty — write code before checking answer.")
|
||||
return
|
||||
|
||||
main_box = self.query_one("#main-container")
|
||||
main_box.remove_class("sidebar-handbook-open")
|
||||
main_box.add_class("sidebar-open")
|
||||
main_box.add_class("sidebar-mentor-open")
|
||||
|
||||
check_question = (
|
||||
"Please evaluate my code implementation against all the requirements of this challenge. "
|
||||
"Check if my solution is complete and correct, point out any missing requirements or edge cases, "
|
||||
"and give me feedback on my answer."
|
||||
)
|
||||
|
||||
self.query_one("#mentor-response-display", Markdown).update(
|
||||
"⏳ **Mentor is evaluating your solution against challenge requirements...**\n\n*Checking logic, requirements, and edge cases...*"
|
||||
)
|
||||
self.query_one("#status-bar", Static).update("⏳ Consulting Mentor to check answer...")
|
||||
self.run_worker(self._mentor_worker(challenge, code, check_question))
|
||||
|
||||
def action_unfocus_editor(self) -> None:
|
||||
main_box = self.query_one("#main-container")
|
||||
if main_box.has_class("show-completion"):
|
||||
main_box.remove_class("show-completion")
|
||||
self.query_one("#editor", CodeEditor).focus()
|
||||
return
|
||||
|
||||
self.query_one("#challenge_list", OptionList).focus()
|
||||
self.query_one("#status-bar", Static).update("Focus moved to Challenge List.")
|
||||
|
||||
def action_toggle_panel_focus(self) -> None:
|
||||
focused = self.focused
|
||||
if focused and getattr(focused, "id", None) == "editor":
|
||||
main_box = self.query_one("#main-container")
|
||||
if main_box.has_class("sidebar-handbook-open"):
|
||||
self.query_one("#handbook_topic_list", OptionList).focus()
|
||||
else:
|
||||
if not main_box.has_class("sidebar-mentor-open"):
|
||||
main_box.add_class("sidebar-open")
|
||||
main_box.add_class("sidebar-mentor-open")
|
||||
self.query_one("#mentor-question-input", MentorInputTextArea).focus()
|
||||
elif focused and getattr(focused, "id", None) in ("mentor-question-input", "handbook_topic_list"):
|
||||
self.query_one("#challenge_list", OptionList).focus()
|
||||
else:
|
||||
self.query_one("#editor", CodeEditor).focus()
|
||||
|
||||
# ── Challenge selection & Handbook selection ─────────────
|
||||
|
||||
def _display_challenge(self, cid: str) -> None:
|
||||
self.active_challenge_id = cid
|
||||
challenge = self.challenges_dict.get(cid)
|
||||
if not challenge:
|
||||
return
|
||||
|
||||
req_str = "\n".join(f"- {r}" for r in challenge.requirements) if challenge.requirements else "*None*"
|
||||
hint_str = "\n".join(f"- {h}" for h in challenge.hints) if challenge.hints else "*None*"
|
||||
|
||||
markdown_content = f"""# {challenge.name}
|
||||
**Difficulty:** {challenge.difficulty} | **Language:** {challenge.language} | **Subject:** {challenge.subject}
|
||||
|
||||
## Description
|
||||
{challenge.description}
|
||||
|
||||
## Requirements
|
||||
{req_str}
|
||||
|
||||
## Hints
|
||||
{hint_str}
|
||||
"""
|
||||
self.query_one("#challenge-details", Markdown).update(markdown_content)
|
||||
|
||||
editor = self.query_one("#editor", CodeEditor)
|
||||
lang_key = challenge.language.lower()
|
||||
try:
|
||||
if lang_key in editor.available_languages:
|
||||
editor.language = lang_key
|
||||
else:
|
||||
editor.language = None
|
||||
except Exception:
|
||||
editor.language = None
|
||||
|
||||
main_box = self.query_one("#main-container")
|
||||
if main_box.has_class("sidebar-handbook-open"):
|
||||
self.load_handbook_catalog(challenge.language)
|
||||
|
||||
def on_option_list_option_highlighted(
|
||||
self, event: OptionList.OptionHighlighted
|
||||
) -> None:
|
||||
if event.option_list.id == "challenge_list":
|
||||
selected_option = event.option
|
||||
if selected_option and selected_option.id:
|
||||
self._display_challenge(selected_option.id)
|
||||
self.query_one("#status-bar", Static).update(
|
||||
f"Hovering Challenge: {selected_option.prompt} — Hit Enter to jump to Editor"
|
||||
)
|
||||
|
||||
def on_option_list_option_selected(
|
||||
self, event: OptionList.OptionSelected
|
||||
) -> None:
|
||||
if event.option_list.id == "challenge_list":
|
||||
selected_option = event.option
|
||||
if selected_option and selected_option.id:
|
||||
self._display_challenge(selected_option.id)
|
||||
self.query_one("#editor", CodeEditor).focus()
|
||||
self.query_one("#status-bar", Static).update("Focused: Code Editor")
|
||||
elif event.option_list.id == "completion_list_popup":
|
||||
selected_option = event.option
|
||||
if selected_option and selected_option.prompt:
|
||||
chosen_text = str(selected_option.prompt)
|
||||
editor = self.query_one("#editor", CodeEditor)
|
||||
cursor_row, cursor_col = editor.cursor_location
|
||||
suffix_len = len(self.active_completion_prefix)
|
||||
start_col = max(0, cursor_col - suffix_len)
|
||||
editor.delete((cursor_row, start_col), (cursor_row, cursor_col))
|
||||
editor.insert(chosen_text)
|
||||
|
||||
main_box = self.query_one("#main-container")
|
||||
main_box.remove_class("show-completion")
|
||||
editor.focus()
|
||||
self.query_one("#status-bar", Static).update(f"✓ Inserted '{chosen_text}'")
|
||||
elif event.option_list.id == "handbook_topic_list":
|
||||
selected_option = event.option
|
||||
if selected_option and selected_option.id:
|
||||
all_items = self.catalog_functions + self.catalog_subjects
|
||||
item = next((i for i in all_items if i["id"] == selected_option.id), None)
|
||||
if item:
|
||||
challenge = self.challenges_dict.get(self.active_challenge_id) if self.active_challenge_id else None
|
||||
lang = challenge.language if challenge else "Python"
|
||||
|
||||
self.query_one("#handbook-example-display", Markdown).update(
|
||||
f"⏳ **Generating W3Schools reference card for '{item['title']}' in {lang}...**"
|
||||
)
|
||||
self.run_worker(self._handbook_worker(lang, item["id"], item["title"]))
|
||||
|
||||
def show_completion_popup(self, prefix: str, matches: List[str]) -> None:
|
||||
self.active_completion_prefix = prefix
|
||||
popup_list = self.query_one("#completion_list_popup", OptionList)
|
||||
popup_list.clear_options()
|
||||
|
||||
for match in matches:
|
||||
popup_list.add_option(Option(match, id=match))
|
||||
|
||||
main_box = self.query_one("#main-container")
|
||||
main_box.add_class("show-completion")
|
||||
popup_list.focus()
|
||||
if len(matches) > 0:
|
||||
popup_list.highlighted = 0
|
||||
|
||||
# ── Scrolling Actions ────────────────────────────────────
|
||||
|
||||
def action_scroll_mentor_up(self) -> None:
|
||||
main_box = self.query_one("#main-container")
|
||||
if main_box.has_class("sidebar-handbook-open"):
|
||||
self.query_one("#handbook-example-scroll", VerticalScroll).scroll_up(animate=False)
|
||||
else:
|
||||
self.query_one("#mentor-response-scroll", VerticalScroll).scroll_up(animate=False)
|
||||
|
||||
def action_scroll_mentor_down(self) -> None:
|
||||
main_box = self.query_one("#main-container")
|
||||
if main_box.has_class("sidebar-handbook-open"):
|
||||
self.query_one("#handbook-example-scroll", VerticalScroll).scroll_down(animate=False)
|
||||
else:
|
||||
self.query_one("#mentor-response-scroll", VerticalScroll).scroll_down(animate=False)
|
||||
|
||||
def action_scroll_output_up(self) -> None:
|
||||
self.query_one("#output-scroll-container", VerticalScroll).scroll_up(animate=False)
|
||||
|
||||
def action_scroll_output_down(self) -> None:
|
||||
self.query_one("#output-scroll-container", VerticalScroll).scroll_down(animate=False)
|
||||
|
||||
def action_scroll_details_up(self) -> None:
|
||||
self.query_one("#challenge-details-container", VerticalScroll).scroll_up(animate=False)
|
||||
|
||||
def action_scroll_details_down(self) -> None:
|
||||
self.query_one("#challenge-details-container", VerticalScroll).scroll_down(animate=False)
|
||||
|
||||
# ── Core Workers ─────────────────────────────────────────
|
||||
|
||||
def action_refresh_challenges(self) -> None:
|
||||
self.query_one("#status-bar", Static).update("Refreshing challenges...")
|
||||
self.load_challenges()
|
||||
|
||||
async def _mentor_worker(self, challenge: object, code: str, user_question: str) -> None:
|
||||
res = await mentor.get_guidance_async(challenge, code, user_question=user_question)
|
||||
response_text = res.get("mentor_response", "No response received.")
|
||||
|
||||
q_header = f"### Question / Evaluation:\n> {user_question}\n\n---\n\n" if user_question else ""
|
||||
full_md = f"{q_header}{response_text}"
|
||||
|
||||
self.query_one("#mentor-response-display", Markdown).update(full_md)
|
||||
self.query_one("#status-bar", Static).update("Mentor response received — Press F1 to ask or F2 to check answer")
|
||||
|
||||
async def _handbook_worker(self, language: str, topic_id: str, topic_title: str) -> None:
|
||||
res = await handbook_service.generate_example_async(language, topic_id, topic_title)
|
||||
if res.get("status") == "success":
|
||||
md_text = res.get("example_markdown", "No reference card generated.")
|
||||
self.query_one("#handbook-example-display", Markdown).update(md_text)
|
||||
self.query_one("#status-bar", Static).update(f"✓ Reference card loaded for '{topic_title}'")
|
||||
else:
|
||||
err_msg = res.get("message", "Error generating reference card.")
|
||||
self.query_one("#handbook-example-display", Markdown).update(err_msg)
|
||||
self.query_one("#status-bar", Static).update("✗ Failed to generate reference card")
|
||||
|
||||
def action_lint_code(self) -> None:
|
||||
code = self.query_one("#editor", CodeEditor).text
|
||||
if not code.strip():
|
||||
self.query_one("#status-bar", Static).update("Editor is empty — nothing to lint.")
|
||||
return
|
||||
|
||||
self.query_one("#status-bar", Static).update("Running syntax & style lint...")
|
||||
challenge = self.challenges_dict.get(self.active_challenge_id)
|
||||
lang = challenge.language.lower() if challenge else "python"
|
||||
|
||||
res = linter.lint(lang, code)
|
||||
output_widget = self.query_one("#output-container", Static)
|
||||
|
||||
if res.get("exit_code") == 0:
|
||||
output_widget.update("✓ Syntax & Style clean! No linting errors detected.")
|
||||
else:
|
||||
raw = (res.get("stdout") or "") + "\n" + (res.get("stderr") or "")
|
||||
lines = [line.strip() for line in raw.strip().splitlines() if line.strip()]
|
||||
cleaned = []
|
||||
for line in lines:
|
||||
if ":" in line:
|
||||
parts = line.split(":", 3)
|
||||
if len(parts) >= 3 and parts[1].isdigit():
|
||||
cleaned.append(f"Line {parts[1]}, Col {parts[2]}:{parts[3]}")
|
||||
else:
|
||||
cleaned.append(line)
|
||||
else:
|
||||
cleaned.append(line)
|
||||
msg = "\n".join(cleaned) if cleaned else "Syntax error detected."
|
||||
output_widget.update(f"✗ Lint Error(s):\n{msg}")
|
||||
|
||||
self.query_one("#status-bar", Static).update("Lint complete")
|
||||
|
||||
def action_run_code(self, stdin: str = "") -> None:
|
||||
code = self.query_one("#editor", CodeEditor).text
|
||||
if not code.strip():
|
||||
self.query_one("#status-bar", Static).update("Editor is empty — write code before running.")
|
||||
return
|
||||
|
||||
self.query_one("#status-bar", Static).update("Executing code...")
|
||||
challenge = self.challenges_dict.get(self.active_challenge_id)
|
||||
lang = challenge.language.lower() if challenge else "python"
|
||||
|
||||
res = executor.run(lang, code, stdin=stdin)
|
||||
output_widget = self.query_one("#output-container", Static)
|
||||
|
||||
if res.get("exit_code") == 0:
|
||||
stdout = res.get("stdout", "").strip()
|
||||
if stdout:
|
||||
output_widget.update(f"Output:\n{stdout}")
|
||||
else:
|
||||
output_widget.update("✓ Code executed successfully (exit code 0, no stdout).")
|
||||
else:
|
||||
stderr = res.get("stderr", "").strip()
|
||||
stdout = res.get("stdout", "").strip()
|
||||
err_msg = stderr or stdout or "Unknown runtime error"
|
||||
output_widget.update(f"✗ Runtime Error (exit code {res.get('exit_code')}):\n{err_msg}")
|
||||
|
||||
self.query_one("#status-bar", Static).update("Run complete")
|
||||
|
||||
|
||||
TactTermTUI = TactiTermTUI
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
parser = argparse.ArgumentParser(description="TactiTerm TUI")
|
||||
parser.add_argument("--debug", action="store_true", help="Enable debug logging to tactiterm_tui.log")
|
||||
args = parser.parse_args()
|
||||
|
||||
app = TactiTermTUI(debug=args.debug)
|
||||
app.run()
|
||||
@@ -0,0 +1,414 @@
|
||||
"""GenTUI — Standalone TUI tool for generating and editing TactiTerm challenges with AI."""
|
||||
import os
|
||||
import argparse
|
||||
import asyncio
|
||||
import httpx
|
||||
|
||||
from textual.app import App, ComposeResult
|
||||
from textual.screen import ModalScreen
|
||||
from textual.containers import Container, Horizontal, VerticalScroll
|
||||
from textual.widgets import Header, Footer, Static, Button, Markdown, TextArea, Input, Select
|
||||
from textual.binding import Binding
|
||||
|
||||
from src.core.generator import generator
|
||||
from src.core.config import config
|
||||
from src.core.mentor import get_chat_completions_url
|
||||
|
||||
|
||||
class GenHelpModal(ModalScreen):
|
||||
"""Interactive AI Assistant Modal for helping user design challenges."""
|
||||
|
||||
DEFAULT_CSS = """
|
||||
GenHelpModal {
|
||||
align: center middle;
|
||||
background: rgba(0, 0, 0, 0.7);
|
||||
}
|
||||
|
||||
#help-dialog {
|
||||
width: 75%;
|
||||
height: 80%;
|
||||
background: $surface;
|
||||
border: thick $primary;
|
||||
padding: 1 2;
|
||||
layout: vertical;
|
||||
}
|
||||
|
||||
#help-header {
|
||||
text-align: center;
|
||||
text-style: bold;
|
||||
background: $primary;
|
||||
color: white;
|
||||
height: 1;
|
||||
margin-bottom: 1;
|
||||
}
|
||||
|
||||
#help-response-scroll {
|
||||
height: 1fr;
|
||||
border: solid $secondary;
|
||||
padding: 1;
|
||||
background: $background;
|
||||
margin-bottom: 1;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
#help-input-label {
|
||||
height: 1;
|
||||
text-style: bold;
|
||||
color: $accent;
|
||||
}
|
||||
|
||||
#help-input {
|
||||
margin-bottom: 1;
|
||||
}
|
||||
|
||||
#help-button-container {
|
||||
height: 3;
|
||||
layout: horizontal;
|
||||
}
|
||||
|
||||
#btn-ask-help {
|
||||
width: 1fr;
|
||||
margin-right: 1;
|
||||
}
|
||||
|
||||
#btn-close-help {
|
||||
width: 1fr;
|
||||
}
|
||||
"""
|
||||
|
||||
BINDINGS = [
|
||||
Binding("ctrl+up", "scroll_up", "Scroll Up (Ctrl+↑)"),
|
||||
Binding("ctrl+down", "scroll_down", "Scroll Down (Ctrl+↓)"),
|
||||
]
|
||||
|
||||
def action_scroll_up(self) -> None:
|
||||
self.query_one("#help-response-scroll", VerticalScroll).scroll_up(animate=False)
|
||||
|
||||
def action_scroll_down(self) -> None:
|
||||
self.query_one("#help-response-scroll", VerticalScroll).scroll_down(animate=False)
|
||||
|
||||
def compose(self) -> ComposeResult:
|
||||
|
||||
with Container(id="help-dialog"):
|
||||
yield Static("💡 TactiTerm AI Challenge Design Assistant", id="help-header")
|
||||
with VerticalScroll(id="help-response-scroll"):
|
||||
yield Markdown(
|
||||
"### How can I help you design your challenge?\n"
|
||||
"Ask me anything if you're unsure about what language features, topics, requirements, or expected outputs to use!",
|
||||
id="help-response-markdown",
|
||||
)
|
||||
yield Static("Ask Assistant a Question:", id="help-input-label")
|
||||
yield Input(
|
||||
placeholder="e.g. What are good topics for a Medium Rust challenge? Or what requirements should a Java OOP challenge have?",
|
||||
id="help-input",
|
||||
)
|
||||
with Horizontal(id="help-button-container"):
|
||||
yield Button("✨ Ask Assistant (Enter)", id="btn-ask-help", variant="warning")
|
||||
yield Button("Close Window", id="btn-close-help", variant="error")
|
||||
|
||||
def on_mount(self) -> None:
|
||||
self.query_one("#help-input", Input).focus()
|
||||
|
||||
def on_button_pressed(self, event: Button.Pressed) -> None:
|
||||
if event.button.id == "btn-ask-help":
|
||||
self.action_ask_assistant()
|
||||
elif event.button.id == "btn-close-help":
|
||||
self.dismiss()
|
||||
|
||||
def on_input_submitted(self, event: Input.Submitted) -> None:
|
||||
if event.input.id == "help-input":
|
||||
self.action_ask_assistant()
|
||||
|
||||
def action_ask_assistant(self) -> None:
|
||||
question = self.query_one("#help-input", Input).value.strip()
|
||||
if not question:
|
||||
return
|
||||
|
||||
self.query_one("#help-response-markdown", Markdown).update("⏳ **Assistant is thinking...**\n\n*Consulting LLM backend for advice...*")
|
||||
self.run_worker(self._ask_worker(question))
|
||||
|
||||
async def _ask_worker(self, question: str) -> None:
|
||||
endpoint = get_chat_completions_url(config.llm_base_url)
|
||||
payload = {
|
||||
"model": config.llm_model,
|
||||
"messages": [
|
||||
{
|
||||
"role": "system",
|
||||
"content": (
|
||||
"You are an expert Software Engineering Curriculum Designer assisting a teacher in creating coding challenges. "
|
||||
"Give clear, concise, actionable advice for challenge topics, difficulties, programming languages, and requirements."
|
||||
),
|
||||
},
|
||||
{"role": "user", "content": question},
|
||||
],
|
||||
"max_tokens": 1000,
|
||||
}
|
||||
|
||||
headers = {"Content-Type": "application/json"}
|
||||
if config.llm_api_key and config.llm_api_key != "not-needed":
|
||||
headers["Authorization"] = f"Bearer {config.llm_api_key}"
|
||||
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=config.llm_timeout, follow_redirects=True) as client:
|
||||
response = await client.post(endpoint, json=payload, headers=headers)
|
||||
response.raise_for_status()
|
||||
data = response.json()
|
||||
text = (
|
||||
data.get("choices", [{}])[0]
|
||||
.get("message", {})
|
||||
.get("content", "No response received.")
|
||||
)
|
||||
self.query_one("#help-response-markdown", Markdown).update(text)
|
||||
except Exception as e:
|
||||
self.query_one("#help-response-markdown", Markdown).update(f"⚠️ Error asking Assistant: {e}")
|
||||
|
||||
|
||||
class GenTUIApp(App):
|
||||
"""Standalone TUI Challenge Generator Application."""
|
||||
|
||||
TITLE = "TactiTerm GenTUI"
|
||||
|
||||
CSS = """
|
||||
Screen {
|
||||
layout: vertical;
|
||||
}
|
||||
|
||||
#main-container {
|
||||
layout: horizontal;
|
||||
height: 1fr;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
/* Left Panel: Generator Controls */
|
||||
#control-panel {
|
||||
width: 32%;
|
||||
min-width: 28;
|
||||
height: 100%;
|
||||
border-right: heavy $primary;
|
||||
padding: 0 1;
|
||||
}
|
||||
|
||||
/* Middle Panel: Raw Markdown Editor */
|
||||
#editor-panel {
|
||||
width: 36%;
|
||||
height: 100%;
|
||||
padding: 0 1;
|
||||
border-right: heavy $primary;
|
||||
}
|
||||
|
||||
/* Right Panel: Live Markdown Preview */
|
||||
#preview-panel {
|
||||
width: 32%;
|
||||
height: 100%;
|
||||
padding: 0 1;
|
||||
}
|
||||
|
||||
.section-header {
|
||||
height: 3;
|
||||
background: $primary;
|
||||
color: white;
|
||||
content-align: center middle;
|
||||
text-style: bold;
|
||||
margin-bottom: 1;
|
||||
}
|
||||
|
||||
.input-label {
|
||||
height: 1;
|
||||
text-style: bold;
|
||||
color: $accent;
|
||||
margin-top: 1;
|
||||
}
|
||||
|
||||
Input, Select {
|
||||
margin-bottom: 1;
|
||||
}
|
||||
|
||||
TextArea {
|
||||
height: 1fr;
|
||||
border: solid $accent;
|
||||
margin-bottom: 1;
|
||||
}
|
||||
|
||||
#preview-scroll {
|
||||
height: 1fr;
|
||||
border: solid $secondary;
|
||||
padding: 1;
|
||||
background: $surface;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
#btn-help-modal {
|
||||
width: 100%;
|
||||
margin-top: 1;
|
||||
margin-bottom: 1;
|
||||
}
|
||||
|
||||
#btn-generate, #btn-save {
|
||||
width: 100%;
|
||||
margin-top: 1;
|
||||
margin-bottom: 1;
|
||||
}
|
||||
|
||||
#status-bar {
|
||||
height: 1;
|
||||
background: $primary;
|
||||
color: white;
|
||||
padding: 0 1;
|
||||
text-style: bold;
|
||||
}
|
||||
"""
|
||||
|
||||
BINDINGS = [
|
||||
Binding("ctrl+h", "open_help_modal", "AI Helper"),
|
||||
Binding("ctrl+g", "generate_ai", "Generate AI"),
|
||||
Binding("ctrl+s", "save_challenge", "Save File"),
|
||||
Binding("ctrl+q", "quit", "Quit"),
|
||||
]
|
||||
|
||||
LANGUAGES = ["Python", "C#", "C++", "Java", "JavaScript", "TypeScript", "Rust", "Lua", "HTML", "Go"]
|
||||
DIFFICULTIES = ["Easy", "Medium", "Hard", "Advanced"]
|
||||
|
||||
def __init__(self, debug: bool = False) -> None:
|
||||
super().__init__()
|
||||
self.debug_mode = debug
|
||||
|
||||
def compose(self) -> ComposeResult:
|
||||
yield Header()
|
||||
|
||||
with Horizontal(id="main-container"):
|
||||
# Left Panel: Prompt Controls
|
||||
with Container(id="control-panel"):
|
||||
yield Static("1. Prompt AI Generator", classes="section-header")
|
||||
|
||||
yield Button("💡 Help Me Decide (AI Assist) [Ctrl+H]", id="btn-help-modal", variant="default")
|
||||
|
||||
yield Static("AI Challenge Prompt:", classes="input-label")
|
||||
yield Input(
|
||||
placeholder="e.g. Medium Rust challenge on Ownership & References",
|
||||
id="input-prompt",
|
||||
)
|
||||
|
||||
yield Static("Language (Dropdown):", classes="input-label")
|
||||
yield Select.from_values(
|
||||
self.LANGUAGES,
|
||||
allow_blank=False,
|
||||
value="Python",
|
||||
id="select-language",
|
||||
)
|
||||
|
||||
yield Static("Difficulty (Dropdown):", classes="input-label")
|
||||
yield Select.from_values(
|
||||
self.DIFFICULTIES,
|
||||
allow_blank=False,
|
||||
value="Medium",
|
||||
id="select-difficulty",
|
||||
)
|
||||
|
||||
yield Static("Subject / Topic:", classes="input-label")
|
||||
yield Input(placeholder="e.g. Memory Management, Data Structures", id="input-subject", value="General Concepts")
|
||||
|
||||
yield Button("✨ Generate with AI (Ctrl+G)", id="btn-generate", variant="warning")
|
||||
|
||||
yield Static("Filename (.md):", classes="input-label")
|
||||
yield Input(placeholder="new-challenge.md", id="input-filename")
|
||||
|
||||
yield Button("💾 Save Challenge to Disk (Ctrl+S)", id="btn-save", variant="success")
|
||||
|
||||
# Middle Panel: Markdown Code Editor
|
||||
with Container(id="editor-panel"):
|
||||
yield Static("2. Raw Markdown (.md) Editor", classes="section-header")
|
||||
yield TextArea(id="markdown-editor")
|
||||
|
||||
# Right Panel: Live Preview
|
||||
with Container(id="preview-panel"):
|
||||
yield Static("3. Live Rendered Preview", classes="section-header")
|
||||
with VerticalScroll(id="preview-scroll"):
|
||||
yield Markdown("AI generated preview will appear here...", id="markdown-preview")
|
||||
|
||||
yield Static("Ready — Type your prompt and click Generate with AI", id="status-bar")
|
||||
yield Footer()
|
||||
|
||||
def on_mount(self) -> None:
|
||||
editor = self.query_one("#markdown-editor", TextArea)
|
||||
if "markdown" in editor.available_languages:
|
||||
editor.language = "markdown"
|
||||
self.query_one("#input-prompt", Input).focus()
|
||||
|
||||
def on_button_pressed(self, event: Button.Pressed) -> None:
|
||||
if event.button.id == "btn-help-modal":
|
||||
self.action_open_help_modal()
|
||||
elif event.button.id == "btn-generate":
|
||||
self.action_generate_ai()
|
||||
elif event.button.id == "btn-save":
|
||||
self.action_save_challenge()
|
||||
|
||||
def on_text_area_changed(self, event: TextArea.Changed) -> None:
|
||||
"""Update live preview whenever markdown editor content changes."""
|
||||
if event.text_area.id == "markdown-editor":
|
||||
self.query_one("#markdown-preview", Markdown).update(event.text_area.text)
|
||||
|
||||
def action_open_help_modal(self) -> None:
|
||||
"""Pop up the AI assistant help window."""
|
||||
self.push_screen(GenHelpModal())
|
||||
|
||||
def action_generate_ai(self) -> None:
|
||||
"""Generate challenge markdown using LLM service worker."""
|
||||
prompt_text = self.query_one("#input-prompt", Input).value.strip()
|
||||
if not prompt_text:
|
||||
self.query_one("#status-bar", Static).update("Please enter an AI prompt description.")
|
||||
return
|
||||
|
||||
lang_select = self.query_one("#select-language", Select)
|
||||
diff_select = self.query_one("#select-difficulty", Select)
|
||||
|
||||
lang = str(lang_select.value) if lang_select.value != Select.BLANK else "Python"
|
||||
diff = str(diff_select.value) if diff_select.value != Select.BLANK else "Medium"
|
||||
subj = self.query_one("#input-subject", Input).value.strip() or "General"
|
||||
|
||||
self.query_one("#status-bar", Static).update("⏳ Generating challenge via LLM backend...")
|
||||
self.run_worker(self._generate_worker(prompt_text, diff, lang, subj))
|
||||
|
||||
async def _generate_worker(self, prompt: str, difficulty: str, language: str, subject: str) -> None:
|
||||
res = await generator.generate_challenge_async(prompt, difficulty=difficulty, language=language, subject=subject)
|
||||
|
||||
if res.get("status") == "success":
|
||||
md_text = res.get("markdown", "")
|
||||
filename = res.get("filename", "new-challenge.md")
|
||||
|
||||
editor = self.query_one("#markdown-editor", TextArea)
|
||||
editor.text = md_text
|
||||
self.query_one("#input-filename", Input).value = filename
|
||||
self.query_one("#markdown-preview", Markdown).update(md_text)
|
||||
|
||||
self.query_one("#status-bar", Static).update(f"✓ Challenge generated: '{res.get('title')}' — Edit or click Save to disk")
|
||||
else:
|
||||
err_msg = res.get("message", "Unknown error")
|
||||
self.query_one("#status-bar", Static).update(f"✗ Challenge Generation Failed: {err_msg}")
|
||||
|
||||
def action_save_challenge(self) -> None:
|
||||
"""Save the markdown content to disk."""
|
||||
filename = self.query_one("#input-filename", Input).value.strip()
|
||||
md_text = self.query_one("#markdown-editor", TextArea).text.strip()
|
||||
|
||||
if not filename:
|
||||
self.query_one("#status-bar", Static).update("Please specify a filename (.md) before saving.")
|
||||
return
|
||||
|
||||
if not md_text:
|
||||
self.query_one("#status-bar", Static).update("Markdown editor is empty — write or generate content first.")
|
||||
return
|
||||
|
||||
res = generator.save_challenge(filename, md_text)
|
||||
if res.get("status") == "success":
|
||||
self.query_one("#status-bar", Static).update(f"✓ Saved challenge file to {res.get('path')}")
|
||||
else:
|
||||
self.query_one("#status-bar", Static).update(f"✗ Save Error: {res.get('message')}")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
parser = argparse.ArgumentParser(description="GenTUI — TactiTerm AI Challenge Generator")
|
||||
parser.add_argument("--debug", action="store_true", help="Enable debug logging")
|
||||
args = parser.parse_args()
|
||||
|
||||
app = GenTUIApp(debug=args.debug)
|
||||
app.run()
|
||||
@@ -0,0 +1,190 @@
|
||||
"""Custom widgets for the TactiTerm TUI with auto-completion engine and StdinInput."""
|
||||
import asyncio
|
||||
from typing import List, Dict
|
||||
from textual import events
|
||||
from textual.message import Message
|
||||
from textual.widgets import TextArea, Input
|
||||
|
||||
# Comprehensive auto-completion syntax catalog per language for TUI
|
||||
TUI_COMPLETIONS: Dict[str, List[str]] = {
|
||||
"python": [
|
||||
"int", "float", "bool", "str", "list", "dict", "set", "tuple", "bytes", "None",
|
||||
"True", "False", "print()", "len()", "range()", "enumerate()", "zip()", "map()",
|
||||
"filter()", "sorted()", "isinstance()", "type()", "open()", "input()", ".append()",
|
||||
".split()", ".join()", ".get()", "def ", "class ", "return ", "import ", "from ",
|
||||
"if ", "elif ", "else:", "try:", "except ", "finally:", "raise ", "with ", "as ",
|
||||
"yield ", "async ", "await ", "lambda ", "pass", "break", "continue", "assert "
|
||||
],
|
||||
"rust": [
|
||||
"bool", "i32", "i64", "u32", "u64", "usize", "f32", "f64", "char", "str", "String",
|
||||
"Vec", "Option", "Result", "Some", "None", "Ok", "Err", "println!()", "print!()",
|
||||
"format!()", "vec![]", "Vec::push()", "String::from()", "Option::unwrap()",
|
||||
"Result::expect()", ".iter()", ".collect()", "Box::new()", "fn ", "let ", "let mut ",
|
||||
"pub ", "struct ", "enum ", "impl ", "trait ", "match ", "if ", "else ", "loop ",
|
||||
"while ", "for ", "in ", "return ", "use std::", "async ", "await "
|
||||
],
|
||||
"cpp": [
|
||||
"int", "float", "double", "bool", "char", "void", "size_t", "std::string",
|
||||
"std::vector<", "std::map<", "std::set<", "std::unique_ptr<", "std::shared_ptr<",
|
||||
"std::cout << ", "std::cin >> ", "std::endl", ".push_back()", "std::sort()",
|
||||
"std::make_unique<", "std::make_shared<", "class ", "struct ", "public:", "private:",
|
||||
"protected:", "virtual ", "override", "const ", "constexpr ", "auto ", "template<typename T>",
|
||||
"namespace ", "using namespace std;", "return ", "if ", "else ", "for ", "while "
|
||||
],
|
||||
"go": [
|
||||
"int", "int64", "float64", "bool", "string", "byte", "rune", "error", "nil", "true",
|
||||
"false", "fmt.Println()", "fmt.Printf()", "fmt.Sprintf()", "make()", "append()",
|
||||
"len()", "cap()", "delete()", "strings.Split()", "os.Open()", "func ", "var ", "type ",
|
||||
"struct ", "interface ", "package main", "import ", "return ", "if ", "else ", "for ",
|
||||
"range ", "switch ", "select ", "chan ", "go func()"
|
||||
],
|
||||
"javascript": [
|
||||
"number", "string", "boolean", "bool", "any", "void", "null", "undefined", "Array",
|
||||
"Object", "Promise", "console.log()", "console.error()", "console.warn()", ".map()",
|
||||
".filter()", ".reduce()", ".find()", ".push()", ".includes()", "Object.keys()",
|
||||
"JSON.parse()", "fetch()", "const ", "let ", "var ", "function ", "class ", "return ",
|
||||
"if ", "else ", "for ", "while ", "switch ", "try ", "catch ", "async ", "await ", "new "
|
||||
],
|
||||
"typescript": [
|
||||
"number", "string", "boolean", "bool", "any", "void", "unknown", "never", "null",
|
||||
"undefined", "Array<", "Record<", "Partial<", "Readonly<", "Pick<", "Omit<",
|
||||
"Promise<", "console.log()", ".map()", ".filter()", "interface ", "type ", "const ",
|
||||
"let ", "function ", "class ", "export ", "import ", "async ", "await "
|
||||
],
|
||||
"java": [
|
||||
"int", "long", "double", "float", "boolean", "bool", "char", "byte", "short", "void",
|
||||
"String", "List<", "ArrayList<", "Map<", "HashMap<", "Set<", "HashSet<",
|
||||
"System.out.println()", ".add()", ".get()", ".size()", "public ", "private ",
|
||||
"protected ", "static ", "final ", "class ", "interface ", "extends ", "implements ",
|
||||
"new ", "return ", "if ", "else ", "for ", "while ", "try ", "catch "
|
||||
],
|
||||
"csharp": [
|
||||
"int", "long", "double", "float", "bool", "char", "string", "object", "void", "var",
|
||||
"List<", "Dictionary<", "HashSet<", "Task<", "Console.WriteLine()", "Console.ReadLine()",
|
||||
".Add()", "Enumerable.Where()", "public ", "private ", "protected ", "internal ",
|
||||
"static ", "class ", "struct ", "interface ", "record ", "namespace ", "using System;",
|
||||
"new ", "return ", "if ", "else ", "foreach ", "while ", "async ", "await "
|
||||
],
|
||||
"lua": [
|
||||
"number", "int", "string", "boolean", "bool", "table", "nil", "function", "print()",
|
||||
"type()", "tostring()", "tonumber()", "table.insert()", "string.sub()", "local ",
|
||||
"if ", "then", "else", "elseif ", "end", "for ", "while ", "do", "return ", "require()"
|
||||
],
|
||||
"html": [
|
||||
"<!DOCTYPE html>", "<html>", "<head>", "<title>", "<body>", "<h1>", "<h2>", "<h3>",
|
||||
"<p>", "<span>", "<div>", "<a href=\"\">", "<img src=\"\">", "<form>", "<input type=\"text\">",
|
||||
"<button>", "<select>", "<option>", "<ul>", "<ol>", "<li>", "<table>", "<tr>", "<td>", "<th>",
|
||||
"<script>", "<link rel=\"stylesheet\">"
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
class CodeEditor(TextArea):
|
||||
"""A code editor with auto-completion dropdown trigger."""
|
||||
|
||||
DEFAULT_CSS = """
|
||||
CodeEditor {
|
||||
height: 1fr;
|
||||
min-height: 8;
|
||||
border: solid $accent;
|
||||
}
|
||||
CodeEditor:focus {
|
||||
border: heavy $primary;
|
||||
}
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
language: str = "python",
|
||||
theme: str = "vscode_dark",
|
||||
id: str | None = None,
|
||||
classes: str | None = None,
|
||||
) -> None:
|
||||
super().__init__(
|
||||
language=language,
|
||||
theme=theme,
|
||||
soft_wrap=False,
|
||||
show_line_numbers=True,
|
||||
id=id,
|
||||
classes=classes,
|
||||
)
|
||||
|
||||
def on_mount(self) -> None:
|
||||
self.tab_behavior = "indent"
|
||||
self.cursor_style = "bold blue"
|
||||
|
||||
def get_code(self) -> str:
|
||||
return self.text
|
||||
|
||||
def _on_key(self, event: events.Key) -> None:
|
||||
"""Handle Tab completion and Ctrl+E toggle."""
|
||||
if event.key == "ctrl+e":
|
||||
event.prevent_default()
|
||||
event.stop()
|
||||
if hasattr(self.app, "action_toggle_expand_output"):
|
||||
self.app.action_toggle_expand_output()
|
||||
return
|
||||
|
||||
if event.key == "tab":
|
||||
cursor_row, cursor_col = self.cursor_location
|
||||
lines = self.text.split("\n")
|
||||
if cursor_row < len(lines):
|
||||
current_line = lines[cursor_row][:cursor_col]
|
||||
words = current_line.replace("(", " ").replace(")", " ").split()
|
||||
if words:
|
||||
prefix = words[-1].lower()
|
||||
lang = (self.language or "python").lower()
|
||||
candidates = TUI_COMPLETIONS.get(lang, TUI_COMPLETIONS["python"])
|
||||
matches = [c for c in candidates if c.lower().startswith(prefix) or prefix in c.lower()]
|
||||
|
||||
if matches:
|
||||
event.prevent_default()
|
||||
event.stop()
|
||||
if hasattr(self.app, "show_completion_popup"):
|
||||
self.app.show_completion_popup(words[-1], matches)
|
||||
return
|
||||
|
||||
super()._on_key(event)
|
||||
|
||||
|
||||
class StdinInput(Input):
|
||||
"""Input control for stdin that forwards Ctrl+E to screen output toggler."""
|
||||
|
||||
def _on_key(self, event: events.Key) -> None:
|
||||
if event.key == "ctrl+e":
|
||||
event.prevent_default()
|
||||
event.stop()
|
||||
if hasattr(self.app, "action_toggle_expand_output"):
|
||||
self.app.action_toggle_expand_output()
|
||||
return
|
||||
res = super()._on_key(event)
|
||||
if asyncio.iscoroutine(res):
|
||||
asyncio.create_task(res)
|
||||
|
||||
|
||||
class MentorInputTextArea(TextArea):
|
||||
"""Multi-line question input for Socratic Mentor. Submits on Enter, inserts newline on Shift+Enter."""
|
||||
|
||||
class SubmitQuestion(Message):
|
||||
"""Event posted when Enter is pressed."""
|
||||
|
||||
def __init__(self, question: str) -> None:
|
||||
self.question = question
|
||||
super().__init__()
|
||||
|
||||
def _on_key(self, event: events.Key) -> None:
|
||||
if event.key == "ctrl+e":
|
||||
event.prevent_default()
|
||||
event.stop()
|
||||
if hasattr(self.app, "action_toggle_expand_output"):
|
||||
self.app.action_toggle_expand_output()
|
||||
return
|
||||
elif event.key == "enter":
|
||||
event.prevent_default()
|
||||
event.stop()
|
||||
self.post_message(self.SubmitQuestion(self.text))
|
||||
elif event.key in ["shift+enter", "ctrl+j"]:
|
||||
self.insert("\n")
|
||||
event.prevent_default()
|
||||
else:
|
||||
super()._on_key(event)
|
||||
Reference in New Issue
Block a user