| 1 | """Engine-side query-quality pre-flight. |
| 2 | |
| 3 | Detects Class 1 (demographic shopping) keyword-trap queries and returns a |
| 4 | structured REFUSE message. The caller (scripts/last30days.py main()) writes |
| 5 | the message to stderr and exits code 2. No pipeline work runs on a doomed |
| 6 | query; the model sees the REFUSE on stderr and asks the user for the |
| 7 | hobbies/relationship/budget context it needs. |
| 8 | |
| 9 | Patterns ported from SKILL.md Step 0.45 prose. Only Class 1 is implemented |
| 10 | here because it has a verified failure mode on v3.0.8 (2026-04-18 'birthday |
| 11 | gift for 40 year old' run returned r/todayilearned and unrelated drama |
| 12 | posts). |
| 13 | """ |
| 14 | |
| 15 | from __future__ import annotations |
| 16 | |
| 17 | import re |
| 18 | |
| 19 | _CLASS_1_PATTERNS = [ |
| 20 | re.compile( |
| 21 | r"^\s*(birthday\s+)?(gift|gifts|present|presents)\s+" |
| 22 | r"(for|ideas\s+for)\s+(a\s+|my\s+)?\d+[\s-]?year[\s-]?old\b", |
| 23 | re.IGNORECASE, |
| 24 | ), |
| 25 | re.compile( |
| 26 | r"^\s*(best|top)\s+[\w\s-]+?\s+for\s+" |
| 27 | r"(men|women|kids|guys|girls|teens|dads|moms|husbands|wives|brothers|sisters|friends)\b", |
| 28 | re.IGNORECASE, |
| 29 | ), |
| 30 | re.compile( |
| 31 | r"^\s*what\s+to\s+(buy|get|gift)\s+(for\s+)?(a\s+|my\s+)?" |
| 32 | r"(\d+[\s-]?year[\s-]?old|husband|wife|dad|mom|brother|sister|friend|boss|coworker)\b", |
| 33 | re.IGNORECASE, |
| 34 | ), |
| 35 | re.compile( |
| 36 | r"^\s*(present|presents|gift|gifts)\s+for\s+(a\s+|my\s+)?" |
| 37 | r"(husband|wife|dad|mom|brother|sister|friend|boss|coworker)\b", |
| 38 | re.IGNORECASE, |
| 39 | ), |
| 40 | ] |
| 41 | |
| 42 | _QUALIFIER_PATTERNS = [ |
| 43 | re.compile(r"\$\d+"), |
| 44 | re.compile(r"\bbudget\b", re.IGNORECASE), |
| 45 | re.compile(r"\bwho\s+(loves|likes|is\s+into|enjoys)\b", re.IGNORECASE), |
| 46 | re.compile(r"\bhobbies?\b", re.IGNORECASE), |
| 47 | re.compile(r"\b(cooking|running|reading|gaming|golf|woodworking|coding|hiking|cycling|fishing|music)[\s-]?(obsessed|enthusiast|fan|lover)\b", re.IGNORECASE), |
| 48 | ] |
| 49 | |
| 50 | _RELATIONSHIP_WORDS = { |
| 51 | "husband", "wife", "dad", "mom", "father", "mother", "brother", "sister", |
| 52 | "friend", "boss", "coworker", "son", "daughter", "grandma", "grandpa", |
| 53 | "aunt", "uncle", "nephew", "niece", "partner", "boyfriend", "girlfriend", |
| 54 | } |
| 55 | |
| 56 | _YEAR_OLD_NOUN = re.compile(r"\byear[\s-]?old\s+(\w+)", re.IGNORECASE) |
| 57 | |
| 58 | |
| 59 | def _has_qualifier(topic: str) -> bool: |
| 60 | """Return True if the topic contains hobbies/relationship/budget context. |
| 61 | |
| 62 | A Class 1 base pattern plus a qualifier means the user already filled in |
| 63 | the specificity Step 0.45 would ask for. Skip the refuse-gate and let |
| 64 | the engine run. |
| 65 | |
| 66 | Also skips when `{n} year old <activity-noun>` is present, but only when |
| 67 | the noun is NOT a relationship word. 'year old runner' qualifies as an |
| 68 | interest and skips; 'year old husband' is just another relationship |
| 69 | reframing of the demographic query and does not skip. |
| 70 | """ |
| 71 | if any(pattern.search(topic) for pattern in _QUALIFIER_PATTERNS): |
| 72 | return True |
| 73 | |
| 74 | match = _YEAR_OLD_NOUN.search(topic) |
| 75 | if match and match.group(1).lower() not in _RELATIONSHIP_WORDS: |
| 76 | return True |
| 77 | |
| 78 | return False |
| 79 | |
| 80 | |
| 81 | def check_class_1_trap(topic: str) -> str | None: |
| 82 | """Return a REFUSE message string if the topic matches Class 1, else None. |
| 83 | |
| 84 | Class 1 is the demographic-shopping keyword trap. The literal phrase |
| 85 | 'birthday gift for 40 year old' is not the vocabulary of actual gift |
| 86 | discussions on Reddit, X, or TikTok, so running the engine returns |
| 87 | low-signal generic posts. Refuse up-front and ask for context. |
| 88 | """ |
| 89 | if not topic: |
| 90 | return None |
| 91 | |
| 92 | matched = any(pattern.search(topic) for pattern in _CLASS_1_PATTERNS) |
| 93 | if not matched: |
| 94 | return None |
| 95 | |
| 96 | if _has_qualifier(topic): |
| 97 | return None |
| 98 | |
| 99 | return _refuse_message(topic.strip()) |
| 100 | |
| 101 | |
| 102 | def _refuse_message(topic: str) -> str: |
| 103 | return ( |
| 104 | f'[last30days] REFUSE: topic "{topic}" matches Class 1 keyword-trap ' |
| 105 | "pattern (demographic shopping).\n" |
| 106 | "\n" |
| 107 | "The literal phrase is not the vocabulary of actual gift discussions " |
| 108 | "on Reddit, X, or TikTok. Running the engine will return low-signal " |
| 109 | "generic posts (the 2026-04-18 validation run returned " |
| 110 | "r/todayilearned and unrelated drama).\n" |
| 111 | "\n" |
| 112 | "Ask the user for at least one of:\n" |
| 113 | " - hobbies (cooks / runs / reads / gaming / outdoors / golf / music)\n" |
| 114 | " - relationship (husband / dad / friend / boss / brother)\n" |
| 115 | " - budget range\n" |
| 116 | "\n" |
| 117 | "Then re-run with the enriched query. If the user insists 'just run it',\n" |
| 118 | "re-invoke with LAST30DAYS_SKIP_PREFLIGHT=1 to bypass this gate.\n" |
| 119 | ) |
| 120 |