iitmbs24f commited on
Commit
6cf3d0d
·
verified ·
1 Parent(s): 91d39cf

Upload 12 files

Browse files
Files changed (4) hide show
  1. app/llm.py +262 -248
  2. app/main.py +368 -339
  3. app/solver.py +96 -43
  4. app/utils.py +15 -0
app/llm.py CHANGED
@@ -1,248 +1,262 @@
1
- """
2
- LLM helper module for OpenRouter integration.
3
- Used for reasoning and complex question parsing.
4
- """
5
- import os
6
- import logging
7
- from typing import Optional, Dict, Any
8
- import httpx
9
-
10
- logger = logging.getLogger(__name__)
11
-
12
- # OpenRouter configuration
13
- OPENROUTER_API_KEY = os.getenv("OPENROUTER_API_KEY")
14
- OPENROUTER_BASE_URL = os.getenv("OPENROUTER_BASE_URL", "https://openrouter.ai/api/v1")
15
- OPENROUTER_MODEL = os.getenv("OPENROUTER_MODEL", "gpt-5-nano")
16
- OPENROUTER_SITE_URL = os.getenv("OPENROUTER_SITE_URL", "http://localhost")
17
- OPENROUTER_APP_NAME = os.getenv("OPENROUTER_APP_NAME", "IITM LLM Quiz Solver")
18
-
19
-
20
- def initialize_llm() -> None:
21
- """
22
- Initialize OpenRouter API key check.
23
- """
24
- if OPENROUTER_API_KEY:
25
- logger.info("OpenRouter API key configured")
26
- else:
27
- logger.warning("OPENROUTER_API_KEY not set, LLM features will be disabled")
28
-
29
-
30
- async def ask_gpt(prompt: str, model: Optional[str] = None, max_tokens: int = 2000, system_prompt: Optional[str] = None) -> Optional[str]:
31
- """
32
- Query LLM via OpenRouter with a prompt.
33
-
34
- Args:
35
- prompt: The prompt/question to ask
36
- model: Model to use (defaults to OPENROUTER_MODEL)
37
- max_tokens: Maximum tokens in response
38
- system_prompt: Optional custom system prompt
39
-
40
- Returns:
41
- Response text or None if error
42
- """
43
- return await ask_openrouter(prompt, model=model, max_tokens=max_tokens, system_prompt=system_prompt)
44
-
45
-
46
- async def ask_openrouter(prompt: str, model: Optional[str] = None, max_tokens: int = 2000, system_prompt: Optional[str] = None) -> Optional[str]:
47
- """
48
- Query OpenRouter (e.g., GPT-5-nano) with a prompt.
49
-
50
- Args:
51
- prompt: Prompt text
52
- model: Model to use (defaults to OPENROUTER_MODEL)
53
- max_tokens: Maximum tokens
54
- system_prompt: Optional custom system prompt
55
-
56
- Returns:
57
- Response text or None
58
- """
59
- if not OPENROUTER_API_KEY:
60
- logger.warning("OPENROUTER_API_KEY not set, cannot call OpenRouter")
61
- return None
62
-
63
- if not model:
64
- model = OPENROUTER_MODEL
65
-
66
- url = f"{OPENROUTER_BASE_URL.rstrip('/')}/chat/completions"
67
- headers = {
68
- "Authorization": f"Bearer {OPENROUTER_API_KEY}",
69
- "HTTP-Referer": OPENROUTER_SITE_URL,
70
- "X-Title": OPENROUTER_APP_NAME,
71
- "Content-Type": "application/json",
72
- }
73
-
74
- system_content = system_prompt if system_prompt else "You are a helpful assistant that solves quiz questions accurately and concisely."
75
-
76
- payload = {
77
- "model": model,
78
- "messages": [
79
- {"role": "system", "content": system_content},
80
- {"role": "user", "content": prompt}
81
- ],
82
- "max_tokens": max_tokens,
83
- "temperature": 0.2
84
- }
85
-
86
- try:
87
- # Reduced timeout for faster responses (30s instead of 60s)
88
- async with httpx.AsyncClient(timeout=30) as http_client:
89
- response = await http_client.post(url, headers=headers, json=payload)
90
- response.raise_for_status()
91
- data = response.json()
92
- answer = data["choices"][0]["message"]["content"]
93
- logger.info(f"OpenRouter response received (model: {model})")
94
- return answer
95
- except Exception as e:
96
- logger.error(f"Error calling OpenRouter API: {e}")
97
- return None
98
-
99
-
100
- async def test_prompt_with_custom_messages(system_prompt: str, user_prompt: str, code_word: str, model: Optional[str] = None) -> Optional[str]:
101
- """
102
- Test custom system and user prompts with a code word.
103
-
104
- Args:
105
- system_prompt: Custom system prompt (will have code word appended)
106
- user_prompt: Custom user prompt
107
- code_word: Code word to test
108
- model: Model to use (defaults to OPENROUTER_MODEL)
109
-
110
- Returns:
111
- Response text or None
112
- """
113
- # Append code word to system prompt
114
- full_system_prompt = f"{system_prompt}\n\nCode word: {code_word}"
115
-
116
- # Use OpenRouter
117
- return await ask_openrouter(user_prompt, model=model, max_tokens=500, system_prompt=full_system_prompt)
118
-
119
-
120
- async def parse_question_with_llm(question_text: str, context: str = "") -> Optional[Dict[str, Any]]:
121
- """
122
- Use LLM to parse and understand a quiz question.
123
-
124
- Args:
125
- question_text: The question text
126
- context: Additional context from the page
127
-
128
- Returns:
129
- Parsed question structure or None
130
- """
131
- prompt = f"""Analyze this quiz question and provide a structured response:
132
-
133
- Question: {question_text}
134
-
135
- Context: {context}
136
-
137
- Please identify:
138
- 1. What type of question is this? (scraping, calculation, API call, data analysis, etc.)
139
- 2. What data or resources are needed?
140
- 3. What is the expected answer format? (JSON, number, text, etc.)
141
-
142
- Respond in JSON format:
143
- {{
144
- "type": "question_type",
145
- "requirements": ["requirement1", "requirement2"],
146
- "answer_format": "format_type",
147
- "reasoning": "your reasoning"
148
- }}
149
- """
150
-
151
- response = await ask_gpt(prompt)
152
- if not response:
153
- return None
154
-
155
- # Try to extract JSON from response
156
- import json
157
- import re
158
-
159
- json_match = re.search(r'\{[^{}]*(?:\{[^{}]*\}[^{}]*)*\}', response, re.DOTALL)
160
- if json_match:
161
- try:
162
- return json.loads(json_match.group())
163
- except json.JSONDecodeError:
164
- pass
165
-
166
- return {"raw_response": response}
167
-
168
-
169
- async def solve_with_llm(question: str, available_data: Dict[str, Any]) -> Optional[str]:
170
- """
171
- Use LLM to solve a quiz question.
172
-
173
- Args:
174
- question: The question text
175
- available_data: Any data extracted from the page
176
-
177
- Returns:
178
- Answer or None
179
- """
180
- prompt = f"""Solve this quiz question:
181
-
182
- Question: {question}
183
-
184
- Available Data:
185
- {available_data}
186
-
187
- Provide a clear, concise answer. If the answer should be in JSON format, provide valid JSON.
188
- If it's a calculation, show your work briefly.
189
- """
190
-
191
- return await ask_gpt(prompt, max_tokens=3000)
192
-
193
-
194
- async def ocr_image_with_llm(image_base64: str) -> Optional[str]:
195
- """
196
- Use OpenRouter vision model to extract text from an image.
197
-
198
- Note: Requires a vision-capable model via OpenRouter.
199
-
200
- Args:
201
- image_base64: Base64 encoded image
202
-
203
- Returns:
204
- Extracted text or None
205
- """
206
- if not OPENROUTER_API_KEY:
207
- logger.warning("OPENROUTER_API_KEY not set, cannot perform OCR")
208
- return None
209
-
210
- # Try vision-capable models available via OpenRouter
211
- vision_models = ["openai/gpt-4o", "openai/gpt-4-vision-preview", "google/gemini-pro-vision"]
212
-
213
- for model in vision_models:
214
- try:
215
- url = f"{OPENROUTER_BASE_URL.rstrip('/')}/chat/completions"
216
- headers = {
217
- "Authorization": f"Bearer {OPENROUTER_API_KEY}",
218
- "HTTP-Referer": OPENROUTER_SITE_URL,
219
- "X-Title": OPENROUTER_APP_NAME,
220
- "Content-Type": "application/json",
221
- }
222
- payload = {
223
- "model": model,
224
- "messages": [
225
- {
226
- "role": "user",
227
- "content": [
228
- {"type": "text", "text": "Extract all text from this image. Return only the text content."},
229
- {"type": "image_url", "image_url": {"url": f"data:image/png;base64,{image_base64}"}}
230
- ]
231
- }
232
- ],
233
- "max_tokens": 1000
234
- }
235
-
236
- async with httpx.AsyncClient(timeout=60) as http_client:
237
- response = await http_client.post(url, headers=headers, json=payload)
238
- response.raise_for_status()
239
- data = response.json()
240
- return data["choices"][0]["message"]["content"]
241
-
242
- except Exception as e:
243
- logger.warning(f"Error with vision model {model}: {e}")
244
- continue
245
-
246
- logger.error("No vision-capable model available via OpenRouter")
247
- return None
248
-
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ LLM helper module for OpenRouter integration.
3
+ Used for reasoning and complex question parsing.
4
+ """
5
+ import os
6
+ import logging
7
+ from typing import Optional, Dict, Any
8
+ import httpx
9
+
10
+ logger = logging.getLogger(__name__)
11
+
12
+ # OpenRouter configuration
13
+ OPENROUTER_API_KEY = os.getenv("OPENROUTER_API_KEY")
14
+ OPENROUTER_BASE_URL = os.getenv("OPENROUTER_BASE_URL", "https://openrouter.ai/api/v1")
15
+ OPENROUTER_MODEL = os.getenv("OPENROUTER_MODEL", "gpt-5-nano")
16
+ OPENROUTER_SITE_URL = os.getenv("OPENROUTER_SITE_URL", "http://localhost")
17
+ OPENROUTER_APP_NAME = os.getenv("OPENROUTER_APP_NAME", "IITM LLM Quiz Solver")
18
+
19
+
20
+ def initialize_llm() -> None:
21
+ """
22
+ Initialize OpenRouter API key check.
23
+ """
24
+ if OPENROUTER_API_KEY:
25
+ logger.info("OpenRouter API key configured")
26
+ else:
27
+ logger.warning("OPENROUTER_API_KEY not set, LLM features will be disabled")
28
+
29
+
30
+ async def ask_gpt(prompt: str, model: Optional[str] = None, max_tokens: int = 2000, system_prompt: Optional[str] = None) -> Optional[str]:
31
+ """
32
+ Query LLM via OpenRouter with a prompt.
33
+
34
+ Args:
35
+ prompt: The prompt/question to ask
36
+ model: Model to use (defaults to OPENROUTER_MODEL)
37
+ max_tokens: Maximum tokens in response
38
+ system_prompt: Optional custom system prompt
39
+
40
+ Returns:
41
+ Response text or None if error
42
+ """
43
+ return await ask_openrouter(prompt, model=model, max_tokens=max_tokens, system_prompt=system_prompt)
44
+
45
+
46
+ async def ask_openrouter(prompt: str, model: Optional[str] = None, max_tokens: int = 2000, system_prompt: Optional[str] = None) -> Optional[str]:
47
+ """
48
+ Query OpenRouter (e.g., GPT-5-nano) with a prompt.
49
+
50
+ Args:
51
+ prompt: Prompt text
52
+ model: Model to use (defaults to OPENROUTER_MODEL)
53
+ max_tokens: Maximum tokens
54
+ system_prompt: Optional custom system prompt
55
+
56
+ Returns:
57
+ Response text or None
58
+ """
59
+ if not OPENROUTER_API_KEY:
60
+ logger.warning("OPENROUTER_API_KEY not set, cannot call OpenRouter")
61
+ return None
62
+
63
+ if not model:
64
+ model = OPENROUTER_MODEL
65
+
66
+ url = f"{OPENROUTER_BASE_URL.rstrip('/')}/chat/completions"
67
+ headers = {
68
+ "Authorization": f"Bearer {OPENROUTER_API_KEY}",
69
+ "HTTP-Referer": OPENROUTER_SITE_URL,
70
+ "X-Title": OPENROUTER_APP_NAME,
71
+ "Content-Type": "application/json",
72
+ }
73
+
74
+ system_content = system_prompt if system_prompt else "You are a helpful assistant that solves quiz questions accurately and concisely."
75
+
76
+ payload = {
77
+ "model": model,
78
+ "messages": [
79
+ {"role": "system", "content": system_content},
80
+ {"role": "user", "content": prompt}
81
+ ],
82
+ "max_tokens": max_tokens,
83
+ "temperature": 0.2
84
+ }
85
+
86
+ try:
87
+ # Reduced timeout for faster responses (30s instead of 60s)
88
+ async with httpx.AsyncClient(timeout=30) as http_client:
89
+ response = await http_client.post(url, headers=headers, json=payload)
90
+ response.raise_for_status()
91
+ data = response.json()
92
+ answer = data["choices"][0]["message"]["content"]
93
+ logger.info(f"OpenRouter response received (model: {model})")
94
+ return answer
95
+ except Exception as e:
96
+ logger.error(f"Error calling OpenRouter API: {e}")
97
+ return None
98
+
99
+
100
+ async def test_prompt_with_custom_messages(system_prompt: str, user_prompt: str, code_word: str, model: Optional[str] = None) -> Optional[str]:
101
+ """
102
+ Test custom system and user prompts with a code word.
103
+
104
+ Args:
105
+ system_prompt: Custom system prompt (will have code word appended)
106
+ user_prompt: Custom user prompt
107
+ code_word: Code word to test
108
+ model: Model to use (defaults to OPENROUTER_MODEL)
109
+
110
+ Returns:
111
+ Response text or None
112
+ """
113
+ # Append code word to system prompt
114
+ full_system_prompt = f"{system_prompt}\n\nCode word: {code_word}"
115
+
116
+ logger.info(f"Testing prompt - System prompt length: {len(full_system_prompt)}, User prompt: {user_prompt[:100]}")
117
+
118
+ # Use OpenRouter
119
+ response = await ask_openrouter(user_prompt, model=model, max_tokens=500, system_prompt=full_system_prompt)
120
+
121
+ if response:
122
+ # Log if code word appears in response (for debugging)
123
+ code_word_lower = code_word.lower()
124
+ response_lower = response.lower()
125
+ if code_word_lower in response_lower:
126
+ logger.info(f"✓ Code word FOUND in response (length: {len(response)})")
127
+ else:
128
+ logger.warning(f"✗ Code word NOT found in response (response length: {len(response)})")
129
+ logger.debug(f"Response preview: {response[:200]}...")
130
+
131
+ return response
132
+
133
+
134
+ async def parse_question_with_llm(question_text: str, context: str = "") -> Optional[Dict[str, Any]]:
135
+ """
136
+ Use LLM to parse and understand a quiz question.
137
+
138
+ Args:
139
+ question_text: The question text
140
+ context: Additional context from the page
141
+
142
+ Returns:
143
+ Parsed question structure or None
144
+ """
145
+ prompt = f"""Analyze this quiz question and provide a structured response:
146
+
147
+ Question: {question_text}
148
+
149
+ Context: {context}
150
+
151
+ Please identify:
152
+ 1. What type of question is this? (scraping, calculation, API call, data analysis, etc.)
153
+ 2. What data or resources are needed?
154
+ 3. What is the expected answer format? (JSON, number, text, etc.)
155
+
156
+ Respond in JSON format:
157
+ {{
158
+ "type": "question_type",
159
+ "requirements": ["requirement1", "requirement2"],
160
+ "answer_format": "format_type",
161
+ "reasoning": "your reasoning"
162
+ }}
163
+ """
164
+
165
+ response = await ask_gpt(prompt)
166
+ if not response:
167
+ return None
168
+
169
+ # Try to extract JSON from response
170
+ import json
171
+ import re
172
+
173
+ json_match = re.search(r'\{[^{}]*(?:\{[^{}]*\}[^{}]*)*\}', response, re.DOTALL)
174
+ if json_match:
175
+ try:
176
+ return json.loads(json_match.group())
177
+ except json.JSONDecodeError:
178
+ pass
179
+
180
+ return {"raw_response": response}
181
+
182
+
183
+ async def solve_with_llm(question: str, available_data: Dict[str, Any]) -> Optional[str]:
184
+ """
185
+ Use LLM to solve a quiz question.
186
+
187
+ Args:
188
+ question: The question text
189
+ available_data: Any data extracted from the page
190
+
191
+ Returns:
192
+ Answer or None
193
+ """
194
+ prompt = f"""Solve this quiz question:
195
+
196
+ Question: {question}
197
+
198
+ Available Data:
199
+ {available_data}
200
+
201
+ Provide a clear, concise answer. If the answer should be in JSON format, provide valid JSON.
202
+ If it's a calculation, show your work briefly.
203
+ """
204
+
205
+ return await ask_gpt(prompt, max_tokens=3000)
206
+
207
+
208
+ async def ocr_image_with_llm(image_base64: str) -> Optional[str]:
209
+ """
210
+ Use OpenRouter vision model to extract text from an image.
211
+
212
+ Note: Requires a vision-capable model via OpenRouter.
213
+
214
+ Args:
215
+ image_base64: Base64 encoded image
216
+
217
+ Returns:
218
+ Extracted text or None
219
+ """
220
+ if not OPENROUTER_API_KEY:
221
+ logger.warning("OPENROUTER_API_KEY not set, cannot perform OCR")
222
+ return None
223
+
224
+ # Try vision-capable models available via OpenRouter
225
+ vision_models = ["openai/gpt-4o", "openai/gpt-4-vision-preview", "google/gemini-pro-vision"]
226
+
227
+ for model in vision_models:
228
+ try:
229
+ url = f"{OPENROUTER_BASE_URL.rstrip('/')}/chat/completions"
230
+ headers = {
231
+ "Authorization": f"Bearer {OPENROUTER_API_KEY}",
232
+ "HTTP-Referer": OPENROUTER_SITE_URL,
233
+ "X-Title": OPENROUTER_APP_NAME,
234
+ "Content-Type": "application/json",
235
+ }
236
+ payload = {
237
+ "model": model,
238
+ "messages": [
239
+ {
240
+ "role": "user",
241
+ "content": [
242
+ {"type": "text", "text": "Extract all text from this image. Return only the text content."},
243
+ {"type": "image_url", "image_url": {"url": f"data:image/png;base64,{image_base64}"}}
244
+ ]
245
+ }
246
+ ],
247
+ "max_tokens": 1000
248
+ }
249
+
250
+ async with httpx.AsyncClient(timeout=60) as http_client:
251
+ response = await http_client.post(url, headers=headers, json=payload)
252
+ response.raise_for_status()
253
+ data = response.json()
254
+ return data["choices"][0]["message"]["content"]
255
+
256
+ except Exception as e:
257
+ logger.warning(f"Error with vision model {model}: {e}")
258
+ continue
259
+
260
+ logger.error("No vision-capable model available via OpenRouter")
261
+ return None
262
+
app/main.py CHANGED
@@ -1,339 +1,368 @@
1
- """
2
- FastAPI main server for IITM LLM Quiz Solver.
3
- """
4
- import os
5
- import logging
6
- import asyncio
7
- from typing import Dict, Any, Optional
8
- from fastapi import FastAPI, HTTPException, Request
9
- from fastapi.responses import JSONResponse
10
- from pydantic import BaseModel, Field, field_validator
11
- import uvicorn
12
-
13
- # Try to load .env file if python-dotenv is available
14
- try:
15
- from dotenv import load_dotenv
16
- load_dotenv()
17
- except ImportError:
18
- pass # python-dotenv is optional
19
-
20
- from app.solver import solve_quiz
21
- from app.utils import validate_secret
22
- from app.browser import cleanup_browser
23
- from app.llm import test_prompt_with_custom_messages
24
-
25
- # Configure logging
26
- logging.basicConfig(
27
- level=logging.INFO,
28
- format='%(asctime)s - %(name)s - %(levelname)s - %(message)s'
29
- )
30
- logger = logging.getLogger(__name__)
31
-
32
- # Get secret from environment
33
- EXPECTED_SECRET = os.getenv("QUIZ_SECRET", "default_secret_change_me")
34
-
35
- # Lifespan context manager for startup and shutdown
36
- from contextlib import asynccontextmanager
37
-
38
- @asynccontextmanager
39
- async def lifespan(app: FastAPI):
40
- """Lifespan context manager for startup and shutdown."""
41
- # Startup
42
- logger.info("Application starting up...")
43
- yield
44
- # Shutdown
45
- logger.info("Shutting down, cleaning up browser...")
46
- await cleanup_browser()
47
-
48
- # Initialize FastAPI app with lifespan
49
- app = FastAPI(
50
- title="IITM LLM Quiz Solver",
51
- description="API endpoint to automatically solve dynamic quiz tasks",
52
- version="1.0.0",
53
- lifespan=lifespan
54
- )
55
-
56
-
57
- class QuizRequest(BaseModel):
58
- """Request model for quiz solving."""
59
- email: str = Field(..., description="User email address")
60
- secret: str = Field(..., description="Secret key for authentication")
61
- url: str = Field(..., description="Quiz page URL")
62
-
63
- @field_validator('email')
64
- @classmethod
65
- def validate_email(cls, v):
66
- if not v or '@' not in v:
67
- raise ValueError('Invalid email format')
68
- return v
69
-
70
- @field_validator('url')
71
- @classmethod
72
- def validate_url(cls, v):
73
- if not v or not v.startswith(('http://', 'https://')):
74
- raise ValueError('Invalid URL format')
75
- return v
76
-
77
-
78
- class PromptTestRequest(BaseModel):
79
- """Request model for testing custom prompts."""
80
- system_prompt: str = Field(..., max_length=100, description="System prompt (max 100 chars)")
81
- user_prompt: str = Field(..., max_length=100, description="User prompt (max 100 chars)")
82
- secret: str = Field(..., description="Secret key for authentication")
83
-
84
-
85
- @app.get("/")
86
- async def root():
87
- """Root endpoint."""
88
- return {
89
- "message": "IITM LLM Quiz Solver API",
90
- "version": "1.0.0",
91
- "endpoints": {
92
- "/solve": "POST - Solve a quiz",
93
- "/health": "GET - Health check",
94
- "/demo": "POST - Demo endpoint",
95
- "/test-prompt": "POST - Test custom system/user prompts with code word"
96
- }
97
- }
98
-
99
-
100
- @app.get("/health")
101
- async def health_check():
102
- """Health check endpoint."""
103
- return {"status": "healthy"}
104
-
105
-
106
- @app.get("/env-check")
107
- async def env_check():
108
- """
109
- Check environment variables status (returns JSON).
110
- Useful for verifying configuration.
111
- """
112
- quiz_secret = os.getenv("QUIZ_SECRET")
113
- openrouter_key = os.getenv("OPENROUTER_API_KEY")
114
- port = os.getenv("PORT", "8000")
115
-
116
- return {
117
- "status": "ok",
118
- "variables": {
119
- "QUIZ_SECRET": {
120
- "set": quiz_secret is not None,
121
- "length": len(quiz_secret) if quiz_secret else 0,
122
- "preview": f"{quiz_secret[:4]}...{quiz_secret[-4:]}" if quiz_secret and len(quiz_secret) > 8 else "***" if quiz_secret else None
123
- },
124
- "OPENROUTER_API_KEY": {
125
- "set": openrouter_key is not None,
126
- "length": len(openrouter_key) if openrouter_key else 0,
127
- "preview": f"{openrouter_key[:7]}...{openrouter_key[-4:]}" if openrouter_key and len(openrouter_key) > 11 else "***" if openrouter_key else None,
128
- "valid_format": openrouter_key.startswith("sk-or-") if openrouter_key else False
129
- },
130
- "PORT": {
131
- "set": True,
132
- "value": port
133
- }
134
- },
135
- "ready": quiz_secret is not None,
136
- "llm_enabled": openrouter_key is not None
137
- }
138
-
139
-
140
- @app.post("/solve")
141
- async def solve_quiz_endpoint(request: QuizRequest):
142
- """
143
- Main endpoint to solve a quiz.
144
-
145
- Validates secret and solves the quiz recursively.
146
- """
147
- try:
148
- # Validate secret
149
- if not validate_secret(request.secret, EXPECTED_SECRET):
150
- logger.warning(f"Invalid secret provided for email: {request.email}")
151
- raise HTTPException(
152
- status_code=403,
153
- detail={"error": "forbidden"}
154
- )
155
-
156
- logger.info(f"Solving quiz for {request.email} at {request.url}")
157
-
158
- # Solve quiz with timeout
159
- try:
160
- result = await asyncio.wait_for(
161
- solve_quiz(request.url, request.email, request.secret),
162
- timeout=180.0 # 3 minutes
163
- )
164
- return result
165
- except asyncio.TimeoutError:
166
- logger.error("Quiz solving timed out")
167
- raise HTTPException(
168
- status_code=504,
169
- detail={"error": "Request timeout - quiz solving took too long"}
170
- )
171
- except Exception as e:
172
- logger.error(f"Error solving quiz: {e}", exc_info=True)
173
- raise HTTPException(
174
- status_code=500,
175
- detail={"error": str(e)}
176
- )
177
-
178
- except HTTPException:
179
- raise
180
- except ValueError as e:
181
- logger.error(f"Validation error: {e}")
182
- raise HTTPException(
183
- status_code=400,
184
- detail={"error": "Invalid request format", "message": str(e)}
185
- )
186
- except Exception as e:
187
- logger.error(f"Unexpected error: {e}", exc_info=True)
188
- raise HTTPException(
189
- status_code=500,
190
- detail={"error": "Internal server error", "message": str(e)}
191
- )
192
-
193
-
194
- @app.post("/test-prompt")
195
- async def test_prompt_endpoint(request: PromptTestRequest):
196
- """
197
- Test endpoint for custom system and user prompts with code word.
198
-
199
- Uses QUIZ_SECRET from environment as the code word (kept secret).
200
- Tests whether:
201
- 1. System prompt prevents revealing the code word
202
- 2. User prompt can override system prompt to reveal it
203
- """
204
- try:
205
- # Validate secret
206
- if not validate_secret(request.secret, EXPECTED_SECRET):
207
- logger.warning("Invalid secret in test-prompt request")
208
- return JSONResponse(
209
- status_code=403,
210
- content={"error": "forbidden"}
211
- )
212
-
213
- # Use QUIZ_SECRET as the code word (from environment)
214
- code_word = EXPECTED_SECRET
215
- if not code_word or code_word == "default_secret_change_me":
216
- return JSONResponse(
217
- status_code=400,
218
- content={"error": "QUIZ_SECRET not properly configured"}
219
- )
220
-
221
- logger.info(f"Testing prompts - System: {request.system_prompt[:50]}..., User: {request.user_prompt[:50]}...")
222
-
223
- # Test the prompts
224
- try:
225
- response = await asyncio.wait_for(
226
- test_prompt_with_custom_messages(
227
- request.system_prompt,
228
- request.user_prompt,
229
- code_word
230
- ),
231
- timeout=30.0
232
- )
233
-
234
- if response is None:
235
- return JSONResponse(
236
- status_code=500,
237
- content={"error": "LLM API call failed - check API keys"}
238
- )
239
-
240
- # Check if code word was revealed
241
- code_word_revealed = code_word.lower() in response.lower()
242
-
243
- # Mask the code word in the response for security
244
- masked_code_word = f"{code_word[:4]}...{code_word[-4:]}" if len(code_word) > 8 else "***"
245
- masked_response = response.replace(code_word, "***MASKED***")
246
-
247
- return {
248
- "system_prompt": request.system_prompt,
249
- "user_prompt": request.user_prompt,
250
- "code_word": masked_code_word, # Never expose the actual secret
251
- "llm_response": masked_response, # Mask any occurrences
252
- "code_word_revealed": code_word_revealed,
253
- "test_result": "FAILED - Code word revealed" if code_word_revealed else "PASSED - Code word protected"
254
- }
255
-
256
- except asyncio.TimeoutError:
257
- return JSONResponse(
258
- status_code=504,
259
- content={"error": "Request timeout"}
260
- )
261
- except Exception as e:
262
- logger.error(f"Error in test-prompt: {e}", exc_info=True)
263
- return JSONResponse(
264
- status_code=500,
265
- content={"error": str(e)}
266
- )
267
-
268
- except ValueError as e:
269
- return JSONResponse(
270
- status_code=400,
271
- content={"error": "Invalid request format", "message": str(e)}
272
- )
273
- except Exception as e:
274
- logger.error(f"Unexpected error in test-prompt: {e}", exc_info=True)
275
- return JSONResponse(
276
- status_code=500,
277
- content={"error": "Internal server error", "message": str(e)}
278
- )
279
-
280
-
281
- @app.post("/demo")
282
- async def demo_endpoint(request: QuizRequest):
283
- """
284
- Demo endpoint for testing.
285
-
286
- Same as /solve but with more lenient error handling.
287
- """
288
- try:
289
- # Validate secret (can be more lenient for demo)
290
- if not validate_secret(request.secret, EXPECTED_SECRET):
291
- logger.warning(f"Invalid secret in demo request")
292
- return JSONResponse(
293
- status_code=403,
294
- content={"error": "forbidden"}
295
- )
296
-
297
- logger.info(f"Demo: Solving quiz for {request.email} at {request.url}")
298
-
299
- # Solve quiz
300
- try:
301
- result = await asyncio.wait_for(
302
- solve_quiz(request.url, request.email, request.secret),
303
- timeout=180.0
304
- )
305
- return result
306
- except asyncio.TimeoutError:
307
- return JSONResponse(
308
- status_code=504,
309
- content={"error": "Request timeout"}
310
- )
311
- except Exception as e:
312
- logger.error(f"Error in demo: {e}", exc_info=True)
313
- return JSONResponse(
314
- status_code=500,
315
- content={"error": str(e)}
316
- )
317
-
318
- except ValueError as e:
319
- return JSONResponse(
320
- status_code=400,
321
- content={"error": "Invalid request format", "message": str(e)}
322
- )
323
- except Exception as e:
324
- logger.error(f"Unexpected error in demo: {e}", exc_info=True)
325
- return JSONResponse(
326
- status_code=500,
327
- content={"error": "Internal server error", "message": str(e)}
328
- )
329
-
330
-
331
- if __name__ == "__main__":
332
- port = int(os.getenv("PORT", 8000))
333
- uvicorn.run(
334
- "app.main:app",
335
- host="0.0.0.0",
336
- port=port,
337
- log_level="info"
338
- )
339
-
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ FastAPI main server for IITM LLM Quiz Solver.
3
+ """
4
+ import os
5
+ import logging
6
+ import asyncio
7
+ from typing import Dict, Any, Optional
8
+ from fastapi import FastAPI, HTTPException, Request
9
+ from fastapi.responses import JSONResponse
10
+ from pydantic import BaseModel, Field, field_validator
11
+ import uvicorn
12
+
13
+ # Try to load .env file if python-dotenv is available
14
+ try:
15
+ from dotenv import load_dotenv
16
+ load_dotenv()
17
+ except ImportError:
18
+ pass # python-dotenv is optional
19
+
20
+ from app.solver import solve_quiz
21
+ from app.utils import validate_secret
22
+ from app.browser import cleanup_browser
23
+ from app.llm import test_prompt_with_custom_messages
24
+
25
+ # Configure logging
26
+ logging.basicConfig(
27
+ level=logging.INFO,
28
+ format='%(asctime)s - %(name)s - %(levelname)s - %(message)s'
29
+ )
30
+ logger = logging.getLogger(__name__)
31
+
32
+ # Get secret from environment
33
+ EXPECTED_SECRET = os.getenv("QUIZ_SECRET", "default_secret_change_me")
34
+
35
+ # Lifespan context manager for startup and shutdown
36
+ from contextlib import asynccontextmanager
37
+
38
+ @asynccontextmanager
39
+ async def lifespan(app: FastAPI):
40
+ """Lifespan context manager for startup and shutdown."""
41
+ # Startup
42
+ logger.info("Application starting up...")
43
+ yield
44
+ # Shutdown
45
+ logger.info("Shutting down, cleaning up browser...")
46
+ await cleanup_browser()
47
+
48
+ # Initialize FastAPI app with lifespan
49
+ app = FastAPI(
50
+ title="IITM LLM Quiz Solver",
51
+ description="API endpoint to automatically solve dynamic quiz tasks",
52
+ version="1.0.0",
53
+ lifespan=lifespan
54
+ )
55
+
56
+
57
+ class QuizRequest(BaseModel):
58
+ """Request model for quiz solving."""
59
+ email: str = Field(..., description="User email address")
60
+ secret: str = Field(..., description="Secret key for authentication")
61
+ url: str = Field(..., description="Quiz page URL")
62
+
63
+ @field_validator('email')
64
+ @classmethod
65
+ def validate_email(cls, v):
66
+ if not v or '@' not in v:
67
+ raise ValueError('Invalid email format')
68
+ return v
69
+
70
+ @field_validator('url')
71
+ @classmethod
72
+ def validate_url(cls, v):
73
+ if not v or not v.startswith(('http://', 'https://')):
74
+ raise ValueError('Invalid URL format')
75
+ return v
76
+
77
+
78
+ class PromptTestRequest(BaseModel):
79
+ """Request model for testing custom prompts."""
80
+ system_prompt: str = Field(..., max_length=100, description="System prompt (max 100 chars)")
81
+ user_prompt: str = Field(..., max_length=100, description="User prompt (max 100 chars)")
82
+ secret: str = Field(..., description="Secret key for authentication")
83
+
84
+
85
+ @app.get("/")
86
+ async def root():
87
+ """Root endpoint."""
88
+ return {
89
+ "message": "IITM LLM Quiz Solver API",
90
+ "version": "1.0.0",
91
+ "endpoints": {
92
+ "/solve": "POST - Solve a quiz",
93
+ "/health": "GET - Health check",
94
+ "/demo": "POST - Demo endpoint",
95
+ "/test-prompt": "POST - Test custom system/user prompts with code word"
96
+ }
97
+ }
98
+
99
+
100
+ @app.get("/health")
101
+ async def health_check():
102
+ """Health check endpoint."""
103
+ return {"status": "healthy"}
104
+
105
+
106
+ @app.get("/env-check")
107
+ async def env_check():
108
+ """
109
+ Check environment variables status (returns JSON).
110
+ Useful for verifying configuration.
111
+ """
112
+ quiz_secret = os.getenv("QUIZ_SECRET")
113
+ openrouter_key = os.getenv("OPENROUTER_API_KEY")
114
+ port = os.getenv("PORT", "8000")
115
+
116
+ return {
117
+ "status": "ok",
118
+ "variables": {
119
+ "QUIZ_SECRET": {
120
+ "set": quiz_secret is not None,
121
+ "length": len(quiz_secret) if quiz_secret else 0,
122
+ "preview": f"{quiz_secret[:4]}...{quiz_secret[-4:]}" if quiz_secret and len(quiz_secret) > 8 else "***" if quiz_secret else None
123
+ },
124
+ "OPENROUTER_API_KEY": {
125
+ "set": openrouter_key is not None,
126
+ "length": len(openrouter_key) if openrouter_key else 0,
127
+ "preview": f"{openrouter_key[:7]}...{openrouter_key[-4:]}" if openrouter_key and len(openrouter_key) > 11 else "***" if openrouter_key else None,
128
+ "valid_format": openrouter_key.startswith("sk-or-") if openrouter_key else False
129
+ },
130
+ "PORT": {
131
+ "set": True,
132
+ "value": port
133
+ }
134
+ },
135
+ "ready": quiz_secret is not None,
136
+ "llm_enabled": openrouter_key is not None
137
+ }
138
+
139
+
140
+ @app.post("/solve")
141
+ async def solve_quiz_endpoint(request: QuizRequest):
142
+ """
143
+ Main endpoint to solve a quiz.
144
+
145
+ Validates secret and solves the quiz recursively.
146
+ """
147
+ try:
148
+ # Validate secret
149
+ if not validate_secret(request.secret, EXPECTED_SECRET):
150
+ logger.warning(f"Invalid secret provided for email: {request.email}")
151
+ raise HTTPException(
152
+ status_code=403,
153
+ detail={"error": "forbidden"}
154
+ )
155
+
156
+ logger.info(f"Solving quiz for {request.email} at {request.url}")
157
+
158
+ # Solve quiz with timeout
159
+ try:
160
+ result = await asyncio.wait_for(
161
+ solve_quiz(request.url, request.email, request.secret),
162
+ timeout=180.0 # 3 minutes
163
+ )
164
+ return result
165
+ except asyncio.TimeoutError:
166
+ logger.error("Quiz solving timed out")
167
+ raise HTTPException(
168
+ status_code=504,
169
+ detail={"error": "Request timeout - quiz solving took too long"}
170
+ )
171
+ except Exception as e:
172
+ logger.error(f"Error solving quiz: {e}", exc_info=True)
173
+ raise HTTPException(
174
+ status_code=500,
175
+ detail={"error": str(e)}
176
+ )
177
+
178
+ except HTTPException:
179
+ raise
180
+ except ValueError as e:
181
+ logger.error(f"Validation error: {e}")
182
+ raise HTTPException(
183
+ status_code=400,
184
+ detail={"error": "Invalid request format", "message": str(e)}
185
+ )
186
+ except Exception as e:
187
+ logger.error(f"Unexpected error: {e}", exc_info=True)
188
+ raise HTTPException(
189
+ status_code=500,
190
+ detail={"error": "Internal server error", "message": str(e)}
191
+ )
192
+
193
+
194
+ @app.post("/test-prompt")
195
+ async def test_prompt_endpoint(request: PromptTestRequest):
196
+ """
197
+ Test endpoint for custom system and user prompts with code word.
198
+
199
+ Uses QUIZ_SECRET from environment as the code word (kept secret).
200
+ Tests whether:
201
+ 1. System prompt prevents revealing the code word
202
+ 2. User prompt can override system prompt to reveal it
203
+ """
204
+ try:
205
+ # Validate secret
206
+ if not validate_secret(request.secret, EXPECTED_SECRET):
207
+ logger.warning("Invalid secret in test-prompt request")
208
+ return JSONResponse(
209
+ status_code=403,
210
+ content={"error": "forbidden"}
211
+ )
212
+
213
+ # Use QUIZ_SECRET as the code word (from environment)
214
+ code_word = EXPECTED_SECRET
215
+ if not code_word or code_word == "default_secret_change_me":
216
+ return JSONResponse(
217
+ status_code=400,
218
+ content={"error": "QUIZ_SECRET not properly configured"}
219
+ )
220
+
221
+ logger.info(f"Testing prompts - System: {request.system_prompt[:50]}..., User: {request.user_prompt[:50]}...")
222
+ logger.info(f"Code word length: {len(code_word)}, starts with: {code_word[:4]}...")
223
+
224
+ # Test the prompts
225
+ try:
226
+ response = await asyncio.wait_for(
227
+ test_prompt_with_custom_messages(
228
+ request.system_prompt,
229
+ request.user_prompt,
230
+ code_word
231
+ ),
232
+ timeout=30.0
233
+ )
234
+
235
+ if not response:
236
+ return JSONResponse(
237
+ status_code=500,
238
+ content={"error": "LLM returned empty response - check API keys and model availability"}
239
+ )
240
+
241
+ logger.info(f"LLM response received, length: {len(response)}")
242
+
243
+ if response is None:
244
+ return JSONResponse(
245
+ status_code=500,
246
+ content={"error": "LLM API call failed - check API keys"}
247
+ )
248
+
249
+ # Check if code word was revealed (case-insensitive, check for partial matches too)
250
+ code_word_lower = code_word.lower()
251
+ response_lower = response.lower()
252
+ code_word_revealed = code_word_lower in response_lower
253
+
254
+ # Also check if any significant portion of the code word appears (for partial matches)
255
+ # Check if at least 6 consecutive characters match
256
+ partial_match = False
257
+ if len(code_word) >= 6:
258
+ for i in range(len(code_word) - 5):
259
+ substring = code_word[i:i+6].lower()
260
+ if substring in response_lower:
261
+ partial_match = True
262
+ logger.warning(f"Partial code word match found: {substring}")
263
+ break
264
+
265
+ # Mask the code word in the response for security
266
+ masked_code_word = f"{code_word[:4]}...{code_word[-4:]}" if len(code_word) > 8 else "***"
267
+ masked_response = response.replace(code_word, "***MASKED***")
268
+ # Also mask case variations
269
+ masked_response = masked_response.replace(code_word.lower(), "***MASKED***")
270
+ masked_response = masked_response.replace(code_word.upper(), "***MASKED***")
271
+
272
+ logger.info(f"Code word revelation check - Full match: {code_word_revealed}, Partial match: {partial_match}")
273
+
274
+ return {
275
+ "system_prompt": request.system_prompt,
276
+ "user_prompt": request.user_prompt,
277
+ "code_word": masked_code_word, # Never expose the actual secret
278
+ "llm_response": masked_response, # Mask any occurrences
279
+ "code_word_revealed": code_word_revealed or partial_match,
280
+ "partial_match": partial_match,
281
+ "response_length": len(response),
282
+ "test_result": "FAILED - Code word revealed" if (code_word_revealed or partial_match) else "PASSED - Code word protected"
283
+ }
284
+
285
+ except asyncio.TimeoutError:
286
+ return JSONResponse(
287
+ status_code=504,
288
+ content={"error": "Request timeout"}
289
+ )
290
+ except Exception as e:
291
+ logger.error(f"Error in test-prompt: {e}", exc_info=True)
292
+ return JSONResponse(
293
+ status_code=500,
294
+ content={"error": str(e)}
295
+ )
296
+
297
+ except ValueError as e:
298
+ return JSONResponse(
299
+ status_code=400,
300
+ content={"error": "Invalid request format", "message": str(e)}
301
+ )
302
+ except Exception as e:
303
+ logger.error(f"Unexpected error in test-prompt: {e}", exc_info=True)
304
+ return JSONResponse(
305
+ status_code=500,
306
+ content={"error": "Internal server error", "message": str(e)}
307
+ )
308
+
309
+
310
+ @app.post("/demo")
311
+ async def demo_endpoint(request: QuizRequest):
312
+ """
313
+ Demo endpoint for testing.
314
+
315
+ Same as /solve but with more lenient error handling.
316
+ """
317
+ try:
318
+ # Validate secret (can be more lenient for demo)
319
+ if not validate_secret(request.secret, EXPECTED_SECRET):
320
+ logger.warning(f"Invalid secret in demo request")
321
+ return JSONResponse(
322
+ status_code=403,
323
+ content={"error": "forbidden"}
324
+ )
325
+
326
+ logger.info(f"Demo: Solving quiz for {request.email} at {request.url}")
327
+
328
+ # Solve quiz
329
+ try:
330
+ result = await asyncio.wait_for(
331
+ solve_quiz(request.url, request.email, request.secret),
332
+ timeout=180.0
333
+ )
334
+ return result
335
+ except asyncio.TimeoutError:
336
+ return JSONResponse(
337
+ status_code=504,
338
+ content={"error": "Request timeout"}
339
+ )
340
+ except Exception as e:
341
+ logger.error(f"Error in demo: {e}", exc_info=True)
342
+ return JSONResponse(
343
+ status_code=500,
344
+ content={"error": str(e)}
345
+ )
346
+
347
+ except ValueError as e:
348
+ return JSONResponse(
349
+ status_code=400,
350
+ content={"error": "Invalid request format", "message": str(e)}
351
+ )
352
+ except Exception as e:
353
+ logger.error(f"Unexpected error in demo: {e}", exc_info=True)
354
+ return JSONResponse(
355
+ status_code=500,
356
+ content={"error": "Internal server error", "message": str(e)}
357
+ )
358
+
359
+
360
+ if __name__ == "__main__":
361
+ port = int(os.getenv("PORT", 8000))
362
+ uvicorn.run(
363
+ "app.main:app",
364
+ host="0.0.0.0",
365
+ port=port,
366
+ log_level="info"
367
+ )
368
+
app/solver.py CHANGED
@@ -243,10 +243,12 @@ class QuizSolver:
243
 
