from fastapi import FastAPI, HTTPException from pydantic import BaseModel from typing import List, Optional import os import json from typing import TypedDict from langchain_groq import ChatGroq from langgraph.graph import StateGraph, END from langchain_core.messages import SystemMessage, HumanMessage # Pydantic Models for API class Question(BaseModel): question: str options: List[str] answer: str class Questions(BaseModel): questions: List[Question] class MCQRequest(BaseModel): topic: str class MCQResponse(BaseModel): topic: str questions: List[Question] total_questions: int # Agent State class AgentState(TypedDict): topic: str response: Optional[Questions] def generate(state: AgentState) -> AgentState: system_prompt = """Generate ONLY valid JSON. Nothing else. No markdown. RETURN THIS FORMAT EXACTLY - 25 QUESTIONS: { "questions": [ {"question": "Q1", "options": ["A) opt1", "B) opt2", "C) opt3", "D) opt4"], "answer": "A) opt1"}, {"question": "Q2", "options": ["A) opt1", "B) opt2", "C) opt3", "D) opt4"], "answer": "B) opt2"}, {"question": "Q3", "options": ["A) opt1", "B) opt2", "C) opt3", "D) opt4"], "answer": "C) opt3"}, ... continue for 25 questions total ... {"question": "Q25", "options": ["A) opt1", "B) opt2", "C) opt3", "D) opt4"], "answer": "D) opt4"} ] } RULES: 1. Return ONLY the JSON object above. No extra text before or after. 2. ALWAYS 4 options per question labeled A) B) C) D) 3. "answer" MUST be one of the 4 options exactly (e.g., "A) opt1") 4. Do NOT use quotes inside question/option strings 5. Do NOT use newlines in strings - use single line CONTENT EXAMPLES FOR JLPT: N5 HIRAGANA: {"question": "How to read き?", "options": ["A) ku", "B) ki", "C) ke", "D) ko"], "answer": "B) ki"} N5 KATAKANA: {"question": "What is コーヒー?", "options": ["A) tea", "B) coffee", "C) water", "D) juice"], "answer": "B) coffee"} N4 KANJI: {"question": "Read 日本", "options": ["A) hinomoto", "B) nihon", "C) nipppon", "D) nikkan"], "answer": "B) nihon"} N4 PARTICLE: {"question": "Complete: 私__学生です", "options": ["A) を", "B) に", "C) は", "D) で"], "answer": "C) は"} N4 VOCAB: {"question": "What means 飲む?", "options": ["A) to eat", "B) to drink", "C) to sleep", "D) to walk"], "answer": "B) to drink"} Now generate 25 questions about the given topic using the same format.""" llm = ChatGroq( model_name="groq/compound", api_key=os.getenv("GROQ_API_KEY"), temperature=0.3 # Lower temperature for more consistent JSON ) # Create messages with proper structure messages = [ SystemMessage(content=system_prompt), HumanMessage(content=f"Topic: {state['topic']}\n\nGenerate 25 questions. Return ONLY valid JSON array wrapped in {{\"questions\": [...]}}") ] try: response = llm.invoke(messages) response_text = response.content.strip() print(f"Raw response: {response_text[:300]}") # Debug log # Remove markdown code blocks if present if "```json" in response_text: response_text = response_text.split("```json")[1].split("```")[0].strip() elif "```" in response_text: response_text = response_text.split("```")[1].split("```")[0].strip() # Try to find JSON - could be array [{ or object { start_idx = -1 end_idx = -1 # Check for array first if '[' in response_text: array_start = response_text.find('[') if '{' in response_text: obj_start = response_text.find('{') if array_start < obj_start: start_idx = array_start end_idx = response_text.rfind(']') + 1 else: start_idx = obj_start end_idx = response_text.rfind('}') + 1 else: start_idx = array_start end_idx = response_text.rfind(']') + 1 else: start_idx = response_text.find('{') end_idx = response_text.rfind('}') + 1 if start_idx == -1 or end_idx == 0: raise ValueError(f"No JSON found in response: {response_text}") json_string = response_text[start_idx:end_idx] # Clean up problematic characters - but preserve JSON structure # Only escape newlines inside strings, not structural newlines json_string = json_string.replace('\n', ' ').replace('\r', ' ').replace('\t', ' ') print(f"Cleaned JSON: {json_string[:300]}") # Parse JSON response json_data = json.loads(json_string) # Handle both array and object responses if isinstance(json_data, list): questions_list = json_data elif isinstance(json_data, dict) and "questions" in json_data: questions_list = json_data["questions"] else: raise ValueError(f"Unexpected JSON structure: {json_data}") # Validate each question for q in questions_list: if not isinstance(q, dict): raise ValueError(f"Question is not a dict: {q}") if not all(key in q for key in ["question", "options", "answer"]): raise ValueError(f"Question missing required fields: {q}") if not isinstance(q["options"], list) or len(q["options"]) != 4: raise ValueError(f"Question must have exactly 4 options: {q}") if q["answer"] not in q["options"]: raise ValueError(f"Answer '{q['answer']}' not in options: {q['options']}") # Convert to Questions model state["response"] = Questions(questions=[ Question(**q) for q in questions_list ]) except json.JSONDecodeError as e: print(f"JSON Parse Error: {e}") print(f"Response text: {response_text}") raise Exception(f"Failed to parse JSON response: {str(e)}") except Exception as e: print(f"Generation Error: {str(e)}") raise return state # Create graph workflow = StateGraph(AgentState) workflow.add_node("generate", generate) workflow.set_entry_point("generate") workflow.add_edge("generate", END) app_agent = workflow.compile() # FastAPI App app = FastAPI( title="Japanese MCQ Generator API", description="Generate JLPT-style Japanese language MCQs using Groq LLM", version="1.0.0" ) @app.get("/") async def root(): return { "message": "Japanese MCQ Generator API is running!", "status": "healthy", "supported_topics": [ "N5 Kanji", "N4 Kanji", "N3 Kanji", "N5 Grammar", "N4 Grammar", "N3 Grammar", "Hiragana", "Katakana", "N5 Vocabulary", "N4 Vocabulary", "N3 Vocabulary", "Particles", "Verb conjugation" ] } @app.post("/generate-mcqs", response_model=MCQResponse) async def generate_mcqs(request: MCQRequest): """ Generate Japanese language MCQs based on the specified topic. Examples: - "N5 Hiragana" - "N4 Grammar particles" - "N3 Kanji readings" - "Katakana vocabulary" """ try: result = app_agent.invoke({"topic": request.topic, "response": None}) questions = result.get("response") # Validate that we got questions if not questions or not questions.questions: raise HTTPException( status_code=500, detail="No questions were generated. Please try again." ) return MCQResponse( topic=request.topic, questions=questions.questions, total_questions=len(questions.questions) ) except HTTPException: raise except Exception as e: error_msg = str(e) print(f"Error details: {error_msg}") raise HTTPException( status_code=500, detail=f"Error generating MCQs: {error_msg}" ) @app.get("/health") async def health_check(): return { "status": "healthy", "service": "Japanese MCQ Generator", "model": "llama-3.1-8b-instant" } @app.get("/topics") async def get_topics(): """Get suggested topics for MCQ generation""" return { "beginner_n5": [ "N5 Hiragana basics", "N5 Katakana basics", "N5 Basic Kanji", "N5 Basic vocabulary", "N5 Particles", "N5 です/ます forms" ], "elementary_n4": [ "N4 Kanji readings", "N4 Verb forms", "N4 Grammar particles", "N4 Common expressions", "N4 Adjective conjugations" ], "intermediate_n3": [ "N3 Kanji compounds", "N3 Causative forms", "N3 Passive forms", "N3 Complex particles", "N3 Idiomatic expressions" ] } if __name__ == "__main__": import uvicorn uvicorn.run(app, host="0.0.0.0", port=7860)