1513e69a79
Angle frais jamais balayé (cohérence tri-lingue FR/EN/ES du module seo/). Un Explore a remonté « es manque 3 mots-clés projet P01 » ; refusé au mot (#6, verify-non-defects) et prouvé au byte-level un NON-DÉFAUT : l'artefact étant byte-repro-gaté, l'écart reflète la LOGIQUE du générateur. Preuve (repro du générateur sans dedup) : génération symétrique par langue → raw 115/langue identique. L'écart vient du seul dedup (lang,term). country[es] = « Republica Dominicana » == la localisation générique de 5 projets (P01/02/03/06/09) → le mot-clé global {type} Republica Dominicana absorbe le mot-clé projet identique de P01 (dedup correct, terme présent 1× en scope global). En fr/en country != localisation → pas de collision, P01 garde ses 7 termes. D'où +3 collisions en es → 87 vs 84. Rien n'est perdu ; invariants (≥ min_keywords_per_lang + unicité (term,lang)) tiennent ; es=84 déjà documenté et gaté README.md:15 (check_readme_claims). Sortie CORRECTE. Livrable = note explicative au point de dedup keywords.py:106 (asymétrie subtile non-évidente qu'un futur balayage refera l'enquête). Aucune logique/fixture/claim -gaté touché (gates byte visent out/*.json, pas la source). Style aligné ASCII + em-dash existant, NFC-clean. run_ci = 33 PASS/0/0 (inchangé). 0 code moteur V18 (#6), 0 logique modifiée, 0 artefact/fixture touché (#6), 0 gate ajouté (#5), 0 VPS (#8), 0 API externe. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
135 lines
5.8 KiB
Python
135 lines
5.8 KiB
Python
"""Generation deterministe des mots-cles SEO FR/EN/ES.
|
|
|
|
PRINCIPE ANTI-INVENTION (#6) : un mot-cle n'est JAMAIS un fait invente. C'est une
|
|
composition de :
|
|
- tokens factuels issus de `projets_master.json` (nom, localisation) — sourcables ;
|
|
- vocabulaire editorial generique du lexique (immobilier / a vendre / ...) —
|
|
non chiffre, sans reference a un projet particulier.
|
|
Chaque mot-cle porte donc une liste `sources` non vide (soit `lexicon:*`, soit
|
|
`projet:<code>.<champ>`), et ne peut contenir que des chiffres deja presents dans
|
|
son champ projet source. Cette regle est verifiee par un invariant du generateur.
|
|
|
|
Sortie : liste triee de dicts {term, lang, scope, projet, intent, category,
|
|
sources}. Tri stable (lang, scope, projet, term) -> diffable + re-generable.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
|
|
def _confotur_source(projets: list[dict], marker: str) -> str | None:
|
|
"""Code du premier projet documentant le regime dans `inclus` (ou None)."""
|
|
for p in projets:
|
|
if any(marker in (s or "") for s in p.get("inclus", [])):
|
|
return p["code"]
|
|
return None
|
|
|
|
|
|
def generate(spec: dict, master: dict) -> list[dict]:
|
|
site = spec["site"]
|
|
lex = spec["lexicon"]
|
|
langs = site["langs"]
|
|
projets = master["projets"]
|
|
|
|
property_generic = lex["property_generic"]
|
|
property_types = lex["property_types"]
|
|
intents = lex["intents"]
|
|
intents_for_type = set(lex.get("intents_for_type", [i["key"] for i in intents]))
|
|
country = lex["country"]
|
|
regime = lex.get("regime")
|
|
confotur_code = None
|
|
if regime:
|
|
confotur_code = _confotur_source(projets, regime["requires_inclus"])
|
|
|
|
lang_index = {lang: i for i, lang in enumerate(langs)}
|
|
acc: list[dict] = []
|
|
|
|
def add(term, lang, scope, projet, intent, category, sources):
|
|
term = " ".join(str(term).split()).strip()
|
|
acc.append({
|
|
"term": term,
|
|
"lang": lang,
|
|
"scope": scope,
|
|
"projet": projet,
|
|
"intent": intent,
|
|
"category": category,
|
|
"sources": list(sources),
|
|
})
|
|
|
|
for lang in langs:
|
|
pg = property_generic[lang]
|
|
ctry = country[lang]
|
|
|
|
# --- Mots-cles GLOBAUX (marque / pays / regime) : independants d'un
|
|
# projet mais adosses au lexique editorial (jamais un fait invente).
|
|
add(f"{pg} {ctry}", lang, "global", None, None, "generic",
|
|
["lexicon:property_generic", "lexicon:country"])
|
|
for pt in property_types:
|
|
add(f"{pt[lang]} {ctry}", lang, "global", None, None, pt["key"],
|
|
["lexicon:property_types", "lexicon:country"])
|
|
for it in intents:
|
|
add(f"{pg} {it[lang]} {ctry}", lang, "global", None, it["key"], "generic",
|
|
["lexicon:property_generic", "lexicon:intents", "lexicon:country"])
|
|
if regime and confotur_code:
|
|
add(f"{pg} {regime['term'][lang]} {ctry}", lang, "global", None, None,
|
|
regime["key"],
|
|
["lexicon:regime", "lexicon:country", f"projet:{confotur_code}.inclus"])
|
|
|
|
# --- Mots-cles PAR PROJET : nom (factuel) + localisation (factuel)
|
|
# croises avec le lexique editorial.
|
|
for p in projets:
|
|
code, nom, loc = p["code"], p["nom"], p["localisation"]
|
|
nom_src = f"projet:{code}.nom"
|
|
loc_src = f"projet:{code}.localisation"
|
|
|
|
add(nom, lang, "projet", code, None, None, [nom_src])
|
|
add(f"{nom} {ctry}", lang, "projet", code, None, None,
|
|
[nom_src, "lexicon:country"])
|
|
for it in intents:
|
|
add(f"{nom} {it[lang]}", lang, "projet", code, it["key"], None,
|
|
[nom_src, "lexicon:intents"])
|
|
|
|
add(f"{pg} {loc}", lang, "projet", code, None, "generic",
|
|
["lexicon:property_generic", loc_src])
|
|
for pt in property_types:
|
|
add(f"{pt[lang]} {loc}", lang, "projet", code, None, pt["key"],
|
|
["lexicon:property_types", loc_src])
|
|
for pt in property_types:
|
|
for it in intents:
|
|
if it["key"] not in intents_for_type:
|
|
continue
|
|
add(f"{pt[lang]} {it[lang]} {loc}", lang, "projet", code,
|
|
it["key"], pt["key"],
|
|
["lexicon:property_types", "lexicon:intents", loc_src])
|
|
|
|
# --- Dedup (term, lang) en conservant la premiere occurrence, puis tri
|
|
# deterministe.
|
|
#
|
|
# NOTE — asymetrie de cardinalite PAR LANGUE (by-design, pas un bug).
|
|
# Le nombre de mots-cles UNIQUES peut differer d'une langue a l'autre
|
|
# (ex. fr=87, en=87, es=84) sans qu'aucun mot-cle ne soit "manquant".
|
|
# Cause : un projet dont la `localisation` coincide, dans une langue,
|
|
# avec le `country[lang]` produit un mot-cle projet `{type} {loc}`
|
|
# BYTE-IDENTIQUE au mot-cle global `{type} {country}` (emis avant) ;
|
|
# la 2e occurrence est donc dedupliquee — le terme reste present une
|
|
# fois (scope `global`). En espagnol `country[es]` == "Republica
|
|
# Dominicana" == la localisation generique de 5 projets, d'ou 3
|
|
# collisions global<->projet de plus qu'en fr/en (ou "Republique
|
|
# Dominicaine"/"Dominican Republic" != la localisation). Les invariants
|
|
# ne contraignent que `>= min_keywords_per_lang` + unicite (term, lang),
|
|
# jamais l'egalite inter-langues : ne pas "corriger" en forcant une
|
|
# symetrie (ce serait emettre un doublon strict).
|
|
seen: set[tuple[str, str]] = set()
|
|
deduped: list[dict] = []
|
|
for k in acc:
|
|
key = (k["lang"], k["term"])
|
|
if key in seen:
|
|
continue
|
|
seen.add(key)
|
|
deduped.append(k)
|
|
|
|
scope_order = {"global": 0, "projet": 1}
|
|
deduped.sort(key=lambda k: (
|
|
lang_index[k["lang"]], scope_order[k["scope"]], k["projet"] or "", k["term"]
|
|
))
|
|
return deduped
|