# Trouve-KA — scoreur Québec # Author: Simon-Pierre Boucher # Contact: contact@spboucher.ai """Calcul de page_quebec_score ∈ [0, 1]. Combinaison de signaux hétérogènes (CLAUDE.md §7) : TLD, toponymes, codes postaux QC, indicatifs téléphoniques, organisations connues, langue française, mentions structurées de la province. Aucun signal seul ne suffit (un .ca seul ne prouve rien); le score sature progressivement via une somme amortie. """ import re from trouveka.types import ParsedPage, QuebecSignals from .gazetteer import ( AMBIGUOUS_TOPONYMS, QUEBEC_AREA_CODES, QUEBEC_ORGS, QUEBEC_TOPONYMS, STRONG_DOMAIN_SUFFIXES, ) # Codes postaux du Québec : G, H, J en première lettre (format A1A 1A1) _POSTAL_RE = re.compile(r"\b[GHJ]\d[A-Z]\s?\d[A-Z]\d\b", re.IGNORECASE) _PHONE_RE = re.compile(r"(?:\+?1[\s.-]?)?\(?(\d{3})\)?[\s.-]?\d{3}[\s.-]?\d{4}\b") _PROVINCE_RE = re.compile( r"\b(?:province\s+(?:de\s+|du\s+)?qu[ée]bec|qu[ée]bec\s*\(qc\)|,\s*(?:qc|qu[ée]bec)\b)", re.IGNORECASE, ) _WORD_BOUNDARY = r"(? tuple[int, list[str]]: found: list[str] = [] total = 0 for term in terms: pattern = re.compile(_WORD_BOUNDARY.format(re.escape(term)), re.IGNORECASE) n = len(pattern.findall(text)) if n: found.append(term) total += min(n, 4) # une page qui répète 200× « Montréal » n'est pas 200× plus québécoise if total >= cap: break return min(total, cap), found def score_page(page: ParsedPage, domain: str) -> QuebecSignals: """Score Québec d'une page. Déterministe, sans LLM, économique (§12).""" signals = QuebecSignals() reasons: list[str] = [] points = 0.0 text = " ".join([page.title, page.description, " ".join(page.headings), page.body[:20_000]]) text_with_hints = text + " " + " ".join(page.structured_hints) lower = text_with_hints.lower() # 1. Domaine (signal fort mais pas suffisant seul) host = domain.lower() if any(host.endswith(suffix) or host == suffix.lstrip(".") for suffix in STRONG_DOMAIN_SUFFIXES): points += 4.0 reasons.append("tld_quebec") # 2. Toponymes non ambigus (titre/headings pèsent plus que le corps) head_text = " ".join([page.title, page.description, " ".join(page.headings)]) head_hits, head_names = _count_terms(head_text, QUEBEC_TOPONYMS, cap=6) body_hits, body_names = _count_terms(page.body[:20_000], QUEBEC_TOPONYMS, cap=8) if head_hits: points += 1.2 * head_hits reasons.append("toponymes_titre") if body_hits: points += 0.4 * body_hits reasons.append("toponymes_corps") signals.locations = sorted({*head_names, *body_names})[:12] # 3. Toponymes ambigus — poids réduit amb_hits, amb_names = _count_terms(lower, AMBIGUOUS_TOPONYMS, cap=3) if amb_hits: points += 0.15 * amb_hits reasons.append("toponymes_ambigus") signals.locations = sorted({*signals.locations, *amb_names})[:12] # 4. Codes postaux QC (signal fort : preuve d'adresse physique) postal_hits = len(set(_POSTAL_RE.findall(text_with_hints))) if postal_hits: points += min(postal_hits, 3) * 1.5 reasons.append("code_postal_qc") # 5. Mention structurée de la province (adresses, footers) if _PROVINCE_RE.search(text_with_hints): points += 1.5 reasons.append("province_quebec") # 6. Indicatifs téléphoniques (signal faible) area_codes = {m for m in _PHONE_RE.findall(text_with_hints) if m in QUEBEC_AREA_CODES} if area_codes: points += min(len(area_codes), 2) * 0.6 reasons.append("indicatif_qc") # 7. Organisations québécoises connues org_hits, _ = _count_terms(lower, QUEBEC_ORGS, cap=6) if org_hits: points += 0.8 * org_hits reasons.append("organisations_qc") # 8. Langue : le français augmente la probabilité sans la prouver if page.language == "fr": points += 0.8 reasons.append("francais") # Saturation douce : 0 pt → 0, ~3 pts → 0.5, ≥9 pts → ~0.95 signals.score = round(points / (points + 3.0), 4) if points > 0 else 0.0 signals.reasons = reasons return signals