YazeedBinShihah commited on
Commit
aa8fa0a
·
verified ·
1 Parent(s): 1a32ee7

Update smart_tutor_core.py

Browse files
Files changed (1) hide show
  1. smart_tutor_core.py +676 -676
smart_tutor_core.py CHANGED
@@ -1,676 +1,676 @@
1
- import os, json, re, random
2
- import uuid
3
- import time
4
- import logging
5
- from typing import Literal, List, Dict, Any, Optional
6
-
7
- from pydantic import BaseModel, Field, ValidationError
8
- from crewai import Agent, Task, Crew, Process
9
- from crewai.tools import tool
10
- from crewai.llm import LLM
11
-
12
- import dotenv
13
-
14
- dotenv.load_dotenv("api_key.env")
15
-
16
- # ============================================================
17
- # Guardrails: logging, retries, deterministic config
18
- # ============================================================
19
-
20
- logging.basicConfig(
21
- level=logging.INFO,
22
- format="%(asctime)s | %(levelname)s | %(message)s",
23
- )
24
- logger = logging.getLogger("smart_tutor_guardrails")
25
-
26
- DETERMINISTIC_TEMPERATURE = float(os.getenv("DETERMINISTIC_TEMPERATURE", "0.1"))
27
- TOOL_MAX_RETRIES = int(os.getenv("TOOL_MAX_RETRIES", "2"))
28
-
29
- # ============================================================
30
- # Guardrails: rate limits / timeouts / policies
31
- # ============================================================
32
-
33
- MAX_FILE_SIZE_MB = int(os.getenv("MAX_FILE_SIZE_MB", "500"))
34
- MAX_PDF_PAGES = int(os.getenv("MAX_PDF_PAGES", "2000"))
35
- PDF_EXTRACTION_TIMEOUT = float(os.getenv("PDF_EXTRACTION_TIMEOUT", "200")) # seconds
36
-
37
- ALLOWED_TOOLS = {"process_file", "store_quiz", "grade_quiz"}
38
-
39
- PROMPT_INJECTION_PATTERNS = [
40
- "ignore previous instructions",
41
- "ignore all previous instructions",
42
- "system:",
43
- "assistant:",
44
- "developer:",
45
- "act as",
46
- "you must",
47
- "follow these instructions",
48
- "override",
49
- ]
50
-
51
- # ============================================================
52
- # Helpers
53
- # ============================================================
54
-
55
-
56
- def clean_text(text: str) -> str:
57
- text = text.replace("\x00", " ")
58
- text = re.sub(r"[ \t]+", " ", text)
59
- text = re.sub(r"\n{3,}", "\n\n", text)
60
- return text.strip()
61
-
62
-
63
- def detect_prompt_injection(text: str) -> bool:
64
- lower = text.lower()
65
- return any(p in lower for p in PROMPT_INJECTION_PATTERNS)
66
-
67
-
68
- def chunk_text(text: str, max_chars: int = 1200, overlap: int = 150) -> List[str]:
69
- text = clean_text(text)
70
- if not text:
71
- return []
72
- chunks = []
73
- start = 0
74
- n = len(text)
75
- while start < n:
76
- end = min(start + max_chars, n)
77
- part = text[start:end].strip()
78
- if part:
79
- chunks.append(part)
80
- if end == n:
81
- break
82
- start = max(0, end - overlap)
83
- return chunks
84
-
85
-
86
- def keyword_retrieve(chunks: List[str], query: str, top_k: int) -> List[str]:
87
- q_terms = [w for w in re.findall(r"\w+", query.lower()) if len(w) > 2]
88
-
89
- def score(c: str) -> int:
90
- c_l = c.lower()
91
- return sum(1 for t in q_terms if t in c_l)
92
-
93
- ranked = sorted(chunks, key=score, reverse=True)
94
- return [c for c in ranked[:top_k] if c]
95
-
96
-
97
- # ============================================================
98
- # File extraction with limits + timeout
99
- # ============================================================
100
-
101
-
102
- def extract_text(file_path: str) -> str:
103
- if os.path.getsize(file_path) > MAX_FILE_SIZE_MB * 1024 * 1024:
104
- raise ValueError(f"File too large (> {MAX_FILE_SIZE_MB} MB)")
105
-
106
- ext = os.path.splitext(file_path)[1].lower()
107
-
108
- if ext == ".txt":
109
- with open(file_path, "r", encoding="utf-8", errors="ignore") as f:
110
- return f.read()
111
-
112
- if ext == ".pdf":
113
- import fitz # PyMuPDF
114
-
115
- start_time = time.time()
116
- doc = fitz.open(file_path)
117
-
118
- if len(doc) > MAX_PDF_PAGES:
119
- raise ValueError(f"PDF exceeds max page limit ({MAX_PDF_PAGES})")
120
-
121
- parts = []
122
- for i in range(len(doc)):
123
- if time.time() - start_time > PDF_EXTRACTION_TIMEOUT:
124
- raise TimeoutError("PDF extraction timeout")
125
- t = doc.load_page(i).get_text("text") or ""
126
- t = clean_text(t)
127
- if t:
128
- parts.append(t)
129
- return "\n\n".join(parts).strip()
130
-
131
- raise ValueError("Unsupported file type (PDF/TXT only).")
132
-
133
-
134
- # ============================================================
135
- # Schemas (Structured Inputs / Outputs)
136
- # ============================================================
137
-
138
-
139
- class ProcessArgs(BaseModel):
140
- file_path: str = Field(..., description="Local path to PDF/TXT")
141
- query: str = Field(..., description="User question or instruction")
142
- mode: Literal["summarize", "quiz", "explain"] = Field(..., description="Task type")
143
- top_k: int = Field(6, ge=1, le=15, description="How many chunks to use as context")
144
-
145
-
146
- class QuizQuestion(BaseModel):
147
- qid: str
148
- question: str
149
- options: Dict[Literal["A", "B", "C", "D"], str]
150
- correct: Literal["A", "B", "C", "D"]
151
- explanation: str = ""
152
- supporting_context: str = ""
153
-
154
-
155
- class StoreQuizArgs(BaseModel):
156
- file_path: str = Field(
157
- ..., description="The absolute file path of the document used"
158
- )
159
- questions: List[QuizQuestion]
160
-
161
-
162
- class GradeQuizArgs(BaseModel):
163
- quiz_id: str
164
- answers: Dict[str, Literal["A", "B", "C", "D"]]
165
-
166
-
167
- class ToolError(BaseModel):
168
- error: str
169
- details: Optional[Any] = None
170
-
171
-
172
- class ProcessFileResult(BaseModel):
173
- mode: str
174
- query: str
175
- context_chunks: List[str]
176
- stats: Dict[str, Any]
177
-
178
-
179
- class StoreQuizResult(BaseModel):
180
- quiz_id: str
181
- questions: List[Dict[str, Any]] # masked questions
182
-
183
-
184
- class GradeQuizResult(BaseModel):
185
- quiz_id: str
186
- score: int
187
- total: int
188
- percentage: float
189
- file_path: Optional[str] = None
190
- details: List[Dict[str, Any]]
191
-
192
-
193
- # ============================================================
194
- # Memory/State with Persistence
195
- # ============================================================
196
-
197
- QUIZ_FILE = "quizzes_db.json"
198
-
199
-
200
- def load_quizzes():
201
- if os.path.exists(QUIZ_FILE):
202
- try:
203
- with open(QUIZ_FILE, "r", encoding="utf-8") as f:
204
- return json.load(f)
205
- except:
206
- return {}
207
- return {}
208
-
209
-
210
- def save_quizzes(data):
211
- try:
212
- with open(QUIZ_FILE, "w", encoding="utf-8") as f:
213
- json.dump(data, f, ensure_ascii=False, indent=2)
214
- except Exception as e:
215
- logger.error(f"Failed to save quizzes: {e}")
216
-
217
-
218
- QUIZ_STORE: Dict[str, Dict[str, Any]] = load_quizzes()
219
-
220
-
221
- # ============================================================
222
- # Tool wrapper: retries + logs + redaction
223
- # ============================================================
224
-
225
-
226
- def _redact(obj: Any) -> Any:
227
- """Redact secrets + quiz answer key in logs."""
228
- try:
229
- if isinstance(obj, dict):
230
- out = {}
231
- for k, v in obj.items():
232
- lk = str(k).lower()
233
- if lk in {"openai_api_key", "api_key", "authorization", "x-api-key"}:
234
- out[k] = "***"
235
- elif lk == "correct":
236
- out[k] = "***"
237
- else:
238
- out[k] = _redact(v)
239
- return out
240
- if isinstance(obj, list):
241
- return [_redact(x) for x in obj]
242
- if isinstance(obj, str):
243
- key = os.getenv("OPENAI_API_KEY") or ""
244
- if key and key in obj:
245
- return obj.replace(key, "***")
246
- return obj
247
- return obj
248
- except Exception:
249
- return "<redacted>"
250
-
251
-
252
- def safe_tool_call(tool_name: str, fn):
253
- if tool_name not in ALLOWED_TOOLS:
254
- raise RuntimeError("Tool not allowed by policy")
255
-
256
- last_err = None
257
- for attempt in range(1, TOOL_MAX_RETRIES + 2):
258
- try:
259
- logger.info(f"[TOOL_CALL] {tool_name} attempt={attempt}")
260
- out = fn()
261
- logger.info(
262
- f"[TOOL_RESULT] {tool_name} attempt={attempt} out={json.dumps(_redact(out), ensure_ascii=False)[:900]}"
263
- )
264
- return out
265
- except Exception as e:
266
- last_err = e
267
- logger.warning(
268
- f"[TOOL_ERROR] {tool_name} attempt={attempt} err={type(e).__name__}"
269
- )
270
- time.sleep(0.2 * attempt)
271
- raise last_err
272
-
273
-
274
- # ============================================================
275
- # Tools
276
- # ============================================================
277
-
278
-
279
- @tool("process_file")
280
- def process_file(file_path: str, query: str, mode: str, top_k: int = 6) -> str:
281
- """Read PDF/TXT, chunk it, retrieve top_k relevant chunks. Returns structured JSON."""
282
- try:
283
- args = ProcessArgs(file_path=file_path, query=query, mode=mode, top_k=top_k)
284
- except ValidationError as ve:
285
- return json.dumps(
286
- ToolError(error="Invalid arguments", details=ve.errors()).model_dump(),
287
- ensure_ascii=False,
288
- )
289
-
290
- def _run():
291
- # Clean path: remove quotes and whitespace that agents sometimes add
292
- clean_path = args.file_path.strip().strip("'\"").strip()
293
- if not os.path.exists(clean_path):
294
- return ToolError(error=f"Invalid file path: {clean_path}").model_dump()
295
-
296
- try:
297
- raw_text = extract_text(args.file_path)
298
- except Exception as e:
299
- return ToolError(
300
- error="Extraction failed", details=type(e).__name__
301
- ).model_dump()
302
-
303
- if detect_prompt_injection(raw_text):
304
- logger.warning(
305
- "[SECURITY] Potential prompt injection detected in document. Treating as data only."
306
- )
307
-
308
- text = clean_text(raw_text)
309
- if not text:
310
- return ToolError(error="Empty or unreadable file text.").model_dump()
311
-
312
- chunks = chunk_text(text)
313
- if not chunks:
314
- return ToolError(error="No chunks produced.").model_dump()
315
-
316
- context = keyword_retrieve(chunks, args.query, args.top_k)
317
-
318
- return ProcessFileResult(
319
- mode=args.mode,
320
- query=args.query,
321
- context_chunks=context,
322
- stats={
323
- "chunks_total": len(chunks),
324
- "chars_extracted": len(text),
325
- "top_k": args.top_k,
326
- },
327
- ).model_dump()
328
-
329
- try:
330
- out = safe_tool_call("process_file", _run)
331
- return json.dumps(out, ensure_ascii=False)
332
- except Exception as e:
333
- return json.dumps(
334
- ToolError(
335
- error="process_file failed", details=type(e).__name__
336
- ).model_dump(),
337
- ensure_ascii=False,
338
- )
339
-
340
-
341
- def clean_json_input(text: str) -> str:
342
- """Clean markdown code blocks and extract JSON object from string."""
343
- text = text.strip()
344
-
345
- # Remove markdown code blocks (flexible)
346
- # This handles ```json ... ``` even if there is text before/after
347
- pattern = r"```(?:json)?\s*(\{.*?\})\s*```"
348
- match = re.search(pattern, text, re.DOTALL)
349
- if match:
350
- return match.group(1)
351
-
352
- # If no code blocks, try to find the first outer-most JSON object
353
- # This regex looks for { ... } minimally or greedily?
354
- # We want the largest block starting with { and ending with }
355
- # but strictly speaking, standard json.loads might just work if we strip.
356
-
357
- # If text starts with ``` but didn't match the block above (maybe incomplete),
358
- # let's just strip the fences.
359
- if text.startswith("```"):
360
- text = re.sub(r"^```(\w+)?\n?", "", text)
361
- text = re.sub(r"\n?```$", "", text)
362
-
363
- # Remove single backticks
364
- if text.startswith("`") and text.endswith("`"):
365
- text = text.strip("`")
366
-
367
- return text.strip()
368
-
369
-
370
- @tool("store_quiz")
371
- def store_quiz(quiz_package_json: str) -> str:
372
- """Store quiz with hidden answers; return masked quiz (no correct answers)."""
373
-
374
- def _run():
375
- try:
376
- cleaned_json = clean_json_input(quiz_package_json)
377
- # First try: direct parse
378
- pkg_raw = json.loads(cleaned_json)
379
- except json.JSONDecodeError:
380
- # Second try: liberal regex search for { ... }
381
- # Use dotall and greedy to capture nested objects
382
- match = re.search(r"(\{.*\})", quiz_package_json, re.DOTALL)
383
- if match:
384
- try:
385
- pkg_raw = json.loads(match.group(1))
386
- except json.JSONDecodeError as e:
387
- return ToolError(
388
- error=f"quiz_package_json is not valid JSON. Parse error: {str(e)}",
389
- details=f"Input fragment: {quiz_package_json[:200]}...",
390
- ).model_dump()
391
- else:
392
- return ToolError(
393
- error="quiz_package_json is not valid JSON (no braces found)",
394
- details=f"Input fragment: {quiz_package_json[:200]}...",
395
- ).model_dump()
396
-
397
- try:
398
- pkg = StoreQuizArgs(**pkg_raw)
399
- except ValidationError as ve:
400
- return ToolError(
401
- error="Invalid quiz_package_json", details=ve.errors()
402
- ).model_dump()
403
-
404
- quiz_id = str(uuid.uuid4())
405
-
406
- # Randomize options for each question
407
- final_questions = []
408
- for q in pkg.questions:
409
- # q is a QuizQuestion object
410
- original_options = q.options # dict e.g. {"A": "...", "B": "..."}
411
- original_correct_key = q.correct # "A"
412
- correct_text = original_options[original_correct_key]
413
-
414
- # Extract texts
415
- option_texts = list(original_options.values())
416
- random.shuffle(option_texts)
417
-
418
- # Re-map to A, B, C, D
419
- new_options = {}
420
- new_correct_key = ""
421
- keys = ["A", "B", "C", "D"]
422
-
423
- # Handle cases with fewer than 4 options just in case
424
- for i, text in enumerate(option_texts):
425
- if i < len(keys):
426
- key = keys[i]
427
- new_options[key] = text
428
- if text == correct_text:
429
- new_correct_key = key
430
-
431
- # Update the question object (create a copy/dict)
432
- q_dump = q.model_dump()
433
- q_dump["options"] = new_options
434
- q_dump["correct"] = new_correct_key
435
- final_questions.append(q_dump)
436
-
437
- QUIZ_STORE[quiz_id] = {
438
- "file_path": pkg.file_path,
439
- "questions": final_questions,
440
- }
441
- save_quizzes(QUIZ_STORE)
442
-
443
- masked = [
444
- {"qid": q["qid"], "question": q["question"], "options": q["options"]}
445
- for q in final_questions
446
- ]
447
- return StoreQuizResult(quiz_id=quiz_id, questions=masked).model_dump()
448
-
449
- try:
450
- out = safe_tool_call("store_quiz", _run)
451
- return json.dumps(out, ensure_ascii=False)
452
- except Exception as e:
453
- return json.dumps(
454
- ToolError(error="store_quiz failed", details=type(e).__name__).model_dump(),
455
- ensure_ascii=False,
456
- )
457
-
458
-
459
- @tool("grade_quiz")
460
- def grade_quiz(quiz_id: str, answers_json: str) -> str:
461
- """Grade quiz answers by quiz_id and answers_json. Returns score + details as structured JSON.
462
- Also returns 'file_path' and 'question' text for further processing."""
463
-
464
- def _run():
465
- if quiz_id not in QUIZ_STORE:
466
- return ToolError(error="Unknown quiz_id.").model_dump()
467
-
468
- try:
469
- cleaned_json = clean_json_input(answers_json)
470
- submitted_raw = json.loads(cleaned_json)
471
- except json.JSONDecodeError:
472
- # Fallback
473
- match = re.search(r"(\{.*\})", answers_json, re.DOTALL)
474
- if match:
475
- try:
476
- submitted_raw = json.loads(match.group(1))
477
- except:
478
- return ToolError(
479
- error="answers_json is not valid JSON"
480
- ).model_dump()
481
- else:
482
- return ToolError(error="answers_json is not valid JSON").model_dump()
483
-
484
- try:
485
- args = GradeQuizArgs(quiz_id=quiz_id, answers=submitted_raw)
486
- except ValidationError as ve:
487
- return ToolError(
488
- error="Invalid answers_json", details=ve.errors()
489
- ).model_dump()
490
-
491
- stored_data = QUIZ_STORE[args.quiz_id]
492
- questions = stored_data["questions"]
493
- file_path = stored_data.get("file_path")
494
-
495
- total = len(questions)
496
- score = 0
497
- details = []
498
-
499
- for q in questions:
500
- qid = q["qid"]
501
- correct = q["correct"]
502
- question_text = q.get("question", "")
503
-
504
- your = (args.answers.get(qid) or "").strip().upper()
505
- is_correct = your == correct
506
- score += 1 if is_correct else 0
507
-
508
- details.append(
509
- {
510
- "qid": qid,
511
- "question": question_text, # Added for Agent context
512
- "is_correct": is_correct,
513
- "your_answer": your,
514
- "correct_answer": correct, # NOTE: returned to tutor; OK for feedback
515
- "explanation": q.get("explanation", "") or "",
516
- "supporting_context": q.get("supporting_context", "") or "",
517
- }
518
- )
519
-
520
- percentage = round((score / total) * 100, 2) if total else 0.0
521
-
522
- return GradeQuizResult(
523
- quiz_id=args.quiz_id,
524
- score=score,
525
- total=total,
526
- percentage=percentage,
527
- file_path=file_path,
528
- details=details,
529
- ).model_dump()
530
-
531
- try:
532
- out = safe_tool_call("grade_quiz", _run)
533
- return json.dumps(out, ensure_ascii=False)
534
- except Exception as e:
535
- return json.dumps(
536
- ToolError(error="grade_quiz failed", details=type(e).__name__).model_dump(),
537
- ensure_ascii=False,
538
- )
539
-
540
-
541
- # ============================================================
542
- # CrewAI setup
543
- # ============================================================
544
-
545
- llm = LLM(
546
- model="gpt-4o-mini",
547
- api_key=os.getenv("OPENAI_API_KEY"),
548
- temperature=DETERMINISTIC_TEMPERATURE,
549
- )
550
-
551
- manager = Agent(
552
- role="Manager (Router)",
553
- goal=(
554
- "Route user request to the correct specialist co-worker."
555
- " Pass ALL user constraints (line count, "
556
- "paragraph count, language, etc.) to the specialist."
557
- ),
558
- backstory=(
559
- "You are a routing agent. You HAVE specialist co-workers: "
560
- "Summarizer, Quiz Maker, and Tutor. "
561
- "Your ONLY job is to delegate the task to the right co-worker "
562
- "using your delegation tool. "
563
- "NEVER answer the user yourself. NEVER use internal knowledge. "
564
- "Always forward the FULL user request including any constraints."
565
- ),
566
- allow_delegation=True,
567
- llm=llm,
568
- verbose=True,
569
- )
570
-
571
- summarizer = Agent(
572
- role="Summarizer",
573
- goal=(
574
- "Produce a summary grounded strictly in "
575
- "context_chunks from process_file. STRICTLY "
576
- "follow any user constraints on length, "
577
- "number of lines, paragraphs, or format."
578
- ),
579
- backstory=(
580
- "Call process_file(mode=summarize) first. "
581
- "Summarize ONLY from context_chunks. "
582
- "If the user specifies constraints like "
583
- "'3 lines', '2 paragraphs', 'short', or "
584
- "'detailed', you MUST follow them exactly. "
585
- "Use bullet points (- or *) for lists instead of numbering. "
586
- "No outside knowledge."
587
- ),
588
- tools=[process_file],
589
- llm=llm,
590
- verbose=True,
591
- )
592
-
593
- quizzer = Agent(
594
- role="Quiz Maker",
595
- goal="Generate EXACTLY the number of multiple-choice questions requested by the user, grounded strictly in process_file context.",
596
- backstory=(
597
- "STEP 1: Extract the EXACT number of questions from user request (e.g., '3 questions' = 3, default = 5).\n"
598
- "STEP 2: Call process_file(mode=quiz) with file_path. Create ONLY that exact number of MCQs A-D from context_chunks.\n"
599
- "STEP 3: Build quiz_package_json with absolute 'file_path' and correct answers, call store_quiz.\n"
600
- 'CRITICAL: The \'qid\' field for each question MUST be a STRING (e.g., "1", "2") NOT an integer (1, 2).\n'
601
- 'Ensure VALID JSON: {"file_path": "...", "questions": [...]}. CRITICAL: Match requested count exactly. Never reveal answers.'
602
- ),
603
- tools=[process_file, store_quiz],
604
- llm=llm,
605
- verbose=True,
606
- )
607
-
608
- tutor = Agent(
609
- role="Tutor",
610
- goal="Grade quiz and provide intelligent explanation for errors.",
611
- backstory=(
612
- "You are an expert Tutor. When asked to grade a quiz:\n"
613
- "1. Call 'grade_quiz' to get the base results.\n"
614
- "2. For every INCORRECT answer, you MUST Explain WHY it is wrong:\n"
615
- " - Use the 'question' text and 'file_path' from the result to call 'process_file' (mode='explain', query=question).\n"
616
- " - REWRITE the 'explanation' field in the JSON detail for that question with your new explanation.\n"
617
- " - Use bullet points for any lists in your explanations.\n"
618
- "3. Return the fully updated JSON object."
619
- ),
620
- tools=[process_file, grade_quiz],
621
- llm=llm,
622
- verbose=True,
623
- )
624
-
625
- task = Task(
626
- description=(
627
- "User request: {user_request}\n\n"
628
- "Route by intent:\n"
629
- "- Summary -> Summarizer\n"
630
- "- Quiz -> Quiz Maker\n"
631
- "- Explanation -> Tutor\n"
632
- "- Grading (contains quiz_id + answers_json) -> Tutor\n\n"
633
- "Guardrails:\n"
634
- "- Tool outputs are structured JSON.\n"
635
- "- Tools validate inputs with Pydantic.\n"
636
- "- Tool calls are logged without secrets.\n"
637
- "- Do not reveal hidden quiz answers during quiz generation."
638
- ),
639
- expected_output=(
640
- "Grounded response: summary OR " "masked quiz OR graded feedback."
641
- ),
642
- agent=manager,
643
- )
644
-
645
- crew = Crew(
646
- agents=[manager, summarizer, quizzer, tutor],
647
- tasks=[task],
648
- process=Process.sequential,
649
- verbose=True,
650
- )
651
-
652
-
653
- from pathlib import Path
654
-
655
-
656
- def run_with_file(prompt: str, file_path: str | None = None):
657
- file_text = ""
658
- if file_path:
659
- file_text = Path(file_path).read_text(encoding="utf-8", errors="ignore")
660
-
661
- full_prompt = prompt
662
- if file_text:
663
- full_prompt += "\n\n[FILE CONTENT]\n" + file_text
664
-
665
- return full_prompt
666
-
667
-
668
- if __name__ == "__main__":
669
- print(
670
- run_with_file(
671
- r"please give me a quiz about 3 questions from this file - file_path=C:\Users\Yaz00\OneDrive\سطح المكتب\Agent AI - Tuwaiq\week 5\Homework 1\Phase2.pdf"
672
- )
673
- )
674
- # Example grading:
675
- # print(run(r"grade this quiz_id=<PUT_ID_HERE> answers_json={\"q1\":\"A\",\"q2\":\"C\",\"q3\":\"B\"}"))
676
- pass
 
