nativeprompt 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,30 @@
1
+ """nativeprompt — перепиши промпт на РОДНОЙ диалект той модели/CLI, которой ты
2
+ реально пользуешься (Claude Code / Codex), строго по ОФИЦИАЛЬНЫМ правилам вендора,
3
+ и объясни почему.
4
+
5
+ Zero-deps, stdlib-only, детерминированное ядро. См. README.md.
6
+ """
7
+
8
+ __version__ = "0.1.0"
9
+
10
+ from .catalog import load_family, available_families, RulesError
11
+ from .detect import detect_model
12
+ from .analyze import analyze, task_shape
13
+ from .harness import recommend_harness
14
+ from .rewrite import rewrite, build_metaprompt
15
+ from .explain import build_report, render_report
16
+
17
+ __all__ = [
18
+ "__version__",
19
+ "load_family",
20
+ "available_families",
21
+ "RulesError",
22
+ "detect_model",
23
+ "analyze",
24
+ "task_shape",
25
+ "recommend_harness",
26
+ "rewrite",
27
+ "build_metaprompt",
28
+ "build_report",
29
+ "render_report",
30
+ ]
@@ -0,0 +1,162 @@
1
+ """CLI: nativeprompt <команда>.
2
+
3
+ improve "<промпт>" [--model M] [--json] [--no-metaprompt] переписать + объяснить
4
+ detect [--model M] какая модель определилась
5
+ rules [claude|codex] показать правила + источники
6
+ update [--write] [--timeout N] сверить свежесть офиц. доков
7
+ """
8
+
9
+ import argparse
10
+ import json
11
+ import sys
12
+
13
+ from . import __version__, catalog, detect as _detect
14
+ from .explain import build_report, render_report
15
+ from . import update as _update
16
+
17
+
18
+ def _read_prompt(arg):
19
+ if arg:
20
+ return arg
21
+ if not sys.stdin.isatty():
22
+ data = sys.stdin.read().strip()
23
+ if data:
24
+ return data
25
+ return None
26
+
27
+
28
+ def cmd_improve(args):
29
+ prompt = _read_prompt(args.prompt)
30
+ if not prompt:
31
+ print("Дайте промпт: nativeprompt improve \"<текст>\" (или через stdin).", file=sys.stderr)
32
+ return 2
33
+ report = build_report(prompt, model=args.model)
34
+ if args.json:
35
+ print(json.dumps(report, ensure_ascii=False, indent=2))
36
+ return 0 if not report.get("error") else 1
37
+ print(render_report(report, show_metaprompt=not args.no_metaprompt))
38
+ return 0 if not report.get("error") else 1
39
+
40
+
41
+ def cmd_detect(args):
42
+ res = _detect.resolve(args.model)
43
+ if args.json:
44
+ print(json.dumps(res, ensure_ascii=False, indent=2))
45
+ return 0
46
+ if not res.get("family"):
47
+ print("Модель не определена. Задайте --model claude-opus-5 / gpt-5.6 / codex.")
48
+ print("Сигналы: env ANTHROPIC_MODEL/OPENAI_MODEL, ~/.claude/settings.json, ~/.codex/config.toml.")
49
+ return 1
50
+ gen = (" · " + res["generation"]) if res.get("generation") else ""
51
+ print("Модель: %s%s" % (res.get("model_id") or res["family"], gen))
52
+ print("Семейство/CLI: %s (%s)" % (res["family"], res.get("cli", "")))
53
+ print("Определено: %s" % res.get("source", "—"))
54
+ return 0
55
+
56
+
57
+ def cmd_rules(args):
58
+ fam = args.family
59
+ if fam:
60
+ fam = catalog.family_for(fam) or fam
61
+ families = [fam]
62
+ else:
63
+ families = catalog.available_families()
64
+ for family in families:
65
+ try:
66
+ data = catalog.load_family(family)
67
+ except catalog.RulesError as e:
68
+ print(str(e), file=sys.stderr)
69
+ return 1
70
+ print("=" * 66)
71
+ print("%s — %s (версия правил: %s)" % (data.get("display", family), data.get("vendor", ""), data.get("rules_version", "?")))
72
+ print(data.get("surface", ""))
73
+ print("=" * 66)
74
+
75
+ gens = data.get("generations", {})
76
+ if gens:
77
+ print("\nМОДЕЛИ, КОТОРЫЕ ИНСТРУМЕНТ ЗНАЕТ (%d):" % len(gens))
78
+ own = [g for g, v in gens.items() if v.get("doc")]
79
+ for gid, g in gens.items():
80
+ if g.get("doc"):
81
+ mark, tail = "★", "своя страница правил у вендора"
82
+ elif g.get("rules_of"):
83
+ mark, tail = "↳", "правила от %s (так решил вендор)" % g["rules_of"]
84
+ else:
85
+ mark, tail = "·", "своей страницы нет — правила семейства"
86
+ print(" %s %-12s %-22s %s" % (mark, gid, g.get("label", ""), tail))
87
+ print(" ─ ★ %d из %d моделей имеют собственные правила; остальные работают"
88
+ % (len(own), len(gens)))
89
+ print(" на правилах семейства — они НЕ выдуманы, их просто не публиковал вендор.")
90
+ print(" Незнакомая новая модель тоже получит правила семейства, а не откажет.")
91
+
92
+ print("\nПРАВИЛА:")
93
+ for r in data.get("rules", []):
94
+ scope = "" if r.get("scope") == "family" else (" [%s]" % r["scope"])
95
+ print("• %s%s" % (r["title"], scope))
96
+ print(" %s" % r["source"])
97
+ h = data.get("harness")
98
+ if h:
99
+ print("\nХАРНЕСС (%s): когда какая команда" % h.get("cli", ""))
100
+ for c in h.get("commands", []):
101
+ print(" [%s] %s → %s" % (c.get("when"), c.get("title"), c.get("command")))
102
+ print(" %s" % c.get("source"))
103
+ print("")
104
+ return 0
105
+
106
+
107
+ def cmd_update(args):
108
+ res = _update.update(write=args.write, timeout=args.timeout)
109
+ if args.json:
110
+ print(json.dumps(res, ensure_ascii=False, indent=2))
111
+ else:
112
+ print(_update.render_update(res, show_diff=args.diff))
113
+ # ненулевой код, если нужны действия (для CI)
114
+ return 1 if res["summary"]["action_needed"] and not args.write else 0
115
+
116
+
117
+ def build_parser():
118
+ p = argparse.ArgumentParser(
119
+ prog="nativeprompt",
120
+ description="Перепиши промпт под родной диалект твоей модели (Claude Code / Codex) по офиц. правилам вендора — и объясни почему.",
121
+ )
122
+ p.add_argument("--version", action="version", version="nativeprompt " + __version__)
123
+ sub = p.add_subparsers(dest="command")
124
+
125
+ pi = sub.add_parser("improve", help="переписать промпт под текущую модель + объяснить")
126
+ pi.add_argument("prompt", nargs="?", help="текст промпта (или из stdin)")
127
+ pi.add_argument("--model", help="целевая модель: claude-opus-5 / gpt-5.6 / codex …")
128
+ pi.add_argument("--json", action="store_true", help="выдать отчёт как JSON")
129
+ pi.add_argument("--no-metaprompt", action="store_true", help="без блока мета-промпта")
130
+ pi.set_defaults(func=cmd_improve)
131
+
132
+ pd = sub.add_parser("detect", help="показать, какая модель определилась")
133
+ pd.add_argument("--model", help="переопределить явно")
134
+ pd.add_argument("--json", action="store_true")
135
+ pd.set_defaults(func=cmd_detect)
136
+
137
+ pr = sub.add_parser("rules", help="показать правила + источники")
138
+ pr.add_argument("family", nargs="?", help="claude | codex (по умолчанию — все)")
139
+ pr.set_defaults(func=cmd_rules)
140
+
141
+ pu = sub.add_parser("update", help="сверить свежесть офиц. доков (self-update)")
142
+ pu.add_argument("--write", action="store_true", help="записать снимки (после ревью правил)")
143
+ pu.add_argument("--diff", action="store_true",
144
+ help="показать, ЧТО именно изменилось в доке вендора (было → стало)")
145
+ pu.add_argument("--timeout", type=int, default=20)
146
+ pu.add_argument("--json", action="store_true")
147
+ pu.set_defaults(func=cmd_update)
148
+
149
+ return p
150
+
151
+
152
+ def main(argv=None):
153
+ parser = build_parser()
154
+ args = parser.parse_args(argv)
155
+ if not getattr(args, "command", None):
156
+ parser.print_help()
157
+ return 0
158
+ return args.func(args)
159
+
160
+
161
+ if __name__ == "__main__":
162
+ sys.exit(main())
@@ -0,0 +1,297 @@
1
+ """Детерминированный разбор промпта: какие правила семейства «срабатывают»
2
+ и какая у задачи форма (для рекомендации харнесса).
3
+
4
+ Ноль обращений к модели — только регэкспы/эвристики (RU+EN). Каждый детектор
5
+ привязан к правилу из rules/<family>.json по имени в поле "check".
6
+ """
7
+
8
+ import re
9
+
10
+ from . import catalog
11
+
12
+ _CODING_VERB = re.compile(
13
+ r"(напиш|добав|почин|исправ|сдела|реализ|перепиш|отрефактор|рефактор|создай|"
14
+ r"удали|обнови|напис|implement|fix\b|add\b|write\b|refactor|create|build\b|"
15
+ r"update\b|remove\b|migrat|тест|debug|отлад)",
16
+ re.I,
17
+ )
18
+ _PATH = re.compile(
19
+ r"@|/|\b\w+\.(py|ts|tsx|js|jsx|go|rs|java|rb|php|json|md|html|css|scss|sql|yml|yaml|toml)\b"
20
+ r"|\bsrc\b|\bкаталог|\bпапк|\bмодул",
21
+ re.I,
22
+ )
23
+ _VERIFY_MEANS = re.compile(
24
+ r"(тест|test|сборк|build|pytest|jest|npm |lint|прогони|run |скрин|screenshot|"
25
+ r"exit code|assert|провер\w* (что|сборк|прошл)|passes|компилир)",
26
+ re.I,
27
+ )
28
+
29
+
30
+ def _coding_task(low):
31
+ return bool(_CODING_VERB.search(low))
32
+
33
+
34
+ # ── детекторы (check-имена из шпаргалок) ─────────────────────────────
35
+ def _c_always(p, low, t):
36
+ return True
37
+
38
+
39
+ def _c_missing_context(p, low, t):
40
+ return _coding_task(low) and not _PATH.search(p)
41
+
42
+
43
+ def _c_missing_verification(p, low, t):
44
+ return _coding_task(low) and not _VERIFY_MEANS.search(low)
45
+
46
+
47
+ def _c_describe_location(p, low, t):
48
+ return bool(
49
+ re.search(r"(файл[,]?\s+(где|в котором|котор)|там,?\s+где|где-то в|"
50
+ r"in the file (that|where)|the file that)", low)
51
+ ) and "@" not in p
52
+
53
+
54
+ def _c_fix_symptom(p, low, t):
55
+ fix = re.search(r"(почин|исправ|fix)\b", low) and re.search(
56
+ r"(баг|ошибк|не работает|падает|failing|broken|\bbug\b|crash|сломал)", low
57
+ )
58
+ detail = re.search(r"(причин|root cause|симптом|потому что|because|@|/|src)", low)
59
+ return bool(fix) and not detail
60
+
61
+
62
+ def _c_missing_output_contract(p, low, t):
63
+ produce = re.search(
64
+ r"(напиш|сдела|сгенер|состав|выведи|подготов|summar|\blist\b|generate|"
65
+ r"\bwrite\b|отчёт|report|объясн|explain)",
66
+ low,
67
+ )
68
+ fmt = re.search(
69
+ r"(формат|в виде|верни|возврат|структур|json|таблиц|список|markdown|bullet|"
70
+ r"ответь\s|\bformat\b|\boutput\b|критери\w* готов|что считается|counts as done|"
71
+ r"exit 0|ok или fail|ok/fail)",
72
+ low,
73
+ )
74
+ return bool(produce) and not fmt
75
+
76
+
77
+ def _c_vague_ask(p, low, t):
78
+ return bool(
79
+ re.search(
80
+ r"(не мог(ли)? бы (ты|вы)|мог бы ты|можешь ли ты|\bможешь\b|could you|"
81
+ r"can you|would you|пожалуйста,?\s+(предлож|подумай|посмотр)|давай(те)? попроб)",
82
+ low,
83
+ )
84
+ )
85
+
86
+
87
+ def _c_pushy_caps(p, low, t):
88
+ if re.search(r"!!!", p):
89
+ return True
90
+ if re.search(r"\b(КРИТИЧНО|ОБЯЗАТЕЛЬНО|ВАЖНО|СРОЧНО|CRITICAL|MUST|ALWAYS|NEVER|IMPORTANT)\b", p):
91
+ return True
92
+ if re.search(r"\b(ты|вы|you)\s+(ДОЛЖЕН|ДОЛЖНЫ|ОБЯЗАН|MUST)\b", p):
93
+ return True
94
+ return False
95
+
96
+
97
+ def _c_missing_xml(p, low, t):
98
+ if t.get("family") != "claude":
99
+ return False
100
+ multi = len(p) > 500 or bool(
101
+ re.search(r"(пример:|example:|данные:|контекст:|вот текст|```|<<<|инструкци\w*:)", low)
102
+ )
103
+ has_xml = re.search(r"<[a-zA-Z_][a-zA-Z0-9_]*>", p)
104
+ return multi and not has_xml
105
+
106
+
107
+ def _c_verification_demand(p, low, t):
108
+ return bool(
109
+ re.search(
110
+ r"(перепровер|убедись,?\s+что|дважды провер|double.?check|re-?verify|"
111
+ r"verify your|проверь себя|make sure you|тщательно проверь)",
112
+ low,
113
+ )
114
+ )
115
+
116
+
117
+ def _c_report_only_high(p, low, t):
118
+ return bool(
119
+ re.search(
120
+ r"(только\s+(важн|критич|серьёзн|high|severe|major|значим)|report only|"
121
+ r"только самое важное|only (high|critical|major|the most))",
122
+ low,
123
+ )
124
+ )
125
+
126
+
127
+ def _c_forced_cot(p, low, t):
128
+ return bool(
129
+ re.search(
130
+ r"(думай пошагово|шаг за шагом|по шагам|пошагово|think step by step|"
131
+ r"step[- ]by[- ]step|let'?s think|chain of thought|рассуждай вслух|"
132
+ r"распиши (свои )?рассуждени|покажи ход мыслей)",
133
+ low,
134
+ )
135
+ )
136
+
137
+
138
+ def _c_repetition(p, low, t):
139
+ sents = [s.strip() for s in re.split(r"[.!?\n]+", low) if len(s.strip()) > 12]
140
+ seen = {}
141
+ for s in sents:
142
+ seen[s] = seen.get(s, 0) + 1
143
+ if seen[s] >= 2:
144
+ return True
145
+ for key in ("обязательно", "сначала спроси", "не забудь", "напоминаю"):
146
+ if low.count(key) >= 2:
147
+ return True
148
+ return False
149
+
150
+
151
+ def _c_contradiction_hint(p, low, t):
152
+ pairs = [
153
+ ("кратко", "подробно"),
154
+ ("кратко", "детальн"),
155
+ ("коротко", "развернут"),
156
+ ("markdown", "без форматирован"),
157
+ ("без форматирован", "в формате"),
158
+ ("быстро", "тщательно"),
159
+ ("brief", "detailed"),
160
+ ]
161
+ return any(a in low and b in low for a, b in pairs)
162
+
163
+
164
+ def _c_needs_agents_md(p, low, t):
165
+ if t.get("family") != "openai":
166
+ return False
167
+ return bool(
168
+ re.search(
169
+ r"(всегда|каждый раз|во всех задачах|в целом по проекту|стиль кода|"
170
+ r"code style|always use|every time|по всему проекту)",
171
+ low,
172
+ )
173
+ )
174
+
175
+
176
+ _CHECKS = {
177
+ "always": _c_always,
178
+ "missing_context": _c_missing_context,
179
+ "missing_verification": _c_missing_verification,
180
+ "describe_location": _c_describe_location,
181
+ "fix_symptom": _c_fix_symptom,
182
+ "missing_output_contract": _c_missing_output_contract,
183
+ "vague_ask": _c_vague_ask,
184
+ "pushy_caps": _c_pushy_caps,
185
+ "missing_xml": _c_missing_xml,
186
+ "verification_demand": _c_verification_demand,
187
+ "report_only_high": _c_report_only_high,
188
+ "forced_cot": _c_forced_cot,
189
+ "repetition": _c_repetition,
190
+ "contradiction_hint": _c_contradiction_hint,
191
+ "needs_agents_md": _c_needs_agents_md,
192
+ }
193
+
194
+
195
+ def analyze(prompt, target):
196
+ """Вернуть список сработавших правил (findings) для данной модели.
197
+
198
+ target: {family, generation}. Правила scope='family' проверяются всегда;
199
+ scope=<generation-id> — только если target.generation совпадает.
200
+ Правила с action='add'/check='always' не включаем в findings как «проблему»,
201
+ их подхватывает rewrite/harness (кроме явных — они попадают в 'additions').
202
+ """
203
+ family = target.get("family")
204
+ if not family:
205
+ return []
206
+ data = catalog.load_family(family)
207
+ low = prompt.lower()
208
+ shape = None
209
+ findings = []
210
+ # поколение + те, чьи правила вендор объявил применимыми к нему (rules_of):
211
+ # Mythos 5 ключуется на страницу Fable 5, Opus 4.7 — на guidance Opus 4.8
212
+ scopes = catalog.scopes_for(family, target.get("generation"))
213
+ for rule in data.get("rules", []):
214
+ scope = rule.get("scope", "family")
215
+ if scope != "family" and scope not in scopes:
216
+ continue
217
+ allowed_shapes = rule.get("when_shapes")
218
+ if allowed_shapes:
219
+ if shape is None:
220
+ shape = task_shape(prompt)
221
+ if shape not in allowed_shapes:
222
+ continue
223
+ check = rule.get("check", "always")
224
+ fn = _CHECKS.get(check)
225
+ if fn is None:
226
+ continue
227
+ if check == "always":
228
+ # 'always'-правила — это добавления по умолчанию для поколения,
229
+ # их показываем как рекомендации, но помечаем отдельно
230
+ findings.append(_as_finding(rule, always=True))
231
+ continue
232
+ if fn(prompt, low, target):
233
+ findings.append(_as_finding(rule, always=False))
234
+ return findings
235
+
236
+
237
+ def _as_finding(rule, always):
238
+ return {
239
+ "id": rule["id"],
240
+ "title": rule["title"],
241
+ "action": rule.get("action", "warn"),
242
+ "why": rule.get("why", ""),
243
+ "source": rule.get("source", ""),
244
+ "check": rule.get("check", ""),
245
+ "scope": rule.get("scope", "family"),
246
+ "always": always,
247
+ }
248
+
249
+
250
+ # ── форма задачи (для харнесса) ──────────────────────────────────────
251
+ _SHAPE_WORKFLOW = re.compile(
252
+ r"(по всем файлам|во всех файлах|(все|всех|каждый|каждого)\s+файл\w*|по всему проекту|"
253
+ r"across (all|every)|every file|codebase[- ]wide|весь проект целиком|тысяч\w* файл|"
254
+ r"аудит безопасност|security audit|миграци\w* тысяч|(entire|whole) codebase)",
255
+ re.I,
256
+ )
257
+ _SHAPE_LOOP = re.compile(
258
+ r"(кажд(ый|ое|ую)\s+(день|утро|ночь|неделю|час)|ежедневн|ежечас|nightly|"
259
+ r"по расписани|periodic|every (night|morning|day|hour|week)|раз в день|раз в час)",
260
+ re.I,
261
+ )
262
+ _SHAPE_GOAL = re.compile(
263
+ r"((пока|until|до тех пор)\b[^.\n]{0,60}"
264
+ r"(пройд|прошл|проход|зелён|готов|заверш|compil|pass|hold|green|empty|исчез|пуст|clean|чист)"
265
+ r"|до зелён|all tests pass|acceptance criteria|критери\w* приёмк|до готовн|backlog|"
266
+ r"очередь задач|доведи до|доведи задачу)",
267
+ re.I,
268
+ )
269
+ _SHAPE_PLANNING = re.compile(
270
+ r"(как лучше|какой подход|с чего начать|спроектир|design (a|the)|архитектур|"
271
+ r"architecture|отрефактор|\brefactor\b|не уверен|несколько файлов|multiple files|"
272
+ r"how (should|do) (i|we)|продумай план|состав\w* план|нужно продумать)",
273
+ re.I,
274
+ )
275
+ _SHAPE_TRIVIAL = re.compile(
276
+ r"(переименуй|опечат|\btypo\b|\brename\b|добавь лог|add a log|подправь|"
277
+ r"поправь мелк|один символ|измени строк)",
278
+ re.I,
279
+ )
280
+
281
+
282
+ def task_shape(prompt):
283
+ """Классифицировать задачу: workflow | loop | goal | planning | trivial | normal."""
284
+ low = prompt.lower()
285
+ if _SHAPE_WORKFLOW.search(low):
286
+ return "workflow"
287
+ if _SHAPE_LOOP.search(low):
288
+ return "loop"
289
+ if _SHAPE_GOAL.search(low):
290
+ return "goal"
291
+ if _SHAPE_PLANNING.search(low):
292
+ return "planning"
293
+ if len(prompt.strip()) <= 120 and _SHAPE_TRIVIAL.search(low):
294
+ return "trivial"
295
+ if len(prompt.strip()) <= 90 and _coding_task(low):
296
+ return "trivial"
297
+ return "normal"
@@ -0,0 +1,99 @@
1
+ """Загрузка шпаргалок правил (rules/*.json) и выбор семейства по имени модели."""
2
+
3
+ import json
4
+ import os
5
+
6
+ _RULES_DIR = os.path.join(os.path.dirname(__file__), "rules")
7
+ _CACHE = {}
8
+
9
+
10
+ class RulesError(Exception):
11
+ pass
12
+
13
+
14
+ def _rules_path(name):
15
+ return os.path.join(_RULES_DIR, name + ".json")
16
+
17
+
18
+ def available_families():
19
+ """Список доступных семейств (по файлам rules/<family>.json, кроме служебных)."""
20
+ out = []
21
+ for fn in sorted(os.listdir(_RULES_DIR)):
22
+ if fn.endswith(".json") and not fn.startswith("_"):
23
+ out.append(fn[:-5])
24
+ return out
25
+
26
+
27
+ def load_family(family):
28
+ """Загрузить шпаргалку семейства ('claude' | 'openai'). Кэшируется."""
29
+ if family in _CACHE:
30
+ return _CACHE[family]
31
+ path = _rules_path(family)
32
+ if not os.path.exists(path):
33
+ raise RulesError(
34
+ "Нет шпаргалки для семейства %r. Доступны: %s"
35
+ % (family, ", ".join(available_families()))
36
+ )
37
+ with open(path, "r", encoding="utf-8") as f:
38
+ data = json.load(f)
39
+ _CACHE[family] = data
40
+ return data
41
+
42
+
43
+ def load_sources():
44
+ """Манифест самообновления (_sources.json)."""
45
+ path = _rules_path("_sources")
46
+ with open(path, "r", encoding="utf-8") as f:
47
+ return json.load(f)
48
+
49
+
50
+ def family_for(family_or_alias):
51
+ """Нормализовать вход ('claude code', 'codex', 'gpt', ...) в id семейства."""
52
+ key = (family_or_alias or "").strip().lower()
53
+ if not key:
54
+ return None
55
+ for fam in available_families():
56
+ data = load_family(fam)
57
+ if key == fam:
58
+ return fam
59
+ det = data.get("detect", {})
60
+ for alias in det.get("aliases", []):
61
+ if key == alias.lower():
62
+ return fam
63
+ return None
64
+
65
+
66
+ def scopes_for(family, generation):
67
+ """Какие scope-ключи применимы к этому поколению.
68
+
69
+ Вендор иногда прямо пишет, что правила одной модели применимы к другой
70
+ (Mythos 5 ключуется на страницу Fable 5; guidance Opus 4.7 применим к 4.8).
71
+ Такие связи описаны полем "rules_of" в generations — здесь мы их
72
+ разворачиваем, чтобы правило с scope="fable-5" сработало и для mythos-5.
73
+ """
74
+ if not generation:
75
+ return set()
76
+ data = load_family(family)
77
+ gens = data.get("generations", {})
78
+ out, cur, guard = {generation}, generation, 0
79
+ while guard < 5:
80
+ parent = (gens.get(cur) or {}).get("rules_of")
81
+ if not parent or parent in out:
82
+ break
83
+ out.add(parent)
84
+ cur = parent
85
+ guard += 1
86
+ return out
87
+
88
+
89
+ def generation_for(family, model_id):
90
+ """По id/имени модели найти поколение внутри семейства (например 'opus-5')."""
91
+ if not model_id:
92
+ return None
93
+ data = load_family(family)
94
+ m = model_id.strip().lower().replace(" ", "-")
95
+ for gen_id, gen in data.get("generations", {}).items():
96
+ for token in gen.get("match", []):
97
+ if token.lower() in m:
98
+ return gen_id
99
+ return None