"""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:.`), 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. 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