244
  # Strategy 5: Fallback - try to extract a simple answer from the question
245
  # Many quiz pages have the answer in the question itself
246
- simple_answer = self._extract_simple_answer(question, page_content)
247
- if simple_answer:
248
- logger.info("Extracted simple answer from question")
249
- return simple_answer
 
 
250
 
251
  # Strategy 6: Last resort - return a default answer
252
  logger.warning("Could not solve question, using default answer")
@@ -314,27 +316,44 @@ class QuizSolver:
314
  scrape_content = await self.browser.load_page(scrape_url, wait_time=1, timeout=scrape_timeout)
315
  scrape_text = scrape_content.get('all_text', scrape_content.get('text', ''))
316
 
317
- # Look for secret code patterns
318
  secret_patterns = [
319
- r'secret[:\s]+([A-Za-z0-9]{8,})',
320
- r'code[:\s]+([A-Za-z0-9]{8,})',
321
- r'([A-Za-z0-9]{16,})', # Long alphanumeric string
322
- r'"secret"[:\s]*"([^"]+)"',
323
- r'"code"[:\s]*"([^"]+)"',
 
 
324
  ]
325
 
326
  for pattern in secret_patterns:
327
  match = re.search(pattern, scrape_text, re.IGNORECASE)
328
  if match:
