BonelliLab commited on
Commit
f37a598
·
1 Parent(s): 714138b

Apply production improvements from demo branch: inference adapter, history, UI, rate limiting, tests, docs

Browse files
Files changed (5) hide show
  1. README.md +206 -39
  2. api/ask.py +159 -0
  3. api/history.py +60 -0
  4. public/index.html +200 -0
  5. tests/test_api.py +80 -0
README.md CHANGED
@@ -33,57 +33,224 @@ A simple implementation of a cognitive language model using Qwen3-7B-Instruct fr
33
  pip install -r requirements.txt
34
  ```
35
 
36
- ## Usage
 
 
37
 
38
- 1. Run the interactive CLI:
39
 
40
- ```bash
41
- python cognitive_llm.py
42
- ```
 
 
 
 
 
 
 
 
 
 
 
 
43
 
44
- 2. Enter your prompt when prompted with `>>` and press Enter
45
- 3. Type 'quit' or 'exit' to exit the program
 
46
 
47
- ### Example Usage
 
48
 
49
- ```python
50
- from cognitive_llm import CognitiveLLM
 
 
 
 
51
 
52
- # Initialize the LLM
53
- llm = CognitiveLLM()
54
 
55
- # Generate text
56
- response = llm.generate(
57
- "Explain quantum computing in simple terms.",
58
- max_new_tokens=256,
59
- temperature=0.7
60
- )
61
- print(response)
62
  ```
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
63
 
64
- ## Configuration
65
 
66
- You can customize the model and generation parameters:
 
 
67
 
68
- ```python
69
- llm = CognitiveLLM(
70
- model_name="Qwen/Qwen3-7B-Instruct", # Model name or path
71
- device="cuda" # 'cuda', 'mps', or 'cpu'
72
- )
73
 
74
- # Generate with custom parameters
75
- response = llm.generate(
76
- "Your prompt here",
77
- max_new_tokens=512,
78
- temperature=0.7,
79
- top_p=0.9,
80
- do_sample=True
81
- )
 
82
  ```
83
 
84
- ## Note
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
85
 
86
- - First run will download the model weights (several GB)
87
- - A CUDA-compatible GPU is recommended for reasonable performance
88
- - Ensure you have sufficient disk space for the model weights
89
- - Internet connection is required for the initial download
 
33
  pip install -r requirements.txt
34
  ```
35
 
36
+ ---
37
+ title: Eidolon
38
+ ---
39
 
40
+ # Eidolon Interactive Tutor Demo
41
 
42
+ Production-ready demo application: a static frontend with a serverless API that accepts prompts and returns adaptive responses. Built for easy deployment to Vercel or Hugging Face Spaces with optional inference backend integration.
43
+
44
+ ## ✨ Features
45
+
46
+ - **Demo Mode**: Safe, deterministic responses for public demos (no API keys or model hosting required)
47
+ - **External Inference**: Plug in any hosted inference API (Hugging Face, Replicate, custom endpoints)
48
+ - **Conversation History**: SQLite-backed session storage with history retrieval
49
+ - **Rate Limiting**: Configurable IP-based rate limiting to prevent abuse
50
+ - **Modern UI**: Interactive interface with example prompts, copy buttons, and loading states
51
+ - **Retry Logic**: Automatic retries with exponential backoff for inference calls
52
+ - **CORS Support**: Cross-origin requests enabled for flexible deployment
53
+
54
+ ## Quick Start (Demo Mode)
55
+
56
+ Run the demo locally without any external services:
57
 
