返回 last30days-skill
preflight.py
根目录 / skills / last30days / scripts / lib / preflight.py
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
120 lines PYTHON