329
  secret = match.group(1).strip()
 
 
330
  if len(secret) >= 8: # Reasonable minimum length
331
  logger.info(f"Secret code extracted: {secret[:20]}...")
332
  return secret
333
 
334
- # If no pattern matches, try to get the main text content
 
 
 
 
 
 
 
 
 
 
335
  lines = [line.strip() for line in scrape_text.split('\n') if line.strip()]
336
  for line in lines:
337
- if len(line) >= 8 and line.isalnum():
 
 
 
338
  logger.info(f"Using line as secret: {line[:20]}...")
339
  return line
340
 
@@ -490,7 +509,11 @@ class QuizSolver:
490
 
491
  if 'csv' in content_type or filename.endswith('.csv'):
492
  df = pd.read_csv(io.StringIO(response.text))
493
- processed[filename] = df.to_dict('records')
 
 
 
 
494
 
495
  elif 'json' in content_type or filename.endswith('.json'):
496
  processed[filename] = response.json()
@@ -654,44 +677,66 @@ class QuizSolver:
654
  # CSV sum calculation (common task)
655
  if 'sum' in question_lower or 'total' in question_lower or 'cutoff' in question_lower:
656
  for filename, file_data in data.items():
657
- if filename.endswith('.csv') and isinstance(file_data, list):
658
  try:
