Rakshitjan commited on
Commit
39fdb35
·
verified ·
1 Parent(s): 9dd7d56

Update app.py

Browse files
Files changed (1) hide show
  1. app.py +491 -142
app.py CHANGED
@@ -1,3 +1,389 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
  from fastapi import FastAPI, HTTPException, Request
2
  from fastapi.middleware.cors import CORSMiddleware
3
  from fastapi.responses import JSONResponse
@@ -7,17 +393,22 @@ 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
@@ -82,9 +473,7 @@ SUBJECT_CHAPTERS = {
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)
@@ -92,11 +481,9 @@ for subject, chapters in SUBJECT_CHAPTERS.items():
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] = {
@@ -109,44 +496,41 @@ class SessionState(BaseModel):
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)
@@ -154,7 +538,6 @@ Return a JSON object with all the following keys:
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
@@ -170,74 +553,60 @@ Important rules:
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",
@@ -247,139 +616,119 @@ async def llm_getMissingParams(missing_keys: List[str]) -> str:
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)
 
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)
386
+
387
  from fastapi import FastAPI, HTTPException, Request
388
  from fastapi.middleware.cors import CORSMiddleware
389
  from fastapi.responses import JSONResponse
 
393
  import json
394
  import re
395
  from typing import Dict, List, Optional, Tuple, Any
396
+ import uuid
397
+ from datetime import datetime, timedelta
398
 
399
+ app = FastAPI(
400
+ title="TestCreationAgent",
401
+ description="An API for collecting test creation parameters through conversation",
402
+ version="1.0.0"
403
+ )
404
 
405
  # Add CORS middleware to allow requests from frontend
406
  app.add_middleware(
407
  CORSMiddleware,
408
+ allow_origins=["*"],
409
  allow_credentials=True,
410
+ allow_methods=["*"],
411
+ allow_headers=["*"],
412
  )
413
 
414
  # Define subject chapters mapping
 
473
  CHAPTER_MAPPING = {}
474
  for subject, chapters in SUBJECT_CHAPTERS.items():
475
  for chapter in chapters:
 
476
  CHAPTER_MAPPING[chapter.lower()] = (subject, chapter)
 
477
  # Add common misspellings/variations
478
  if chapter.lower() == "thermodynamics":
479
  CHAPTER_MAPPING["termodyanamics"] = (subject, chapter)
 
481
  CHAPTER_MAPPING["thermo"] = (subject, chapter)
482
  CHAPTER_MAPPING["thermodynamic"] = (subject, chapter)
483
 
 
484
  class UserInput(BaseModel):
485
  message: str
486
+ session_id: Optional[str] = None
 
487
 
488
  class SessionState(BaseModel):
489
  params: Dict[str, str] = {
 
496
  }
497
  completed: bool = False
498
  attempt_count: int = 0
499
+ created_at: datetime = datetime.utcnow()
500
+ last_accessed: datetime = datetime.utcnow()
501
 
502
+ # In-memory session storage with automatic cleanup
503
+ sessions: Dict[str, SessionState] = {}
 
 
504
 
505
  def normalize_chapter_name(chapter_input: str) -> Optional[Tuple[str, str]]:
506
+ """Maps user input to standardized chapter names from the curriculum."""
 
 
 
507
  if not chapter_input:
508
  return None
509
 
 
510
  norm_input = chapter_input.lower().strip()
511
  if norm_input in CHAPTER_MAPPING:
512
  return CHAPTER_MAPPING[norm_input]
513
 
514
  # Try fuzzy matching if no direct match
 
515
  for chapter_key, (subject, correct_name) in CHAPTER_MAPPING.items():
516
  if norm_input in chapter_key or chapter_key in norm_input:
517
  return (subject, correct_name)
518
 
 
519
  return None
520
 
521
+ async def cleanup_sessions():
522
+ """Remove sessions older than 24 hours"""
523
+ now = datetime.utcnow()
524
+ expired = [sid for sid, session in sessions.items()
525
+ if now - session.last_accessed > timedelta(hours=24)]
526
+ for sid in expired:
527
+ del sessions[sid]
528
 
529
+ async def llm_extract_params(user_input: str, current_params: Dict[str, str]) -> Dict[str, str]:
530
+ """Extracts structured test parameters from natural language input."""
 
 
 
531
  system_prompt = """
532
  You are an expert educational test creation assistant that extracts test setup parameters from user input.
533
  Extract ONLY the parameters explicitly mentioned in the user's message.
 
534
  Return a JSON object with all the following keys:
535
  - chapters_of_the_test (string: list of chapters or topics)
536
  - questions_per_chapter (string or number: how many questions per chapter)
 
538
  - test_duration (string or number: time in minutes)
539
  - test_date (string: in any reasonable date format)
540
  - test_time (string: time of day)
 
541
  Important rules:
542
  - Do NOT make assumptions - if information isn't provided, leave as empty string ("")
543
  - Only fill in values explicitly mentioned by the user
 
553
  ]
554
 
555
  try:
556
+ response = openai.ChatCompletion.create(
557
  model="gpt-4o-mini",
558
  messages=messages,
559
  temperature=0.2
560
  )
561
 
562
  extracted_json = response.choices[0].message.content.strip()
563
+
564
+ # Safely parse the JSON response
565
+ try:
566
+ extracted_dict = json.loads(extracted_json)
567
+ except json.JSONDecodeError:
568
+ # Try to extract JSON from malformed response
569
+ start = extracted_json.find('{')
570
+ end = extracted_json.rfind('}') + 1
571
+ if start >= 0 and end > start:
572
+ extracted_dict = json.loads(extracted_json[start:end])
573
  else:
574
+ raise ValueError("Invalid JSON response from LLM")
575
 
 
 
576
  updated_params = current_params.copy()
577
 
578
  for key in updated_params:
579
+ if key in extracted_dict and extracted_dict[key]:
580
+ updated_params[key] = str(extracted_dict[key])
 
 
581
 
582
  # Apply chapter mapping if chapters were specified
583
  if updated_params["chapters_of_the_test"] and updated_params["chapters_of_the_test"] != current_params["chapters_of_the_test"]:
584
  chapters_input = updated_params["chapters_of_the_test"]
585
+ chapter_list = [ch.strip() for ch in re.split(r'[,;]', chapters_input)]
 
 
586
  mapped_chapters = []
587
+
588
  for chapter in chapter_list:
589
  result = normalize_chapter_name(chapter)
590
  if result:
591
  subject, correct_name = result
592
  mapped_chapters.append(f"{correct_name} ({subject})")
593
  else:
594
+ mapped_chapters.append(chapter)
595
 
596
  updated_params["chapters_of_the_test"] = ", ".join(mapped_chapters)
597
 
598
  return updated_params
599
 
 
 
 
600
  except Exception as e:
601
+ print(f"Error during parameter extraction: {str(e)}")
602
  return current_params
603
 
604
+ def get_missing_params(params: Dict[str, str]) -> List[str]:
605
+ """Returns list of keys with empty values."""
 
 
 
 
606
  return [key for key, val in params.items() if not val]
607
 
608
+ async def llm_generate_prompt(missing_keys: List[str]) -> str:
609
+ """Generates a human-readable prompt to ask user for missing fields."""
 
 
 
 
610
  context_details = {
611
  "chapters_of_the_test": "such as Math, Science, History, etc.",
612
  "questions_per_chapter": "the number of questions for each chapter",
 
616
  "test_time": "the time of day for the test"
617
  }
618
 
 
619
  if len(missing_keys) == 1:
620
  key = missing_keys[0]
621
+ return f"Please provide the {key.replace('_', ' ')} {context_details.get(key, '')}."
622
  else:
623
+ formatted_missing = [f"{key.replace('_', ' ')} ({context_details.get(key, '')})"
624
+ for key in missing_keys]
625
+ return f"Please provide: {', '.join(formatted_missing)}."
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
626
 
627
  @app.on_event("startup")
628
  async def startup_event():
629
+ """Initialize the application."""
630
  openai.api_key = os.getenv("OPENAI_API_KEY")
631
  if not openai.api_key:
632
+ raise RuntimeError("OPENAI_API_KEY environment variable not set")
 
633
 
634
  @app.get("/")
635
+ async def health_check():
636
+ """Health check endpoint."""
637
+ return {"status": "healthy", "timestamp": datetime.utcnow().isoformat()}
638
 
639
  @app.post("/chat")
640
  async def chat(user_input: UserInput):
641
+ """Main chat endpoint for test parameter collection."""
642
+ await cleanup_sessions()
643
 
644
+ # Create new session if none provided
645
+ if not user_input.session_id or user_input.session_id not in sessions:
646
+ session_id = str(uuid.uuid4())
647
  sessions[session_id] = SessionState()
648
+ else:
649
+ session_id = user_input.session_id
650
 
651
  session = sessions[session_id]
652
+ session.last_accessed = datetime.utcnow()
653
 
654
+ # Initial welcome message
655
  if session.attempt_count == 0:
656
  session.attempt_count += 1
657
  return {
658
+ "response": "👋 Welcome! Let's set up your test. Please provide: chapters, questions per chapter, difficulty, duration, date, and time.",
659
+ "session_id": session_id,
660
+ "session_state": session.dict(),
661
+ "completed": False
 
662
  }
663
 
664
+ # Process user input
665
+ session.params = await llm_extract_params(user_input.message, session.params)
666
  session.attempt_count += 1
667
 
668
+ # Check for completion
669
+ missing = get_missing_params(session.params)
670
+ max_attempts = 8
671
 
672
+ if not missing or session.attempt_count >= max_attempts:
 
 
673
  session.completed = True
674
+ response = ["✅ Test setup complete:" if not missing else "⚠️ Partial information collected:"]
 
 
 
675
 
 
676
  for k, v in session.params.items():
677
+ response.append(f"- {k.replace('_', ' ').title()}: {v or 'Not provided'}")
678
 
679
  return {
680
+ "response": "\n".join(response),
681
+ "session_id": session_id,
682
+ "session_state": session.dict(),
683
+ "completed": True
 
684
  }
685
 
686
+ # Ask for missing information
687
+ follow_up = await llm_generate_prompt(missing)
688
 
689
  return {
690
+ "response": follow_up,
691
+ "session_id": session_id,
692
+ "session_state": session.dict(),
693
+ "completed": False
 
694
  }
695
 
 
696
  @app.get("/session/{session_id}")
697
  async def get_session(session_id: str):
698
+ """Retrieve session state."""
699
+ await cleanup_sessions()
700
+
701
  if session_id not in sessions:
702
  raise HTTPException(status_code=404, detail="Session not found")
703
 
704
+ sessions[session_id].last_accessed = datetime.utcnow()
705
  return {
706
+ "session_state": sessions[session_id].dict(),
707
+ "completed": sessions[session_id].completed
 
708
  }
709
 
 
710
  @app.delete("/session/{session_id}")
711
  async def delete_session(session_id: str):
712
+ """Delete a session."""
713
  if session_id in sessions:
714
  del sessions[session_id]
715
+ return {"message": "Session deleted"}
 
716
 
717
  @app.post("/reset")
718
  async def reset_session(user_input: UserInput):
719
+ """Reset a session."""
720
+ if not user_input.session_id or user_input.session_id not in sessions:
721
+ raise HTTPException(status_code=400, detail="Invalid session ID")
722
 
723
+ sessions[user_input.session_id] = SessionState()
724
  return {
725
+ "response": "Session reset. Please provide test details.",
726
+ "session_id": user_input.session_id,
727
+ "session_state": sessions[user_input.session_id].dict(),
728
+ "completed": False
 
729
  }
730
 
731
+ # For Hugging Face Spaces deployment
732
  if __name__ == "__main__":
733
  import uvicorn
734
+ uvicorn.run(app, host="0.0.0.0", port=int(os.getenv("PORT", 8000)))