1
+ import os, json, re, random
2
+ import uuid
3
+ import time
4
+ import logging
5
+ from typing import Literal, List, Dict, Any, Optional
6
+
7
+ from pydantic import BaseModel, Field, ValidationError
8
+ from crewai import Agent, Task, Crew, Process
9
+ from crewai.tools import tool
10
+ from crewai.llm import LLM
11
+
12
+ import dotenv
13
+
14
+ dotenv.load_dotenv("api_key.env")
15
+
16
+ # ============================================================
17
+ # Guardrails: logging, retries, deterministic config
18
+ # ============================================================
19
+
20
+ logging.basicConfig(
21
+ level=logging.INFO,
22
+ format="%(asctime)s | %(levelname)s | %(message)s",
23
+ )
24
+ logger = logging.getLogger("smart_tutor_guardrails")
25
+
26
+ DETERMINISTIC_TEMPERATURE = float(os.getenv("DETERMINISTIC_TEMPERATURE", "0.1"))
27
+ TOOL_MAX_RETRIES = int(os.getenv("TOOL_MAX_RETRIES", "2"))
28
+
29
+ # ============================================================
30
+ # Guardrails: rate limits / timeouts / policies
31
+ # ============================================================
32
+
33
+ MAX_FILE_SIZE_MB = int(os.getenv("MAX_FILE_SIZE_MB", "500"))
34
+ MAX_PDF_PAGES = int(os.getenv("MAX_PDF_PAGES", "2000"))
35
+ PDF_EXTRACTION_TIMEOUT = float(os.getenv("PDF_EXTRACTION_TIMEOUT", "200")) # seconds
36
+
37
+ ALLOWED_TOOLS = {"process_file", "store_quiz", "grade_quiz"}
38
+
39
+ PROMPT_INJECTION_PATTERNS = [
40
+ "ignore previous instructions",
41
+ "ignore all previous instructions",
42
+ "system:",
43
+ "assistant:",
44
+ "developer:",
45
+ "act as",
46
+ "you must",
47
+ "follow these instructions",
48
+ "override",
49
+ ]
50
+
51
+ # ============================================================
52
+ # Helpers
53
+ # ============================================================
54
+
55
+
56
+ def clean_text(text: str) -> str:
57
+ text = text.replace("\x00", " ")
58
+ text = re.sub(r"[ \t]+", " ", text)
59
+ text = re.sub(r"\n{3,}", "\n\n", text)
60
+ return text.strip()
61
+
62
+
63
+ def detect_prompt_injection(text: str) -> bool:
64
+ lower = text.lower()
65
+ return any(p in lower for p in PROMPT_INJECTION_PATTERNS)
66
+
67
+
68
+ def chunk_text(text: str, max_chars: int = 1200, overlap: int = 150) -> List[str]:
69
+ text = clean_text(text)
70
+ if not text:
71
+ return []
72
+ chunks = []
73
+ start = 0
74
+ n = len(text)
75
+ while start < n:
76
+ end = min(start + max_chars, n)
77
+ part = text[start:end].strip()
78
+ if part:
79
+ chunks.append(part)
80
+ if end == n:
81
+ break
82
+ start = max(0, end - overlap)
83
+ return chunks
84
+
85
+
86
+ def keyword_retrieve(chunks: List[str], query: str, top_k: int) -> List[str]:
87
+ q_terms = [w for w in re.findall(r"\w+", query.lower()) if len(w) > 2]
88
+
89
+ def score(c: str) -> int:
90
+ c_l = c.lower()
91
+ return sum(1 for t in q_terms if t in c_l)
92
+
93
+ ranked = sorted(chunks, key=score, reverse=True)
94
+ return [c for c in ranked[:top_k] if c]
95
+
96
+
97
+ # ============================================================
98
+ # File extraction with limits + timeout
99
+ # ============================================================
100
+
101
+
102
+ def extract_text(file_path: str) -> str:
103
+ if os.path.getsize(file_path) > MAX_FILE_SIZE_MB * 1024 * 1024:
104
+ raise ValueError(f"File too large (> {MAX_FILE_SIZE_MB} MB)")
105
+
106
+ ext = os.path.splitext(file_path)[1].lower()
107
+
108
+ if ext == ".txt":
109
+ with open(file_path, "r", encoding="utf-8", errors="ignore") as f:
110
+ return f.read()
111
+
112
+ if ext == ".pdf":
113
+ import fitz # PyMuPDF
114
+
115
+ start_time = time.time()
116
+ doc = fitz.open(file_path)
117
+
118
+ if len(doc) > MAX_PDF_PAGES:
119
+ raise ValueError(f"PDF exceeds max page limit ({MAX_PDF_PAGES})")
120
+
121
+ parts = []
122
+ for i in range(len(doc)):
123
+ if time.time() - start_time > PDF_EXTRACTION_TIMEOUT:
124
+ raise TimeoutError("PDF extraction timeout")
125
+ t = doc.load_page(i).get_text("text") or ""
126
+ t = clean_text(t)
127
+ if t:
128
+ parts.append(t)
129
+ return "\n\n".join(parts).strip()
130
+
131
+ raise ValueError("Unsupported file type (PDF/TXT only).")
132
+
133
+
134
+ # ============================================================
135
+ # Schemas (Structured Inputs / Outputs)
136
+ # ============================================================
137
+
138
+
139
+ class ProcessArgs(BaseModel):
140
+ file_path: str = Field(..., description="Local path to PDF/TXT")
141
+ query: str = Field(..., description="User question or instruction")
142
+ mode: Literal["summarize", "quiz", "explain"] = Field(..., description="Task type")
143
+ top_k: int = Field(6, ge=1, le=15, description="How many chunks to use as context")
144
+
145
+
146
+ class QuizQuestion(BaseModel):
147
+ qid: str
148
+ question: str
149
+ options: Dict[Literal["A", "B", "C", "D"], str]
150
+ correct: Literal["A", "B", "C", "D"]
151
+ explanation: str = ""
152
+ supporting_context: str = ""
153
+
154
+
155
+ class StoreQuizArgs(BaseModel):
156
+ file_path: str = Field(
157
+ ..., description="The absolute file path of the document used"
158
+ )
159
+ questions: List[QuizQuestion]
160
+
161
+
162
+ class GradeQuizArgs(BaseModel):
163
+ quiz_id: str
164
+ answers: Dict[str, Literal["A", "B", "C", "D"]]
165
+
166
+
167
+ class ToolError(BaseModel):
168
+ error: str
169
+ details: Optional[Any] = None
170
+
171
+
172
+ class ProcessFileResult(BaseModel):
173
+ mode: str
174
+ query: str
175
+ context_chunks: List[str]
176
+ stats: Dict[str, Any]
177
+
178
+
179
+ class StoreQuizResult(BaseModel):
180
+ quiz_id: str
181
+ questions: List[Dict[str, Any]] # masked questions
182
+
183
+
184
+ class GradeQuizResult(BaseModel):
185
+ quiz_id: str
186
+ score: int
187
+ total: int
188
+ percentage: float
189
+ file_path: Optional[str] = None
190
+ details: List[Dict[str, Any]]
191
+
192
+
193
+ # ============================================================
194
+ # Memory/State with Persistence
195
+ # ============================================================
196
+
197
+ QUIZ_FILE = "quizzes_db.json"
198
+
199
+
200
+ def load_quizzes():
201
+ if os.path.exists(QUIZ_FILE):
202
+ try:
203
+ with open(QUIZ_FILE, "r", encoding="utf-8") as f:
204
+ return json.load(f)
205
+ except:
206
+ return {}
207
+ return {}
208
+
209
+
210
+ def save_quizzes(data):
211
+ try:
212
+ with open(QUIZ_FILE, "w", encoding="utf-8") as f:
213
+ json.dump(data, f, ensure_ascii=False, indent=2)
214
+ except Exception as e:
215
+ logger.error(f"Failed to save quizzes: {e}")
216
+
217
+
218
+ QUIZ_STORE: Dict[str, Dict[str, Any]] = load_quizzes()
219
+
220
+
221
+ # ============================================================
222
+ # Tool wrapper: retries + logs + redaction
223
+ # ============================================================
224
+
225
+
226
+ def _redact(obj: Any) -> Any:
227
+ """Redact secrets + quiz answer key in logs."""
228
+ try:
229
+ if isinstance(obj, dict):
230
+ out = {}
231
+ for k, v in obj.items():
232
+ lk = str(k).lower()
233
+ if lk in {"openai_api_key", "api_key", "authorization", "x-api-key"}:
234
+ out[k] = "***"
235
+ elif lk == "correct":
236
+ out[k] = "***"
237
+ else:
238
+ out[k] = _redact(v)
239
+ return out
240
+ if isinstance(obj, list):
241
+ return [_redact(x) for x in obj]
242
+ if isinstance(obj, str):
243
+ key = os.getenv("OPENAI_API_KEY") or ""
244
+ if key and key in obj:
245
+ return obj.replace(key, "***")
246
+ return obj
247
+ return obj
248
+ except Exception:
249
+ return "<redacted>"
250
+
251
+
252
+ def safe_tool_call(tool_name: str, fn):
253
+ if tool_name not in ALLOWED_TOOLS:
254
+ raise RuntimeError("Tool not allowed by policy")
255
+
256
+ last_err = None
257
+ for attempt in range(1, TOOL_MAX_RETRIES + 2):
258
+ try:
259
+ logger.info(f"[TOOL_CALL] {tool_name} attempt={attempt}")
260
+ out = fn()
261
+ logger.info(
262
+ f"[TOOL_RESULT] {tool_name} attempt={attempt} out={json.dumps(_redact(out), ensure_ascii=False)[:900]}"
263
+ )
264
+ return out
265
+ except Exception as e:
266
+ last_err = e
267
+ logger.warning(
268
+ f"[TOOL_ERROR] {tool_name} attempt={attempt} err={type(e).__name__}"
269
+ )
270
+ time.sleep(0.2 * attempt)
271
+ raise last_err
272
+
273
+
274
+ # ============================================================
275
+ # Tools
276
+ # ============================================================
277
+
278
+
279
+ @tool("process_file")
280
+ def process_file(file_path: str, query: str, mode: str, top_k: int = 6) -> str:
281
+ """Read PDF/TXT, chunk it, retrieve top_k relevant chunks. Returns structured JSON."""
282
+ try:
283
+ args = ProcessArgs(file_path=file_path, query=query, mode=mode, top_k=top_k)
284
+ except ValidationError as ve:
285
+ return json.dumps(
286
+ ToolError(error="Invalid arguments", details=ve.errors()).model_dump(),
287
+ ensure_ascii=False,
288
+ )
289
+
290
+ def _run():
291
+ # Clean path: remove quotes and whitespace that agents sometimes add
292
+ clean_path = args.file_path.strip().strip("'\"").strip()
293
+ if not os.path.exists(clean_path):
294
+ return ToolError(error=f"Invalid file path: {clean_path}").model_dump()
295
+
296
+ try:
297
+ raw_text = extract_text(args.file_path)
298
+ except Exception as e:
299
+ return ToolError(
300
+ error="Extraction failed", details=type(e).__name__
301
+ ).model_dump()
302
+
303
+ if detect_prompt_injection(raw_text):
304
+ logger.warning(
305
+ "[SECURITY] Potential prompt injection detected in document. Treating as data only."
306
+ )
307
+
308
+ text = clean_text(raw_text)
309
+ if not text:
310
+ return ToolError(error="Empty or unreadable file text.").model_dump()
311
+
312
+ chunks = chunk_text(text)
313
+ if not chunks:
314
+ return ToolError(error="No chunks produced.").model_dump()
315
+
316
+ context = keyword_retrieve(chunks, args.query, args.top_k)
317
+
318
+ return ProcessFileResult(
319
+ mode=args.mode,
320
+ query=args.query,
321
+ context_chunks=context,
322
+ stats={
323
+ "chunks_total": len(chunks),
324
+ "chars_extracted": len(text),
325
+ "top_k": args.top_k,
326
+ },
327
+ ).model_dump()
328
+
329
+ try:
330
+ out = safe_tool_call("process_file", _run)
331
+ return json.dumps(out, ensure_ascii=False)
332
+ except Exception as e:
333
+ return json.dumps(
334
+ ToolError(
335
+ error="process_file failed", details=type(e).__name__
336
+ ).model_dump(),
337
+ ensure_ascii=False,
338
+ )
339
+
340
+
341
+ def clean_json_input(text: str) -> str:
342
+ """Clean markdown code blocks and extract JSON object from string."""
343
+ text = text.strip()
344
+
345
+ # Remove markdown code blocks (flexible)
346
+ # This handles ```json ... ``` even if there is text before/after
347
+ pattern = r"```(?:json)?\s*(\{.*?\})\s*```"
348
+ match = re.search(pattern, text, re.DOTALL)
349
+ if match:
350
+ return match.group(1)
351
+
352
+ # If no code blocks, try to find the first outer-most JSON object
353
+ # This regex looks for { ... } minimally or greedily?
354
+ # We want the largest block starting with { and ending with }
355
+ # but strictly speaking, standard json.loads might just work if we strip.
356
+
357
+ # If text starts with ``` but didn't match the block above (maybe incomplete),
358
+ # let's just strip the fences.
359
+ if text.startswith("```"):
360
+ text = re.sub(r"^```(\w+)?\n?", "", text)
361
+ text = re.sub(r"\n?```$", "", text)
362
+
363
+ # Remove single backticks
364
+ if text.startswith("`") and text.endswith("`"):
365
+ text = text.strip("`")
366
+
367
+ return text.strip()
368
+
369
+
370
+ @tool("store_quiz")
371
+ def store_quiz(quiz_package_json: str) -> str:
372
+ """Store quiz with hidden answers; return masked quiz (no correct answers)."""
373
+
374
+ def _run():
375
+ try:
376
+ cleaned_json = clean_json_input(quiz_package_json)
377
+ # First try: direct parse
378
+ pkg_raw = json.loads(cleaned_json)
379
+ except json.JSONDecodeError:
380
+ # Second try: liberal regex search for { ... }
381
+ # Use dotall and greedy to capture nested objects
382
+ match = re.search(r"(\{.*\})", quiz_package_json, re.DOTALL)
383
+ if match:
384
+ try:
385
+ pkg_raw = json.loads(match.group(1))
386
+ except json.JSONDecodeError as e:
387
+ return ToolError(
388
+ error=f"quiz_package_json is not valid JSON. Parse error: {str(e)}",
389
+ details=f"Input fragment: {quiz_package_json[:200]}...",
390
+ ).model_dump()
391
+ else:
392
+ return ToolError(
393
+ error="quiz_package_json is not valid JSON (no braces found)",
394
+ details=f"Input fragment: {quiz_package_json[:200]}...",
395
+ ).model_dump()
396
+
397
+ try:
398
+ pkg = StoreQuizArgs(**pkg_raw)
399
+ except ValidationError as ve:
400
+ return ToolError(
401
+ error="Invalid quiz_package_json", details=ve.errors()
402
+ ).model_dump()
403
+
404
+ quiz_id = str(uuid.uuid4())
405
+
406
+ # Randomize options for each question
407
+ final_questions = []
408
+ for q in pkg.questions:
409
+ # q is a QuizQuestion object
410
+ original_options = q.options # dict e.g. {"A": "...", "B": "..."}
411
+ original_correct_key = q.correct # "A"
412
+ correct_text = original_options[original_correct_key]
413
+
414
+ # Extract texts
415
+ option_texts = list(original_options.values())
416
+ random.shuffle(option_texts)
417
+
418
+ # Re-map to A, B, C, D
419
+ new_options = {}
420
+ new_correct_key = ""
421
+ keys = ["A", "B", "C", "D"]
422
+
423
+ # Handle cases with fewer than 4 options just in case
424
+ for i, text in enumerate(option_texts):
425
+ if i < len(keys):
426
+ key = keys[i]
427
+ new_options[key] = text
428
+ if text == correct_text:
429
+ new_correct_key = key
430
+
431
+ # Update the question object (create a copy/dict)
432
+ q_dump = q.model_dump()
433
+ q_dump["options"] = new_options
434
+ q_dump["correct"] = new_correct_key
435
+ final_questions.append(q_dump)
436
+
437
+ QUIZ_STORE[quiz_id] = {
438
+ "file_path": pkg.file_path,
439
+ "questions": final_questions,
440
+ }
441
+ save_quizzes(QUIZ_STORE)
442
+
443
+ masked = [
444
+ {"qid": q["qid"], "question": q["question"], "options": q["options"]}
445
+ for q in final_questions
446
+ ]
447
+ return StoreQuizResult(quiz_id=quiz_id, questions=masked).model_dump()
448
+
449
+ try:
450
+ out = safe_tool_call("store_quiz", _run)
451
+ return json.dumps(out, ensure_ascii=False)
452
+ except Exception as e:
453
+ return json.dumps(
454
+ ToolError(error="store_quiz failed", details=type(e).__name__).model_dump(),
455
+ ensure_ascii=False,
456
+ )
457
+
458
+
459
+ @tool("grade_quiz")
460
+ def grade_quiz(quiz_id: str, answers_json: str) -> str:
461
+ """Grade quiz answers by quiz_id and answers_json. Returns score + details as structured JSON.
462
+ Also returns 'file_path' and 'question' text for further processing."""
463
+
464
+ def _run():
465
+ if quiz_id not in QUIZ_STORE:
466
+ return ToolError(error="Unknown quiz_id.").model_dump()
467
+
468
+ try:
469
+ cleaned_json = clean_json_input(answers_json)
470
+ submitted_raw = json.loads(cleaned_json)
471
+ except json.JSONDecodeError:
472
+ # Fallback
473
+ match = re.search(r"(\{.*\})", answers_json, re.DOTALL)
474
+ if match:
475
+ try:
476
+ submitted_raw = json.loads(match.group(1))
477
+ except:
478
+ return ToolError(
479
+ error="answers_json is not valid JSON"
480
+ ).model_dump()
481
+ else:
482
+ return ToolError(error="answers_json is not valid JSON").model_dump()
483
+
484
+ try:
485
+ args = GradeQuizArgs(quiz_id=quiz_id, answers=submitted_raw)
486
+ except ValidationError as ve:
487
+ return ToolError(
488
+ error="Invalid answers_json", details=ve.errors()
489
+ ).model_dump()
490
+
491
+ stored_data = QUIZ_STORE[args.quiz_id]
492
+ questions = stored_data["questions"]
493
+ file_path = stored_data.get("file_path")
494
+
495
+ total = len(questions)
496
+ score = 0
497
+ details = []
498
+
499
+ for q in questions:
500
+ qid = q["qid"]
501
+ correct = q["correct"]
502
+ question_text = q.get("question", "")
503
+
504
+ your = (args.answers.get(qid) or "").strip().upper()
505
+ is_correct = your == correct
506
+ score += 1 if is_correct else 0
507
+
508
+ details.append(
509
+ {
510
+ "qid": qid,
511
+ "question": question_text, # Added for Agent context
512
+ "is_correct": is_correct,
513
+ "your_answer": your,
514
+ "correct_answer": correct, # NOTE: returned to tutor; OK for feedback
515
+ "explanation": q.get("explanation", "") or "",
516
+ "supporting_context": q.get("supporting_context", "") or "",
517
+ }
518
+ )
519
+
520
+ percentage = round((score / total) * 100, 2) if total else 0.0
521
+
522
+ return GradeQuizResult(
523
+ quiz_id=args.quiz_id,
524
+ score=score,
525
+ total=total,
526
+ percentage=percentage,
527
+ file_path=file_path,
528
+ details=details,
529
+ ).model_dump()
530
+
531
+ try:
532
+ out = safe_tool_call("grade_quiz", _run)
533
+ return json.dumps(out, ensure_ascii=False)
534
+ except Exception as e:
535
+ return json.dumps(
536
+ ToolError(error="grade_quiz failed", details=type(e).__name__).model_dump(),
537
+ ensure_ascii=False,
538
+ )
539
+
540
+
541
+ # ============================================================
542
+ # CrewAI setup
543
+ # ============================================================
544
+
545
+ llm = LLM(
546
+ model="gpt-4o-mini",
547
+ api_key=os.getenv("OPENAI_API_KEY"),
548
+ temperature=DETERMINISTIC_TEMPERATURE,
549
+ )
550
+
551
+ manager = Agent(
552
+ role="Manager (Router)",
553
+ goal=(
554
+ "Route user request to the correct specialist co-worker."
555
+ " Pass ALL user constraints (line count, "
556
+ "paragraph count, language, etc.) to the specialist."
557
+ ),
558
+ backstory=(
559
+ "You are a routing agent. You HAVE specialist co-workers: "
560
+ "Summarizer, Quiz Maker, and Tutor. "
561
+ "Your ONLY job is to delegate the task to the right co-worker "
562
+ "using your delegation tool. "
563
+ "NEVER answer the user yourself. NEVER use internal knowledge. "
564
+ "Always forward the FULL user request including any constraints."
565
+ ),
566
+ allow_delegation=True,
567
+ llm=llm,
568
+ verbose=True,
569
+ )
570
+
571
+ summarizer = Agent(
572
+ role="Summarizer",
573
+ goal=(
574
+ "Produce a summary grounded strictly in "
575
+ "context_chunks from process_file. STRICTLY "
576
+ "follow any user constraints on length, "
577
+ "number of lines, paragraphs, or format."
578
+ ),
579
+ backstory=(
580
+ "Call process_file(mode=summarize) first. "
581
+ "Summarize ONLY from context_chunks. "
582
+ "If the user specifies constraints like "
583
+ "'3 lines', '2 paragraphs', 'short', or "
584
+ "'detailed', you MUST follow them exactly. "
585
+ "Use bullet points (- or *) for lists instead of numbering. "
586
+ "No outside knowledge."
587
+ ),
588
+ tools=[process_file],
589
+ llm=llm,
590
+ verbose=True,
591
+ )
592
+
593
+ quizzer = Agent(
594
+ role="Quiz Maker",
595
+ goal="Generate EXACTLY the number of multiple-choice questions requested by the user, grounded strictly in process_file context.",
596
+ backstory=(
597
+ "STEP 1: Extract the EXACT number of questions from user request (e.g., '3 questions' = 3, default = 5).\n"
598
+ "STEP 2: Call process_file(mode=quiz) with file_path. Create ONLY that exact number of MCQs A-D from context_chunks.\n"
599
+ "STEP 3: Build quiz_package_json with absolute 'file_path' and correct answers, call store_quiz.\n"
600
+ 'CRITICAL: The \'qid\' field for each question MUST be a STRING (e.g., "1", "2") NOT an integer (1, 2).\n'
601
+ 'Ensure VALID JSON: {"file_path": "...", "questions": [...]}. CRITICAL: Match requested count exactly. Never reveal answers.'
602
+ ),
603
+ tools=[process_file, store_quiz],
604
+ llm=llm,
605
+ verbose=True,
606
+ )
607
+
608
+ tutor = Agent(
609
+ role="Tutor",
610
+ goal="Grade quiz and provide intelligent explanation for errors.",
611
+ backstory=(
612
+ "You are an expert Tutor. When asked to grade a quiz:\n"
613
+ "1. Call 'grade_quiz' to get the base results.\n"
614
+ "2. For every INCORRECT answer, you MUST Explain WHY it is wrong:\n"
615
+ " - Use the 'question' text and 'file_path' from the result to call 'process_file' (mode='explain', query=question).\n"
616
+ " - REWRITE the 'explanation' field in the JSON detail for that question with your new explanation.\n"
617
+ " - Use bullet points for any lists in your explanations.\n"
618
+ "3. Return the fully updated JSON object."
619
+ ),
620
+ tools=[process_file, grade_quiz],
621
+ llm=llm,
622
+ verbose=True,
623
+ )
624
+
625
+ task = Task(
626
+ description=(
627
+ "User request: {user_request}\n\n"
628
+ "Route by intent:\n"
629
+ "- Summary -> Summarizer\n"
630
+ "- Quiz -> Quiz Maker\n"
631
+ "- Explanation -> Tutor\n"
632
+ "- Grading (contains quiz_id + answers_json) -> Tutor\n\n"
633
+ "Guardrails:\n"
634
+ "- Tool outputs are structured JSON.\n"
635
+ "- Tools validate inputs with Pydantic.\n"
636
+ "- Tool calls are logged without secrets.\n"
637
+ "- Do not reveal hidden quiz answers during quiz generation."
638
+ ),
639
+ expected_output=(
640
+ "Grounded response: summary OR " "masked quiz OR graded feedback."
641
+ ),
642
+ agent=manager,
643
+ )
644
+
645
+ crew = Crew(
646
+ agents=[manager, summarizer, quizzer, tutor],
647
+ tasks=[task],
648
+ process=Process.sequential,
649
+ verbose=True,
650
+ )
651
+
652
+
653
+ from pathlib import Path
654
+
655
+
656
+ def run_with_file(prompt: str, file_path: str | None = None):
657
+ file_text = ""
658
+ if file_path:
659
+ file_text = Path(file_path).read_text(encoding="utf-8", errors="ignore")
660
+
661
+ full_prompt = prompt
662
+ if file_text:
663
+ full_prompt += "\n\n[FILE CONTENT]\n" + file_text
664
+
665
+ return full_prompt
666
+
667
+
668
+ if __name__ == "__main__":
669
+ print(
670
+ run_with_file(
671
+ r"please give me a quiz about 3 questions from this file - file_path=C:\Users\Yaz00\OneDrive\سطح المكتب\Agent AI - Tuwaiq\week 5\Homework 1\Phase2.pdf"
672
+ )
673
+ )
674
+ # Example grading:
675
+ # print(run(r"grade this quiz_id=<PUT_ID_HERE> answers_json={\"q1\":\"A\",\"q2\":\"C\",\"q3\":\"B\"}"))
676
+ pass