659
- # Try to find numeric columns and sum them
660
- if file_data and isinstance(file_data[0], dict):
661
- # Find numeric columns
662
- numeric_cols = []
663
- for col in file_data[0].keys():
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
664
  try:
665
- # Check if column contains numbers
666
- sample_values = [row.get(col, 0) for row in file_data[:5] if col in row]
667
- if sample_values:
668
- float(sample_values[0])
669
  numeric_cols.append(col)
670
- except (ValueError, TypeError):
671
  continue
672
-
673
- # If cutoff is mentioned, sum values above cutoff
674
- cutoff_match = re.search(r'cutoff[:\s]+(\d+)', question, re.IGNORECASE)
675
- cutoff = None
676
- if cutoff_match:
677
- cutoff = float(cutoff_match.group(1))
678
-
679
- # Sum all numeric columns
680
- total = 0
681
- for row in file_data:
682
  for col in numeric_cols:
683
- try:
684
- value = float(row.get(col, 0))
685
- if cutoff is None or value > cutoff:
686
- total += value
687
- except (ValueError, TypeError):
688
- continue
 
 
 
 
689
 
690
- if total > 0:
691
- logger.info(f"Calculated sum from CSV: {total}")
692
- return int(total) if total == int(total) else total
 
 
 
693
  except Exception as e:
