"""Engine-side query-quality pre-flight. Detects Class 1 (demographic shopping) keyword-trap queries and returns a structured REFUSE message. The caller (scripts/last30days.py main()) writes the message to stderr and exits code 2. No pipeline work runs on a doomed query; the model sees the REFUSE on stderr and asks the user for the hobbies/relationship/budget context it needs. Patterns ported from SKILL.md Step 0.45 prose. Only Class 1 is implemented here because it has a verified failure mode on v3.0.8 (2026-04-18 'birthday gift for 40 year old' run returned r/todayilearned and unrelated drama posts). """ from __future__ import annotations import re _CLASS_1_PATTERNS = [ re.compile( r"^\s*(birthday\s+)?(gift|gifts|present|presents)\s+" r"(for|ideas\s+for)\s+(a\s+|my\s+)?\d+[\s-]?year[\s-]?old\b", re.IGNORECASE, ), re.compile( r"^\s*(best|top)\s+[\w\s-]+?\s+for\s+" r"(men|women|kids|guys|girls|teens|dads|moms|husbands|wives|brothers|sisters|friends)\b", re.IGNORECASE, ), re.compile( r"^\s*what\s+to\s+(buy|get|gift)\s+(for\s+)?(a\s+|my\s+)?" r"(\d+[\s-]?year[\s-]?old|husband|wife|dad|mom|brother|sister|friend|boss|coworker)\b", re.IGNORECASE, ), re.compile( r"^\s*(present|presents|gift|gifts)\s+for\s+(a\s+|my\s+)?" r"(husband|wife|dad|mom|brother|sister|friend|boss|coworker)\b", re.IGNORECASE, ), ] _QUALIFIER_PATTERNS = [ re.compile(r"\$\d+"), re.compile(r"\bbudget\b", re.IGNORECASE), re.compile(r"\bwho\s+(loves|likes|is\s+into|enjoys)\b", re.IGNORECASE), re.compile(r"\bhobbies?\b", re.IGNORECASE), re.compile(r"\b(cooking|running|reading|gaming|golf|woodworking|coding|hiking|cycling|fishing|music)[\s-]?(obsessed|enthusiast|fan|lover)\b", re.IGNORECASE), ] _RELATIONSHIP_WORDS = { "husband", "wife", "dad", "mom", "father", "mother", "brother", "sister", "friend", "boss", "coworker", "son", "daughter", "grandma", "grandpa", "aunt", "uncle", "nephew", "niece", "partner", "boyfriend", "girlfriend", } _YEAR_OLD_NOUN = re.compile(r"\byear[\s-]?old\s+(\w+)", re.IGNORECASE) def _has_qualifier(topic: str) -> bool: """Return True if the topic contains hobbies/relationship/budget context. A Class 1 base pattern plus a qualifier means the user already filled in the specificity Step 0.45 would ask for. Skip the refuse-gate and let the engine run. Also skips when `{n} year old ` is present, but only when the noun is NOT a relationship word. 'year old runner' qualifies as an interest and skips; 'year old husband' is just another relationship reframing of the demographic query and does not skip. """ if any(pattern.search(topic) for pattern in _QUALIFIER_PATTERNS): return True match = _YEAR_OLD_NOUN.search(topic) if match and match.group(1).lower() not in _RELATIONSHIP_WORDS: return True return False def check_class_1_trap(topic: str) -> str | None: """Return a REFUSE message string if the topic matches Class 1, else None. Class 1 is the demographic-shopping keyword trap. The literal phrase 'birthday gift for 40 year old' is not the vocabulary of actual gift discussions on Reddit, X, or TikTok, so running the engine returns low-signal generic posts. Refuse up-front and ask for context. """ if not topic: return None matched = any(pattern.search(topic) for pattern in _CLASS_1_PATTERNS) if not matched: return None if _has_qualifier(topic): return None return _refuse_message(topic.strip()) def _refuse_message(topic: str) -> str: return ( f'[last30days] REFUSE: topic "{topic}" matches Class 1 keyword-trap ' "pattern (demographic shopping).\n" "\n" "The literal phrase is not the vocabulary of actual gift discussions " "on Reddit, X, or TikTok. Running the engine will return low-signal " "generic posts (the 2026-04-18 validation run returned " "r/todayilearned and unrelated drama).\n" "\n" "Ask the user for at least one of:\n" " - hobbies (cooks / runs / reads / gaming / outdoors / golf / music)\n" " - relationship (husband / dad / friend / boss / brother)\n" " - budget range\n" "\n" "Then re-run with the enriched query. If the user insists 'just run it',\n" "re-invoke with LAST30DAYS_SKIP_PREFLIGHT=1 to bypass this gate.\n" )