Files changed (1) hide show
  1. app.py +385 -29
app.py CHANGED
@@ -1,29 +1,385 @@
1
- # Modified app.py to serve both the FastAPI backend and Gradio frontend
2
- import os
3
- import gradio as gr
4
- import subprocess
5
- import threading
6
- import sys
7
-
8
- # Import the FastAPI app
9
- from app_main import app as fastapi_app
10
-
11
- # Import the gradio interface
12
- from gradio_app import app as gradio_interface
13
-
14
- # Function to start the FastAPI server in a separate thread
15
- def run_fastapi():
16
- import uvicorn
17
- uvicorn.run(fastapi_app, host="0.0.0.0", port=8000)
18
-
19
- # Main entry point
20
- if __name__ == "__main__":
21
- # Start FastAPI in a background thread
22
- threading.Thread(target=run_fastapi, daemon=True).start()
23
-
24
- # Start Gradio on the main thread - using port 7860 which is the default for Gradio
25
- # When running in a Space, use 0.0.0.0 as the server
26
- if os.getenv("SPACE_ID"):
27
- gradio_interface.launch(server_name="0.0.0.0", server_port=7860)
28
- else:
29
- gradio_interface.launch()
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from fastapi import FastAPI, HTTPException, Request
2
+ from fastapi.middleware.cors import CORSMiddleware
3
+ from fastapi.responses import JSONResponse
4
+ from pydantic import BaseModel
5
+ import openai
6
+ import os
7
+ import json
8
+ import re
9
+ from typing import Dict, List, Optional, Tuple, Any
10
+
11
+ app = FastAPI(title="TestCreationAgent",
12
+ description="An API for collecting test creation parameters through conversation")
13
+
14
+ # Add CORS middleware to allow requests from frontend
15
+ app.add_middleware(
16
+ CORSMiddleware,
17
+ allow_origins=["*"], # Allows all origins
18
+ allow_credentials=True,
19
+ allow_methods=["*"], # Allows all methods
20
+ allow_headers=["*"], # Allows all headers
21
+ )
22
+
23
+ # Define subject chapters mapping
24
+ SUBJECT_CHAPTERS = {
25
+ "Mathematics": [
26
+ "Number Systems", "Polynomials", "Coordinate Geometry", "Linear Equations in Two Variables",
27
+ "Introduction to Euclid's Geometry", "Lines and Angles", "Triangles", "Quadrilaterals",
28
+ "Areas of Parallelograms and Triangles", "Circles", "Constructions", "Heron's Formula",
29
+ "Surface Areas and Volumes", "Statistics", "Probability", "Real Numbers",
30
+ "Pair of Linear Equations in Two Variables", "Quadratic Equations", "Arithmetic Progressions",
31
+ "Introduction to Trigonometry", "Some Applications of Trigonometry", "Areas Related to Circles",
32
+ "Sets", "Relations and Functions", "Trigonometric Functions", "Principle of Mathematical Induction",
33
+ "Complex Numbers and Quadratic Equations", "Linear Inequalities", "Permutations and Combinations",
34
+ "Binomial Theorem", "Sequences and Series", "Straight Lines", "Conic Sections",
35
+ "Introduction to Three Dimensional Geometry", "Limits and Derivatives",
36
+ "Inverse Trigonometric Functions", "Matrices", "Determinants",
37
+ "Continuity and Differentiability", "Application of Derivatives", "Integrals",
38
+ "Application of Integrals", "Differential Equations", "Vector Algebra",
39
+ "Three Dimensional Geometry", "Linear Programming"
40
+ ],
41
+ "Physics": [
42
+ "Motion", "Force and Laws of Motion", "Gravitation", "Work and Energy", "Sound",
43
+ "Light: Reflection and Refraction", "Human Eye and Colourful World", "Electricity",
44
+ "Magnetic Effects of Electric Current", "Physical World and Measurement", "Kinematics",
45
+ "Laws of Motion", "Work, Energy and Power", "Motion of System of Particles and Rigid Body",
46
+ "Properties of Bulk Matter", "Thermodynamics", "Behaviour of Perfect Gases and Kinetic Theory",
47
+ "Oscillations and Waves", "Electrostatics", "Current Electricity",
48
+ "Magnetic Effects of Current and Magnetism", "Electromagnetic Induction and Alternating Currents",
49
+ "Electromagnetic Waves", "Optics", "Dual Nature of Radiation and Matter", "Atoms", "Nuclei",
50
+ "Semiconductor Electronics: Materials, Devices and Simple Circuits", "Vectors"
51
+ ],
52
+ "Chemistry": [
53
+ "Matter in Our Surroundings", "Is Matter Around Us Pure?", "Atoms and Molecules",
54
+ "Structure of the Atom", "Chemical Reactions and Equations", "Acids, Bases and Salts",
55
+ "Metals and Non-metals", "Carbon and Its Compounds", "Periodic Classification of Elements",
56
+ "Some Basic Concepts of Chemistry", "Structure of Atom",
57
+ "Classification of Elements and Periodicity in Properties",
58
+ "Chemical Bonding and Molecular Structure", "States of Matter: Gases and Liquids",
59
+ "Thermodynamics", "Equilibrium", "Redox Reactions",
60
+ "Organic Chemistry: Some Basic Principles and Techniques", "Hydrocarbons",
61
+ "Environmental Chemistry", "Solid State", "Solutions", "Electrochemistry",
62
+ "Chemical Kinetics", "Surface Chemistry", "General Principles and Processes of Isolation of Elements",
63
+ "p-Block Elements", "d- and f-Block Elements", "Coordination Compounds",
64
+ "Haloalkanes and Haloarenes", "Alcohols, Phenols and Ethers",
65
+ "Aldehydes, Ketones and Carboxylic Acids", "Amines", "Biomolecules", "Polymers",
66
+ "Chemistry in Everyday Life"
67
+ ],
68
+ "Organic Chemistry": [
69
+ "Organic Chemistry: Some Basic Principles and Techniques", "Hydrocarbons",
70
+ "Haloalkanes and Haloarenes", "Alcohols, Phenols and Ethers",
71
+ "Aldehydes, Ketones and Carboxylic Acids", "Amines", "Biomolecules",
72
+ "Polymers", "Chemistry in Everyday Life"
73
+ ],
74
+ "Inorganic Chemistry": [
75
+ "Classification of Elements and Periodicity in Properties",
76
+ "Chemical Bonding and Molecular Structure", "Redox Reactions",
77
+ "p-Block Elements", "d- and f-Block Elements", "Coordination Compounds"
78
+ ]
79
+ }
80
+
81
+ # Create a flat mapping of misspelled/approximate chapter names to correct ones
82
+ CHAPTER_MAPPING = {}
83
+ for subject, chapters in SUBJECT_CHAPTERS.items():
84
+ for chapter in chapters:
85
+ # Add the correct chapter name
86
+ CHAPTER_MAPPING[chapter.lower()] = (subject, chapter)
87
+
88
+ # Add common misspellings/variations
89
+ if chapter.lower() == "thermodynamics":
90
+ CHAPTER_MAPPING["termodyanamics"] = (subject, chapter)
91
+ CHAPTER_MAPPING["termodyn"] = (subject, chapter)
92
+ CHAPTER_MAPPING["thermo"] = (subject, chapter)
93
+ CHAPTER_MAPPING["thermodynamic"] = (subject, chapter)
94
+
95
+
96
+ class UserInput(BaseModel):
97
+ message: str
98
+ session_id: str
99
+
100
+
101
+ class SessionState(BaseModel):
102
+ params: Dict[str, str] = {
103
+ "chapters_of_the_test": "",
104
+ "questions_per_chapter": "",
105
+ "difficulty_distribution": "",
106
+ "test_duration": "",
107
+ "test_date": "",
108
+ "test_time": ""
109
+ }
110
+ completed: bool = False
111
+ attempt_count: int = 0
112
+
113
+
114
+ # In-memory session storage
115
+ sessions = {}
116
+
117
+
118
+ def normalize_chapter_name(chapter_input: str) -> Optional[Tuple[str, str]]:
119
+ """
120
+ Maps user input to standardized chapter names from the curriculum.
121
+ Returns tuple of (subject, correct_chapter_name) or None if no match.
122
+ """
123
+ if not chapter_input:
124
+ return None
125
+
126
+ # Direct mapping for exact matches or known misspellings
127
+ norm_input = chapter_input.lower().strip()
128
+ if norm_input in CHAPTER_MAPPING:
129
+ return CHAPTER_MAPPING[norm_input]
130
+
131
+ # Try fuzzy matching if no direct match
132
+ # Look for partial matches
133
+ for chapter_key, (subject, correct_name) in CHAPTER_MAPPING.items():
134
+ if norm_input in chapter_key or chapter_key in norm_input:
135
+ return (subject, correct_name)
136
+
137
+ # No match found
138
+ return None
139
+
140
+
141
+ async def llm_extractParams(user_input: str, current_params: Dict[str, str]) -> Dict[str, str]:
142
+ """
143
+ Extracts structured test parameters from natural language input
144
+ and updates the provided params dictionary.
145
+ """
146
+ system_prompt = """
147
+ You are an expert educational test creation assistant that extracts test setup parameters from user input.
148
+ Extract ONLY the parameters explicitly mentioned in the user's message.
149
+
150
+ Return a JSON object with all the following keys:
151
+ - chapters_of_the_test (string: list of chapters or topics)
152
+ - questions_per_chapter (string or number: how many questions per chapter)
153
+ - difficulty_distribution (string: e.g., "easy:40%, medium:40%, hard:20%" or any format specified)
154
+ - test_duration (string or number: time in minutes)
155
+ - test_date (string: in any reasonable date format)
156
+ - test_time (string: time of day)
157
+
158
+ Important rules:
159
+ - Do NOT make assumptions - if information isn't provided, leave as empty string ("")
160
+ - Only fill in values explicitly mentioned by the user
161
+ - For difficulty_distribution:
162
+ * Convert numeric sequences like "30 40 30" to "easy:30%, medium:40%, hard:30%" if they appear to be distributions
163
+ * Convert descriptions like "mostly hard" to approximate percentages (e.g., "easy:20%, medium:20%, hard:60%")
164
+ * Accept formats like "60 easy, 20 medium, 20 hard" and convert to percentages
165
+ - Return valid JSON with all keys, even if empty
166
+ """
167
+ messages = [
168
+ {"role": "system", "content": system_prompt},
169
+ {"role": "user", "content": user_input}
170
+ ]
171
+
172
+ try:
173
+ response = openai.chat.completions.create(
174
+ model="gpt-4o-mini",
175
+ messages=messages,
176
+ temperature=0.2
177
+ )
178
+
179
+ extracted_json = response.choices[0].message.content.strip()
180
+
181
+ # Handle potential JSON formatting issues by extracting JSON from response
182
+ if not extracted_json.startswith('{'):
183
+ # Find JSON object in text if it's not a clean JSON response
184
+ start_idx = extracted_json.find('{')
185
+ end_idx = extracted_json.rfind('}') + 1
186
+ if start_idx >= 0 and end_idx > start_idx:
187
+ extracted_json = extracted_json[start_idx:end_idx]
188
+ else:
189
+ raise ValueError("Unable to extract valid JSON from response")
190
+
191
+ # Parse and update the current_params safely
192
+ extracted_dict = json.loads(extracted_json)
193
+ updated_params = current_params.copy()
194
+
195
+ for key in updated_params:
196
+ if key.lower() in extracted_dict and extracted_dict[key.lower()]:
197
+ updated_params[key] = extracted_dict[key.lower()]
198
+ elif key in extracted_dict and extracted_dict[key]:
199
+ updated_params[key] = extracted_dict[key]
200
+
201
+ # Apply chapter mapping if chapters were specified
202
+ if updated_params["chapters_of_the_test"] and updated_params["chapters_of_the_test"] != current_params["chapters_of_the_test"]:
203
+ chapters_input = updated_params["chapters_of_the_test"]
204
+ # Split multiple chapters if comma-separated
205
+ chapter_list = [ch.strip() for ch in re.split(r',|;', chapters_input)]
206
+
207
+ mapped_chapters = []
208
+ for chapter in chapter_list:
209
+ result = normalize_chapter_name(chapter)
210
+ if result:
211
+ subject, correct_name = result
212
+ mapped_chapters.append(f"{correct_name} ({subject})")
213
+ else:
214
+ mapped_chapters.append(chapter) # Keep as-is if no mapping found
215
+
216
+ updated_params["chapters_of_the_test"] = ", ".join(mapped_chapters)
217
+
218
+ return updated_params
219
+
220
+ except json.JSONDecodeError as e:
221
+ print(f"Error: Could not parse response as JSON: {e}")
222
+ return current_params
223
+ except Exception as e:
224
+ print(f"Error during parameter extraction: {e}")
225
+ return current_params
226
+
227
+
228
+ def gate(params: Dict[str, str]) -> List[str]:
229
+ """
230
+ Checks which fields are still empty in the params.
231
+ Returns a list of missing parameter keys.
232
+ """
233
+ return [key for key, val in params.items() if not val]
234
+
235
+
236
+ async def llm_getMissingParams(missing_keys: List[str]) -> str:
237
+ """
238
+ Generates a human-readable prompt to ask user for missing fields.
239
+ """
240
+ # Create context-aware prompts for specific missing fields
241
+ context_details = {
242
+ "chapters_of_the_test": "such as Math, Science, History, etc.",
243
+ "questions_per_chapter": "the number of questions for each chapter",
244
+ "difficulty_distribution": "as percentages or numbers (easy, medium, hard)",
245
+ "test_duration": "in minutes",
246
+ "test_date": "when the test will be given",
247
+ "test_time": "the time of day for the test"
248
+ }
249
+
250
+ # Create a more specific prompt based on what's missing
251
+ if len(missing_keys) == 1:
252
+ key = missing_keys[0]
253
+ prompt = f"Please provide the {key.replace('_', ' ')} {context_details.get(key, '')}."
254
+ else:
255
+ formatted_missing = [f"{key.replace('_', ' ')} ({context_details.get(key, '')})" for key in missing_keys]
256
+ prompt = f"The following test details are still needed: {', '.join(formatted_missing)}."
257
+
258
+ messages = [
259
+ {"role": "system", "content": "You are a helpful assistant who creates clear, concise questions to collect missing test setup information. Keep your response under 2 sentences and focus only on what's missing."},
260
+ {"role": "user", "content": prompt}
261
+ ]
262
+
263
+ try:
264
+ response = openai.chat.completions.create(
265
+ model="gpt-4o-mini",
266
+ messages=messages,
267
+ temperature=0.3
268
+ )
269
+ return response.choices[0].message.content.strip()
270
+ except Exception as e:
271
+ print(f"Error generating prompt for missing values: {e}")
272
+ return f"Please provide the following missing information: {', '.join(missing_keys)}."
273
+
274
+
275
+ @app.on_event("startup")
276
+ async def startup_event():
277
+ # Set up OpenAI API key from environment variable
278
+ openai.api_key = os.getenv("OPENAI_API_KEY")
279
+ if not openai.api_key:
280
+ print("โš ๏ธ WARNING: OPENAI_API_KEY environment variable not set.")
281
+
282
+
283
+ @app.get("/")
284
+ async def root():
285
+ return {"message": "Test Creation Agent API is running"}
286
+
287
+
288
+ @app.post("/chat")
289
+ async def chat(user_input: UserInput):
290
+ session_id = user_input.session_id
291
+
292
+ # Initialize session if it doesn't exist
293
+ if session_id not in sessions:
294
+ sessions[session_id] = SessionState()
295
+
296
+ session = sessions[session_id]
297
+
298
+ # If this is the first message, send a welcome message
299
+ if session.attempt_count == 0:
300
+ session.attempt_count += 1
301
+ return {
302
+ "response": "๐Ÿ‘‹ Welcome! Please provide the test setup details. I need: chapters, questions per chapter, difficulty distribution, test duration, date, and time.",
303
+ "session_state": {
304
+ "params": session.params,
305
+ "completed": False
306
+ }
307
+ }
308
+
309
+ # Process user input to extract parameters
310
+ session.params = await llm_extractParams(user_input.message, session.params)
311
+ session.attempt_count += 1
312
+
313
+ # Check if we have all required parameters
314
+ missing = gate(session.params)
315
+
316
+ # If we have all parameters or exceeded max attempts, return completion
317
+ max_attempts = 10
318
+ if not missing or session.attempt_count > max_attempts:
319
+ session.completed = True
320
+ if not missing:
321
+ result = "โœ… All test parameters are now complete:"
322
+ else:
323
+ result = "โš ๏ธ Some parameters could not be filled after multiple attempts:"
324
+
325
+ # Format the parameters as a readable string
326
+ for k, v in session.params.items():
327
+ result += f"\n- {k.replace('_', ' ').title()}: {v or 'Not provided'}"
328
+
329
+ return {
330
+ "response": result,
331
+ "session_state": {
332
+ "params": session.params,
333
+ "completed": True
334
+ }
335
+ }
336
+
337
+ # Otherwise, ask for missing parameters
338
+ follow_up_prompt = await llm_getMissingParams(missing)
339
+
340
+ return {
341
+ "response": follow_up_prompt,
342
+ "session_state": {
343
+ "params": session.params,
344
+ "completed": False
345
+ }
346
+ }
347
+
348
+
349
+ @app.get("/session/{session_id}")
350
+ async def get_session(session_id: str):
351
+ if session_id not in sessions:
352
+ raise HTTPException(status_code=404, detail="Session not found")
353
+
354
+ session = sessions[session_id]
355
+ return {
356
+ "params": session.params,
357
+ "completed": session.completed,
358
+ "attempt_count": session.attempt_count
359
+ }
360
+
361
+
362
+ @app.delete("/session/{session_id}")
363
+ async def delete_session(session_id: str):
364
+ if session_id in sessions:
365
+ del sessions[session_id]
366
+ return {"message": "Session deleted successfully"}
367
+
368
+
369
+ @app.post("/reset")
370
+ async def reset_session(user_input: UserInput):
371
+ session_id = user_input.session_id
372
+ sessions[session_id] = SessionState()
373
+
374
+ return {
375
+ "response": "Session reset. ๐Ÿ‘‹ Welcome! Please provide the test setup details. I need: chapters, questions per chapter, difficulty distribution, test duration, date, and time.",
376
+ "session_state": {
377
+ "params": sessions[session_id].params,
378
+ "completed": False
379
+ }
380
+ }
381
+
382
+
383
+ if __name__ == "__main__":
384
+ import uvicorn
385
+ uvicorn.run("app:app", host="0.0.0.0", port=int(os.getenv("PORT", 8000)), reload=True)