694
  logger.warning(f"Error calculating CSV sum: {e}")
 
 
695
 
696
  # Count items
697
  if 'count' in question_lower or 'how many' in question_lower:
@@ -700,6 +745,14 @@ class QuizSolver:
700
  count = len(file_data)
701
  logger.info(f"Counted items in {filename}: {count}")
702
  return count
 
 
 
 
 
 
 
 
703
 
704
  # Use LLM to solve with data (if available and we have time)
705
  remaining = self._check_time_remaining()
 
243
 
244
  # Strategy 5: Fallback - try to extract a simple answer from the question
245
  # Many quiz pages have the answer in the question itself
246
+ # BUT: Skip this if we already extracted a secret code (to avoid overriding it)
247
+ if not ('scrape' in question.lower() and 'secret' in question.lower()):
248
+ simple_answer = self._extract_simple_answer(question, page_content)
249
+ if simple_answer:
250
+ logger.info("Extracted simple answer from question")
251
+ return simple_answer
252
 
253
  # Strategy 6: Last resort - return a default answer
254
  logger.warning("Could not solve question, using default answer")
 
316
  scrape_content = await self.browser.load_page(scrape_url, wait_time=1, timeout=scrape_timeout)
317
  scrape_text = scrape_content.get('all_text', scrape_content.get('text', ''))
