rairo commited on
Commit
bdfea50
·
verified ·
1 Parent(s): 7ec4af5

Create question_generator.py

Browse files
Files changed (1) hide show
  1. question_generator.py +591 -0
question_generator.py ADDED
@@ -0,0 +1,591 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ QuestionGenerator — Generates Korean grammar questions using rule engine + Gemini.
3
+ Produces standardized payloads consumed by Unity XR client.
4
+ """
5
+
6
+ import json
7
+ import uuid
8
+ import random
9
+ import logging
10
+ from typing import Optional
11
+
12
+ from korean_rules import rule_engine
13
+ from content_pack import get_active_pack, get_nouns, get_pronouns, get_verbs, get_adjectives
14
+
15
+ logger = logging.getLogger(__name__)
16
+
17
+ # ---------------------------------------------------------------------------
18
+ # Question type → grammar rule mapping
19
+ # ---------------------------------------------------------------------------
20
+ QUESTION_TYPES = [
21
+ "topic_marker",
22
+ "copula",
23
+ "negative_copula",
24
+ "scrabble",
25
+ "indirect_quote_dago",
26
+ "indirect_quote_commands",
27
+ "indirect_quote_questions",
28
+ "indirect_quote_suggestions",
29
+ "regret_expression",
30
+ ]
31
+
32
+ # Question types that map to grammar rules for mastery tracking
33
+ QTYPE_TO_RULE = {
34
+ "topic_marker": "topic_marker",
35
+ "copula": "copula",
36
+ "negative_copula": "negative_copula",
37
+ "scrabble": "topic_marker", # scrabble covers basic sentence structure
38
+ "indirect_quote_dago": "indirect_quote_dago",
39
+ "indirect_quote_commands": "indirect_quote_commands",
40
+ "indirect_quote_questions": "indirect_quote_questions",
41
+ "indirect_quote_suggestions": "indirect_quote_suggestions",
42
+ "regret_expression": "regret_expression",
43
+ }
44
+
45
+ # Difficulty → available question types
46
+ DIFFICULTY_TYPES = {
47
+ 1: ["topic_marker", "copula", "negative_copula", "scrabble"],
48
+ 2: ["topic_marker", "copula", "negative_copula", "scrabble",
49
+ "indirect_quote_dago", "indirect_quote_commands"],
50
+ 3: QUESTION_TYPES,
51
+ }
52
+
53
+
54
+ class QuestionGenerator:
55
+
56
+ def __init__(self, gemini_client=None):
57
+ self.client = gemini_client
58
+
59
+ # ── Main Entry Point ─────────────────────────────────────────────────────
60
+
61
+ def generate(self, difficulty: int = 1, grammar_rule: str = None,
62
+ history: list = None, session_id: str = None) -> dict:
63
+ """
64
+ Generate a question payload for Unity.
65
+ Returns a standardized dict with all fields Unity needs.
66
+ """
67
+ pack = get_active_pack()
68
+ history = history or []
69
+
70
+ # Select question type
71
+ q_type = grammar_rule if grammar_rule in QUESTION_TYPES else self._select_type(difficulty, history)
72
+
73
+ try:
74
+ if q_type == "topic_marker":
75
+ return self._q_topic_marker(pack)
76
+ elif q_type == "copula":
77
+ return self._q_copula(pack)
78
+ elif q_type == "negative_copula":
79
+ return self._q_negative_copula(pack)
80
+ elif q_type == "scrabble":
81
+ return self._q_scrabble(pack, difficulty)
82
+ elif q_type == "indirect_quote_dago":
83
+ return self._q_indirect_dago(pack, difficulty)
84
+ elif q_type == "indirect_quote_commands":
85
+ return self._q_indirect_commands(pack)
86
+ elif q_type == "indirect_quote_questions":
87
+ return self._q_indirect_questions(pack)
88
+ elif q_type == "indirect_quote_suggestions":
89
+ return self._q_indirect_suggestions(pack)
90
+ elif q_type == "regret_expression":
91
+ return self._q_regret(pack)
92
+ except Exception as e:
93
+ logger.error(f"Question generation error for {q_type}: {e}")
94
+ # Fallback to simplest question type
95
+ return self._q_topic_marker(pack)
96
+
97
+ # ── Type 1: Topic Marker Choose ─────────────────────────────────────────
98
+
99
+ def _q_topic_marker(self, pack: dict) -> dict:
100
+ noun_data = random.choice(get_nouns(pack) + get_pronouns(pack))
101
+ noun = noun_data["korean"]
102
+ correct = rule_engine.get_topic_marker(noun)
103
+ wrong = '는' if correct == '은' else '은'
104
+
105
+ choices = [correct, wrong]
106
+ random.shuffle(choices)
107
+
108
+ return self._build_payload(
109
+ question_type="topic_marker",
110
+ interaction_mode="choose_select",
111
+ prompt_korean=noun + "____",
112
+ prompt_english=f"Choose the correct topic marker for: {noun_data['english']}",
113
+ choices=choices,
114
+ answer_key=correct,
115
+ tokens=[noun, correct],
116
+ correct_order=[0, 1],
117
+ slot_count=1,
118
+ translation=f"{noun_data['english']} (topic)",
119
+ grammar_rule="topic_marker",
120
+ hint=rule_engine.get_hint(noun, 'topic'),
121
+ difficulty=1,
122
+ metadata={"lesson": "KLP7-base", "word_tested": noun},
123
+ )
124
+
125
+ # ── Type 2: Copula Choose ────────────────────────────────────────────────
126
+
127
+ def _q_copula(self, pack: dict) -> dict:
128
+ noun_data = random.choice(get_nouns(pack))
129
+ noun = noun_data["korean"]
130
+ correct = rule_engine.get_copula(noun)
131
+ wrong = '예요' if correct == '이에요' else '이에요'
132
+
133
+ subject_data = random.choice(get_pronouns(pack))
134
+ subject = subject_data["korean"]
135
+ topic = rule_engine.attach_topic_marker(subject)
136
+
137
+ choices = [correct, wrong]
138
+ random.shuffle(choices)
139
+
140
+ return self._build_payload(
141
+ question_type="copula",
142
+ interaction_mode="choose_select",
143
+ prompt_korean=f"{topic} {noun}____",
144
+ prompt_english=f"{subject_data['english']} is a/an {noun_data['english']}",
145
+ choices=choices,
146
+ answer_key=correct,
147
+ tokens=[topic, noun + correct],
148
+ correct_order=[0, 1],
149
+ slot_count=1,
150
+ translation=f"{subject_data['english']} is a/an {noun_data['english']}",
151
+ grammar_rule="copula",
152
+ hint=rule_engine.get_hint(noun, 'copula'),
153
+ difficulty=1,
154
+ metadata={"lesson": "KLP7-base", "word_tested": noun},
155
+ )
156
+
157
+ # ── Type 3: Negative Copula ──────────────────────────────────────────────
158
+
159
+ def _q_negative_copula(self, pack: dict) -> dict:
160
+ noun_data = random.choice(get_nouns(pack))
161
+ noun = noun_data["korean"]
162
+ correct_marker = rule_engine.get_subject_marker(noun)
163
+ wrong_marker = '가' if correct_marker == '이' else '이'
164
+
165
+ subject_data = random.choice(get_pronouns(pack))
166
+ subject = subject_data["korean"]
167
+ topic = rule_engine.attach_topic_marker(subject)
168
+
169
+ choices = [correct_marker, wrong_marker]
170
+ random.shuffle(choices)
171
+
172
+ return self._build_payload(
173
+ question_type="negative_copula",
174
+ interaction_mode="choose_select",
175
+ prompt_korean=f"{topic} {noun}____ 아니에요",
176
+ prompt_english=f"{subject_data['english']} is not a/an {noun_data['english']}",
177
+ choices=choices,
178
+ answer_key=correct_marker,
179
+ tokens=[topic, noun + correct_marker + " 아니에요"],
180
+ correct_order=[0, 1],
181
+ slot_count=1,
182
+ translation=f"{subject_data['english']} is not a/an {noun_data['english']}",
183
+ grammar_rule="negative_copula",
184
+ hint=rule_engine.get_hint(noun, 'negative'),
185
+ difficulty=1,
186
+ metadata={"lesson": "KLP7-base", "word_tested": noun},
187
+ )
188
+
189
+ # ── Type 4: Scrabble (Sentence Assembly) ─────────────────────────────────
190
+
191
+ def _q_scrabble(self, pack: dict, difficulty: int = 1) -> dict:
192
+ """
193
+ Build a shuffled token assembly question.
194
+ For difficulty 1: simple [Subject+Topic] [Noun+Copula]
195
+ For difficulty 2+: use Gemini to generate a more complex sentence
196
+ """
197
+ if difficulty >= 2 and self.client:
198
+ return self._q_scrabble_gemini(pack, difficulty)
199
+
200
+ # Simple rule-based sentence
201
+ subject_data = random.choice(get_pronouns(pack))
202
+ noun_data = random.choice(get_nouns(pack))
203
+
204
+ subject = subject_data["korean"]
205
+ noun = noun_data["korean"]
206
+
207
+ token_1 = rule_engine.attach_topic_marker(subject)
208
+ token_2 = rule_engine.attach_copula(noun)
209
+
210
+ tokens = [token_1, token_2]
211
+ correct_order = [0, 1]
212
+ shuffled_tokens = list(tokens)
213
+ random.shuffle(shuffled_tokens)
214
+ shuffled_indices = [tokens.index(t) for t in shuffled_tokens]
215
+
216
+ return self._build_payload(
217
+ question_type="scrabble",
218
+ interaction_mode="assemble",
219
+ prompt_korean="",
220
+ prompt_english=f"{subject_data['english']} is a/an {noun_data['english']}",
221
+ choices=[],
222
+ answer_key=None,
223
+ tokens=shuffled_tokens,
224
+ correct_order=correct_order,
225
+ slot_count=len(tokens),
226
+ translation=f"{subject_data['english']} is a/an {noun_data['english']}",
227
+ grammar_rule="topic_marker",
228
+ hint=f"Topic comes first, then the noun with copula",
229
+ difficulty=difficulty,
230
+ metadata={
231
+ "lesson": "KLP7-base",
232
+ "sentence": f"{token_1} {token_2}",
233
+ "shuffled_indices": shuffled_indices,
234
+ },
235
+ )
236
+
237
+ def _q_scrabble_gemini(self, pack: dict, difficulty: int) -> dict:
238
+ """Use Gemini to generate a varied Korean sentence for assembly."""
239
+ try:
240
+ vocab_sample = random.sample(
241
+ [v["korean"] for v in pack["vocab"] if v["type"] in ("noun", "verb")],
242
+ min(8, len(pack["vocab"]))
243
+ )
244
+ prompt = f"""You are a Korean language teacher generating a sentence assembly exercise.
245
+
246
+ Create a natural Korean sentence using words from this vocabulary:
247
+ {', '.join(vocab_sample)}
248
+
249
+ Difficulty level: {difficulty} (1=simple 2=intermediate 3=advanced)
250
+
251
+ Rules:
252
+ - Difficulty 2: 3-4 tokens, include at least one of: 은/는, 이에요/예요, 을/를
253
+ - Difficulty 3: 4-6 tokens, may include indirect quotation patterns like -다고, -자고, -냐고
254
+
255
+ Return ONLY valid JSON, no markdown:
256
+ {{
257
+ "sentence": "complete Korean sentence",
258
+ "tokens": ["token1", "token2", "token3"],
259
+ "correct_order": [0, 1, 2],
260
+ "translation": "English translation",
261
+ "grammar_focus": "what grammar point this tests"
262
+ }}
263
+
264
+ The tokens array must be in shuffled order (not the correct order).
265
+ correct_order must be indices into the tokens array giving the right sequence."""
266
+
267
+ response = self.client.models.generate_content(
268
+ model="gemini-2.0-flash",
269
+ contents=prompt,
270
+ )
271
+
272
+ text = response.text.strip()
273
+ if text.startswith("```"):
274
+ text = text.split("```")[1]
275
+ if text.startswith("json"):
276
+ text = text[4:]
277
+
278
+ data = json.loads(text)
279
+
280
+ return self._build_payload(
281
+ question_type="scrabble",
282
+ interaction_mode="assemble",
283
+ prompt_korean="",
284
+ prompt_english=data.get("translation", ""),
285
+ choices=[],
286
+ answer_key=None,
287
+ tokens=data.get("tokens", []),
288
+ correct_order=data.get("correct_order", []),
289
+ slot_count=len(data.get("tokens", [])),
290
+ translation=data.get("translation", ""),
291
+ grammar_rule=QTYPE_TO_RULE.get("scrabble", "topic_marker"),
292
+ hint=f"Focus on: {data.get('grammar_focus', 'sentence structure')}",
293
+ difficulty=difficulty,
294
+ metadata={"lesson": "KLP7-10", "sentence": data.get("sentence", ""), "source": "gemini"},
295
+ )
296
+
297
+ except Exception as e:
298
+ logger.warning(f"Gemini scrabble failed, falling back to rule-based: {e}")
299
+ return self._q_scrabble(pack, difficulty=1)
300
+
301
+ # ── Type 5: Indirect Quote -다고 ─────────────────────────────────────────
302
+
303
+ def _q_indirect_dago(self, pack: dict, difficulty: int = 2) -> dict:
304
+ """Generate a -다고 indirect quotation question."""
305
+ if self.client:
306
+ return self._q_indirect_gemini(
307
+ pack, "indirect_quote_dago",
308
+ system="""Generate a -다고 indirect quotation exercise.
309
+ Return JSON:
310
+ {
311
+ "direct_speech": "original Korean sentence",
312
+ "speaker": "Korean name (e.g. 민호, 지수, 현민)",
313
+ "indirect_speech": "correctly converted indirect speech",
314
+ "tokens": ["token1", "token2", ...],
315
+ "correct_order": [0, 1, ...],
316
+ "translation": "English translation of indirect speech",
317
+ "tense": "past/present/future"
318
+ }
319
+ Use patterns: verb+ㄴ/는다고, adjective+다고, past+었/았다고, future+ㄹ 거라고
320
+ Tokens should be 3-5 elements in shuffled order."""
321
+ )
322
+ # Fallback: static example
323
+ return self._build_payload(
324
+ question_type="indirect_quote_dago",
325
+ interaction_mode="assemble",
326
+ prompt_korean='민호: "감기 때문에 많이 아파요"',
327
+ prompt_english="Minho says he is very sick because of a cold",
328
+ choices=[],
329
+ answer_key=None,
330
+ tokens=["많이", "민호가", "아프다고", "감기 때문에", "했어요"],
331
+ correct_order=[1, 3, 0, 2, 4],
332
+ slot_count=5,
333
+ translation="Minho said he is very sick because of a cold",
334
+ grammar_rule="indirect_quote_dago",
335
+ hint="V/Adj + 다고 하다 for statements",
336
+ difficulty=2,
337
+ metadata={"lesson": "KLP7-8", "form": "adjective_present"},
338
+ )
339
+
340
+ # ── Type 6: Indirect Quote Commands ─────────────────────────────────────
341
+
342
+ def _q_indirect_commands(self, pack: dict) -> dict:
343
+ if self.client:
344
+ return self._q_indirect_gemini(
345
+ pack, "indirect_quote_commands",
346
+ system="""Generate a command indirect quotation exercise using one of:
347
+ -(으)라고 (command), -지 말라고 (negative command), -달라고 (request for self), -주라고 (request for other).
348
+
349
+ Return JSON:
350
+ {
351
+ "direct_speech": "original Korean sentence with command/request",
352
+ "speaker": "Korean name",
353
+ "listener": "Korean name",
354
+ "form": "command|neg_command|request_me|request_other",
355
+ "indirect_speech": "correctly converted indirect speech",
356
+ "tokens": ["token1", ...],
357
+ "correct_order": [0, ...],
358
+ "translation": "English translation"
359
+ }
360
+ Tokens 3-5 elements, shuffled."""
361
+ )
362
+ return self._build_payload(
363
+ question_type="indirect_quote_commands",
364
+ interaction_mode="assemble",
365
+ prompt_korean='의사: "약을 먹으세요"',
366
+ prompt_english="The doctor said to take medicine",
367
+ choices=[],
368
+ answer_key=None,
369
+ tokens=["했어요", "의사가", "약을", "먹으라고"],
370
+ correct_order=[1, 2, 3, 0],
371
+ slot_count=4,
372
+ translation="The doctor said to take medicine",
373
+ grammar_rule="indirect_quote_commands",
374
+ hint="V + (으)라고 for commands",
375
+ difficulty=2,
376
+ metadata={"lesson": "KLP7-9", "form": "command"},
377
+ )
378
+
379
+ # ── Type 7: Indirect Quote Questions ────────────────────────────────────
380
+
381
+ def _q_indirect_questions(self, pack: dict) -> dict:
382
+ if self.client:
383
+ return self._q_indirect_gemini(
384
+ pack, "indirect_quote_questions",
385
+ system="""Generate a question indirect quotation exercise using -냐고 or -느냐고.
386
+
387
+ Return JSON:
388
+ {
389
+ "direct_speech": "original Korean question",
390
+ "speaker": "Korean name",
391
+ "indirect_speech": "correctly converted indirect speech",
392
+ "tokens": ["token1", ...],
393
+ "correct_order": [0, ...],
394
+ "translation": "English translation"
395
+ }
396
+ Remember: drop ㄹ from stem before 냐고.
397
+ Tokens 3-5 elements, shuffled."""
398
+ )
399
+ return self._build_payload(
400
+ question_type="indirect_quote_questions",
401
+ interaction_mode="assemble",
402
+ prompt_korean='현민: "사는 곳이 어디예요?"',
403
+ prompt_english="Hyunmin asked where you live",
404
+ choices=[],
405
+ answer_key=None,
406
+ tokens=["현민이", "사는 곳이", "물어봤어요", "어디냐고"],
407
+ correct_order=[0, 1, 3, 2],
408
+ slot_count=4,
409
+ translation="Hyunmin asked where you live",
410
+ grammar_rule="indirect_quote_questions",
411
+ hint="V/Adj + 냐고 for questions (drop ㄹ)",
412
+ difficulty=2,
413
+ metadata={"lesson": "KLP7-10", "form": "question"},
414
+ )
415
+
416
+ # ── Type 8: Indirect Quote Suggestions ──────────────────────────────────
417
+
418
+ def _q_indirect_suggestions(self, pack: dict) -> dict:
419
+ if self.client:
420
+ return self._q_indirect_gemini(
421
+ pack, "indirect_quote_suggestions",
422
+ system="""Generate a suggestion indirect quotation exercise using -자고.
423
+
424
+ Return JSON:
425
+ {
426
+ "direct_speech": "original Korean suggestion",
427
+ "speaker": "Korean name",
428
+ "indirect_speech": "correctly converted indirect speech",
429
+ "tokens": ["token1", ...],
430
+ "correct_order": [0, ...],
431
+ "translation": "English translation"
432
+ }
433
+ Pattern: V + 자고 하다.
434
+ Tokens 3-5 elements, shuffled."""
435
+ )
436
+ return self._build_payload(
437
+ question_type="indirect_quote_suggestions",
438
+ interaction_mode="assemble",
439
+ prompt_korean='친구: "같이 밥 먹자"',
440
+ prompt_english="My friend suggested eating together",
441
+ choices=[],
442
+ answer_key=None,
443
+ tokens=["친구가", "밥 먹자고", "같이", "했어요"],
444
+ correct_order=[0, 2, 1, 3],
445
+ slot_count=4,
446
+ translation="My friend suggested eating together",
447
+ grammar_rule="indirect_quote_suggestions",
448
+ hint="V + 자고 for suggestions",
449
+ difficulty=2,
450
+ metadata={"lesson": "KLP7-10", "form": "suggestion"},
451
+ )
452
+
453
+ # ── Type 9: Regret Expression ────────────────────────────────────────────
454
+
455
+ def _q_regret(self, pack: dict) -> dict:
456
+ if self.client:
457
+ return self._q_indirect_gemini(
458
+ pack, "regret_expression",
459
+ system="""Generate a Korean regret expression exercise using -(으)ㄹ 걸 그랬다 or -지 말 걸 그랬다.
460
+
461
+ Return JSON:
462
+ {
463
+ "situation": "brief situation description in English",
464
+ "sentence": "Korean regret sentence",
465
+ "tokens": ["token1", ...],
466
+ "correct_order": [0, ...],
467
+ "translation": "English translation (I should have...)",
468
+ "negative": false
469
+ }
470
+ Tokens 3-5 elements, shuffled.
471
+ Use realistic daily life situations from the KLP7 lessons."""
472
+ )
473
+ return self._build_payload(
474
+ question_type="regret_expression",
475
+ interaction_mode="assemble",
476
+ prompt_korean="You were late to class because you overslept.",
477
+ prompt_english="I should have gotten up earlier",
478
+ choices=[],
479
+ answer_key=None,
480
+ tokens=["더", "일어날 걸", "일찍"],
481
+ correct_order=[0, 2, 1],
482
+ slot_count=3,
483
+ translation="I should have gotten up earlier",
484
+ grammar_rule="regret_expression",
485
+ hint="Verb stem + (으)ㄹ 걸 그랬다",
486
+ difficulty=2,
487
+ metadata={"lesson": "KLP7-10", "negative": False},
488
+ )
489
+
490
+ # ── Gemini Generic Indirect Quote Helper ─────────────────────────────────
491
+
492
+ def _q_indirect_gemini(self, pack: dict, q_type: str, system: str) -> dict:
493
+ """Generic Gemini-powered indirect quote generator."""
494
+ vocab_sample = random.sample(
495
+ [f"{v['korean']} ({v['english']})" for v in pack["vocab"]
496
+ if v["type"] in ("noun", "verb", "adjective")],
497
+ min(10, len(pack["vocab"]))
498
+ )
499
+
500
+ prompt = f"""{system}
501
+
502
+ Use vocabulary from this list where natural:
503
+ {', '.join(vocab_sample)}
504
+
505
+ Return ONLY valid JSON, no markdown backticks."""
506
+
507
+ try:
508
+ response = self.client.models.generate_content(
509
+ model="gemini-2.0-flash",
510
+ contents=prompt,
511
+ )
512
+ text = response.text.strip()
513
+ if "```" in text:
514
+ text = text.split("```")[1]
515
+ if text.startswith("json"):
516
+ text = text[4:]
517
+
518
+ data = json.loads(text.strip())
519
+
520
+ tokens = data.get("tokens", [])
521
+ correct_order = data.get("correct_order", list(range(len(tokens))))
522
+ translation = data.get("translation", "")
523
+ indirect = data.get("indirect_speech", data.get("sentence", ""))
524
+
525
+ grammar_rule = QTYPE_TO_RULE.get(q_type, q_type)
526
+
527
+ return self._build_payload(
528
+ question_type=q_type,
529
+ interaction_mode="assemble",
530
+ prompt_korean=data.get("direct_speech", ""),
531
+ prompt_english=data.get("situation", translation),
532
+ choices=[],
533
+ answer_key=None,
534
+ tokens=tokens,
535
+ correct_order=correct_order,
536
+ slot_count=len(tokens),
537
+ translation=translation,
538
+ grammar_rule=grammar_rule,
539
+ hint=self._get_hint_for_type(q_type),
540
+ difficulty=2,
541
+ metadata={
542
+ "lesson": "KLP7-10",
543
+ "indirect_speech": indirect,
544
+ "source": "gemini",
545
+ **{k: v for k, v in data.items()
546
+ if k not in ("tokens", "correct_order", "translation")},
547
+ },
548
+ )
549
+
550
+ except Exception as e:
551
+ logger.error(f"Gemini indirect quote failed for {q_type}: {e}")
552
+ raise
553
+
554
+ # ── Helpers ───────────────────────────────────────────────────────────────
555
+
556
+ def _select_type(self, difficulty: int, history: list) -> str:
557
+ available = DIFFICULTY_TYPES.get(difficulty, DIFFICULTY_TYPES[1])
558
+ recent = [h.get("question_type") for h in history[-3:]]
559
+ # Avoid repeating the same type 3 times in a row
560
+ choices = [t for t in available if t not in recent] or available
561
+ return random.choice(choices)
562
+
563
+ def _get_hint_for_type(self, q_type: str) -> str:
564
+ hints = {
565
+ "indirect_quote_dago": "V+ㄴ/는다고, Adj+다고, Past+었/았다고, Future+ㄹ 거라고",
566
+ "indirect_quote_commands": "(으)라고 commands, 지 말라고 negatives, 달라고/주라고 requests",
567
+ "indirect_quote_questions": "V/Adj + 냐고 (remember to drop ㄹ from stem)",
568
+ "indirect_quote_suggestions": "V + 자고 for suggestions",
569
+ "regret_expression": "(으)ㄹ 걸 그랬다 = should have done; 지 말 걸 = should not have done",
570
+ }
571
+ return hints.get(q_type, "Check the grammar rule pattern")
572
+
573
+ def _build_payload(self, **kwargs) -> dict:
574
+ """Build the standardized question payload sent to Unity."""
575
+ return {
576
+ "question_id": str(uuid.uuid4()),
577
+ "question_type": kwargs.get("question_type"),
578
+ "interaction_mode": kwargs.get("interaction_mode"),
579
+ "prompt_korean": kwargs.get("prompt_korean", ""),
580
+ "prompt_english": kwargs.get("prompt_english", ""),
581
+ "tokens": kwargs.get("tokens", []),
582
+ "correct_order": kwargs.get("correct_order", []),
583
+ "slot_count": kwargs.get("slot_count", 0),
584
+ "choices": kwargs.get("choices", []),
585
+ "answer_key": kwargs.get("answer_key"),
586
+ "translation": kwargs.get("translation", ""),
587
+ "grammar_rule": kwargs.get("grammar_rule"),
588
+ "hint": kwargs.get("hint", ""),
589
+ "difficulty": kwargs.get("difficulty", 1),
590
+ "metadata": kwargs.get("metadata", {}),
591
+ }