"""Model-free keyword vibe matcher — the always-available safety net. When Call 1 (the LLM vibe→weights extractor) is unavailable or returns malformed JSON, this runs instantly with no model: it scans the vibe text for known cues, unions and averages the matched brief-key weight dicts, and hands the result to :func:`mapping.brief_scores_to_affinity`. Returns ``None`` when nothing matches, so the caller can fall through to a neutral (equal-interest) reading. """ from __future__ import annotations from discoverroute.interpret import mapping # substring cue -> brief-key scores (category keys + quiet/green/busy modifiers). KEYWORD_WEIGHTS: dict[str, dict[str, float]] = { "quiet": {"quiet": 0.9, "busy": 0.1, "park": 0.6}, "calm": {"quiet": 0.85, "park": 0.5}, "peace": {"quiet": 0.85, "park": 0.5}, "coffee": {"cafe": 0.9, "bakery": 0.7}, "café": {"cafe": 0.9, "bakery": 0.6}, "cafe": {"cafe": 0.9, "bakery": 0.6}, "espresso": {"cafe": 0.9}, "book": {"bookshop": 0.95}, "librair": {"bookshop": 0.8}, # libraire / librairie "read": {"bookshop": 0.7, "quiet": 0.5}, "green": {"park": 0.9, "green": 0.85}, "park": {"park": 0.9, "green": 0.8}, "garden": {"park": 0.9, "green": 0.85}, "nature": {"park": 0.85, "green": 0.8}, "water": {"park": 0.6, "viewpoint": 0.6, "green": 0.4}, "river": {"viewpoint": 0.7, "green": 0.4}, "canal": {"viewpoint": 0.7, "green": 0.4}, "histor": {"museum": 0.7, "historic": 0.9}, "herit": {"historic": 0.9}, "old": {"historic": 0.7}, "church": {"historic": 0.8, "quiet": 0.6}, "museum": {"museum": 0.9, "historic": 0.5}, "art": {"museum": 0.85}, "galler": {"museum": 0.85}, "view": {"viewpoint": 0.9}, "panoram": {"viewpoint": 0.9}, "scenic": {"viewpoint": 0.8, "green": 0.5}, "food": {"restaurant": 0.8, "market": 0.7, "bakery": 0.6}, "eat": {"restaurant": 0.8, "bakery": 0.5}, "lunch": {"restaurant": 0.8, "cafe": 0.5}, "dinner": {"restaurant": 0.85}, "bakery": {"bakery": 0.9}, "pastr": {"bakery": 0.9}, "market": {"market": 0.9}, "shop": {"market": 0.7}, "bar": {"bar": 0.85, "busy": 0.5}, "pub": {"bar": 0.85, "busy": 0.5}, "drink": {"bar": 0.8}, "wine": {"bar": 0.8}, "lively": {"busy": 0.9, "bar": 0.6, "market": 0.6}, "busy": {"busy": 0.9, "market": 0.6}, "bustl": {"busy": 0.85, "market": 0.7}, } def keyword_scores(vibe: str) -> dict[str, float] | None: """Union + average the brief-key scores of every cue found in ``vibe``.""" text = (vibe or "").lower() if not text: return None sums: dict[str, float] = {} counts: dict[str, int] = {} matched = False for cue, weights in KEYWORD_WEIGHTS.items(): if cue in text: matched = True for key, val in weights.items(): sums[key] = sums.get(key, 0.0) + val counts[key] = counts.get(key, 0) + 1 if not matched: return None return {key: sums[key] / counts[key] for key in sums} def keyword_affinity(vibe: str) -> dict[str, float] | None: """Keyword scores mapped to a taxonomy affinity dict, or ``None``.""" scores = keyword_scores(vibe) if not scores: return None return mapping.brief_scores_to_affinity(scores)