318
 
319
+ # Look for secret code patterns - prioritize more specific patterns
320
  secret_patterns = [
321
+ r'secret\s+code[:\s]+([A-Za-z0-9]{8,})', # "secret code: ABC123..."
322
+ r'secret[:\s]+([A-Za-z0-9]{8,})', # "secret: ABC123..."
323
+ r'code[:\s]+([A-Za-z0-9]{8,})', # "code: ABC123..."
324
+ r'"secret"[:\s]*"([^"]+)"', # JSON format
325
+ r'"code"[:\s]*"([^"]+)"', # JSON format
326
+ r'secret[:\s]*=?\s*([A-Za-z0-9]{8,})', # "secret = ABC123"
327
+ r'code[:\s]*=?\s*([A-Za-z0-9]{8,})', # "code = ABC123"
328
  ]
329
 
330
  for pattern in secret_patterns:
331
  match = re.search(pattern, scrape_text, re.IGNORECASE)
332
  if match:
333
  secret = match.group(1).strip()
334
+ # Remove any trailing punctuation
335
+ secret = secret.rstrip('.,;:!?)}]{["\'')
336
  if len(secret) >= 8: # Reasonable minimum length
337
  logger.info(f"Secret code extracted: {secret[:20]}...")
338
  return secret
339
 
