NaveenKumar Namachivayam commited on
Commit
f8a4887
·
1 Parent(s): 6540178

refactor: extract game, kural, and model logic into separate modules

Browse files

Split monolithic app.py into focused modules for maintainability:
- config.py: constants, regex patterns, file paths, hyperparameters
- model_engine.py: GPT model loading and text generation
- kural_engine.py: kural formatting, validation, anti-memorization logic
- game_engine.py: Valluvar-or-AI quiz round generation and scoring

Simplify app.py to UI-only concerns (Gradio interface). Add *.log to
.gitignore for runtime logs. Preserve

Files changed (7) hide show
  1. .gitignore +1 -0
  2. app.log +0 -0
  3. app.py +44 -325
  4. config.py +21 -0
  5. game_engine.py +122 -0
  6. kural_engine.py +205 -0
  7. model_engine.py +61 -0
.gitignore CHANGED
@@ -8,6 +8,7 @@ wheels/
8
 
9
  # Virtual environments
10
  .venv
 
11
 
12
  # Scratchpads (working code, not committed)
13
  **/scratchpad/
 
8
 
9
  # Virtual environments
10
  .venv
11
+ app.log
12
 
13
  # Scratchpads (working code, not committed)
14
  **/scratchpad/
app.log ADDED
File without changes
app.py CHANGED
@@ -1,11 +1,6 @@
1
- """Gradio app for Thirukkural GPT - Valluvar or AI."""
2
- import random
3
- import re
4
 
5
  # Patch Jinja2 LRUCache to handle unhashable keys (gradio 4.44.0 bug).
6
- # HF Spaces force-installs gradio==4.44.0 which passes a dict as a Jinja2
7
- # cache key. We make the key hashable by converting dicts/lists to a frozenset
8
- # or tuple representation before the LRUCache operations.
9
  try:
10
  from jinja2.utils import LRUCache
11
 
@@ -48,291 +43,11 @@ if not hasattr(huggingface_hub, "HfFolder"):
48
  huggingface_hub.HfFolder = _HfFolder
49
 
50
  import gradio as gr
