From 37b55bf82df424ecc201c250b672b8813811d4f1 Mon Sep 17 00:00:00 2001 From: anx4758 Date: Tue, 24 Feb 2026 18:48:32 +0800 Subject: [PATCH] fix: sanitize forbidden aiRules and harden rule conflict checks --- README.md | 1 + scripts/audit_style_rule_conflicts.py | 154 ++++++++++++++++++++++ scripts/generate_brief.py | 176 +++++++++++++++++++++++++- scripts/qa_prompt.py | 42 ++++++ 4 files changed, 366 insertions(+), 7 deletions(-) create mode 100644 scripts/audit_style_rule_conflicts.py diff --git a/README.md b/README.md index a498189..7a2330c 100644 --- a/README.md +++ b/README.md @@ -48,6 +48,7 @@ npx @anxforever/stylekit-style-prompts-skill doctor node bin/stylekit-style-prompts-skill.js doctor node bin/stylekit-style-prompts-skill.js install --tool codex --target /tmp/stylekit-skill-test --force node bin/stylekit-style-prompts-skill.js uninstall --target /tmp/stylekit-skill-test +python3 scripts/audit_style_rule_conflicts.py --format text python3 scripts/smoke_test.py python3 scripts/run_pipeline.py --query "高端科技SaaS财务后台,玻璃质感,强调可读性" --stack nextjs --format json ``` diff --git a/scripts/audit_style_rule_conflicts.py b/scripts/audit_style_rule_conflicts.py new file mode 100644 index 0000000..00e51ce --- /dev/null +++ b/scripts/audit_style_rule_conflicts.py @@ -0,0 +1,154 @@ +#!/usr/bin/env python3 +"""Audit raw style-rule quality and effective rule quality after normalization.""" + +from __future__ import annotations + +import argparse +import json +import re +from pathlib import Path +from typing import Any + +from generate_brief import ( + detect_lang, + ensure_min_rules, + extract_rules, + rule_polarity, +) + +SCRIPT_DIR = Path(__file__).resolve().parent +SKILL_ROOT = SCRIPT_DIR.parent +REF_DIR = SKILL_ROOT / "references" +CATALOG_DEFAULT = REF_DIR / "style-prompts.json" + +ROUNDED_NONE_RE = re.compile(r"\brounded-none\b", re.IGNORECASE) +ROUNDED_OTHER_RE = re.compile(r"\brounded-(?!none\b)(?:sm|md|lg|xl|2xl|3xl|full|base)\b", re.IGNORECASE) +SHADOW_NONE_RE = re.compile(r"\bshadow-none\b", re.IGNORECASE) +SHADOW_OTHER_RE = re.compile(r"\bshadow-(?!none\b)(?:sm|md|lg|xl|2xl|3xl|base|\[[^\]]+\])\b", re.IGNORECASE) +BG_WHITE_OPAQUE_RE = re.compile(r"\bbg-white\b(?!/)", re.IGNORECASE) +BG_WHITE_TRANS_RE = re.compile(r"\bbg-white/[0-9]{1,3}\b", re.IGNORECASE) +CLASS_TOKEN_RE = re.compile(r"\b[a-z]+(?:-[a-z0-9\[\]#/%.]+)+\b", re.IGNORECASE) + + +def load_catalog(path: Path) -> list[dict[str, Any]]: + payload = json.loads(path.read_text(encoding="utf-8")) + return payload.get("styles", []) + + +def has_radius_mix(text: str) -> bool: + return bool(ROUNDED_NONE_RE.search(text) and ROUNDED_OTHER_RE.search(text)) + + +def has_shadow_mix(text: str) -> bool: + return bool(SHADOW_NONE_RE.search(text) and SHADOW_OTHER_RE.search(text)) + + +def has_bg_opacity_mix(text: str) -> bool: + return bool(BG_WHITE_OPAQUE_RE.search(text) and BG_WHITE_TRANS_RE.search(text)) + + +def do_dont_overlap(style: dict[str, Any]) -> list[str]: + do_text = "\n".join(style.get("doList", [])) + dont_text = "\n".join(style.get("dontList", [])) + do_tokens = set(CLASS_TOKEN_RE.findall(do_text.lower())) + dont_tokens = set(CLASS_TOKEN_RE.findall(dont_text.lower())) + overlap = sorted(do_tokens & dont_tokens) + return overlap + + +def summarize_conflicts(styles: list[dict[str, Any]]) -> dict[str, Any]: + raw_radius: list[str] = [] + raw_shadow: list[str] = [] + raw_bg: list[str] = [] + effective_radius: list[str] = [] + effective_shadow: list[str] = [] + effective_bg: list[str] = [] + do_dont_overlaps: list[dict[str, Any]] = [] + + for style in styles: + slug = str(style.get("slug", "unknown")) + ai_rules_text = str(style.get("aiRules", "")) + do_text = "\n".join(style.get("doList", [])) + + if has_radius_mix(ai_rules_text): + raw_radius.append(slug) + if has_shadow_mix(ai_rules_text): + raw_shadow.append(slug) + if has_bg_opacity_mix(ai_rules_text): + raw_bg.append(slug) + # Keep visibility for raw doList quality as well. + if has_radius_mix(do_text): + raw_radius.append(f"{slug}#doList") + if has_shadow_mix(do_text): + raw_shadow.append(f"{slug}#doList") + if has_bg_opacity_mix(do_text): + raw_bg.append(f"{slug}#doList") + + lang = detect_lang(f"{style.get('name', '')}\n{ai_rules_text}") + rules = extract_rules(ai_rules_text, lang) + rules = ensure_min_rules(rules, style.get("doList", []), style.get("dontList", []), lang) + positive_rules = [rule for rule in rules if rule_polarity(rule) == "pos"] + positive_text = "\n".join(positive_rules) + + if has_radius_mix(positive_text): + effective_radius.append(slug) + if has_shadow_mix(positive_text): + effective_shadow.append(slug) + if has_bg_opacity_mix(positive_text): + effective_bg.append(slug) + + overlap = do_dont_overlap(style) + if overlap: + do_dont_overlaps.append({"slug": slug, "overlap": overlap[:8], "count": len(overlap)}) + + return { + "total_styles": len(styles), + "raw_conflicts": { + "rounded_mix_styles": sorted(raw_radius), + "shadow_mix_styles": sorted(raw_shadow), + "bg_opacity_mix_styles": sorted(raw_bg), + }, + "effective_conflicts": { + "rounded_mix_styles": sorted(effective_radius), + "shadow_mix_styles": sorted(effective_shadow), + "bg_opacity_mix_styles": sorted(effective_bg), + }, + "do_dont_overlap": { + "styles": do_dont_overlaps, + }, + } + + +def main() -> None: + parser = argparse.ArgumentParser(description="Audit style rule conflicts") + parser.add_argument("--catalog", default=str(CATALOG_DEFAULT), help="Path to style-prompts.json") + parser.add_argument("--format", choices=["json", "text"], default="json") + args = parser.parse_args() + + styles = load_catalog(Path(args.catalog)) + summary = summarize_conflicts(styles) + + if args.format == "json": + print(json.dumps(summary, ensure_ascii=False, indent=2)) + return + + raw = summary["raw_conflicts"] + eff = summary["effective_conflicts"] + print(f"Total styles: {summary['total_styles']}") + print( + "Raw conflicts:" + f" rounded={len(raw['rounded_mix_styles'])}" + f", shadow={len(raw['shadow_mix_styles'])}" + f", bg={len(raw['bg_opacity_mix_styles'])}" + ) + print( + "Effective conflicts (after normalization):" + f" rounded={len(eff['rounded_mix_styles'])}" + f", shadow={len(eff['shadow_mix_styles'])}" + f", bg={len(eff['bg_opacity_mix_styles'])}" + ) + print(f"Do/Dont class overlap styles: {len(summary['do_dont_overlap']['styles'])}") + + +if __name__ == "__main__": + main() diff --git a/scripts/generate_brief.py b/scripts/generate_brief.py index d8d80a7..0851524 100644 --- a/scripts/generate_brief.py +++ b/scripts/generate_brief.py @@ -215,6 +215,28 @@ } NEGATOR_WORDS = ["avoid", "don't", "do not", "禁止", "不要", "避免", "严禁"] +NEG_SECTION_MARKERS = [ + "绝对禁止", + "禁止使用", + "禁止", + "must avoid", + "must not", + "forbidden", + "absolutely forbidden", + "do not", +] +POS_SECTION_MARKERS = [ + "必须遵守", + "必须使用", + "必须", + "must follow", + "must use", + "required", +] +RADIUS_TOKEN_RE = re.compile(r"\brounded(?:-[a-z0-9]+)?\b", re.IGNORECASE) +SHADOW_TOKEN_RE = re.compile(r"\bshadow(?:-[a-z0-9\[\]_/.-]+)?\b", re.IGNORECASE) +BG_WHITE_TOKEN_RE = re.compile(r"\bbg-white(?:/[0-9]{1,3})?\b", re.IGNORECASE) +BG_BLACK_TOKEN_RE = re.compile(r"\bbg-black(?:/[0-9]{1,3})?\b", re.IGNORECASE) RULE_STOPWORDS = { "use", "using", @@ -361,6 +383,120 @@ def has_cjk(text: str) -> bool: return bool(CJK_RE.search(text or "")) +def section_polarity_from_heading(line: str) -> str | None: + text = str(line or "").strip() + if not text: + return None + if not text.startswith("#"): + return None + + normalized = re.sub(r"^[#\s]+", "", text).lower() + if any(marker in normalized for marker in NEG_SECTION_MARKERS): + return "neg" + if any(marker in normalized for marker in POS_SECTION_MARKERS): + return "pos" + return None + + +def to_negative_rule(rule: str, lang: str) -> str: + text = str(rule or "").strip() + if not text: + return text + if any(word in text.lower() for word in NEGATOR_WORDS): + return text + if lang == "zh": + return f"禁止{text}" + if text[0].isupper(): + return f"Do not {text[0].lower()}{text[1:]}" + return f"Do not {text}" + + +def extract_utility_signatures(rule: str) -> dict[str, set[str]]: + low = str(rule or "").lower() + signatures: dict[str, set[str]] = {} + + for token in RADIUS_TOKEN_RE.findall(low): + value = token.split("-", 1)[1] if "-" in token else "base" + signatures.setdefault("radius", set()).add(value) + + for token in SHADOW_TOKEN_RE.findall(low): + value = token.split("-", 1)[1] if "-" in token else "base" + signatures.setdefault("shadow", set()).add(value) + + for token in BG_WHITE_TOKEN_RE.findall(low): + value = "translucent" if "/" in token else "opaque" + signatures.setdefault("bg-white", set()).add(value) + + for token in BG_BLACK_TOKEN_RE.findall(low): + value = "translucent" if "/" in token else "opaque" + signatures.setdefault("bg-black", set()).add(value) + + return signatures + + +def utility_family_conflicts(values_a: set[str], values_b: set[str], family: str) -> bool: + # Opposite polarity is required by caller; treat only same-value collisions as conflicts. + # This allows valid pairs like "禁止 rounded-none" + "使用 rounded-xl". + return bool(values_a & values_b) + + +def utility_rules_conflict(rule_a: str, rule_b: str) -> bool: + signatures_a = extract_utility_signatures(rule_a) + signatures_b = extract_utility_signatures(rule_b) + for family in signatures_a.keys() & signatures_b.keys(): + if utility_family_conflicts(signatures_a[family], signatures_b[family], family): + return True + return False + + +def has_internal_family_conflict(values: set[str], family: str) -> bool: + if family in {"radius", "shadow"}: + return "none" in values and any(v != "none" for v in values) + if family in {"bg-white", "bg-black"}: + return "opaque" in values and "translucent" in values + return False + + +def has_internal_utility_conflict(rule: str) -> bool: + signatures = extract_utility_signatures(rule) + for family, values in signatures.items(): + if has_internal_family_conflict(values, family): + return True + return False + + +def rewrite_ambiguous_positive_rule(rule: str, lang: str) -> str: + if is_negative_rule(rule): + return rule + signatures = extract_utility_signatures(rule) + radius_values = signatures.get("radius", set()) + shadow_values = signatures.get("shadow", set()) + bg_white_values = signatures.get("bg-white", set()) + bg_black_values = signatures.get("bg-black", set()) + + if has_internal_family_conflict(radius_values, "radius"): + return ( + "圆角策略保持一致,禁止在同一界面混用直角和大圆角。" + if lang == "zh" + else "Keep one consistent corner strategy; do not mix sharp and rounded corners in the same screen." + ) + if has_internal_family_conflict(shadow_values, "shadow"): + return ( + "阴影策略保持一致,避免同时要求无阴影和重阴影。" + if lang == "zh" + else "Keep one consistent shadow strategy; avoid mixing no-shadow and heavy-shadow directives." + ) + if has_internal_family_conflict(bg_white_values, "bg-white") or has_internal_family_conflict( + bg_black_values, "bg-black" + ): + return ( + "背景不透明度策略保持一致,避免同时要求纯色不透明与半透明。" + if lang == "zh" + else "Keep background opacity strategy consistent; avoid mixing opaque and translucent directives." + ) + return rule + + def dedupe_ordered(items: list[str]) -> list[str]: out: list[str] = [] seen: set[str] = set() @@ -394,6 +530,8 @@ def conflict_token_set(rule: str) -> set[str]: def rule_conflicts(rule_a: str, rule_b: str) -> bool: if rule_polarity(rule_a) == rule_polarity(rule_b): return False + if utility_rules_conflict(rule_a, rule_b): + return True a_tokens = conflict_token_set(rule_a) b_tokens = conflict_token_set(rule_b) if not a_tokens or not b_tokens: @@ -962,16 +1100,25 @@ def conflicts_with_dont(rule: str, dont_list: list[str]) -> bool: return False -def extract_rules(ai_rules_text: str) -> list[str]: +def extract_rules(ai_rules_text: str, lang: str) -> list[str]: lines = [] + section_polarity: str | None = None + for raw in str(ai_rules_text or "").splitlines(): - line = raw.strip() - if not line: + raw_line = raw.strip() + if not raw_line: + continue + + heading_polarity = section_polarity_from_heading(raw_line) + if heading_polarity: + section_polarity = heading_polarity continue - line = re.sub(r"^[-*]\s+", "", line) + + line = re.sub(r"^[-*]\s+", "", raw_line) line = re.sub(r"^\d+\.\s+", "", line) if len(line) < 8: continue + low = line.lower() if line.startswith("#"): continue @@ -979,6 +1126,11 @@ def extract_rules(ai_rules_text: str) -> list[str]: continue if "生成的所有代码必须" in line or "all code must" in low: continue + + # Treat bullets inside forbidden sections as negative constraints. + if section_polarity == "neg": + line = to_negative_rule(line, lang) + lines.append(line) deduped = [] @@ -1121,6 +1273,9 @@ def ensure_min_rules(base_rules: list[str], do_list: list[str], dont_list: list[ out = [] for rule in base_rules: normalized = normalize_rule(rule, dont_list, lang) + normalized = rewrite_ambiguous_positive_rule(normalized, lang) + if has_internal_utility_conflict(normalized): + continue if conflicts_with_dont(normalized, dont_list): continue out.append(normalized) @@ -1128,8 +1283,15 @@ def ensure_min_rules(base_rules: list[str], do_list: list[str], dont_list: list[ for item in do_list: if len(out) >= 6: break - if item not in out: - out.append(item) + if is_negative_rule(item): + continue + normalized_item = rewrite_ambiguous_positive_rule(item, lang) + if has_internal_utility_conflict(normalized_item): + continue + if conflicts_with_dont(normalized_item, dont_list): + continue + if normalized_item not in out: + out.append(normalized_item) if len(out) < 3: defaults = ( @@ -1557,7 +1719,7 @@ def main() -> None: lang=lang, ) - base_rules = extract_rules(primary.get("aiRules", "")) + base_rules = extract_rules(primary.get("aiRules", ""), lang) base_rules.extend(reference_signals.get("derived_rules", [])) ai_rules = ensure_min_rules(base_rules, primary.get("doList", []), primary.get("dontList", []), lang) ai_rules = resolve_rule_conflicts(ai_rules, lang) diff --git a/scripts/qa_prompt.py b/scripts/qa_prompt.py index a3dfe62..f331c1b 100644 --- a/scripts/qa_prompt.py +++ b/scripts/qa_prompt.py @@ -138,6 +138,10 @@ "或", } CJK_RE = re.compile(r"[\u4e00-\u9fff]") +RADIUS_TOKEN_RE = re.compile(r"\brounded(?:-[a-z0-9]+)?\b", re.IGNORECASE) +SHADOW_TOKEN_RE = re.compile(r"\bshadow(?:-[a-z0-9\[\]_/.-]+)?\b", re.IGNORECASE) +BG_WHITE_TOKEN_RE = re.compile(r"\bbg-white(?:/[0-9]{1,3})?\b", re.IGNORECASE) +BG_BLACK_TOKEN_RE = re.compile(r"\bbg-black(?:/[0-9]{1,3})?\b", re.IGNORECASE) def _extract_from_json_obj(obj: Any, preferred_field: str) -> tuple[str | None, str | None]: @@ -264,6 +268,42 @@ def rule_polarity(rule: str) -> str: return "neg" if any(neg in low for neg in NEGATORS) else "pos" +def extract_utility_signatures(rule: str) -> dict[str, set[str]]: + low = str(rule or "").lower() + signatures: dict[str, set[str]] = {} + + for token in RADIUS_TOKEN_RE.findall(low): + value = token.split("-", 1)[1] if "-" in token else "base" + signatures.setdefault("radius", set()).add(value) + + for token in SHADOW_TOKEN_RE.findall(low): + value = token.split("-", 1)[1] if "-" in token else "base" + signatures.setdefault("shadow", set()).add(value) + + for token in BG_WHITE_TOKEN_RE.findall(low): + value = "translucent" if "/" in token else "opaque" + signatures.setdefault("bg-white", set()).add(value) + + for token in BG_BLACK_TOKEN_RE.findall(low): + value = "translucent" if "/" in token else "opaque" + signatures.setdefault("bg-black", set()).add(value) + + return signatures + + +def utility_family_conflicts(values_a: set[str], values_b: set[str], family: str) -> bool: + return bool(values_a & values_b) + + +def utility_rules_conflict(rule_a: str, rule_b: str) -> bool: + signatures_a = extract_utility_signatures(rule_a) + signatures_b = extract_utility_signatures(rule_b) + for family in signatures_a.keys() & signatures_b.keys(): + if utility_family_conflicts(signatures_a[family], signatures_b[family], family): + return True + return False + + def conflict_token_set(rule: str) -> set[str]: tokens = tokenize(rule) ignored = RULE_STOPWORDS | set(NEGATORS) | {"not", "no", "without", "non", "无", "非", "不"} @@ -273,6 +313,8 @@ def conflict_token_set(rule: str) -> set[str]: def rules_conflict(rule_a: str, rule_b: str) -> bool: if rule_polarity(rule_a) == rule_polarity(rule_b): return False + if utility_rules_conflict(rule_a, rule_b): + return True a_tokens = conflict_token_set(rule_a) b_tokens = conflict_token_set(rule_b) if not a_tokens or not b_tokens: