endurasolution commited on
Commit
a828eeb
·
verified ·
1 Parent(s): 2982ec3

Initial release: Aksharakuppy Manglish->Malayalam IME

Browse files
.gitattributes CHANGED
@@ -33,3 +33,4 @@ saved_model/**/* filter=lfs diff=lfs merge=lfs -text
33
  *.zip filter=lfs diff=lfs merge=lfs -text
34
  *.zst filter=lfs diff=lfs merge=lfs -text
35
  *tfevents* filter=lfs diff=lfs merge=lfs -text
 
 
33
  *.zip filter=lfs diff=lfs merge=lfs -text
34
  *.zst filter=lfs diff=lfs merge=lfs -text
35
  *tfevents* filter=lfs diff=lfs merge=lfs -text
36
+ checkpoints_ime/lexicon.json filter=lfs diff=lfs merge=lfs -text
.ipynb_checkpoints/README-checkpoint.md ADDED
@@ -0,0 +1,47 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ ---
2
+ license: apache-2.0
3
+ language:
4
+ - ml
5
+ tags:
6
+ - transliteration
7
+ - malayalam
8
+ - manglish
9
+ - input-method
10
+ - ime
11
+ library_name: pytorch
12
+ pipeline_tag: translation
13
+ datasets:
14
+ - ai4bharat/Aksharantar
15
+ ---
16
+
17
+ # Aksharakuppy — Manglish → Malayalam Transliteration IME
18
+
19
+ A character-level Transformer + frequency-dictionary hybrid that converts
20
+ romanized Malayalam (Manglish) into Malayalam script, with Google-IME-style
21
+ word suggestions.
22
+
23
+ ## Model
24
+ - 4-layer encoder-decoder Transformer, ~16.6M params, character-level tokenization
25
+ - Hybrid: ~677k-word frequency lexicon (trie prefix lookup) + neural fallback for out-of-vocabulary words
26
+ - Trained from scratch on a custom corpus + AI4Bharat Aksharantar (Malayalam) + Google Input Tools corrections
27
+
28
+ ## Usage
29
+ ​```python
30
+ from ime import IME
31
+ ime = IME("checkpoints_ime")
32
+ print(ime.suggest("dhoni")) # ['ധോണി', ...]
33
+ print(ime.suggest("keralam")) # ['കേരളം', ...]
34
+ ​```
35
+
36
+ ## Web demo
37
+ ​```bash
38
+ pip install torch fastapi uvicorn
39
+ uvicorn server:app --host 0.0.0.0 --port 8000
40
+ ​```
41
+
42
+ ## Limitations
43
+ - Ambiguous romanizations may return multiple valid words; the intended word is usually in the top 5.
44
+ - Coverage reflects the training lexicon; rare or new words use the neural fallback.
45
+
46
+ ## License
47
+ Apache-2.0. Training data includes AI4Bharat Aksharantar (manually collected: CC-BY; mined: CC0).
README.md ADDED
@@ -0,0 +1,47 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ ---
2
+ license: apache-2.0
3
+ language:
4
+ - ml
5
+ tags:
6
+ - transliteration
7
+ - malayalam
8
+ - manglish
9
+ - input-method
10
+ - ime
11
+ library_name: pytorch
12
+ pipeline_tag: translation
13
+ datasets:
14
+ - ai4bharat/Aksharantar
15
+ ---
16
+
17
+ # Aksharakuppy — Manglish → Malayalam Transliteration IME
18
+
19
+ A character-level Transformer + frequency-dictionary hybrid that converts
20
+ romanized Malayalam (Manglish) into Malayalam script, with Google-IME-style
21
+ word suggestions.
22
+
23
+ ## Model
24
+ - 4-layer encoder-decoder Transformer, ~16.6M params, character-level tokenization
25
+ - Hybrid: ~677k-word frequency lexicon (trie prefix lookup) + neural fallback for out-of-vocabulary words
26
+ - Trained from scratch on a custom corpus + AI4Bharat Aksharantar (Malayalam) + Google Input Tools corrections
27
+
28
+ ## Usage
29
+ ​```python
30
+ from ime import IME
31
+ ime = IME("checkpoints_ime")
32
+ print(ime.suggest("dhoni")) # ['ധോണി', ...]
33
+ print(ime.suggest("keralam")) # ['കേരളം', ...]
34
+ ​```
35
+
36
+ ## Web demo
37
+ ​```bash
38
+ pip install torch fastapi uvicorn
39
+ uvicorn server:app --host 0.0.0.0 --port 8000
40
+ ​```
41
+
42
+ ## Limitations
43
+ - Ambiguous romanizations may return multiple valid words; the intended word is usually in the top 5.
44
+ - Coverage reflects the training lexicon; rare or new words use the neural fallback.
45
+
46
+ ## License
47
+ Apache-2.0. Training data includes AI4Bharat Aksharantar (manually collected: CC-BY; mined: CC0).
checkpoints_ime/best.pt ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:85f811c32ec277b2017abafb6afe44a11feefe3ca8600707baa61c3a077e6941
3
+ size 199500041
checkpoints_ime/lexicon.json ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:6285398a3e68b7ed1888fa3936026f61a91dfa6b820c5cd3b2e3c0ef8fbff5e2
3
+ size 40547832
checkpoints_ime/vocab.json ADDED
@@ -0,0 +1 @@
 
 
1
+ {"<pad>": 0, "<bos>": 1, "<eos>": 2, "<unk>": 3, "-": 4, "a": 5, "b": 6, "c": 7, "d": 8, "e": 9, "f": 10, "g": 11, "h": 12, "i": 13, "j": 14, "k": 15, "l": 16, "m": 17, "n": 18, "o": 19, "p": 20, "q": 21, "r": 22, "s": 23, "t": 24, "u": 25, "v": 26, "w": 27, "x": 28, "y": 29, "z": 30, "ം": 31, "ഃ": 32, "അ": 33, "ആ": 34, "ഇ": 35, "ഈ": 36, "ഉ": 37, "ഊ": 38, "ഋ": 39, "എ": 40, "ഏ": 41, "ഐ": 42, "ഒ": 43, "ഓ": 44, "ഔ": 45, "ക": 46, "ഖ": 47, "ഗ": 48, "ഘ": 49, "ങ": 50, "ച": 51, "ഛ": 52, "ജ": 53, "ഝ": 54, "ഞ": 55, "ട": 56, "ഠ": 57, "ഡ": 58, "ഢ": 59, "ണ": 60, "ത": 61, "ഥ": 62, "ദ": 63, "ധ": 64, "ന": 65, "പ": 66, "ഫ": 67, "ബ": 68, "ഭ": 69, "മ": 70, "യ": 71, "ര": 72, "റ": 73, "ല": 74, "ള": 75, "ഴ": 76, "വ": 77, "ശ": 78, "ഷ": 79, "സ": 80, "ഹ": 81, "ാ": 82, "ി": 83, "ീ": 84, "ു": 85, "ൂ": 86, "ൃ": 87, "െ": 88, "േ": 89, "ൈ": 90, "ൊ": 91, "ോ": 92, "ൌ": 93, "്": 94, "ൗ": 95, "ൺ": 96, "ൻ": 97, "ർ": 98, "ൽ": 99, "ൾ": 100, "ൿ": 101, "‌": 102, "‍": 103}
config.py ADDED
@@ -0,0 +1,53 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import os
2
+ import torch
3
+
4
+ MODE = os.environ.get("TRANSLIT_MODE", "sentence") # "sentence" | "ime"
5
+
6
+
7
+ class Config:
8
+ # ---------------- common ----------------
9
+ d_model = 384
10
+ nhead = 6
11
+ num_layers = 4
12
+ dim_ff = 1536
13
+ dropout = 0.1
14
+
15
+ batch_size = 1024
16
+ lr = 7e-4
17
+ warmup_steps = 2000
18
+ weight_decay = 0.01
19
+ label_smooth = 0.1
20
+ grad_clip = 1.0
21
+ num_workers = 24
22
+ amp_dtype = torch.bfloat16
23
+
24
+ max_src_len = 256
25
+ max_tgt_len = 320
26
+
27
+ dry_run_samples = 20_000
28
+ dry_run_steps = 100
29
+
30
+ # ---------------- mode specific ----------------
31
+ if MODE == "ime":
32
+ train_path = "data/word_train.jsonl"
33
+ val_path = "data/word_val.jsonl"
34
+ test_path = "data/word_test.jsonl"
35
+ ckpt_dir = "checkpoints_ime"
36
+ max_len = 64
37
+ batch_size = 1024 # big dataset again -> big batch
38
+ epochs = 10 # millions of words -> few epochs suffice
39
+ lr = 7e-4
40
+ warmup_steps = 2000
41
+ dropout = 0.1 # plenty of data -> normal dropout
42
+ else:
43
+ # sentence-level Malayalam -> Manglish (original)
44
+ train_path = "data/train.jsonl"
45
+ val_path = "data/val.jsonl"
46
+ test_path = "data/test.jsonl"
47
+ ckpt_dir = "checkpoints"
48
+ max_len = 512
49
+ epochs = 8
50
+
51
+
52
+ CFG = Config()
53
+ print(f"[config] mode={MODE} data={CFG.train_path} ckpt={CFG.ckpt_dir}")
ime.py ADDED
@@ -0,0 +1,179 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import json
2
+ import torch
3
+ import torch.nn.functional as F
4
+ from tokenizer import CharTokenizer, PAD, BOS, EOS
5
+ from model import TranslitModel
6
+ from config import CFG
7
+
8
+ DEVICE = "cuda" if torch.cuda.is_available() else "cpu"
9
+
10
+
11
+ class Trie:
12
+ def __init__(self):
13
+ self.root = {}
14
+
15
+ def insert(self, word, payload):
16
+ node = self.root
17
+ for c in word:
18
+ node = node.setdefault(c, {})
19
+ node.setdefault("$", []).append(payload)
20
+
21
+ def prefix(self, pre, limit=400):
22
+ node = self.root
23
+ for c in pre:
24
+ if c not in node:
25
+ return []
26
+ node = node[c]
27
+ out, stack = [], [node]
28
+ while stack and len(out) < limit:
29
+ n = stack.pop()
30
+ if "$" in n:
31
+ out.extend(n["$"])
32
+ for k, v in n.items():
33
+ if k != "$":
34
+ stack.append(v)
35
+ return out
36
+
37
+
38
+ class IME:
39
+ def __init__(self, ckpt_dir="checkpoints_ime"):
40
+ # ---------- lexicon trie ----------
41
+ with open(f"{ckpt_dir}/lexicon.json", encoding="utf-8") as f:
42
+ lex = json.load(f)
43
+ self.trie = Trie()
44
+ for g, targets in lex.items():
45
+ for m, n in targets.items():
46
+ self.trie.insert(g, (g, m, n))
47
+
48
+ # ---------- neural fallback model ----------
49
+ self.tok = CharTokenizer.load(f"{ckpt_dir}/vocab.json")
50
+ self.model = TranslitModel(
51
+ len(self.tok), CFG.d_model, CFG.nhead,
52
+ CFG.num_layers, CFG.dim_ff,
53
+ dropout=0.0, max_len=64,
54
+ ).to(DEVICE)
55
+ sd = torch.load(f"{ckpt_dir}/best.pt", map_location=DEVICE)["model"]
56
+ sd = {k.replace("_orig_mod.", ""): v for k, v in sd.items()}
57
+ self.model.load_state_dict(sd)
58
+ self.model.eval()
59
+
60
+ # ---------- dictionary path (trie prefix lookup + noise filtering) ----------
61
+ def _dict_suggest(self, q, k):
62
+ hits = self.trie.prefix(q, limit=400)
63
+ exact = [h for h in hits if h[0] == q]
64
+ pref = [h for h in hits if h[0] != q]
65
+
66
+ # exact-match noise filter: if a dominant exact form exists,
67
+ # drop rare "alternatives" (misaligned one-off pairs)
68
+ if exact:
69
+ top = max(h[2] for h in exact)
70
+ if top >= 5:
71
+ exact = [h for h in exact if h[2] >= max(2, top * 0.02)]
72
+ exact.sort(key=lambda h: -h[2])
73
+
74
+ # prefix completions: Aksharantar has millions of n=1 glued
75
+ # compounds (e.g. "entesuhruthaya"). Keep a completion only if it
76
+ # was seen more than once OR is not much longer than the query,
77
+ # then rank by frequency and shortness.
78
+ pref = [h for h in pref if h[2] >= 2 or len(h[0]) <= len(q) + 6]
79
+ pref.sort(key=lambda h: (-h[2], len(h[0])))
80
+
81
+ out, seen = [], set()
82
+ for g, m, n in exact + pref:
83
+ if m not in seen:
84
+ seen.add(m)
85
+ out.append(m)
86
+ if len(out) == k:
87
+ break
88
+ return out
89
+
90
+ # ---------- neural path (beam search) ----------
91
+ @torch.no_grad()
92
+ def _model_suggest(self, q, k=3, beam=8, max_len=48):
93
+ src = torch.tensor([self.tok.encode(q)], device=DEVICE)
94
+ beams = [(torch.tensor([[BOS]], device=DEVICE), 0.0, False)]
95
+ for _ in range(max_len):
96
+ if all(f for _, _, f in beams):
97
+ break
98
+ cands = []
99
+ for ids, lp, fin in beams:
100
+ if fin:
101
+ cands.append((ids, lp, True))
102
+ continue
103
+ logits = self.model(src, ids)[0, -1]
104
+ logp = F.log_softmax(logits.float(), -1)
105
+ tl, ti = logp.topk(beam)
106
+ for l, ix in zip(tl.tolist(), ti.tolist()):
107
+ nids = torch.cat(
108
+ [ids, torch.tensor([[ix]], device=DEVICE)], 1)
109
+ cands.append((nids, lp + l, ix == EOS))
110
+ cands.sort(key=lambda c: c[1] / (c[0].size(1) ** 0.7),
111
+ reverse=True)
112
+ beams = cands[:beam]
113
+
114
+ out, seen = [], set()
115
+ for ids, _, _ in beams:
116
+ s = self.tok.decode(ids[0].tolist())
117
+ if s and s not in seen:
118
+ seen.add(s)
119
+ out.append(s)
120
+ if len(out) == k:
121
+ break
122
+ return out
123
+
124
+ # ---------- public API ----------
125
+ # ---------- public API ----------
126
+ def suggest(self, manglish_word, k=5):
127
+ q = manglish_word.strip()
128
+ if not q:
129
+ return []
130
+ if self._is_passthrough(q):
131
+ return self._number_suggestions(q)
132
+
133
+ ql = q.lower()
134
+ dict_hits = self._dict_suggest(ql, k)
135
+
136
+ # is there an exact-length dictionary match? (romanization == input)
137
+ has_exact = any(h[0] == ql for h in self.trie.prefix(ql, limit=50))
138
+
139
+ if not has_exact:
140
+ # no bare-form entry -> the model's direct transliteration is
141
+ # usually the clean word the user wants (e.g. dhoni -> ധോണി)
142
+ model_hits = self._model_suggest(ql, k=2)
143
+ merged = []
144
+ for s in model_hits + dict_hits:
145
+ if s not in merged:
146
+ merged.append(s)
147
+ return merged[:k]
148
+
149
+ return dict_hits[:k]
150
+
151
+ @staticmethod
152
+ def _is_passthrough(q):
153
+ # token is all digits / punctuation / has no latin letters to transliterate
154
+ return not any(c.isalpha() and ord(c) < 128 for c in q)
155
+
156
+ # Western -> Malayalam digit map
157
+ _ML_DIGITS = str.maketrans("0123456789", "൦൧൨൩൪൫൬൭൮൯")
158
+
159
+ def _number_suggestions(self, q):
160
+ out = [q] # keep as-is (10)
161
+ if any(c.isdigit() for c in q):
162
+ ml = q.translate(self._ML_DIGITS) # malayalam numerals (൧൦)
163
+ if ml != q:
164
+ out.append(ml)
165
+ return out
166
+
167
+
168
+ if __name__ == "__main__":
169
+ ime = IME()
170
+ tests = [
171
+ "thi", "thila", # prefix -> dict completions
172
+ "amma", "ente", "adukkala", "veedu", # common words
173
+ "krithyamaayi",
174
+ "njan", "nammal", "keralam", # now covered by aksharantar
175
+ "thiruvananthapuram",
176
+ "blockchain", "kunjava", # OOV / rare -> model fallback
177
+ ]
178
+ for w in tests:
179
+ print(f"{w:22s} -> {ime.suggest(w)}")
model.py ADDED
@@ -0,0 +1,65 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import math, torch
2
+ import torch.nn as nn
3
+
4
+
5
+ class PositionalEncoding(nn.Module):
6
+ def __init__(self, d_model, max_len=512):
7
+ super().__init__()
8
+ pe = torch.zeros(max_len, d_model)
9
+ pos = torch.arange(max_len).unsqueeze(1).float()
10
+ div = torch.exp(torch.arange(0, d_model, 2).float()
11
+ * (-math.log(10000.0) / d_model))
12
+ pe[:, 0::2] = torch.sin(pos * div)
13
+ pe[:, 1::2] = torch.cos(pos * div)
14
+ self.register_buffer("pe", pe.unsqueeze(0))
15
+
16
+ def forward(self, x):
17
+ return x + self.pe[:, : x.size(1)]
18
+
19
+
20
+ class TranslitModel(nn.Module):
21
+ def __init__(self, vocab_size, d_model=384, nhead=6,
22
+ num_layers=4, dim_ff=1536, dropout=0.1, max_len=512):
23
+ super().__init__()
24
+ self.d_model = d_model
25
+
26
+ self.embed = nn.Embedding(vocab_size, d_model, padding_idx=0)
27
+ self.pos = PositionalEncoding(d_model, max_len)
28
+
29
+ self.transformer = nn.Transformer(
30
+ d_model=d_model,
31
+ nhead=nhead,
32
+ num_encoder_layers=num_layers,
33
+ num_decoder_layers=num_layers,
34
+ dim_feedforward=dim_ff,
35
+ dropout=dropout,
36
+ batch_first=True,
37
+ norm_first=True,
38
+ )
39
+
40
+ self.out = nn.Linear(d_model, vocab_size)
41
+ self.out.weight = self.embed.weight # weight tying
42
+
43
+ # ---- proper init for tied embedding (fixes huge initial loss) ----
44
+ nn.init.normal_(self.embed.weight, mean=0.0, std=d_model ** -0.5)
45
+ nn.init.zeros_(self.out.bias)
46
+ with torch.no_grad():
47
+ self.embed.weight[0].fill_(0) # keep padding row at zero
48
+
49
+ def forward(self, src, tgt_in):
50
+ src_pad = src == 0
51
+ tgt_pad = tgt_in == 0
52
+ causal = nn.Transformer.generate_square_subsequent_mask(
53
+ tgt_in.size(1), device=src.device)
54
+
55
+ s = self.pos(self.embed(src) * math.sqrt(self.d_model))
56
+ t = self.pos(self.embed(tgt_in) * math.sqrt(self.d_model))
57
+
58
+ h = self.transformer(
59
+ s, t,
60
+ tgt_mask=causal,
61
+ src_key_padding_mask=src_pad,
62
+ tgt_key_padding_mask=tgt_pad,
63
+ memory_key_padding_mask=src_pad,
64
+ )
65
+ return self.out(h)
server.py ADDED
@@ -0,0 +1,123 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from fastapi import FastAPI
2
+ from fastapi.responses import HTMLResponse
3
+ from ime import IME
4
+
5
+ app = FastAPI()
6
+ ime = IME()
7
+
8
+
9
+ @app.get("/suggest")
10
+ def suggest(q: str, k: int = 5):
11
+ return {"input": q, "suggestions": ime.suggest(q, k=k)}
12
+
13
+
14
+ PAGE = """
15
+ <!doctype html>
16
+ <html lang="en">
17
+ <head>
18
+ <meta charset="utf-8">
19
+ <meta name="viewport" content="width=device-width, initial-scale=1">
20
+ <title>Manglish → Malayalam IME</title>
21
+ <style>
22
+ :root{
23
+ --bg:#0f1115; --panel:#171a21; --panel2:#1e222b;
24
+ --text:#e6e8ec; --muted:#8b93a1; --accent:#4f8cff;
25
+ --accent-soft:#22314f; --border:#2a2f3a; --chip:#232833;
26
+ }
27
+ *{box-sizing:border-box}
28
+ body{
29
+ font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,sans-serif;
30
+ background:var(--bg); color:var(--text);
31
+ margin:0; min-height:100vh;
32
+ display:flex; align-items:flex-start; justify-content:center;
33
+ }
34
+ .wrap{width:100%; max-width:680px; padding:56px 20px}
35
+ h1{font-size:22px; font-weight:600; margin:0 0 4px}
36
+ .sub{color:var(--muted); font-size:14px; margin:0 0 28px}
37
+ #out{
38
+ font-size:26px; line-height:1.5; min-height:52px;
39
+ background:var(--panel); border:1px solid var(--border);
40
+ border-radius:12px; padding:14px 16px; margin-bottom:16px;
41
+ word-break:break-word;
42
+ }
43
+ #out:empty::before{content:"Your Malayalam text appears here…"; color:var(--muted); font-size:17px}
44
+ input{
45
+ width:100%; font-size:22px; padding:14px 16px;
46
+ background:var(--panel2); color:var(--text);
47
+ border:1px solid var(--border); border-radius:12px; outline:none;
48
+ transition:border-color .15s;
49
+ }
50
+ input:focus{border-color:var(--accent)}
51
+ input::placeholder{color:var(--muted)}
52
+ #sug{margin-top:12px; min-height:52px; display:flex; flex-wrap:wrap; gap:8px}
53
+ .s{
54
+ padding:9px 16px; background:var(--chip); color:var(--text);
55
+ border:1px solid var(--border); border-radius:10px;
56
+ font-size:22px; cursor:pointer; user-select:none;
57
+ transition:background .12s, border-color .12s;
58
+ }
59
+ .s:hover{background:#2b313d}
60
+ .s:first-child{background:var(--accent-soft); border-color:var(--accent)}
61
+ .hint{color:var(--muted); font-size:13px; margin-top:18px; line-height:1.6}
62
+ .hint b{color:var(--text); font-weight:600}
63
+ </style>
64
+ </head>
65
+ <body>
66
+ <div class="wrap">
67
+ <h1>Manglish → Malayalam IME</h1>
68
+ <p class="sub">Type Malayalam using English letters.</p>
69
+
70
+ <div id="out"></div>
71
+ <input id="inp" placeholder="type manglish… e.g. ente veedu" autocomplete="off" autofocus>
72
+ <div id="sug"></div>
73
+
74
+ <p class="hint">
75
+ <b>space</b> or <b>enter</b> = accept first suggestion ·
76
+ <b>click</b> a chip to pick ·
77
+ <b>backspace</b> on empty input deletes last word
78
+ </p>
79
+ </div>
80
+
81
+ <script>
82
+ const inp=document.getElementById('inp'),
83
+ sug=document.getElementById('sug'),
84
+ out=document.getElementById('out');
85
+ let timer=null,current=[],ctr=0;
86
+
87
+ inp.addEventListener('input',()=>{clearTimeout(timer);timer=setTimeout(fetchSug,110)});
88
+
89
+ async function fetchSug(){
90
+ const q=inp.value.trim();
91
+ if(!q){sug.innerHTML='';current=[];return}
92
+ const my=++ctr;
93
+ try{
94
+ const r=await fetch('/suggest?q='+encodeURIComponent(q));
95
+ const j=await r.json();
96
+ if(my!==ctr)return; // ignore stale responses
97
+ current=j.suggestions;
98
+ sug.innerHTML=current.map(s=>`<span class="s">${s}</span>`).join('');
99
+ [...sug.children].forEach((el,i)=>el.onclick=()=>pick(current[i]));
100
+ }catch(e){/* ignore */}
101
+ }
102
+ function pick(s){
103
+ out.textContent+=s+' ';
104
+ inp.value='';sug.innerHTML='';current=[];inp.focus();
105
+ }
106
+ inp.addEventListener('keydown',e=>{
107
+ if((e.key===' '||e.key==='Enter')&&current.length){
108
+ e.preventDefault();pick(current[0]);
109
+ }else if(e.key==='Backspace'&&!inp.value){
110
+ e.preventDefault();
111
+ out.textContent=out.textContent.trimEnd().split(' ').slice(0,-1).join(' ');
112
+ if(out.textContent)out.textContent+=' ';
113
+ }
114
+ });
115
+ </script>
116
+ </body>
117
+ </html>
118
+ """
119
+
120
+
121
+ @app.get("/", response_class=HTMLResponse)
122
+ def index():
123
+ return PAGE
tokenizer.py ADDED
@@ -0,0 +1,41 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import json
2
+
3
+ PAD, BOS, EOS, UNK = 0, 1, 2, 3
4
+ SPECIALS = ["<pad>", "<bos>", "<eos>", "<unk>"]
5
+
6
+ class CharTokenizer:
7
+ def __init__(self, vocab=None):
8
+ self.vocab = vocab or {}
9
+ self.inv = {i: c for c, i in self.vocab.items()}
10
+
11
+ @classmethod
12
+ def build(cls, texts, min_freq=20):
13
+ from collections import Counter
14
+ cnt = Counter()
15
+ for t in texts:
16
+ cnt.update(t)
17
+ vocab = {s: i for i, s in enumerate(SPECIALS)}
18
+ for ch, freq in sorted(cnt.items()):
19
+ if freq >= min_freq:
20
+ vocab[ch] = len(vocab)
21
+ return cls(vocab)
22
+
23
+ def encode(self, text, add_special=True):
24
+ ids = [self.vocab.get(c, UNK) for c in text]
25
+ return [BOS] + ids + [EOS] if add_special else ids
26
+
27
+ def decode(self, ids):
28
+ return "".join(self.inv.get(i, "") for i in ids
29
+ if i not in (PAD, BOS, EOS, UNK))
30
+
31
+ def save(self, path):
32
+ with open(path, "w", encoding="utf-8") as f:
33
+ json.dump(self.vocab, f, ensure_ascii=False)
34
+
35
+ @classmethod
36
+ def load(cls, path):
37
+ with open(path, encoding="utf-8") as f:
38
+ return cls(json.load(f))
39
+
40
+ def __len__(self):
41
+ return len(self.vocab)