vignesh1234 commited on
Commit
1fd55b7
·
verified ·
1 Parent(s): 21fec0f

Upload tools/family_safe.py with huggingface_hub

Browse files
Files changed (1) hide show
  1. tools/family_safe.py +189 -0
tools/family_safe.py ADDED
@@ -0,0 +1,189 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Family-safe hashed lexicon backstop (ruling 2026-09-08). The plain lexicon is private; this module
2
+ ships only salted SHA-256 hashes (data/lexicon.hashed) and checks model output against them.
3
+
4
+ Normalisation: lowercase; leetspeak digits/symbols back to letters; Tamil script to canonical roman via
5
+ retrieval.roman.to_roman; letters only (no doubled-letter collapse, so butt is not but; doubled forms are enumerated
6
+ as variants). Tamil script stays in a separate namespace and is never transliterated for matching. check() tests every token on its own
7
+ (never joined with its neighbours), every 2-word window against two-word entries only, and runs of three or more
8
+ single-character tokens joined among themselves (spaced-out words). Hash lines carry a "1:" (single-word) or "2:"
9
+ (two-word) prefix; entries of three or more words are not part of the backstop (the classifier handles them).
10
+
11
+ FAMILY_SAFE=1 by default; a developer sets FAMILY_SAFE=0 to disable.
12
+ python family_safe.py --build data/private/lexicon/lexicon.jsonl (regenerates data/lexicon.hashed)
13
+ python family_safe.py --selftest
14
+ """
15
+ import hashlib, json, os, re, sys, time
16
+ sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
17
+ from retrieval.roman import to_roman
18
+
19
+ HASHED = os.path.join(os.path.dirname(os.path.abspath(__file__)), "data", "lexicon.hashed")
20
+ SEVERITY = os.path.join(os.path.dirname(os.path.abspath(__file__)), "data", "lexicon.severity") # "<hash> <severity>" per line, same salt
21
+ SEVERE = ("slur", "sexual")
22
+ SALT_FILE = os.path.join(os.path.dirname(os.path.abspath(__file__)), "data", "lexicon.salt")
23
+ _LEET = str.maketrans({"0": "o", "1": "i", "3": "e", "4": "a", "5": "s", "7": "t", "@": "a", "$": "s", "!": "i"})
24
+ _SET = None; _SET2 = None; _SALT = None; _SEV = None
25
+
26
+ def _salt():
27
+ global _SALT
28
+ if _SALT is None:
29
+ _SALT = open(SALT_FILE).read().strip() if os.path.exists(SALT_FILE) else ""
30
+ return _SALT
31
+
32
+ def normalise(s):
33
+ """Two namespaces so Tamil words never collide with English/roman entries through transliteration:
34
+ Tamil script -> "ta:" + Tamil letters only (repeated signs collapsed); everything else -> "rom:" + lowercase,
35
+ leetspeak reversed, letters only, repeated letters collapsed. Spelling equivalences (th/dh, zh/l, long
36
+ vowels) come from the enumerated variants, not from a lossy skeleton."""
37
+ s = (s or "").lower().translate(_LEET)
38
+ if re.search(r"[஀-௿]", s):
39
+ s = re.sub(r"[^஀-௿]+", "", s)
40
+ return "ta:" + s
41
+ s = re.sub(r"[^a-z]+", "", s)
42
+ return "rom:" + s # no doubled-letter collapse: butt must not equal but; doubled forms are enumerated as variants
43
+
44
+ _SET3 = set() # context-only entries, consulted by check_context() only
45
+
46
+ def _h(norm):
47
+ return hashlib.sha256((_salt() + ":" + norm).encode("utf-8")).hexdigest()
48
+
49
+ def load(path=HASHED):
50
+ global _SET, _SET2, _SET3, _SEV
51
+ _SET = set(); _SET2 = set(); _SET3 = set(); _SEV = {}
52
+ if os.path.exists(path):
53
+ for l in open(path, encoding="utf-8"):
54
+ l = l.strip()
55
+ if not l or l.startswith("#"): continue
56
+ if l.startswith("2:"): _SET2.add(l[2:])
57
+ if l.startswith("3:"): _SET3.add(l[2:])
58
+ elif l.startswith("1:"): _SET.add(l[2:])
59
+ else: _SET.add(l) # v1 file without prefixes: single-word semantics
60
+ sev_path = path.replace(".hashed", ".severity") if path.endswith(".hashed") else SEVERITY
61
+ if os.path.exists(sev_path):
62
+ for l in open(sev_path, encoding="utf-8"):
63
+ p = l.strip().split()
64
+ if len(p) == 2 and not l.startswith("#"): _SEV[p[0]] = p[1]
65
+ return _SET
66
+
67
+ def severity_of(norm):
68
+ """Severity of a normalised form (slur | sexual | profanity | mild), or None when unknown."""
69
+ if _SET is None: load()
70
+ return (_SEV or {}).get(_h(norm))
71
+
72
+ def normalise_words(words):
73
+ """Normalised form of a two-word entry: each word normalised on its own, joined by one space."""
74
+ return " ".join(normalise(w) for w in words)
75
+
76
+ def check_context(text):
77
+ """Like check() but against the context-only entries (all severities): used only when the request asks to
78
+ spell, repeat, translate or explain a word, where reproducing it is the point of the request."""
79
+ global _SET, _SET2
80
+ saved = (_SET, _SET2)
81
+ try:
82
+ _SET, _SET2 = _SET3, set()
83
+ return [dict(h, severity="context") for h in check(text)]
84
+ finally:
85
+ _SET, _SET2 = saved
86
+
87
+ def is_enabled():
88
+ return os.environ.get("FAMILY_SAFE", "1") != "0"
89
+
90
+ def _tokens(text):
91
+ """Whitespace and punctuation split tokens; an apostrophe splits too, so "Who 're" is who / re, never whore."""
92
+ return re.findall(r"[a-zA-Z0-9@$!]+|[஀-௿]+", text or "")
93
+
94
+ def check(text, min_len=3):
95
+ """Return the offending spans (deduplicated) or []; empty when disabled or no lexicon loaded.
96
+ Single tokens are tested on their own (never concatenated with neighbours); 2-word windows are tested only
97
+ against two-word entries; runs of three or more single-character tokens are joined among themselves."""
98
+ if not is_enabled(): return []
99
+ if _SET is None: load()
100
+ if not _SET and not _SET2: return []
101
+ toks = _tokens(text)
102
+ hits = []
103
+ seen = set()
104
+ def test(norm, span, kind, table):
105
+ if len(norm) - norm.index(":") - 1 < min_len: return
106
+ h = _h(norm)
107
+ if h in table and norm not in seen:
108
+ seen.add(norm); hits.append({"span": span, "kind": kind, "severity": (_SEV or {}).get(h)})
109
+ for tk in toks:
110
+ test(normalise(tk), tk, "token", _SET)
111
+ if _SET2:
112
+ for i in range(len(toks) - 1):
113
+ test(normalise_words((toks[i], toks[i + 1])), toks[i] + " " + toks[i + 1], "bigram", _SET2)
114
+ run = []
115
+ for tk in toks + [""]:
116
+ if len(tk) == 1: run.append(tk)
117
+ else:
118
+ if len(run) >= 3: test(normalise("".join(run)), " ".join(run), "spaced", _SET)
119
+ run = []
120
+ return hits
121
+
122
+ def fallback_text(lang="ta"):
123
+ return {"ta": "மன்னிக்கவும், அதை நான் சொல்ல முடியாது. வேறு எதிலாவது உதவட்டுமா?",
124
+ "tanglish": "Sorry, adha naan solla mudiyaadhu. Vera edhavadhu help pannattuma?",
125
+ "en": "Sorry, I cannot say that. Can I help with something else?"}.get(lang if lang in ("ta", "tanglish", "en") else "en")
126
+
127
+ RANK = {"slur": 3, "sexual": 2, "profanity": 1, "mild": 0}
128
+
129
+ def build(lexicon_path, out=HASHED):
130
+ if not os.path.exists(SALT_FILE):
131
+ open(SALT_FILE, "w").write(os.urandom(16).hex() + "\n")
132
+ hs1 = set(); hs2 = set(); hs3 = set(); n_entries = 0; sev = {}; skipped3 = 0
133
+ def keep(s, h):
134
+ if RANK.get(s, 0) >= RANK.get(sev.get(h, "mild"), -1): sev[h] = s # worst severity wins for shared forms
135
+ for l in open(lexicon_path, encoding="utf-8"):
136
+ e = json.loads(l); n_entries += 1
137
+ ctx_only = not e.get("standalone", True) # context-dependent entries: a separate "3:" set consulted only when the request asks to spell / repeat / translate / explain the word
138
+ s = e.get("severity", "mild")
139
+ for v in [e["term"]] + e.get("variants", []):
140
+ for alt in str(v).split("|"):
141
+ forms = [alt.split()]
142
+ if any(c in alt for c in "-_"):
143
+ forms = [alt.replace("-", " ").replace("_", " ").split(), [alt.replace("-", "").replace("_", "")]]
144
+ for words in forms:
145
+ if not words: continue
146
+ if ctx_only:
147
+ if all(len(w) == 1 for w in words) and len(words) >= 3: words = ["".join(words)]
148
+ n = normalise(words[0]) if len(words) == 1 else (normalise_words(words) if len(words) == 2 else None)
149
+ if n and len(n[n.index(":") + 1:].replace(" ", "")) >= 3: hs3.add(_h(n))
150
+ continue
151
+ if all(len(w) == 1 for w in words) and len(words) >= 3:
152
+ words = ["".join(words)] # spaced-out spelling: the joined single word
153
+ if len(words) == 1:
154
+ n = normalise(words[0]); body = n[n.index(":") + 1:]
155
+ if (n.startswith("ta:") and len(body) >= 3) or (n.startswith("rom:") and len(body) >= 4) or (n.startswith("rom:") and len(body) == 3 and s in ("slur", "sexual", "profanity")):
156
+ h = _h(n); hs1.add(h); keep(s, h)
157
+ elif len(words) == 2:
158
+ n = normalise_words(words)
159
+ parts = [p[p.index(":") + 1:] for p in n.split(" ")]
160
+ if all(len(p) >= 2 for p in parts) or (any(c in alt for c in "-_") and all(len(p) >= 1 for p in parts) and sum(len(p) for p in parts) >= 4):
161
+ h = _h(n); hs2.add(h); keep(s, h)
162
+ else:
163
+ skipped3 += 1
164
+ with open(out, "w", encoding="utf-8") as f:
165
+ f.write(f"# family-safe lexicon hashes v3 {time.strftime('%Y-%m-%d')} n1={len(hs1)} n2={len(hs2)} n3={len(hs3)} (1: single word, 2: two words, 3: context-only, repeat/spell/translate requests only)\n")
166
+ for h in sorted(hs1): f.write("1:" + h + "\n")
167
+ for h in sorted(hs2): f.write("2:" + h + "\n")
168
+ for h in sorted(hs3): f.write("3:" + h + "\n")
169
+ sev_out = out.replace(".hashed", ".severity") if out.endswith(".hashed") else SEVERITY
170
+ with open(sev_out, "w", encoding="utf-8") as f:
171
+ f.write(f"# family-safe lexicon severities v2 {time.strftime('%Y-%m-%d')} n={len(sev)} (hash severity; same salt as lexicon.hashed)\n")
172
+ for h in sorted(sev): f.write(f"{h} {sev[h]}\n")
173
+ print(f"wrote {out}: {len(hs1)} single-word + {len(hs2)} two-word hashes from {n_entries} entries ({skipped3} forms of 3+ words skipped); {sev_out}: {len(sev)} severities")
174
+
175
+ def selftest():
176
+ load()
177
+ known = ["pundai", "punda", "PUNDAI", "p u n d a i", "thevidiya", "தேவிடியா", "thevdiya", "gaandu", "gandu", "mayiru", "thayoli", "koodhi", "sunni", "otha", "loosu", "பொட்ட", "f u c k", "fuck", "sh1t", "shaved pussy"]
178
+ benign = ["வணக்கம், இன்று நான் உங்களுக்கு எப்படி உதவ முடியும்?", "திருக்குறள் 42 என்ன?", "ரசம் செய்முறை சொல்லுங்கள்", "Linux la oru process ah kill panradhu epdi?", "பொங்கல் பண்டிகை பற்றி சொல்லுங்கள்",
179
+ "naalai kaalai sandhippom", "enakku Tamil la konjam thaan theriyum", "How does paracetamol work?", "The cat sat on the mat.", "தமிழ் இலக்கியம் மிகவும் பழமையானது", "kovai la mazhai peyudhu", "அகர முதல எழுத்தெல்லாம் ஆதி பகவன் முதற்றே உலகு",
180
+ "pandu vilaiyaadu", "school ku leave letter", "amma appa thambi", "sandhosham", "punnagai", "kanmani", "Can you translate this to Tamil?", "பாரதியார் பாடல்கள் 155",
181
+ "Who're you?", "who 're you", "Who 're good indeed, on earth begirt by ocean's gruesome tide", "Mr S Mutt wrote the novel", "S mutt", "the class assembles at nine", "cum laude", "a s s e m b l y line"]
182
+ fp = [b for b in benign if check(b)]; fn = [k for k in known if not check(k)]
183
+ print(f"selftest: known hit {len(known)-len(fn)}/{len(known)} (missed {fn}); benign clean {len(benign)-len(fp)}/{len(benign)} (false positives {fp})")
184
+ return not fn and not fp
185
+
186
+ if __name__ == "__main__":
187
+ if len(sys.argv) > 2 and sys.argv[1] == "--build": build(sys.argv[2])
188
+ elif len(sys.argv) > 1 and sys.argv[1] == "--selftest": sys.exit(0 if selftest() else 1)
189
+ else: print(__doc__)