mirror of
https://github.com/R0m1k3/Reviflow.git
synced 2026-10-14 18:58:04 +02:00
272 lines
11 KiB
Python
272 lines
11 KiB
Python
"""Quiz/Flashcard generation service."""
|
|
import json
|
|
import httpx
|
|
from typing import Dict, Any, List
|
|
from app.modules.ingest.service import OPENROUTER_API_URL, DEFAULT_MODEL
|
|
|
|
SYSTEM_PROMPT = """You are an expert French teacher.
|
|
Create a multiple-choice quiz (QCM) based on the provided lesson text.
|
|
|
|
Guidelines:
|
|
1. **Language**: French.
|
|
2. **Topic**: Extract the main topic/subject of the text.
|
|
3. **Target Audience**: Students.
|
|
4. **Format**: {num_questions} questions.
|
|
5. **Difficulty**: {difficulty}
|
|
|
|
Output strictly valid JSON with this structure. Do NOT include markdown formatting (like ```json), introduction, or conclusion. Just the raw JSON object.
|
|
{{
|
|
"topic": "string",
|
|
"questions": [
|
|
{{
|
|
"id": 1,
|
|
"question": "The question text",
|
|
"options": ["Option A", "Option B", "Option C", "Option D"],
|
|
"correct_answer": 0, // Index 0-3
|
|
"explanation": "Brief explanation."
|
|
}}
|
|
]
|
|
}}
|
|
"""
|
|
|
|
async def generate_quiz(text_content: str, api_key: str, difficulty: str = "medium", series_index: int = 1) -> Dict[str, Any]:
|
|
"""Generates a quiz from text content using LLM."""
|
|
|
|
# Determine number of questions based on text length
|
|
# Smart Quiz Sizing based on "Information Density"
|
|
# Heuristic: ~400 characters (approx 60-80 words) usually contain 1 indexable fact.
|
|
length = len(text_content)
|
|
|
|
# 1. Estimate maximal unique questions possible
|
|
density_factor = 400
|
|
max_unique_questions = max(3, int(length / density_factor))
|
|
|
|
# 2. Determine session size (Cap at 15 to prevent cognitive overload)
|
|
target_questions = min(max_unique_questions, 15)
|
|
|
|
# 3. Adjust for very short texts to avoid hallucinations/redundancy
|
|
if length < 600:
|
|
target_questions = 3
|
|
elif length < 1200:
|
|
target_questions = 5
|
|
|
|
num_questions = target_questions
|
|
|
|
# Calculate potential series (Party Mode "Revision Series" concept)
|
|
# This is just internal logic for now, could be returned in metadata later
|
|
total_series = (int(length / 300) // 15) + 1
|
|
|
|
print(f"Smart Sizing: Length={length} chars -> {num_questions} questions (Series estimate: {total_series})")
|
|
|
|
# Series Context Hint
|
|
series_hint = ""
|
|
if total_series > 1:
|
|
series_hint = f"\n\nCONTEXT: This text is long and is split into {total_series} parts. This is Part {series_index}. Please focus your questions on the section of the text corresponding roughly to part {series_index}/{total_series}."
|
|
|
|
prompt = SYSTEM_PROMPT.format(difficulty=difficulty, num_questions=num_questions) + series_hint
|
|
|
|
payload = {
|
|
"model": DEFAULT_MODEL,
|
|
"messages": [
|
|
{"role": "system", "content": prompt},
|
|
{"role": "user", "content": f"Here is the lesson text:\n\n{text_content}"}
|
|
],
|
|
"response_format": {"type": "json_object"},
|
|
"max_tokens": 4000
|
|
}
|
|
|
|
headers = {
|
|
"Authorization": f"Bearer {api_key}",
|
|
"Content-Type": "application/json",
|
|
"HTTP-Referer": "https://reviflow.app",
|
|
"X-Title": "Reviflow"
|
|
}
|
|
|
|
async with httpx.AsyncClient() as client:
|
|
response = await client.post(
|
|
OPENROUTER_API_URL,
|
|
json=payload,
|
|
headers=headers,
|
|
timeout=60.0
|
|
)
|
|
|
|
if response.status_code != 200:
|
|
raise Exception(f"OpenRouter API error ({response.status_code}): {response.text}")
|
|
|
|
result = response.json()
|
|
content = result["choices"][0]["message"]["content"]
|
|
usage = result.get("usage", {})
|
|
|
|
try:
|
|
return {
|
|
"quiz": json.loads(content),
|
|
"usage": usage,
|
|
"meta": {"total_series": total_series}
|
|
}
|
|
except json.JSONDecodeError:
|
|
print(f"JSON Parse Error - Raw Content: {content}")
|
|
# Robust Fallback strategy
|
|
import re
|
|
cleaned = content
|
|
|
|
# 1. Try finding a markdown code block
|
|
json_match = re.search(r'```json\s*(.*?)\s*```', content, re.DOTALL)
|
|
if json_match:
|
|
cleaned = json_match.group(1)
|
|
else:
|
|
# 2. Try finding the outermost JSON object
|
|
# Find first '{'
|
|
start = content.find('{')
|
|
# Find last '}'
|
|
end = content.rfind('}')
|
|
|
|
if start != -1 and end != -1:
|
|
cleaned = content[start:end+1]
|
|
|
|
try:
|
|
return {
|
|
"quiz": json.loads(cleaned),
|
|
"usage": usage,
|
|
"meta": {"total_series": total_series}
|
|
}
|
|
except json.JSONDecodeError as e:
|
|
print(f"Deep Parse Failed: {e}")
|
|
raise Exception("Failed to parse AI response as JSON. Content might be truncated or invalid.")
|
|
|
|
async def generate_remediation_quiz_service(context_items: List[Dict[str, Any]], api_key: str, source_text: str = None) -> Dict[str, Any]:
|
|
"""Generates a remediation quiz based on errors."""
|
|
|
|
# Extract topics from context or questions to guide the LLM
|
|
# This prevents it from thinking the topic is "Remediation"
|
|
def clean_topic_name(t):
|
|
if not t: return "General"
|
|
return t.replace(" (Remediation)", "").replace(" (Correction)", "").replace(" (Remédiation)", "").replace(" (Révision)", "").strip()
|
|
|
|
topics_context = list(set([clean_topic_name(item['context']) for item in context_items if item['context']]))
|
|
joined_topics = ", ".join(topics_context)
|
|
|
|
errors_desc = "\n".join([
|
|
f"- Failed Question: {item['question']}\n Student's Wrong Answer: {item['wrong_answer']}\n Correct Answer: {item['correct_answer']}\n Subject Context: {item['context']}"
|
|
for item in context_items
|
|
])
|
|
|
|
context_injection = ""
|
|
if source_text:
|
|
# Truncate source text if too long to save tokens, but keep enough for context
|
|
# 1000 chars is usually enough for key concepts
|
|
truncated_text = source_text[:3000]
|
|
context_injection = f"\n\nREFERENCE LESSON TEXT:\n{truncated_text}\n\n"
|
|
|
|
REMEDIATION_SYSTEM_PROMPT = f"""You are an expert tutor in the following subject(s): {joined_topics}.
|
|
Your Goal: Create a remedial quiz to help a student understand concepts they failed previously.
|
|
|
|
Input: A list of specific questions the student got wrong, including their wrong answer and the correct answer.
|
|
|
|
Task:
|
|
1. Read the provided REFERENCE LESSON TEXT (if available) to understand the exact curriculum and context.
|
|
2. Identify the underlying concept for EACH failed question.
|
|
3. Generate a NEW multiple-choice question to test that SAME concept, but phrased differently or using a different example.
|
|
4. You MUST generate exactly one new question for every failed question provided in the input list. If there are 10 errors, generate 10 new questions.
|
|
5. The "topic" field in the JSON should be: "{joined_topics} (Correction)".
|
|
6. **CRITICAL**: The questions must be about the SUBJECT MATTER ({joined_topics}). Do NOT generate questions about "remediation", "learning strategies", or "translation".
|
|
7. **Language**: output MUST be in French.
|
|
8. Provide a clear, helpful "explanation" for the correct answer.
|
|
|
|
Output strictly valid JSON with this EXACT structure:
|
|
{{
|
|
"topic": "string",
|
|
"questions": [
|
|
{{
|
|
"id": 1,
|
|
"question": "New question text...",
|
|
"options": ["Option A", "Option B", "Option C", "Option D"],
|
|
"correct_answer": 0,
|
|
"explanation": "Clear explanation of why this answer is right."
|
|
}}
|
|
]
|
|
}}
|
|
"""
|
|
|
|
payload = {
|
|
"model": DEFAULT_MODEL,
|
|
"messages": [
|
|
{"role": "system", "content": REMEDIATION_SYSTEM_PROMPT},
|
|
{"role": "user", "content": f"{context_injection}The student made the following mistakes (Generate exactly {len(context_items)} questions):\n{errors_desc}"}
|
|
],
|
|
"response_format": {"type": "json_object"},
|
|
"max_tokens": 8000
|
|
}
|
|
|
|
headers = {
|
|
"Authorization": f"Bearer {api_key}",
|
|
"Content-Type": "application/json",
|
|
"HTTP-Referer": "https://reviflow.app",
|
|
"X-Title": "Reviflow"
|
|
}
|
|
|
|
async with httpx.AsyncClient() as client:
|
|
response = await client.post(
|
|
OPENROUTER_API_URL,
|
|
json=payload,
|
|
headers=headers,
|
|
timeout=120.0
|
|
)
|
|
|
|
if response.status_code != 200:
|
|
raise Exception(f"OpenRouter API error ({response.status_code}): {response.text}")
|
|
|
|
result = response.json()
|
|
content = result["choices"][0]["message"]["content"]
|
|
usage = result.get("usage", {})
|
|
|
|
# --- Robust JSON Parsing Strategy ---
|
|
def try_parse_json(text):
|
|
try:
|
|
return json.loads(text)
|
|
except json.JSONDecodeError:
|
|
return None
|
|
|
|
# 1. Direct parse
|
|
parsed = try_parse_json(content)
|
|
|
|
# 2. Markdown block extraction
|
|
if not parsed:
|
|
import re
|
|
json_match = re.search(r'```json\s*(.*?)\s*```', content, re.DOTALL)
|
|
if json_match:
|
|
parsed = try_parse_json(json_match.group(1))
|
|
|
|
# 3. Brute-force substring extraction (find outer {})
|
|
if not parsed:
|
|
start = content.find('{')
|
|
end = content.rfind('}')
|
|
if start != -1 and end != -1:
|
|
parsed = try_parse_json(content[start:end+1])
|
|
|
|
# 4. Repair Truncated JSON (Simple Auto-Close)
|
|
if not parsed:
|
|
# Heuristic: If it looks like it ended abruptly, try adding closures
|
|
# Try adding "]}" or "}]}" or just "}"
|
|
candidates = [content + "}", content + "]}", content + "}]}", content + '"}]}', content + '"]}}' ]
|
|
for candidate in candidates:
|
|
# Need to strip potential markdown markers if we are patching the raw content
|
|
clean_candidate = candidate
|
|
start = clean_candidate.find('{')
|
|
if start != -1:
|
|
clean_candidate = clean_candidate[start:]
|
|
|
|
parsed = try_parse_json(clean_candidate)
|
|
if parsed:
|
|
print("WARN: JSON was truncated but successfully repaired.")
|
|
break
|
|
|
|
if parsed:
|
|
return {
|
|
"quiz": parsed,
|
|
"usage": usage
|
|
}
|
|
|
|
print(f"FAILED JSON CONTENT (First 500 chars): {content[:500]}...")
|
|
print(f"FAILED JSON CONTENT (Last 500 chars): {content[-500:]}...")
|
|
raise Exception("Failed to parse AI response as JSON (Malformed or Truncated)")
|