51
- import torch
52
-
53
- from model import GPT, GPTConfig
54
-
55
-
56
- def load_model():
57
- """Load the trained model and tokenizer."""
58
- # Allow GPTConfig for safe loading
59
- from model import GPTConfig
60
- torch.serialization.add_safe_globals([GPTConfig])
61
- checkpoint = torch.load("checkpoint_final.pt", map_location="cpu", weights_only=True)
62
- config = checkpoint["config"]
63
- stoi = checkpoint["stoi"]
64
- itos = checkpoint["itos"]
65
-
66
- model = GPT(config)
67
- model.load_state_dict(checkpoint["model_state_dict"])
68
- model.eval()
69
-
70
- return model, stoi, itos
71
-
72
-
73
- def generate(model, prompt, stoi, itos, max_new_tokens=200, temperature=0.8, device="cpu", seed=None):
74
- """Generate text from prompt."""
75
- # Set random seed for reproducible/diverse sampling
76
- if seed is not None:
77
- torch.manual_seed(seed)
78
-
79
- model = model.to(device)
80
-
81
- # Encode prompt
82
- prompt_tokens = [stoi.get(c, stoi.get(" ", 0)) for c in prompt]
83
- idx = torch.tensor([prompt_tokens], dtype=torch.long, device=device)
84
-
85
- # Generate
86
- with torch.no_grad():
87
- for _ in range(max_new_tokens):
88
- # Crop to block size
89
- idx_cond = idx[:, -model.config.block_size :]
90
-
91
- # Get predictions
92
- logits, _ = model(idx_cond)
93
- logits = logits[:, -1, :] / temperature
94
-
95
- # Sample
96
- probs = torch.softmax(logits, dim=-1)
97
- idx_next = torch.multinomial(probs, num_samples=1)
98
-
99
- # Append
100
- idx = torch.cat((idx, idx_next), dim=1)
101
-
102
- # Decode
103
- tokens = idx[0].tolist()
104
- result = "".join([itos.get(t, "") for t in tokens])
105
- return result
106
-
107
-
108
- def is_real_kural(text, original_text):
109
- """Check if generated text exists in original kurals.
110
-
111
- A kural is considered "real" if:
112
- 1. The Tamil couplet (2 lines) exists in original
113
- 2. The English translation matches
114
- """
115
- lines = text.strip().split("\n")
116
-
117
- # Get Tamil lines (contain Tamil Unicode)
118
- tamil_lines = [l.strip() for l in lines if re.search(r"[\u0B80-\u0BFF]", l)]
119
- # Get English lines (no Tamil, just text)
120
- english_lines = [l.strip() for l in lines if l.strip() and not re.search(r"[\u0B80-\u0BFF]", l)]
121
-
122
- if len(tamil_lines) < 2:
123
- return False
124
-
125
- # Check if Tamil couplet exists in original
126
- first_tamil = tamil_lines[0]
127
- second_tamil = tamil_lines[1] if len(tamil_lines) > 1 else ""
128
-
129
- # A true kural needs both Tamil lines to exist consecutively
130
- tamil_couplet = first_tamil + "\n" + second_tamil
131
- if tamil_couplet not in original_text:
132
- return False
133
-
134
- # Also check that English lines roughly match (at least one should exist)
135
- if english_lines:
136
- first_english = english_lines[0]
137
- # Check if this English translation exists near the Tamil
138
- return first_english in original_text
139
-
140
- return True
141
-
142
-
143
- # Load model and data
144
- print("Loading model...")
145
- model, stoi, itos = load_model()
146
- print(f"Model loaded: {sum(p.numel() for p in model.parameters()) / 1e6:.1f}M params")
147
-
148
- # Load original text for verification
149
- with open("thirukkural_clean.txt", "r", encoding="utf-8") as f:
150
- ORIGINAL_TEXT = f.read()
151
-
152
-
153
- def _extract_tamil_couplet(text):
154
- """Extract the first 2 Tamil lines from text, skipping headers."""
155
- lines = text.strip().split("\n")
156
- tamil_lines = []
157
-
158
- for line in lines:
159
- line = line.strip()
160
- if not line or " - " in line:
161
- continue
162
- # Skip short Tamil headers (1-2 words without English)
163
- if re.search(r"[\u0B80-\u0BFF]", line) and len(line.split()) <= 2 and not re.search(r"[a-zA-Z]", line):
164
- continue
165
- # Collect Tamil lines
166
- if re.search(r"[\u0B80-\u0BFF]", line):
167
- if len(tamil_lines) < 2:
168
- tamil_lines.append(line)
169
-
170
- if len(tamil_lines) >= 2:
171
- return tamil_lines[0] + "\n" + tamil_lines[1]
172
- return None
173
-
174
-
175
- def _is_couplet_in_original(couplet, original_text):
176
- """Check if a Tamil couplet exists in the original text."""
177
- return couplet and couplet in original_text
178
-
179
-
180
- def generate_kural(prompt, temperature, max_tokens):
181
- """Generate and format kural with proper structure."""
182
- # Keep generating until we get an AI-generated kural (not a real one)
183
- # Use high temperature and many attempts to overcome memorization
184
- max_attempts = 20
185
- output_raw = None
186
- final_attempt = 0
187
- is_ai_generated = False
188
-
189
- for attempt in range(max_attempts):
190
- final_attempt = attempt + 1
191
- # Aggressive temperature scaling for diversity (0.8 → up to 2.0)
192
- temp = temperature + (attempt * 0.15)
193
- if temp > 2.0:
194
- temp = 2.0
195
- seed = random.randint(1, 10000000)
196
- output_raw = generate(model, prompt, stoi, itos, int(max_tokens) + 100, temp, seed=seed)
197
-
198
- # Check if the formatted Tamil couplet is a real kural
199
- couplet = _extract_tamil_couplet(output_raw)
200
- if not _is_couplet_in_original(couplet, ORIGINAL_TEXT):
201
- # Found an AI-generated one (couplet not in original)
202
- is_ai_generated = True
203
- break
204
- # Otherwise continue - this kural is in the original text
205
-
206
- # Extract first complete kural from generated text
207
- lines = output_raw.strip().split("\n")
208
-
209
- # Find the first proper kural (skip headers, get 2 Tamil + 2 English lines)
210
- tamil_lines = []
211
- english_lines = []
212
-
213
- for line in lines:
214
- line = line.strip()
215
- if not line or " - " in line:
216
- continue
217
- # Skip short Tamil headers (1-2 words)
218
- if re.search(r"[\u0B80-\u0BFF]", line) and len(line.split()) <= 2 and not re.search(r"[a-zA-Z]", line):
219
- continue
220
-
221
- if re.search(r"[\u0B80-\u0BFF]", line):
222
- if len(tamil_lines) < 2:
223
- tamil_lines.append(line)
224
- elif line and len(english_lines) < 2:
225
- english_lines.append(line)
226
-
227
- # Build formatted output
228
- formatted_lines = []
229
- if tamil_lines:
230
- formatted_lines.extend(tamil_lines[:2])
231
- if english_lines:
232
- formatted_lines.extend(english_lines[:2])
233
-
234
- output = "\n".join(formatted_lines) if formatted_lines else format_kural(output_raw)
235
-
236
- # Determine source label based on actual result
237
- if is_ai_generated:
238
- source = "🤖 AI Generated"
239
- # Confidence: higher is better (fewer attempts = higher confidence)
240
- # Scale: 100% (1st attempt) down to 20% (20th attempt)
241
- confidence = max(20, 100 - ((final_attempt - 1) * 4))
242
- else:
243
- source = "📖 Original Thirukkural"
244
- confidence = 100 # 100% confident it's grounded
245
-
246
- source_with_confidence = f"{source} (Confidence: {confidence}%)"
247
- return output, source_with_confidence
248
-
249
-
250
- def format_kural(text):
251
- """Format kural text with proper structure (2 Tamil + 2 English lines)."""
252
- lines = text.strip().split("\n")
253
-
254
- # Skip headers: lines with " - " OR short single Tamil words (chapter names)
255
- def is_header(line):
256
- # Headers have " - " or are short Tamil-only phrases (1-3 words)
257
- if " - " in line:
258
- return True
259
- # Check if it's a short Tamil phrase (likely a chapter title)
260
- if re.search(r"[\u0B80-\u0BFF]", line) and len(line.split()) <= 3:
261
- # And no English words
262
- if not re.search(r"[a-zA-Z]", line):
263
- return True
264
- return False
265
-
266
- content_lines = [l.strip() for l in lines if l.strip() and not is_header(l)]
267
-
268
- # Classify lines
269
- tamil_lines = [l for l in content_lines if re.search(r"[\u0B80-\u0BFF]", l)]
270
- english_lines = [l for l in content_lines if l and not re.search(r"[\u0B80-\u0BFF]", l)]
271
-
272
- # Build proper 4-line kural
273
- formatted = []
274
-
275
- # Tamil couplet (2 lines)
276
- if len(tamil_lines) >= 2:
277
- formatted.extend(tamil_lines[:2])
278
- elif len(tamil_lines) == 1:
279
- formatted.append(tamil_lines[0])
280
- formatted.append("") # Placeholder
281
-
282
- # English translation (2 lines)
283
- if len(english_lines) >= 2:
284
- formatted.extend(english_lines[:2])
285
- elif len(english_lines) == 1:
286
- formatted.append(english_lines[0])
287
- formatted.append("")
288
-
289
- return "\n".join(formatted)
290
-
291
-
292
- def valluvar_or_ai_quiz():
293
- """Generate a quiz: one real, one AI."""
294
- # Get random real kural - find a proper 4-line kural
295
- lines = ORIGINAL_TEXT.strip().split("\n")
296
-
297
- # Find a random valid kural (2 Tamil + 2 English lines)
298
- attempts = 0
299
- real_kural = ""
300
- while attempts < 100:
301
- idx = random.randint(0, len(lines) - 4)
302
- chunk = lines[idx:idx+4]
303
- tamil_count = sum(1 for l in chunk if re.search(r"[\u0B80-\u0BFF]", l))
304
- english_count = sum(1 for l in chunk if l.strip() and not re.search(r"[\u0B80-\u0BFF]", l))
305
- if tamil_count == 2 and english_count == 2:
306
- real_kural = "\n".join(chunk).strip()
307
- break
308
- attempts += 1
309
-
310
- # Fallback if no proper kural found
311
- if not real_kural:
312
- real_kural = "அகர முதல எழுத்தெல்லாம் ஆதி\nபகவன் முதற்றே உலகு\n'A' leads letters; the Ancient Lord\nLeads and lords the entire world"
313
-
314
- # Generate AI kural with random prompt
315
- prompts = ["கடவுள் வாழ்த்து", "��ட்பு", "அறன்", "வான் சிறப்பு", "அரசியல்"]
316
- prompt = random.choice(prompts)
317
- ai_kural_raw = generate(model, prompt, stoi, itos, 150, 0.8)
318
- ai_kural = format_kural(ai_kural_raw)
319
-
320
- # Format real kural too
321
- real_kural = format_kural(real_kural)
322
-
323
- # Shuffle
324
- kurals = [("A", real_kural, True), ("B", ai_kural, False)]
325
- random.shuffle(kurals)
326
-
327
- return (
328
- f"## Option A\n```\n{kurals[0][1]}\n```\n\n---\n\n## Option B\n```\n{kurals[1][1]}\n```",
329
- kurals[0][2],
330
- kurals[1][2],
331
- "A" if kurals[0][2] else "B",
332
- )
333
 
 
 
 
334
 
