Repository navigation
Expand file tree
/
Copy pathvalidators.py
More file actions
309 lines (253 loc) · 12.1 KB
/
Copy pathvalidators.py
File metadata and controls
309 lines (253 loc) · 12.1 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
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
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
"""Pure, deterministic enforcement of the content rules.
Python *guarantees* the hard rules here (keyword-first, char limits, no emojis,
no stop-words, capitalization). The LLM is asked to comply too, but we never
rely on it. No I/O — every function is unit-testable.
"""
from __future__ import annotations
import re
import emoji
from config import (
ANTI_TEMPLATE_VOCAB,
BENEFIT_PHRASES,
CAPITALIZATION_MODE,
DESC_MAX,
STOP_WORDS,
TITLE_MAX,
TITLE_MIN,
)
from models import GeneratedMeta
def _lang_code(language: str) -> str:
"""Normalise an ISO code: 'fr-FR' / 'FR' -> 'fr'."""
return (language or "").lower().split("-")[0].strip()
# ----------------------------------------------------------------- whitespace
def _normalize_spaces(text: str) -> str:
text = re.sub(r"\s+", " ", text or "").strip()
text = re.sub(r"\s+([,.;:!?])", r"\1", text) # no space before punctuation
return text
# ----------------------------------------------------------------- casing
def _cap_first(word: str) -> str:
for i, ch in enumerate(word):
if ch.isalpha():
return word[:i] + ch.upper() + word[i + 1:]
return word
def to_title_case(text: str) -> str:
"""Capitalize the first letter of every word (preserves the rest, e.g. 'PayPal')."""
return " ".join(_cap_first(w) for w in text.split())
def to_sentence_case(text: str) -> str:
"""Capitalize the first letter of each sentence; leave the rest untouched."""
text = _normalize_spaces(text)
out: list[str] = []
capitalize_next = True
for ch in text:
if capitalize_next and ch.isalpha():
out.append(ch.upper())
capitalize_next = False
else:
out.append(ch)
if ch in ".!?":
capitalize_next = True
return "".join(out)
def apply_capitalization(meta: GeneratedMeta, mode: str = "literal", language: str = "") -> GeneratedMeta:
"""Title-Case every word in Title (TZ §3.5, unconditional, all languages);
sentence-case Description (capitalize after . ! ?). H1 is left untouched —
rule §3.5 covers only Title and Description.
The `mode` and `language` parameters are kept for backwards compatibility
with existing callers and tests but are no longer consulted — the previous
`natural` mode produced sentence-case Titles for non-English rows and so
violated §3.5 ("все слова в Title с заглавной буквы"). Removed in Action #5.
"""
return GeneratedMeta(
h1=meta.h1,
meta_title=to_title_case(meta.meta_title),
meta_description=to_sentence_case(meta.meta_description),
)
# ----------------------------------------------------------------- keyword first
def starts_with_keyword(text: str, keyword: str, *, title_case_keyword: bool = False) -> bool:
expected = to_title_case(keyword) if title_case_keyword else keyword
return text.startswith(expected)
def force_keyword_prefix(text: str, keyword: str) -> str:
"""Guarantee `text` begins with `keyword` (raw casing); de-duplicate an
internal occurrence if needed. Capitalization is applied separately afterwards."""
text = _normalize_spaces(text)
if text.lower().startswith(keyword.lower()):
return keyword + text[len(keyword):]
idx = text.lower().find(keyword.lower())
if idx != -1:
text = _normalize_spaces(text[:idx] + text[idx + len(keyword):])
return _normalize_spaces(f"{keyword} {text}")
# ----------------------------------------------------------------- limits
def title_len_ok(title: str) -> bool:
return TITLE_MIN <= len(title) <= TITLE_MAX
def desc_len_ok(desc: str) -> bool:
return len(desc) < DESC_MAX
# ----------------------------------------------------------------- emoji
def has_emoji(text: str) -> bool:
return emoji.emoji_count(text) > 0
def strip_emoji(text: str) -> str:
return _normalize_spaces(emoji.replace_emoji(text, ""))
# ----------------------------------------------------------------- stop-words
def find_stop_words(text: str) -> list[str]:
low = (text or "").lower()
return [sw for sw in STOP_WORDS if re.search(r"\b" + re.escape(sw) + r"\b", low)]
def remove_stop_words(text: str) -> str:
out = text or ""
for sw in STOP_WORDS:
out = re.sub(r"\b" + re.escape(sw) + r"\b", "", out, flags=re.IGNORECASE)
return _normalize_spaces(out)
# ----------------------------------------------------------------- composition
def _has_anti_template_vocab(title: str, desc: str, language: str) -> bool:
"""True if Title OR Description contains at least one bonus / payout-speed
token from the language's vocab (rule 3.4)."""
vocab = ANTI_TEMPLATE_VOCAB.get(_lang_code(language), ANTI_TEMPLATE_VOCAB["en"])
blob = f"{title} {desc}".lower()
return any(tok in blob for tok in vocab)
def collect_problems(meta: GeneratedMeta, language: str = "en") -> list[str]:
"""Problems that must be fixed by *rewriting* (handed back to the LLM).
Emoji / stop-words / prefix / casing are fixed deterministically, not looped.
Length and the anti-template rule (3.4) are checked here so the revise loop
can ask the LLM to differentiate."""
problems: list[str] = []
n = len(meta.meta_title)
if n < TITLE_MIN:
problems.append(
f"The Meta Title is too SHORT ({n} chars). Rewrite it longer and more "
f"descriptive so it is between {TITLE_MIN} and {TITLE_MAX} characters."
)
elif n > TITLE_MAX:
problems.append(
f"The Meta Title is too LONG ({n} chars). Rewrite it more concisely, "
f"without losing meaning, to fit {TITLE_MIN}-{TITLE_MAX} characters."
)
d = len(meta.meta_description)
if d >= DESC_MAX:
problems.append(
f"The Meta Description is too LONG ({d} chars). Rewrite it more "
f"concisely, without losing meaning, to be under {DESC_MAX} characters."
)
if not _has_anti_template_vocab(meta.meta_title, meta.meta_description, language):
problems.append(
"Anti-template (rule 3.4): neither the Title nor the Description "
"mentions bonuses or payout speed. Differentiate the page by "
"rewriting at least one of them to emphasise bonuses, fast payouts, "
"withdrawals or similar concrete benefits in the target language."
)
return problems
def is_compliant(meta: GeneratedMeta, keyword: str = "") -> bool:
"""Full TZ check: length + emoji + stop-words + (when keyword given) the
KEYWORD FIRST rule on H1 and Title. The keyword check is opt-in so existing
callers without keyword context keep working."""
fields = (meta.h1, meta.meta_title, meta.meta_description)
ok = (
title_len_ok(meta.meta_title)
and desc_len_ok(meta.meta_description)
and not any(has_emoji(f) for f in fields)
and not any(find_stop_words(f) for f in fields)
)
if ok and keyword:
ok = (
starts_with_keyword(meta.h1, keyword)
and starts_with_keyword(meta.meta_title, keyword, title_case_keyword=True)
)
return ok
def postprocess(meta: GeneratedMeta, keyword: str, language: str, mode: str | None = None) -> GeneratedMeta:
"""Deterministic cleanup applied after every Gemini response:
strip emojis -> remove stop-words -> force keyword prefix -> apply casing."""
mode = mode or CAPITALIZATION_MODE
h1 = remove_stop_words(strip_emoji(meta.h1))
title = remove_stop_words(strip_emoji(meta.meta_title))
desc = remove_stop_words(strip_emoji(meta.meta_description))
h1 = force_keyword_prefix(h1, keyword)
title = force_keyword_prefix(title, keyword)
return apply_capitalization(
GeneratedMeta(h1=h1, meta_title=title, meta_description=desc), mode, language
)
# ----------------------------------------------------------------- fallback
def _trim_to(text: str, max_len: int) -> str:
if len(text) <= max_len:
return text
cut = text[:max_len]
if " " in cut:
cut = cut[: cut.rfind(" ")]
return cut.rstrip(" -|,:;.")
def _neutral_pad_title(title: str, keyword: str) -> str:
"""Language-PURE Title padding when no localized BENEFIT_PHRASES exist
(Action #2 + post-review purity fix).
Used for languages outside the curated en/fr/de/es/it/pt set (ja, ru, pl,
nl, sv, …) where a too-short LLM output would otherwise land in the Doc
in violation of TZ §3.4 (Title 40-60). The padding is built ONLY from:
• `keyword` — already in the row's target language (came from the sheet);
• `"2026"` — a numeric, language-neutral by construction.
An earlier revision mixed in English tokens like `"bonus"`, `"guide"`,
`"top bonus offers"` to make the padding read better. That violated TZ
§3 ("output strictly in Language") on non-English rows — a German row
with a short LLM output would land in the Doc as
`"Casino | top bonus offers | fast payout guide"`. The current
implementation accepts that the redundant
`"Casino | Casino 2026 | Casino"` reads weakly *but stays in the row's
language*, which is the correct trade-off for a §3.4 safety-net.
Curated BENEFIT_PHRASES are still strongly preferred for the supported
languages — this helper only runs when none exist."""
out = title
# Two language-neutral building blocks. `2026` first (shorter, cheaper to
# fit when title is already close to MIN); `keyword 2026` next; then a
# bare `keyword` repeat as the cheapest filler. All chunks are repeated
# in the loop below until length is in range.
chunks = (f"{keyword} 2026", "2026", keyword)
idx = 0
while len(out) < TITLE_MIN:
chunk = chunks[idx % len(chunks)]
addition = f" | {chunk}"
if len(out) + len(addition) > TITLE_MAX:
# Try without the bar — sometimes a single space fits a chunk
# that " | " would overflow.
addition = f" {chunk}"
if len(out) + len(addition) > TITLE_MAX:
idx += 1
# If every chunk overflows, we're done — caller's keyword is
# so long that we can't reach TITLE_MIN without exceeding
# TITLE_MAX. The audit will log this case.
if idx >= len(chunks) * 3:
break
continue
out += addition
idx += 1
if len(out) > TITLE_MAX:
out = _trim_to(out, TITLE_MAX)
return out
def fallback(meta: GeneratedMeta, keyword: str, language: str, mode: str | None = None) -> GeneratedMeta:
"""Terminal compliance: deterministically force Title into [MIN, MAX] and
Description under MAX. The keyword (front) is never cut — only the tail.
Padding strategy:
1. Use `BENEFIT_PHRASES[language]` when the row's language is in the
curated en/fr/de/es/it/pt set (highest quality — language-correct,
bonus/payout-themed per §3.3).
2. Otherwise fall back to language-neutral keyword padding so §3.4
(Title 40-60) still holds for every language (Action #2).
"""
title = force_keyword_prefix(_normalize_spaces(meta.meta_title), keyword)
desc = _normalize_spaces(meta.meta_description)
if len(title) > TITLE_MAX:
title = _trim_to(title, TITLE_MAX)
phrases = BENEFIT_PHRASES.get(_lang_code(language), [])
i = 0
while len(title) < TITLE_MIN and i < len(phrases):
addition = f" | {phrases[i]}"
if len(title) + len(addition) <= TITLE_MAX:
title += addition
else:
room = TITLE_MAX - len(title) - 3
if room >= 6:
title += f" | {_trim_to(phrases[i], room)}"
i += 1
# Language-neutral safety net (Action #2): any language without curated
# BENEFIT_PHRASES still has to satisfy §3.4. Repeat the keyword + year
# to keep the padding on-language.
if len(title) < TITLE_MIN:
title = _neutral_pad_title(title, keyword)
if len(desc) >= DESC_MAX:
desc = _trim_to(desc, DESC_MAX - 1)
return apply_capitalization(
GeneratedMeta(h1=meta.h1, meta_title=title, meta_description=desc),
mode or CAPITALIZATION_MODE,
language,
)