58
+ ```powershell
59
+ # Install lightweight dependencies
60
+ pip install -r dev-requirements.txt
61
 
62
+ # Start demo (PowerShell)
63
+ .\scripts\run_demo.ps1
64
 
65
+ # Or manually
66
+ $env:DEMO_MODE = "1"
67
+ python app.py
68
+ ```
69
+
70
+ Visit the Gradio URL shown in the terminal (usually http://localhost:7860).
71
 
72
+ ## Project Structure
 
73
 
 
 
 
 
 
 
 
74
  ```
75
+ ├── api/
76
+ │ ├── ask.py # FastAPI serverless endpoint (main API)
77
+ │ └── history.py # Conversation history storage (SQLite)
78
+ ├── public/
79
+ │ ├── index.html # Static demo UI
80
+ │ └── assets/ # UI assets (screenshot, etc.)
81
+ ├── tests/
82
+ │ └── test_api.py # API tests
83
+ ├── scripts/
84
+ │ └── run_demo.ps1 # Quick demo launcher
85
+ ├── app.py # Gradio UI (optional local interface)
86
+ ├── dev-requirements.txt # Lightweight dependencies (FastAPI, pytest, etc.)
87
+ ├── vercel.json # Vercel deployment config
88
+ └── README.md
89
+ ```
90
+
91
+ ## Environment Variables
92
+
93
+ ### Core Settings
94
+
95
+ | Variable | Description | Default | Required |
96
+ |----------|-------------|---------|----------|
97
+ | `DEMO_MODE` | Enable demo responses (no external services) | `0` | No |
98
+ | `INFERENCE_API_URL` | URL of hosted inference endpoint | - | No (required for real inference) |
99
+ | `INFERENCE_API_KEY` | Bearer token for inference API | - | No |
100
+
101
+ ### Rate Limiting
102
+
103
+ | Variable | Description | Default |
104
+ |----------|-------------|---------|
105
+ | `RATE_LIMIT_REQUESTS` | Max requests per window | `10` |
106
+ | `RATE_LIMIT_WINDOW` | Window size in seconds | `60` |
107
 
108
+ ### Storage
109
 
110
+ | Variable | Description | Default |
111
+ |----------|-------------|---------|
112
+ | `HISTORY_DB_PATH` | SQLite database path | `conversation_history.db` |
113
 
114
+ ## Deployment
 
 
 
 
115
 
116
+ ### Vercel (Recommended)
117
+
118
+ 1. Set environment variables in Vercel project settings:
119
+ - `DEMO_MODE=1` (for public demo)
120
+ - Or `INFERENCE_API_URL` + `INFERENCE_API_KEY` (for real inference)
121
+
122
+ 2. Deploy:
123
+ ```powershell
124
+ vercel --prod
125
  ```
126
 
127
+ The `vercel.json` config automatically serves `public/` as static files and `api/*.py` as Python serverless functions.
128
+
129
+ ### Hugging Face Spaces
130
+
131
+ 1. In Space Settings:
132
+ - Set Branch to `demo`
133
+ - Add environment variable: `DEMO_MODE` = `1`
134
+ - Restart the Space
135
+
136
+ 2. Or use the `main` branch with `INFERENCE_API_URL` configured to call a hosted model.
137
+
138
+ ### One-Click Deploy
139
+
140
+ [![Deploy to Vercel](https://vercel.com/button)](https://vercel.com/new/git/external?repository-url=https://github.com/Zwin-ux/Eidolon-Cognitive-Tutor)
141
+
142
+ ## API Reference
143
+
144
+ ### POST `/api/ask`
145
+
146
+ Request body:
147
+ ```json
148
+ {
149
+ "prompt": "Your question here",
150
+ "max_tokens": 512,
151
+ "temperature": 0.7,
152
+ "session_id": "optional-session-id"
153
+ }
154
+ ```
155
+
156
+ Response:
157
+ ```json
158
+ {
159
+ "result": "Response text",
160
+ "source": "demo",
161
+ "session_id": "generated-or-provided-session-id"
162
+ }
163
+ ```
164
+
165
+ ### GET `/api/history/{session_id}`
166
+
167
+ Retrieve conversation history for a session.
168
+
169
+ Response:
170
+ ```json
171
+ {
172
+ "session_id": "...",
173
+ "history": [
174
+ {
175
+ "prompt": "...",
176
+ "response": "...",
177
+ "source": "demo",
178
+ "timestamp": "2025-11-06 12:34:56"
179
+ }
180
+ ]
181
+ }
182
+ ```
183
+
184
+ ## Testing
185
+
186
+ Run the test suite:
187
+
188
+ ```powershell
189
+ pip install -r dev-requirements.txt
190
+ pytest -v
191
+ ```
192
+
193
+ CI is configured via `.github/workflows/ci.yml` and runs automatically on push/PR.
194
+
195
+ ## Development
196
+
197
+ ### Running with a Real Inference Backend
198
+
199
+ Set environment variables and run:
200
+
201
+ ```powershell
202
+ $env:INFERENCE_API_URL = "https://api-inference.huggingface.co/models/your-org/your-model"
203
+ $env:INFERENCE_API_KEY = "hf_..."
204
+ python app.py
205
+ ```
206
+
207
+ The API will automatically retry failed requests and fall back to demo mode if the backend is unavailable.
208
+
209
+ ### Conversation History
210
+
211
+ History is stored in SQLite (`conversation_history.db` by default). The UI includes a "View History" button that loads past conversations for the current session.
212
+
213
+ ## Production Recommendations
214
+
215
+ - **Inference Backend**: Use a hosted service (Hugging Face Inference Endpoints, Replicate, or self-hosted container) rather than loading models in serverless functions.
216
+ - **Rate Limiting**: Adjust `RATE_LIMIT_REQUESTS` and `RATE_LIMIT_WINDOW` based on your traffic expectations.
217
+ - **Caching**: Consider adding Redis or similar for distributed rate limiting in multi-instance deployments.
218
+ - **Authentication**: Add API key authentication for production usage (not included in demo).
219
+ - **Monitoring**: Set up logging and error tracking (Sentry, Datadog, etc.).
220
+
221
+ ## Current Stage
222
+
223
+ **Demo-ready for public presentation.** Key milestones:
224
+
225
+ - ✅ Demo mode with safe, deterministic responses
226
+ - ✅ External inference adapter with retries
227
+ - ✅ Conversation history storage
228
+ - ✅ Rate limiting
229
+ - ✅ Modern, interactive UI
230
+ - ✅ CI/CD with tests and linting
231
+ - ✅ One-click deployment options
232
+
233
+ ## Troubleshooting
234
+
235
+ ### "Repository Not Found" error on Hugging Face Spaces
236
+
237
+ - **Cause**: The Space is trying to load a model at startup (e.g., `Qwen/Qwen3-7B-Instruct`) but the model is gated, private, or doesn't exist.
238
+ - **Fix**: Set `DEMO_MODE=1` in Space environment variables and restart, or switch the Space to use the `demo` branch.
239
+
240
+ ### Rate limit errors in testing
241
+
242
+ - **Cause**: Default rate limit is 10 requests per 60 seconds.
243
+ - **Fix**: Set `RATE_LIMIT_REQUESTS=100` or higher when running local tests.
244
+
245
+ ### Conversation history not persisting
246
+
247
+ - **Cause**: SQLite database may not be writable in some serverless environments.
248
+ - **Fix**: Set `HISTORY_DB_PATH` to a writable location or use an external database (Postgres, etc.) for production.
249
+
250
+ ## Contributing
251
+
252
+ Issues and PRs welcome at https://github.com/Zwin-ux/Eidolon-Cognitive-Tutor
253
+
254
+ ## License
255
 
256
+ Apache 2.0
 
 
 
api/ask.py ADDED
@@ -0,0 +1,159 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from fastapi import FastAPI, Request, HTTPException
2
+ from fastapi.middleware.cors import CORSMiddleware
3
+ from pydantic import BaseModel
4
+ import os
5
+ import httpx
6
+ import time
7
+ import uuid
8
+ from collections import defaultdict
9
+ from typing import Optional
10
+ from .history import save_conversation, get_conversation_history
11
+
12
+ app = FastAPI(title="Eidolon Tutor API", version="0.2.0")
13
+
14
+ # CORS for local development and cross-origin requests
15
+ app.add_middleware(
16
+ CORSMiddleware,
17
+ allow_origins=["*"],
18
+ allow_credentials=True,
19
+ allow_methods=["*"],
20
+ allow_headers=["*"],
21
+ )
22
+
23
+ # Simple in-memory rate limiter (IP-based)
24
+ _rate_limit_store = defaultdict(list)
25
+ RATE_LIMIT_REQUESTS = int(os.getenv("RATE_LIMIT_REQUESTS", "10"))
26
+ RATE_LIMIT_WINDOW = int(os.getenv("RATE_LIMIT_WINDOW", "60")) # seconds
27
+
28
+
29
+ def check_rate_limit(client_ip: str) -> bool:
30
+ """Simple sliding window rate limiter."""
31
+ now = time.time()
32
+ window_start = now - RATE_LIMIT_WINDOW
33
+ # Clean old requests
34
+ _rate_limit_store[client_ip] = [
35
+ req_time for req_time in _rate_limit_store[client_ip] if req_time > window_start
36
+ ]
37
+ if len(_rate_limit_store[client_ip]) >= RATE_LIMIT_REQUESTS:
38
+ return False
39
+ _rate_limit_store[client_ip].append(now)
40
+ return True
41
+
42
+
43
+ class AskIn(BaseModel):
44
+ prompt: str
45
+ max_tokens: Optional[int] = 512
46
+ temperature: Optional[float] = 0.7
47
+ session_id: Optional[str] = None # for conversation history
48
+
49
+
50
+ class AskOut(BaseModel):
51
+ result: Optional[str] = None
52
+ error: Optional[str] = None
53
+ source: str = "demo" # "demo", "inference", or "error"
54
+ session_id: str = "" # returned session ID
55
+
56
+
57
+ def get_demo_response(prompt: str) -> str:
58
+ """Generate deterministic demo responses."""
59
+ p = prompt.strip().lower()
60
+ if not p:
61
+ return "Please enter a question for the demo tutor."
62
+ if "explain" in p or "what is" in p:
63
+ return f"**Demo Explanation:**\n\nHere's a concise explanation for your question: *\"{prompt}\"*.\n\n[Demo mode active. Configure `INFERENCE_API_URL` to use a real model.]"
64
+ if "code" in p or "how to" in p or "implement" in p:
65
+ return f"**Demo Steps:**\n\n1. Understand the problem: *\"{prompt}\"*\n2. Break it down into smaller steps\n3. Implement and test\n4. Iterate and refine\n\n[Demo-mode response]"
66
+ if "compare" in p or "difference" in p:
67
+ return f"**Demo Comparison:**\n\nKey differences related to *\"{prompt}\"*:\n- Point A vs Point B\n- Tradeoffs and use cases\n\n[Demo mode]"
68
+ # Generic fallback
69
+ return f"**Demo Response:**\n\nI understood your prompt: *\"{prompt}\"*.\n\nThis is a demo response showing how the tutor would reply. Set `INFERENCE_API_URL` to enable real model inference."
70
+
71
+
72
+ async def call_inference_api(
73
+ prompt: str, api_url: str, api_key: Optional[str], max_tokens: int, temperature: float
74
+ ) -> dict:
75
+ """Call external inference API with retries and timeout."""
76
+ payload = {
77
+ "inputs": prompt,
78
+ "parameters": {"max_new_tokens": max_tokens, "temperature": temperature},
79
+ }
80
+ headers = {"Accept": "application/json", "Content-Type": "application/json"}
81
+ if api_key:
82
+ headers["Authorization"] = f"Bearer {api_key}"
83
+
84
+ # Retry logic: 2 attempts with exponential backoff
85
+ for attempt in range(2):
86
+ try:
87
+ async with httpx.AsyncClient(timeout=60.0) as client:
88
+ resp = await client.post(api_url, json=payload, headers=headers)
89
+ resp.raise_for_status()
90
+ data = resp.json()
91
+
92
+ # Normalize response
93
+ if isinstance(data, dict) and "error" in data:
94
+ return {"error": data.get("error"), "source": "inference"}
95
+ if isinstance(data, list) and len(data) > 0:
96
+ first = data[0]
97
+ if isinstance(first, dict) and "generated_text" in first:
98
+ return {"result": first["generated_text"], "source": "inference"}
99
+ if isinstance(first, str):
100
+ return {"result": first, "source": "inference"}
101
+ if isinstance(data, dict) and "generated_text" in data:
102
+ return {"result": data["generated_text"], "source": "inference"}
103
+ return {"result": str(data), "source": "inference"}
104
+
105
+ except httpx.HTTPError as e:
106
+ if attempt == 0:
107
+ await httpx.AsyncClient().aclose()
108
+ time.sleep(1) # backoff
109
+ continue
110
+ return {"error": f"Inference API failed after retries: {str(e)}", "source": "error"}
111
+
112
+ return {"error": "Inference API failed", "source": "error"}
113
+
114
+
115
+ @app.post("/", response_model=AskOut)
116
+ async def ask(in_data: AskIn, request: Request):
117
+ """
118
+ Main API endpoint: accepts a prompt and returns a response.
119
+
120
+ Supports:
121
+ - Demo mode (DEMO_MODE=1): returns canned responses
122
+ - External inference (INFERENCE_API_URL set): calls hosted model
123
+ - Rate limiting (configurable via RATE_LIMIT_REQUESTS/RATE_LIMIT_WINDOW)
124
+ - Conversation history (optional session_id)
125
+ """
126
+ # Rate limiting
127
+ client_ip = request.client.host if request.client else "unknown"
128
+ if not check_rate_limit(client_ip):
129
+ raise HTTPException(status_code=429, detail="Rate limit exceeded. Try again later.")
130
+
131
+ # Generate or use provided session ID
132
+ session_id = in_data.session_id or str(uuid.uuid4())
133
+
134
+ api_url = os.environ.get("INFERENCE_API_URL")
135
+ api_key = os.environ.get("INFERENCE_API_KEY")
136
+ demo_mode = os.environ.get("DEMO_MODE", "0").lower() in ("1", "true", "yes")
137
+
138
+ # Demo mode
139
+ if demo_mode or not api_url:
140
+ result_text = get_demo_response(in_data.prompt)
141
+ save_conversation(session_id, in_data.prompt, result_text, "demo")
142
+ return AskOut(result=result_text, source="demo", session_id=session_id)
143
+
144
+ # Call inference API
145
+ result = await call_inference_api(
146
+ in_data.prompt, api_url, api_key, in_data.max_tokens, in_data.temperature
147
+ )
148
+
149
+ # Save to history
150
+ if result.get("result"):
151
+ save_conversation(session_id, in_data.prompt, result["result"], result.get("source", "inference"))
152
+
153
+ return AskOut(**result, session_id=session_id)
154
+
155
+
156
+ @app.get("/history/{session_id}")
157
+ async def get_history(session_id: str, limit: int = 10):
158
+ """Retrieve conversation history for a session."""
159
+ return {"session_id": session_id, "history": get_conversation_history(session_id, limit)}
api/history.py ADDED
@@ -0,0 +1,60 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Simple conversation history storage using SQLite."""
2
+ import sqlite3
3
+ import os
4
+ from datetime import datetime
5
+ from typing import List, Dict, Optional
6
+
7
+ DB_PATH = os.getenv("HISTORY_DB_PATH", "conversation_history.db")
8
+
9
+
10
+ def init_db():
11
+ """Initialize the conversation history database."""
12
+ conn = sqlite3.connect(DB_PATH)
13
+ cursor = conn.cursor()
14
+ cursor.execute("""
15
+ CREATE TABLE IF NOT EXISTS conversations (
16
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
17
+ session_id TEXT NOT NULL,
18
+ prompt TEXT NOT NULL,
19
+ response TEXT NOT NULL,
20
+ source TEXT DEFAULT 'demo',
21
+ timestamp DATETIME DEFAULT CURRENT_TIMESTAMP
22
+ )
23
+ """)
24
+ conn.commit()
25
+ conn.close()
26
+
27
+
28
+ def save_conversation(session_id: str, prompt: str, response: str, source: str = "demo"):
29
+ """Save a conversation turn to the database."""
30
+ init_db()
31
+ conn = sqlite3.connect(DB_PATH)
32
+ cursor = conn.cursor()
33
+ cursor.execute(
34
+ "INSERT INTO conversations (session_id, prompt, response, source) VALUES (?, ?, ?, ?)",
35
+ (session_id, prompt, response, source),
36
+ )
37
+ conn.commit()
38
+ conn.close()
39
+
40
+
41
+ def get_conversation_history(session_id: str, limit: int = 10) -> List[Dict]:
42
+ """Retrieve conversation history for a session."""
43
+ init_db()
44
+ conn = sqlite3.connect(DB_PATH)
45
+ cursor = conn.cursor()
46
+ cursor.execute(
47
+ "SELECT prompt, response, source, timestamp FROM conversations WHERE session_id = ? ORDER BY timestamp DESC LIMIT ?",
48
+ (session_id, limit),
49
+ )
50
+ rows = cursor.fetchall()
51
+ conn.close()
52
+ return [
53
+ {
54
+ "prompt": row[0],
55
+ "response": row[1],
56
+ "source": row[2],
57
+ "timestamp": row[3],
58
+ }
59
+ for row in reversed(rows)
60
+ ]
public/index.html ADDED
@@ -0,0 +1,200 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ <!doctype html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="utf-8" />
5
+ <meta name="viewport" content="width=device-width,initial-scale=1" />
6
+ <title>Eidolon Cognitive Tutor</title>
7
+ <style>
8
+ * { box-sizing: border-box; }
9
+ body { font-family: Inter, system-ui, -apple-system, 'Segoe UI', Roboto, sans-serif; background:#f7f8fb; color:#0f1724; display:flex; align-items:center; justify-content:center; min-height:100vh; margin:0; padding:20px }
10
+ .card { background:#fff; padding:32px; border-radius:16px; box-shadow:0 6px 40px rgba(20,20,40,0.1); width:820px; max-width:100% }
11
+ h1 { margin:0 0 8px 0; font-size:24px; font-weight:700 }
12
+ p.lead { margin:0 0 24px 0; color:#475569; line-height:1.5 }
13
+ .examples { display:flex; gap:8px; flex-wrap:wrap; margin-bottom:16px }
14
+ .example-btn { background:#e0e7ff; color:#3730a3; border:none; padding:8px 14px; border-radius:8px; font-size:13px; cursor:pointer; transition:all 0.2s }
15
+ .example-btn:hover { background:#c7d2fe; transform:translateY(-1px) }
16
+ textarea { width:100%; height:140px; padding:14px; border-radius:10px; border:1px solid #e6e9ef; resize:vertical; font-size:15px; font-family:inherit; transition:border 0.2s }
17
+ textarea:focus { outline:none; border-color:#0b84ff }
18
+ .controls { display:flex; gap:10px; margin-top:14px; align-items:center }
19
+ button.primary { background:#0b84ff; color:white; border:none; padding:12px 20px; border-radius:10px; font-weight:600; cursor:pointer; font-size:15px; transition:all 0.2s }
20
+ button.primary:hover { background:#0070e0; transform:translateY(-1px); box-shadow:0 4px 12px rgba(11,132,255,0.3) }
21
+ button.primary:disabled { background:#cbd5e1; cursor:not-allowed; transform:none }
22
+ button.secondary { background:#f1f5f9; color:#334155; border:none; padding:10px 16px; border-radius:8px; cursor:pointer; font-size:14px; transition:all 0.2s }
23
+ button.secondary:hover { background:#e2e8f0 }
24
+ .out { margin-top:20px; padding:16px; border-radius:10px; background:#f8fafc; min-height:100px; white-space:pre-wrap; border:1px solid #e2e8f0; position:relative }
25
+ .out.loading { background:#fef3c7; border-color:#fde047 }
26
+ .out.error { background:#fee; border-color:#fca5a5 }
27
+ .spinner { display:inline-block; width:16px; height:16px; border:2px solid #cbd5e1; border-top-color:#0b84ff; border-radius:50%; animation:spin 0.7s linear infinite; margin-right:8px }
28
+ @keyframes spin { to { transform: rotate(360deg) } }
29
+ .copy-btn { position:absolute; top:12px; right:12px; background:#fff; border:1px solid #e2e8f0; padding:6px 12px; border-radius:6px; font-size:12px; cursor:pointer; transition:all 0.2s }
30
+ .copy-btn:hover { background:#f1f5f9; border-color:#cbd5e1 }
31
+ .history { margin-top:24px; padding-top:24px; border-top:1px solid #e2e8f0 }
32
+ .history h3 { margin:0 0 12px 0; font-size:16px; color:#64748b }
33
+ .history-item { background:#f8fafc; padding:10px; border-radius:8px; margin-bottom:8px; font-size:13px; border-left:3px solid #0b84ff }
34
+ .meta { font-size:13px; color:#64748b; margin-top:8px }
35
+ </style>
36
+ </head>
37
+ <body>
38
+ <div class="card">
39
+ <h1>🧠 Eidolon Cognitive Tutor</h1>
40
+ <p class="lead">Interactive tutor demo powered by adaptive responses. Try the examples below or ask your own question.</p>
41
+
42
+ <div class="examples">
43
+ <button class="example-btn" data-prompt="Explain Newton's laws in simple terms">📐 Newton's Laws</button>
44
+ <button class="example-btn" data-prompt="How do I implement a binary search in Python?">💻 Binary Search</button>
45
+ <button class="example-btn" data-prompt="Compare supervised vs unsupervised learning">🤖 ML Comparison</button>
46
+ <button class="example-btn" data-prompt="What is the difference between HTTP and HTTPS?">🔒 HTTP vs HTTPS</button>
47
+ </div>
48
+
49
+ <textarea id="prompt" placeholder="Type your question here..."></textarea>
50
+
51
+ <div class="controls">
52
+ <button id="ask" class="primary">Ask Tutor</button>
53
+ <button id="clear" class="secondary">Clear</button>
54
+ <button id="history-btn" class="secondary">View History</button>
55
+ </div>
56
+
57
+ <div class="out" id="out">Awaiting your question...</div>
58
+
59
+ <div class="history" id="history" style="display:none">
60
+ <h3>Recent Conversations</h3>
61
+ <div id="history-list"></div>
62
+ </div>
63
+
64
+ <div class="meta">
65
+ <span id="status">Demo mode active</span> |
66
+ <a href="https://github.com/Zwin-ux/Eidolon-Cognitive-Tutor" target="_blank" style="color:#0b84ff; text-decoration:none">View on GitHub</a>
67
+ </div>
68
+ </div>
69
+
70
+ <script>
71
+ const btn = document.getElementById('ask');
72
+ const clearBtn = document.getElementById('clear');
73
+ const historyBtn = document.getElementById('history-btn');
74
+ const out = document.getElementById('out');
75
+ const promptEl = document.getElementById('prompt');
76
+ const statusEl = document.getElementById('status');
77
+ const historyEl = document.getElementById('history');
78
+ const historyList = document.getElementById('history-list');
79
+
80
+ let sessionId = localStorage.getItem('session_id') || '';
81
+ let conversationHistory = [];
82
+
83
+ // Example button handlers
84
+ document.querySelectorAll('.example-btn').forEach(btn => {
85
+ btn.addEventListener('click', () => {
86
+ promptEl.value = btn.dataset.prompt;
87
+ promptEl.focus();
88
+ });
89
+ });
90
+
91
+ // Copy button
92
+ function addCopyButton() {
93
+ if (document.querySelector('.copy-btn')) return;
94
+ const copyBtn = document.createElement('button');
95
+ copyBtn.className = 'copy-btn';
96
+ copyBtn.textContent = 'Copy';
97
+ copyBtn.onclick = () => {
98
+ navigator.clipboard.writeText(out.textContent);
99
+ copyBtn.textContent = 'Copied!';
100
+ setTimeout(() => copyBtn.textContent = 'Copy', 2000);
101
+ };
102
+ out.appendChild(copyBtn);
103
+ }
104
+
105
+ // Clear functionality
106
+ clearBtn.addEventListener('click', () => {
107
+ promptEl.value = '';
108
+ out.textContent = 'Awaiting your question...';
109
+ out.className = 'out';
110
+ const copyBtn = out.querySelector('.copy-btn');
111
+ if (copyBtn) copyBtn.remove();
112
+ });
113
+
114
+ // History toggle
115
+ historyBtn.addEventListener('click', () => {
116
+ if (historyEl.style.display === 'none') {
117
+ loadHistory();
118
+ historyEl.style.display = 'block';
119
+ historyBtn.textContent = 'Hide History';
120
+ } else {
121
+ historyEl.style.display = 'none';
122
+ historyBtn.textContent = 'View History';
123
+ }
124
+ });
125
+
126
+ // Load history from server
127
+ async function loadHistory() {
128
+ if (!sessionId) return;
129
+ try {
130
+ const resp = await fetch(`/api/history/${sessionId}`);
131
+ const data = await resp.json();
132
+ if (data.history && data.history.length > 0) {
133
+ historyList.innerHTML = data.history.map(item =>
134
+ `<div class="history-item"><strong>Q:</strong> ${item.prompt.substring(0, 60)}...<br><strong>A:</strong> ${item.response.substring(0, 80)}...</div>`
135
+ ).join('');
136
+ } else {
137
+ historyList.innerHTML = '<div style="color:#94a3b8">No conversation history yet.</div>';
138
+ }
139
+ } catch (e) {
140
+ historyList.innerHTML = '<div style="color:#94a3b8">Could not load history.</div>';
141
+ }
142
+ }
143
+
144
+ // Main ask functionality
145
+ btn.addEventListener('click', async () => {
146
+ const prompt = promptEl.value.trim();
147
+ if (!prompt) {
148
+ out.textContent = 'Please enter a question.';
149
+ out.className = 'out error';
150
+ return;
151
+ }
152
+
153
+ out.innerHTML = '<span class="spinner"></span>Thinking...';
154
+ out.className = 'out loading';
155
+ btn.disabled = true;
156
+
157
+ try {
158
+ const resp = await fetch('/api/ask', {
159
+ method: 'POST',
160
+ headers: { 'Content-Type': 'application/json' },
161
+ body: JSON.stringify({
162
+ prompt,
163
+ session_id: sessionId || undefined
164
+ })
165
+ });
166
+
167
+ const data = await resp.json();
168
+
169
+ if (data.session_id && !sessionId) {
170
+ sessionId = data.session_id;
171
+ localStorage.setItem('session_id', sessionId);
172
+ }
173
+
174
+ if (data.error) {
175
+ out.textContent = '❌ Error: ' + (data.detail || data.error);
176
+ out.className = 'out error';
177
+ } else {
178
+ out.textContent = data.result || JSON.stringify(data, null, 2);
179
+ out.className = 'out';
180
+ addCopyButton();
181
+ statusEl.textContent = `Response from: ${data.source}`;
182
+ conversationHistory.push({ prompt, response: data.result });
183
+ }
184
+ } catch (e) {
185
+ out.textContent = '❌ Request failed: ' + e.message;
186
+ out.className = 'out error';
187
+ } finally {
188
+ btn.disabled = false;
189
+ }
190
+ });
191
+
192
+ // Enter key to submit
193
+ promptEl.addEventListener('keydown', (e) => {
194
+ if (e.key === 'Enter' && e.ctrlKey) {
195
+ btn.click();
196
+ }
197
+ });
198
+ </script>
199
+ </body>
200
+ </html>
tests/test_api.py ADDED
@@ -0,0 +1,80 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import os
2
+ import pytest
3
+ from fastapi.testclient import TestClient
4
+
5
+ os.environ["DEMO_MODE"] = "1"
6
+ os.environ["RATE_LIMIT_REQUESTS"] = "100" # high limit for tests
7
+
8
+ from api.ask import app # import after setting env var
9
+
10
+
11
+ @pytest.fixture
12
+ def client():
13
+ return TestClient(app)
14
+
15
+
16
+ def test_api_demo_mode_basic(client):
17
+ """Test basic demo mode response."""
18
+ payload = {"prompt": "Explain gravity in simple terms"}
19
+ resp = client.post("/", json=payload)
20
+ assert resp.status_code == 200
21
+ data = resp.json()
22
+ assert isinstance(data, dict)
23
+ assert "result" in data
24
+ assert data["source"] == "demo"
25
+ assert "Demo" in data["result"] or "explain" in data["result"].lower()
26
+
27
+
28
+ def test_api_demo_mode_code_prompt(client):
29
+ """Test demo mode with code-related prompt."""
30
+ payload = {"prompt": "How to implement quicksort"}
31
+ resp = client.post("/", json=payload)
32
+ assert resp.status_code == 200
33
+ data = resp.json()
34
+ assert "result" in data
35
+ assert "steps" in data["result"].lower() or "implement" in data["result"].lower()
36
+
37
+
38
+ def test_api_session_id_returned(client):
39
+ """Test that session ID is returned."""
40
+ payload = {"prompt": "Test prompt"}
41
+ resp = client.post("/", json=payload)
42
+ assert resp.status_code == 200
43
+ data = resp.json()
44
+ assert "session_id" in data
45
+ assert len(data["session_id"]) > 0
46
+
47
+
48
+ def test_api_session_id_persistence(client):
49
+ """Test that provided session ID is returned."""
50
+ session_id = "test-session-123"
51
+ payload = {"prompt": "Test prompt", "session_id": session_id}
52
+ resp = client.post("/", json=payload)
53
+ assert resp.status_code == 200
54
+ data = resp.json()
55
+ assert data["session_id"] == session_id
56
+
57
+
58
+ def test_api_empty_prompt(client):
59
+ """Test API with empty prompt."""
60
+ payload = {"prompt": ""}
61
+ resp = client.post("/", json=payload)
62
+ assert resp.status_code == 200
63
+ data = resp.json()
64
+ assert "result" in data
65
+ assert "Please enter" in data["result"]
66
+
67
+
68
+ def test_api_history_endpoint(client):
69
+ """Test history retrieval endpoint."""
70
+ # First make a request
71
+ session_id = "test-history-session"
72
+ payload = {"prompt": "Test question", "session_id": session_id}
73
+ client.post("/", json=payload)
74
+
75
+ # Then retrieve history
76
+ resp = client.get(f"/history/{session_id}")
77
+ assert resp.status_code == 200
78
+ data = resp.json()
79
+ assert "history" in data
80
+ assert isinstance(data["history"], list)