335
- # Gradio Interface
336
  with gr.Blocks(title="Valluvar or AI?") as demo:
337
  gr.Markdown("# 🕉️ Valluvar or AI?")
338
  gr.Markdown(
@@ -365,10 +80,7 @@ with gr.Blocks(title="Valluvar or AI?") as demo:
365
  generate_btn = gr.Button("Generate", variant="primary")
366
 
367
  with gr.Column():
368
- output = gr.Textbox(
369
- label="Generated Kural",
370
- lines=10,
371
- )
372
  source = gr.Textbox(label="Source")
373
 
374
  generate_btn.click(
@@ -377,7 +89,6 @@ with gr.Blocks(title="Valluvar or AI?") as demo:
377
  outputs=[output, source],
378
  )
379
 
380
- # Quick theme buttons
381
  gr.Markdown("### Quick Themes")
382
  with gr.Row():
383
  themes = [
@@ -391,41 +102,51 @@ with gr.Blocks(title="Valluvar or AI?") as demo:
391
  btn = gr.Button(theme)
392
  btn.click(lambda t=theme: t, outputs=prompt)
393
 
394
- with gr.Tab("🎯 Valluvar or AI? Quiz"):
395
- gr.Markdown("Can you tell which is the original Thirukkural and which is AI-generated?")
 
 
 
 
 
 
 
 
 
 
 
 
396
 
397
- quiz_output = gr.Markdown()
398
  with gr.Row():
399
- guess_a = gr.Button("Option A is Real", variant="secondary")
400
- guess_b = gr.Button("Option B is Real", variant="secondary")
401
- quiz_result = gr.Markdown()
402
- new_quiz_btn = gr.Button("New Quiz", variant="primary")
403
-
404
- # Store answers
405
- a_is_real = gr.State()
406
- b_is_real = gr.State()
407
- correct_answer = gr.State()
408
-
409
- def check_answer(guess, a_real, b_real, correct):
410
- if guess == correct:
411
- return "✅ Correct! You identified the original Thirukkural."
412
- return "❌ Wrong! The original Thirukkural was: " + correct
413
-
414
- new_quiz_btn.click(
415
- fn=valluvar_or_ai_quiz,
416
- outputs=[quiz_output, a_is_real, b_is_real, correct_answer],
417
  )
418
 
419
- guess_a.click(
420
- fn=lambda a, b, c: check_answer("A", a, b, c),
421
- inputs=[a_is_real, b_is_real, correct_answer],
422
- outputs=quiz_result,
423
  )
424
 
425
- guess_b.click(
426
- fn=lambda a, b, c: check_answer("B", a, b, c),
427
- inputs=[a_is_real, b_is_real, correct_answer],
428
- outputs=quiz_result,
429
  )
430
 
431
  with gr.Tab("📊 About"):
@@ -462,9 +183,7 @@ with gr.Blocks(title="Valluvar or AI?") as demo:
462
  """
463
  )
464
 
465
-
466
  import os
467
 
468
- # Use port from environment (HF Spaces) or default to 7860 (local)
469
  port = int(os.environ.get("PORT", "7860"))
470
  demo.launch(server_name="0.0.0.0", server_port=port)
 
1
+ """Gradio UI for Thirukkural GPT - Valluvar or AI."""
 
 
2
 
3
  # Patch Jinja2 LRUCache to handle unhashable keys (gradio 4.44.0 bug).
 
 
 
4
  try:
5
  from jinja2.utils import LRUCache
6
 
 
43
  huggingface_hub.HfFolder = _HfFolder
44
 
45
  import gradio as gr
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
46
 
47
+ from model_engine import model, stoi, itos
48
+ from kural_engine import generate_kural
49
+ from game_engine import new_round, check_guess
50
 
 
51
  with gr.Blocks(title="Valluvar or AI?") as demo:
52
  gr.Markdown("# 🕉️ Valluvar or AI?")
53
  gr.Markdown(
 
80
  generate_btn = gr.Button("Generate", variant="primary")
81
 
82
  with gr.Column():
83
+ output = gr.Textbox(label="Generated Kural", lines=10)
 
 
 
84
  source = gr.Textbox(label="Source")
85
 
86
  generate_btn.click(
 
89
  outputs=[output, source],
90
  )
91
 
 
92
  gr.Markdown("### Quick Themes")
93
  with gr.Row():
94
  themes = [
 
102
  btn = gr.Button(theme)
103
  btn.click(lambda t=theme: t, outputs=prompt)
104
 
105
+ with gr.Tab("🎯 Valluvar or AI?"):
106
+ gr.Markdown(
107
+ "### Can you tell the difference?\n"
108
+ "Read the Tamil couplet and guess whether it was written by "
109
+ "Thiruvalluvar or generated by the AI."
110
+ )
111
+
112
+ couplet_display = gr.Textbox(
113
+ label="குறள்",
114
+ lines=3,
115
+ interactive=False,
116
+ value="Click **Next Couplet** to begin!",
117
+ )
118
+ game_state = gr.State(value=None)
119
 
 
120
  with gr.Row():
121
+ valluvar_btn = gr.Button("📖 Valluvar", variant="secondary", scale=1)
122
+ ai_btn = gr.Button("🤖 AI", variant="secondary", scale=1)
123
+
124
+ reveal_display = gr.Markdown()
125
+ next_btn = gr.Button("Next Couplet", variant="primary")
126
+
127
+ def safe_new_round():
128
+ try:
129
+ return new_round()
130
+ except Exception as e:
131
+ print(f"[ERROR] Round generation failed: {e}")
132
+ fallback = "அகர முதல் எழுத்தெல்லாம் ஆதி\nபகவன் முதற்றே உலகு"
133
+ return fallback, None, "⚠️ Error loading round. Try again."
134
+
135
+ next_btn.click(
136
+ fn=safe_new_round,
137
+ outputs=[couplet_display, game_state, reveal_display],
 
138
  )
139
 
140
+ valluvar_btn.click(
141
+ fn=lambda s: check_guess("valluvar", s),
142
+ inputs=[game_state],
143
+ outputs=[reveal_display],
144
  )
145
 
146
+ ai_btn.click(
147
+ fn=lambda s: check_guess("ai", s),
148
+ inputs=[game_state],
149
+ outputs=[reveal_display],
150
  )
151
 
152
  with gr.Tab("📊 About"):
 
183
  """
184
  )
185
 
 
186
  import os
187
 
 
188
  port = int(os.environ.get("PORT", "7860"))
189
  demo.launch(server_name="0.0.0.0", server_port=port)
config.py ADDED
@@ -0,0 +1,21 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Application constants and compiled regex patterns."""
2
+ import re
3
+ from pathlib import Path
4
+ from typing import Final
5
+
6
+ # Regex patterns
7
+ _TAMIL_RE: Final = re.compile(r"[\u0B80-\u0BFF]")
8
+ _ENGLISH_RE: Final = re.compile(r"[a-zA-Z]")
9
+
10
+ # File paths
11
+ _KURAL_FILE: Final = Path("thirukkural_clean.txt")
12
+ _CHECKPOINT_FILE: Final = Path("checkpoint_final.pt")
13
+
14
+ # Generation hyperparameters
15
+ _MAX_GENERATION_ATTEMPTS: Final = 15
16
+ _MAX_GENERATE_ATTEMPTS: Final = 20
17
+ _TEMP_INCREMENT: Final = 0.15
18
+ _MAX_TEMP: Final = 2.0
19
+ _BASE_TEMP: Final = 1.0
20
+ _GENERATE_MAX_TOKENS: Final = 150
21
+ _MAX_SEED: Final = 10_000_000
game_engine.py ADDED
@@ -0,0 +1,122 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Game logic for Valluvar or AI guessing game."""
2
+ import random
3
+ from typing import Any, Final
4
+
5
+ from config import (
6
+ _MAX_GENERATION_ATTEMPTS,
7
+ _TEMP_INCREMENT,
8
+ _MAX_TEMP,
9
+ _MAX_SEED,
10
+ _GENERATE_MAX_TOKENS,
11
+ _BASE_TEMP,
12
+ )
13
+ from model_engine import generate, model, stoi, itos
14
+ from kural_engine import (
15
+ _extract_tamil_couplet,
16
+ _is_couplet_in_original,
17
+ _is_valid_kural_structure,
18
+ format_kural,
19
+ _extract_kural_lines,
20
+ _get_kurals_db,
21
+ ORIGINAL_TEXT,
22
+ )
23
+
24
+ _SYNTHETIC_FALLBACKS: Final = (
25
+ "கற்க கசடறக் கற்பவர் கற்றபின்\nநிற்க அதற்கு தக",
26
+ "அன்பும் அறனும் உடைத்தாம் முகத்தில்\nஇன்பும் இறைவன் துணை",
27
+ "அறம் செய்யும் உள்ளத்தார் வாழ்வின்\nஇன்பம் என்றும் தங்கும்",
28
+ "கல்வி கற்பவர் கண்ணோட்டம் கொள்ளின்\nஅறிவு வளர்ந்து வரும்",
29
+ "நல்லாறு நின்றார் நிலைமை சொல்லுங்கால்\nசொல்லுக சான்றோர் முன்",
30
+ )
31
+ _PROMPTS: Final = (
32
+ "கடவுள் வாழ்த்து",
33
+ "நட்பு",
34
+ "அறன்",
35
+ "வான் சிறப்பு",
36
+ "அரசியல்",
37
+ "பொருள்",
38
+ "கல்வி",
39
+ "காதல்",
40
+ )
41
+
42
+
43
+ def new_round() -> tuple[str, dict[str, Any] | None, str]:
44
+ """Start a new guessing round. Returns Tamil couplet, state, and cleared reveal."""
45
+ is_real = random.choice((True, False))
46
+
47
+ if is_real:
48
+ kural = random.choice(_get_kurals_db())
49
+ tamil_only = f"{kural['tamil_1']}\n{kural['tamil_2']}"
50
+ state = {"is_real": True, "data": kural}
51
+ return tamil_only, state, ""
52
+
53
+ prompt = random.choice(_PROMPTS)
54
+ couplet: str | None = None
55
+ ai_kural_raw = ""
56
+ attempts = 0
57
+ temp = _BASE_TEMP
58
+
59
+ for attempt in range(_MAX_GENERATION_ATTEMPTS):
60
+ attempts = attempt + 1
61
+ temp = min(_BASE_TEMP + attempt * _TEMP_INCREMENT, _MAX_TEMP)
62
+ seed = random.randint(1, _MAX_SEED)
63
+ ai_kural_raw = generate(model, prompt, stoi, itos, max_new_tokens=_GENERATE_MAX_TOKENS, temperature=temp, seed=seed)
64
+
65
+ couplet = _extract_tamil_couplet(ai_kural_raw)
66
+ if couplet and not _is_couplet_in_original(couplet, ORIGINAL_TEXT):
67
+ if _is_valid_kural_structure(couplet.split("\n")):
68
+ break
69
+
70
+ if not couplet or _is_couplet_in_original(couplet, ORIGINAL_TEXT):
71
+ tamil_only = random.choice(_SYNTHETIC_FALLBACKS)
72
+ english_lines: list[str] = []
73
+ confidence = 50
74
+ attempts = _MAX_GENERATION_ATTEMPTS
75
+ else:
76
+ formatted = format_kural(ai_kural_raw)
77
+ tamil_lines, english_lines = _extract_kural_lines(formatted.split("\n"))
78
+ tamil_only = "\n".join(tamil_lines[:2]) if len(tamil_lines) >= 2 else formatted
79
+ confidence = max(20, 100 - (attempts - 1) * 6)
80
+
81
+ state = {
82
+ "is_real": False,
83
+ "data": {
84
+ "prompt": prompt,
85
+ "temperature": temp,
86
+ "attempts": attempts,
87
+ "tamil_couplet": tamil_only,
88
+ "english_lines": english_lines[:2],
89
+ "confidence": confidence,
90
+ },
91
+ }
92
+ return tamil_only, state, ""
93
+
94
+
95
+ def check_guess(guess: str, state: dict[str, Any] | None) -> str:
96
+ """Check the user's guess and return reveal markdown."""
97
+ if not state:
98
+ return "⚠️ Click **Next Couplet** to start!"
99
+
100
+ is_real = state["is_real"]
101
+ correct = (guess == "valluvar" and is_real) or (guess == "ai" and not is_real)
102
+ verdict = "✅ Correct!" if correct else "❌ Wrong!"
103
+ data = state["data"]
104
+
105
+ if is_real:
106
+ return (
107
+ f"## {verdict}\n\n"
108
+ f"### 📖 Real Thirukkural #{data['number']}\n\n"
109
+ f"**Chapter:** {data['chapter_tamil']} — *{data['chapter_english']}*\n\n"
110
+ f"**Meaning:**\n> {data['english_1']}\n> {data['english_2']}"
111
+ )
112
+
113
+ eng_text = "\n> ".join(data["english_lines"]) if data["english_lines"] else "*(No translation generated)*"
114
+ return (
115
+ f"## {verdict}\n\n"
116
+ f"### 🤖 AI Generated\n\n"
117
+ f"**Prompt:** `{data['prompt']}` \n"
118
+ f"**Temperature:** {data['temperature']:.2f} \n"
119
+ f"**Generation attempts:** {data['attempts']} \n"
120
+ f"**Confidence:** {data['confidence']}% \n\n"
121
+ f"**Model's translation:**\n> {eng_text}"
122
+ )
kural_engine.py ADDED
@@ -0,0 +1,205 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Kural text processing, parsing, and structured generation."""
2
+ import random
3
+ from typing import Any
4
+
5
+ from config import (
6
+ _TAMIL_RE,
7
+ _ENGLISH_RE,
8
+ _KURAL_FILE,
9
+ _MAX_GENERATE_ATTEMPTS,
10
+ _TEMP_INCREMENT,
11
+ _MAX_TEMP,
12
+ _MAX_SEED,
13
+ _GENERATE_MAX_TOKENS,
14
+ )
15
+ from model_engine import generate, model, stoi, itos
16
+
17
+ # ---------------------------------------------------------------------------
18
+ # Raw corpus
19
+ # ---------------------------------------------------------------------------
20
+ with _KURAL_FILE.open("r", encoding="utf-8") as fh:
21
+ ORIGINAL_TEXT = fh.read()
22
+
23
+
24
+ # ---------------------------------------------------------------------------
25
+ # Helpers
26
+ # ---------------------------------------------------------------------------
27
+ def _is_tamil_line(line: str) -> bool:
28
+ """Return True if the line contains Tamil Unicode characters."""
29
+ return bool(_TAMIL_RE.search(line))
30
+
31
+
32
+ def _is_header(line: str) -> bool:
33
+ """Return True if the line is a chapter header, not a kural."""
34
+ if " - " in line:
35
+ return True
36
+ words = line.split()
37
+ return bool(_TAMIL_RE.search(line) and len(words) <= 2 and not _ENGLISH_RE.search(line))
38
+
39
+
40
+ def _extract_tamil_couplet(text: str) -> str | None:
41
+ """Extract the first 2 Tamil lines from text, skipping headers."""
42
+ lines = text.strip().split("\n")
43
+ tamil_lines = []
44
+ for line in lines:
45
+ line = line.strip()
46
+ if not line or " - " in line or _is_header(line):
47
+ continue
48
+ if _is_tamil_line(line) and len(tamil_lines) < 2:
49
+ tamil_lines.append(line)
50
+ return "\n".join(tamil_lines[:2]) if len(tamil_lines) >= 2 else None
51
+
52
+
53
+ def _is_couplet_in_original(couplet: str | None, original_text: str) -> bool:
54
+ """Check if a Tamil couplet exists in the original text."""
55
+ return bool(couplet and couplet in original_text)
56
+
57
+
58
+ def _is_valid_kural_structure(tamil_lines: list[str]) -> bool:
59
+ """Check if Tamil lines follow Thirukkural structure: 4 words first line, 3 words second line."""
60
+ return len(tamil_lines) >= 2 and len(tamil_lines[0].split()) == 4 and len(tamil_lines[1].split()) == 3
61
+
62
+
63
+ def _extract_kural_lines(lines: list[str]) -> tuple[list[str], list[str]]:
64
+ """Extract Tamil and English lines from raw output, skipping headers."""
65
+ tamil_lines: list[str] = []
66
+ english_lines: list[str] = []
67
+ for line in lines:
68
+ line = line.strip()
69
+ if not line or " - " in line or _is_header(line):
70
+ continue
71
+ if _is_tamil_line(line):
72
+ if len(tamil_lines) < 2:
73
+ tamil_lines.append(line)
74
+ elif len(english_lines) < 2:
75
+ english_lines.append(line)
76
+ return tamil_lines, english_lines
77
+
78
+
79
+ # ---------------------------------------------------------------------------
80
+ # Formatting
81
+ # ---------------------------------------------------------------------------
82
+ def format_kural(text: str) -> str:
83
+ """Format kural text with proper structure (2 Tamil + 2 English lines)."""
84
+ content_lines = [l.strip() for l in text.strip().split("\n") if l.strip() and not _is_header(l)]
85
+ tamil_lines = [l for l in content_lines if _is_tamil_line(l)]
86
+ english_lines = [l for l in content_lines if l and not _is_tamil_line(l)]
87
+
88
+ formatted: list[str] = []
89
+ formatted.extend(tamil_lines[:2] if len(tamil_lines) >= 2 else [tamil_lines[0], ""] if tamil_lines else [])
90
+ formatted.extend(english_lines[:2] if len(english_lines) >= 2 else [english_lines[0], ""] if english_lines else [])
91
+
92
+ return "\n".join(formatted)
93
+
94
+
95
+ # ---------------------------------------------------------------------------
96
+ # Parsing
97
+ # ---------------------------------------------------------------------------
98
+ def parse_kurals(text: str) -> list[dict[str, Any]]:
99
+ """Parse original text into structured kurals with chapter, number, and text."""
100
+ kurals: list[dict[str, Any]] = []
101
+ lines = text.strip().split("\n")
102
+ current_chapter = ""
103
+ current_chapter_en = ""
104
+ kural_number = 0
105
+ i = 0
106
+ total = len(lines)
107
+
108
+ while i < total:
109
+ line = lines[i].strip()
110
+
111
+ if " - " in line:
112
+ current_chapter, current_chapter_en = line.split(" - ", 1)
113
+ current_chapter = current_chapter.strip()
114
+ current_chapter_en = current_chapter_en.strip()
115
+ i += 1
116
+ continue
117
+
118
+ if not line or i + 3 >= total:
119
+ i += 1
120
+ continue
121
+
122
+ chunk = [lines[i + j].strip() for j in range(4)]
123
+ tamil = [l for l in chunk if _is_tamil_line(l)]
124
+ english = [l for l in chunk if l and not _is_tamil_line(l)]
125
+
126
+ if len(tamil) == 2 and len(english) == 2:
127
+ kural_number += 1
128
+ kurals.append({
129
+ "number": kural_number,
130
+ "chapter_tamil": current_chapter,
131
+ "chapter_english": current_chapter_en,
132
+ "tamil_1": tamil[0],
133
+ "tamil_2": tamil[1],
134
+ "english_1": english[0],
135
+ "english_2": english[1],
136
+ })
137
+ i += 5
138
+ continue
139
+
140
+ i += 1
141
+
142
+ return kurals
143
+
144
+
145
+ _KURALS_DB: list[dict[str, Any]] | None = None
146
+
147
+
148
+ def _get_kurals_db() -> list[dict[str, Any]]:
149
+ global _KURALS_DB
150
+ if _KURALS_DB is None:
151
+ _KURALS_DB = parse_kurals(ORIGINAL_TEXT)
152
+ return _KURALS_DB
153
+
154
+
155
+ # ---------------------------------------------------------------------------
156
+ # Structured generation
157
+ # ---------------------------------------------------------------------------
158
+ def generate_kural(prompt: str, temperature: float, max_tokens: float) -> tuple[str, str]:
159
+ """Generate and format kural with proper structure."""
160
+ output_raw = ""
161
+ final_attempt = 0
162
+ is_ai_generated = False
163
+
164
+ for attempt in range(_MAX_GENERATE_ATTEMPTS):
165
+ final_attempt = attempt + 1
166
+ temp = min(temperature + attempt * _TEMP_INCREMENT, _MAX_TEMP)
167
+ seed = random.randint(1, _MAX_SEED)
168
+ output_raw = generate(model, prompt, stoi, itos, max_new_tokens=int(max_tokens) + 100, temperature=temp, seed=seed)
169
+
170
+ couplet = _extract_tamil_couplet(output_raw)
171
+ if not couplet:
172
+ continue
173
+
174
+ tamil_lines = couplet.split("\n")
175
+ if not _is_valid_kural_structure(tamil_lines):
176
+ is_ai_generated = True
177
+ break
178
+
179
+ if not _is_couplet_in_original(couplet, ORIGINAL_TEXT):
180
+ is_ai_generated = True
181
+ break
182
+
183
+ lines = output_raw.strip().split("\n")
184
+ tamil_lines, english_lines = _extract_kural_lines(lines)
185
+
186
+ # If we don't have valid 4-3 structure, try to find lines that do
187
+ if tamil_lines and len(tamil_lines) >= 2 and not _is_valid_kural_structure(tamil_lines):
188
+ all_tamil = [l.strip() for l in lines if _is_tamil_line(l)]
189
+ for i in range(len(all_tamil) - 1):
190
+ candidate = [all_tamil[i], all_tamil[i + 1]]
191
+ if _is_valid_kural_structure(candidate):
192
+ tamil_lines = candidate
193
+ break
194
+
195
+ formatted_lines = tamil_lines[:2] + english_lines[:2]
196
+ output = "\n".join(formatted_lines) if formatted_lines else format_kural(output_raw)
197
+
198
+ if is_ai_generated:
199
+ source = "🤖 AI Generated"
200
+ confidence = max(20, 100 - (final_attempt - 1) * 4)
201
+ else:
202
+ source = "📖 Original Thirukkural"
203
+ confidence = 100
204
+
205
+ return output, f"{source} (Confidence: {confidence}%)"
model_engine.py ADDED
@@ -0,0 +1,61 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Model loading and text generation engine."""
2
+ import torch
3
+
4
+ from model import GPT, GPTConfig
5
+ from config import _CHECKPOINT_FILE
6
+
7
+
8
+ def load_model() -> tuple[GPT, dict[str, int], dict[int, str]]:
9
+ """Load the trained model and tokenizer."""
10
+ torch.serialization.add_safe_globals([GPTConfig])
11
+ checkpoint = torch.load(_CHECKPOINT_FILE, map_location="cpu", weights_only=True)
12
+ config = checkpoint["config"]
13
+ stoi = checkpoint["stoi"]
14
+ itos = checkpoint["itos"]
15
+
16
+ model = GPT(config)
17
+ model.load_state_dict(checkpoint["model_state_dict"])
18
+ model.eval()
19
+
20
+ return model, stoi, itos
21
+
22
+
23
+ def generate(
24
+ model: GPT,
25
+ prompt: str,
26
+ stoi: dict[str, int],
27
+ itos: dict[int, str],
28
+ *,
29
+ max_new_tokens: int = 200,
30
+ temperature: float = 0.8,
31
+ device: str = "cpu",
32
+ seed: int | None = None,
33
+ ) -> str:
34
+ """Generate text from prompt."""
35
+ if seed is not None:
36
+ torch.manual_seed(seed)
37
+
38
+ model = model.to(device)
39
+
40
+ prompt_tokens = [stoi.get(c, stoi.get(" ", 0)) for c in prompt]
41
+ idx = torch.tensor([prompt_tokens], dtype=torch.long, device=device)
42
+
43
+ with torch.no_grad():
44
+ for _ in range(max_new_tokens):
45
+ idx_cond = idx[:, -model.config.block_size :]
46
+ logits, _ = model(idx_cond)
47
+ logits = logits[:, -1, :] / temperature
48
+ probs = torch.softmax(logits, dim=-1)
49
+ idx_next = torch.multinomial(probs, num_samples=1)
50
+ idx = torch.cat((idx, idx_next), dim=1)
51
+
52
+ tokens = idx[0].tolist()
53
+ return "".join([itos.get(t, "") for t in tokens])
54
+
55
+
56
+ # ---------------------------------------------------------------------------
57
+ # Eager-load model on first import
58
+ # ---------------------------------------------------------------------------
59
+ print("Loading model...")
60
+ model, stoi, itos = load_model()
61
+ print(f"Model loaded: {sum(p.numel() for p in model.parameters()) / 1e6:.1f}M params")