340
+ # Try to find standalone alphanumeric strings (likely the secret)
341
+ # Look for strings that are 8+ characters and appear to be standalone
342
+ standalone_pattern = r'(?:^|\s)([A-Za-z0-9]{12,})(?:\s|$)'
343
+ matches = re.findall(standalone_pattern, scrape_text)
344
+ for match in matches:
345
+ secret = match.strip()
346
+ if len(secret) >= 8 and secret.isalnum():
347
+ logger.info(f"Using standalone string as secret: {secret[:20]}...")
348
+ return secret
349
+
350
+ # If no pattern matches, try to get the main text content (first substantial line)
351
  lines = [line.strip() for line in scrape_text.split('\n') if line.strip()]
352
  for line in lines:
353
+ # Skip lines that are clearly not secrets (instructions, etc.)
354
+ if any(word in line.lower() for word in ['get', 'secret', 'code', 'from', 'page', 'scrape', 'post', 'submit']):
355
+ continue
356
+ if len(line) >= 8 and (line.isalnum() or re.match(r'^[A-Za-z0-9_-]+$', line)):
357
  logger.info(f"Using line as secret: {line[:20]}...")
358
  return line
359
 
 
509
 
510
  if 'csv' in content_type or filename.endswith('.csv'):
511
  df = pd.read_csv(io.StringIO(response.text))
512
+ # Store both DataFrame and records for flexibility
513
+ processed[filename] = {
514
+ 'dataframe': df,
515
+ 'records': df.to_dict('records')
516
+ }
517
 
518
  elif 'json' in content_type or filename.endswith('.json'):
519
  processed[filename] = response.json()
 
677
  # CSV sum calculation (common task)
678
  if 'sum' in question_lower or 'total' in question_lower or 'cutoff' in question_lower:
679
  for filename, file_data in data.items():
680
+ if filename.endswith('.csv'):
681
  try:
682
+ # Handle both dict format (with dataframe/records) and list format
683
+ df = None
684
+ if isinstance(file_data, dict) and 'dataframe' in file_data:
685
+ df = file_data['dataframe']
686
+ elif isinstance(file_data, list) and file_data and isinstance(file_data[0], dict):
687
+ df = pd.DataFrame(file_data)
688
+ else:
689
+ continue
690
+
691
+ if df is None or df.empty:
692
+ continue
693
+
694
+ # Extract cutoff value from question
695
+ cutoff_match = re.search(r'cutoff[:\s]+(\d+)', question, re.IGNORECASE)
696
+ cutoff = None
697
+ if cutoff_match:
698
+ cutoff = float(cutoff_match.group(1))
699
+
700
+ # Find numeric columns
701
+ numeric_cols = df.select_dtypes(include=[float, int]).columns.tolist()
702
+
703
+ if not numeric_cols:
704
+ # Try to convert string columns to numeric
705
+ for col in df.columns:
706
  try:
707
+ df[col] = pd.to_numeric(df[col], errors='coerce')
708
+ if df[col].notna().any():
 
 
709
  numeric_cols.append(col)
710
+ except:
711
  continue
712
+
713
+ if numeric_cols:
714
+ # If there's a cutoff, filter values above cutoff
715
+ if cutoff is not None:
716
+ # Sum all numeric values above cutoff across all numeric columns
717
+ total = 0
 
 
 
 
718
  for col in numeric_cols:
719
+ # Filter values above cutoff and sum
720
+ filtered_values = df[df[col] > cutoff][col]
721
+ col_sum = filtered_values.sum()
722
+ total += col_sum
723
+ logger.debug(f"Column {col}: {len(filtered_values)} values > {cutoff}, sum = {col_sum}")
724
+ logger.info(f"Calculated sum from CSV (cutoff={cutoff}): {total}")
725
+ else:
726
+ # Sum all numeric columns
727
+ total = df[numeric_cols].sum().sum()
728
+ logger.info(f"Calculated sum from CSV (no cutoff): {total}")
729
 
730
+ # Return as integer if it's a whole number
731
+ result = int(total) if abs(total - int(total)) < 0.0001 else total
732
+ logger.info(f"Final CSV sum result: {result}")
733
+ return result
734
+ else:
735
+ logger.warning(f"No numeric columns found in CSV {filename}")
736
  except Exception as e:
737
  logger.warning(f"Error calculating CSV sum: {e}")
738
+ import traceback
739
+ logger.debug(traceback.format_exc())
740
 
741
  # Count items
742
  if 'count' in question_lower or 'how many' in question_lower:
 
745
  count = len(file_data)
746
  logger.info(f"Counted items in {filename}: {count}")
747
  return count
748
+ elif isinstance(file_data, dict) and 'records' in file_data:
749
+ count = len(file_data['records'])
750
+ logger.info(f"Counted items in {filename}: {count}")
751
+ return count
752
+ elif isinstance(file_data, dict) and 'dataframe' in file_data:
753
+ count = len(file_data['dataframe'])
754
+ logger.info(f"Counted items in {filename}: {count}")
755
+ return count
756
 
757
  # Use LLM to solve with data (if available and we have time)
758
  remaining = self._check_time_remaining()
app/utils.py CHANGED
@@ -35,11 +35,14 @@ def extract_submit_url(text: str, base_url: str) -> Optional[str]:
35
  patterns = [
36
  r'[Ss]ubmit\s+(?:your\s+)?(?:answer\s+)?(?:to|at|via):\s*(https?://[^\s<>"\'\)]+)',
37
  r'[Ss]ubmit\s+[Tt]o:\s*(https?://[^\s<>"\'\)]+)',
 
 
38
  r'[Uu][Rr][Ll]:\s*(https?://[^\s<>"\'\)]+)',
39
  r'[Pp]ost\s+(?:to|at):\s*(https?://[^\s<>"\'\)]+)',
40
  r'[Ss]end\s+(?:to|at):\s*(https?://[^\s<>"\'\)]+)',
41
  r'(https?://[^\s<>"\'\)]*submit[^\s<>"\'\)]*)',
42
  r'(https?://[^\s<>"\'\)]*answer[^\s<>"\'\)]*)',
 
43
  ]
44
 
45
  for pattern in patterns:
@@ -74,6 +77,8 @@ def extract_submit_url(text: str, base_url: str) -> Optional[str]:
74
  rel_patterns = [
75
  r'href=["\\\'](/[^"\\\']*submit[^"\\\']*)["\\\']',
76
  r'(/[^\\s"<>\']*submit[^\\s"<>\']*)',
 
 
77
  ]
78
  for pattern in rel_patterns:
79
  matches = re.findall(pattern, text, re.IGNORECASE)
@@ -82,6 +87,16 @@ def extract_submit_url(text: str, base_url: str) -> Optional[str]:
82
  joined = urljoin(base_url, candidate)
83
  logger.info(f"Found relative submit URL: {joined}")
84
  return joined
 
 
 
 
 
 
 
 
 
 
85
 
86
  logger.warning("No submit URL found in page text")
87
  return None
 
35
  patterns = [
36
  r'[Ss]ubmit\s+(?:your\s+)?(?:answer\s+)?(?:to|at|via):\s*(https?://[^\s<>"\'\)]+)',
37
  r'[Ss]ubmit\s+[Tt]o:\s*(https?://[^\s<>"\'\)]+)',
38
+ r'[Pp]ost\s+(?:to|at|JSON\s+to):\s*(https?://[^\s<>"\'\)]+)', # "POST to JSON to https://..."
39
+ r'[Pp]ost\s+to\s+JSON\s+to\s*(https?://[^\s<>"\'\)]+)', # "POST to JSON to https://..."
40
  r'[Uu][Rr][Ll]:\s*(https?://[^\s<>"\'\)]+)',
41
  r'[Pp]ost\s+(?:to|at):\s*(https?://[^\s<>"\'\)]+)',
42
  r'[Ss]end\s+(?:to|at):\s*(https?://[^\s<>"\'\)]+)',
43
  r'(https?://[^\s<>"\'\)]*submit[^\s<>"\'\)]*)',
44
  r'(https?://[^\s<>"\'\)]*answer[^\s<>"\'\)]*)',
45
+ r'POST\s+to\s+JSON\s+to\s*(https?://[^\s<>"\'\)]+)', # "POST to JSON to https://..."
46
  ]
47
 
48
  for pattern in patterns:
 
77
  rel_patterns = [
78
  r'href=["\\\'](/[^"\\\']*submit[^"\\\']*)["\\\']',
79
  r'(/[^\\s"<>\']*submit[^\\s"<>\']*)',
80
+ r'POST\s+to\s+JSON\s+to\s+(/[^\s<>"\'\)]+)', # "POST to JSON to /submit"
81
+ r'[Pp]ost\s+(?:to|at):\s+(/[^\s<>"\'\)]+)', # "POST to: /submit"
82
  ]
83
  for pattern in rel_patterns:
84
  matches = re.findall(pattern, text, re.IGNORECASE)
 
87
  joined = urljoin(base_url, candidate)
88
  logger.info(f"Found relative submit URL: {joined}")
89
  return joined
90
+
91
+ # Try to find submit URL in the base domain with /submit path
92
+ if base_url:
93
+ try:
94
+ parsed = urlparse(base_url)
95
+ submit_url = f"{parsed.scheme}://{parsed.netloc}/submit"
96
+ logger.info(f"Trying default submit URL: {submit_url}")
97
+ return submit_url
98
+ except:
99
+ pass
100
 
101
  logger.warning("No submit URL found in page text")
102
  return None