diff --git a/plugins/claude-artic/skills/artic/scripts/artic_init.py b/plugins/claude-artic/skills/artic/scripts/artic_init.py index 1526f31..e274c02 100644 --- a/plugins/claude-artic/skills/artic/scripts/artic_init.py +++ b/plugins/claude-artic/skills/artic/scripts/artic_init.py @@ -28,6 +28,16 @@ } DEFAULT_PRESERVE_TERMS = ["DESIGN.md", "AI-native", "Artic"] ROLE_SELECTION_MIN_SCORE = 20 +ROLE_ALLOWED_SOURCE_ROLES = { + "3d_runtime": {"implementation_reference", "behavior_reference"}, + "3d_safety_and_performance": {"qa_reference", "behavior_reference", "implementation_reference", "system_reference"}, + "trust_and_conversion": {"system_reference", "qa_reference", "behavior_reference", "copy_reference"}, + "component_restraint": {"implementation_reference", "system_reference", "behavior_reference"}, + "token_discipline": {"implementation_reference", "system_reference"}, + "mobile_confidence": {"system_reference", "visual_reference", "implementation_reference", "behavior_reference"}, + "korean_market_fit": {"system_reference", "qa_reference", "asset_source", "implementation_reference"}, + "implementation_clarity": {"implementation_reference", "system_reference", "behavior_reference"}, +} def write(path: Path, content: str) -> None: @@ -157,22 +167,58 @@ def query_from_intent(intent: dict) -> str: return " ".join(part for part in parts if part).strip() +def explicit_role_source_ids(intent: dict) -> set[str]: + explicit: set[str] = set() + for role in intent.get("reference_roles", []): + if isinstance(role, dict): + explicit.update(str(source_id) for source_id in role.get("source_ids", []) if source_id) + return explicit + + +def source_allowed_for_role(row: dict, role_name: str) -> bool: + source_role = row.get("source_role") + allowed = ROLE_ALLOWED_SOURCE_ROLES.get(role_name) + if allowed is None: + return source_role not in {"asset_source", "legal_reference"} + return source_role in allowed + + +def source_eligible_for_selection(row: dict, *, role_name: str | None, explicit_ids: set[str], fallback: bool) -> bool: + source_id = str(row.get("id") or "") + if row.get("requires_explicit_context") is True and source_id not in explicit_ids: + return False + if role_name is not None and not source_allowed_for_role(row, role_name): + return False + return True + + def select_role_grounded_sources(intent: dict, catalog_path: Path, limit: int) -> tuple[list[dict], list[dict]]: query = query_from_intent(intent) avoid_terms = terms(" ".join(str(item) for item in intent.get("avoid_facets", []) if item)) scored = search(query, catalog_path, max(limit, len(load_catalog(catalog_path))), avoid_terms=avoid_terms) by_id = {row["id"]: row for row in scored} + explicit_ids = explicit_role_source_ids(intent) selected: list[dict] = [] seen: set[str] = set() + role_rejected_ids: set[str] = set() role_assignments: list[dict] = [] for role in intent.get("reference_roles", []): if not isinstance(role, dict): continue picked: list[str] = [] + role_name = str(role.get("role") or "") for source_id in role.get("source_ids", []): row = by_id.get(source_id) - if row and row["score"] >= ROLE_SELECTION_MIN_SCORE and row["id"] not in seen: + if row and not source_allowed_for_role(row, role_name): + role_rejected_ids.add(row["id"]) + continue + if ( + row + and row["score"] >= ROLE_SELECTION_MIN_SCORE + and row["id"] not in seen + and source_eligible_for_selection(row, role_name=role_name, explicit_ids=explicit_ids, fallback=False) + ): selected.append(row) seen.add(row["id"]) picked.append(row["id"]) @@ -183,9 +229,14 @@ def select_role_grounded_sources(intent: dict, catalog_path: Path, limit: int) - for row in scored: if len(selected) >= limit: break - if row["id"] not in seen: - selected.append(row) - seen.add(row["id"]) + if row["id"] in seen: + continue + if row["id"] in role_rejected_ids: + continue + if not source_eligible_for_selection(row, role_name=None, explicit_ids=explicit_ids, fallback=True): + continue + selected.append(row) + seen.add(row["id"]) selected = selected[:limit] selected_ids = {row["id"] for row in selected} @@ -216,6 +267,9 @@ def selected_source_payload(row: dict) -> dict: "score": row["score"], "reason": "; ".join(reason_parts) or row.get("type", "reference source"), "extraction_targets": row.get("extraction_targets", row.get("use_for", [])), + "source_role": row.get("source_role", "visual_reference"), + "default_visual_reference": bool(row.get("default_visual_reference", False)), + "requires_explicit_context": bool(row.get("requires_explicit_context", False)), "url": row.get("url"), "license": row.get("license", "unknown"), } @@ -247,6 +301,9 @@ def create_init_outputs(root: Path, args: argparse.Namespace) -> dict: { "source_id": src["id"], "role": role_for_source(src["id"], role_assignments), + "source_role": src.get("source_role", "visual_reference"), + "default_visual_reference": bool(src.get("default_visual_reference", False)), + "requires_explicit_context": bool(src.get("requires_explicit_context", False)), "extract": src.get("extraction_targets", []), "transform": f"Translate {src['name']} into project-specific rules for {args.project}; keep the final layout, copy, and visual identity original.", "avoid": src.get("avoid_when", []) or ["exact layouts", "brand identity", "source copywriting"], diff --git a/plugins/codex-artic/skills/artic/scripts/artic_init.py b/plugins/codex-artic/skills/artic/scripts/artic_init.py index 1526f31..e274c02 100644 --- a/plugins/codex-artic/skills/artic/scripts/artic_init.py +++ b/plugins/codex-artic/skills/artic/scripts/artic_init.py @@ -28,6 +28,16 @@ } DEFAULT_PRESERVE_TERMS = ["DESIGN.md", "AI-native", "Artic"] ROLE_SELECTION_MIN_SCORE = 20 +ROLE_ALLOWED_SOURCE_ROLES = { + "3d_runtime": {"implementation_reference", "behavior_reference"}, + "3d_safety_and_performance": {"qa_reference", "behavior_reference", "implementation_reference", "system_reference"}, + "trust_and_conversion": {"system_reference", "qa_reference", "behavior_reference", "copy_reference"}, + "component_restraint": {"implementation_reference", "system_reference", "behavior_reference"}, + "token_discipline": {"implementation_reference", "system_reference"}, + "mobile_confidence": {"system_reference", "visual_reference", "implementation_reference", "behavior_reference"}, + "korean_market_fit": {"system_reference", "qa_reference", "asset_source", "implementation_reference"}, + "implementation_clarity": {"implementation_reference", "system_reference", "behavior_reference"}, +} def write(path: Path, content: str) -> None: @@ -157,22 +167,58 @@ def query_from_intent(intent: dict) -> str: return " ".join(part for part in parts if part).strip() +def explicit_role_source_ids(intent: dict) -> set[str]: + explicit: set[str] = set() + for role in intent.get("reference_roles", []): + if isinstance(role, dict): + explicit.update(str(source_id) for source_id in role.get("source_ids", []) if source_id) + return explicit + + +def source_allowed_for_role(row: dict, role_name: str) -> bool: + source_role = row.get("source_role") + allowed = ROLE_ALLOWED_SOURCE_ROLES.get(role_name) + if allowed is None: + return source_role not in {"asset_source", "legal_reference"} + return source_role in allowed + + +def source_eligible_for_selection(row: dict, *, role_name: str | None, explicit_ids: set[str], fallback: bool) -> bool: + source_id = str(row.get("id") or "") + if row.get("requires_explicit_context") is True and source_id not in explicit_ids: + return False + if role_name is not None and not source_allowed_for_role(row, role_name): + return False + return True + + def select_role_grounded_sources(intent: dict, catalog_path: Path, limit: int) -> tuple[list[dict], list[dict]]: query = query_from_intent(intent) avoid_terms = terms(" ".join(str(item) for item in intent.get("avoid_facets", []) if item)) scored = search(query, catalog_path, max(limit, len(load_catalog(catalog_path))), avoid_terms=avoid_terms) by_id = {row["id"]: row for row in scored} + explicit_ids = explicit_role_source_ids(intent) selected: list[dict] = [] seen: set[str] = set() + role_rejected_ids: set[str] = set() role_assignments: list[dict] = [] for role in intent.get("reference_roles", []): if not isinstance(role, dict): continue picked: list[str] = [] + role_name = str(role.get("role") or "") for source_id in role.get("source_ids", []): row = by_id.get(source_id) - if row and row["score"] >= ROLE_SELECTION_MIN_SCORE and row["id"] not in seen: + if row and not source_allowed_for_role(row, role_name): + role_rejected_ids.add(row["id"]) + continue + if ( + row + and row["score"] >= ROLE_SELECTION_MIN_SCORE + and row["id"] not in seen + and source_eligible_for_selection(row, role_name=role_name, explicit_ids=explicit_ids, fallback=False) + ): selected.append(row) seen.add(row["id"]) picked.append(row["id"]) @@ -183,9 +229,14 @@ def select_role_grounded_sources(intent: dict, catalog_path: Path, limit: int) - for row in scored: if len(selected) >= limit: break - if row["id"] not in seen: - selected.append(row) - seen.add(row["id"]) + if row["id"] in seen: + continue + if row["id"] in role_rejected_ids: + continue + if not source_eligible_for_selection(row, role_name=None, explicit_ids=explicit_ids, fallback=True): + continue + selected.append(row) + seen.add(row["id"]) selected = selected[:limit] selected_ids = {row["id"] for row in selected} @@ -216,6 +267,9 @@ def selected_source_payload(row: dict) -> dict: "score": row["score"], "reason": "; ".join(reason_parts) or row.get("type", "reference source"), "extraction_targets": row.get("extraction_targets", row.get("use_for", [])), + "source_role": row.get("source_role", "visual_reference"), + "default_visual_reference": bool(row.get("default_visual_reference", False)), + "requires_explicit_context": bool(row.get("requires_explicit_context", False)), "url": row.get("url"), "license": row.get("license", "unknown"), } @@ -247,6 +301,9 @@ def create_init_outputs(root: Path, args: argparse.Namespace) -> dict: { "source_id": src["id"], "role": role_for_source(src["id"], role_assignments), + "source_role": src.get("source_role", "visual_reference"), + "default_visual_reference": bool(src.get("default_visual_reference", False)), + "requires_explicit_context": bool(src.get("requires_explicit_context", False)), "extract": src.get("extraction_targets", []), "transform": f"Translate {src['name']} into project-specific rules for {args.project}; keep the final layout, copy, and visual identity original.", "avoid": src.get("avoid_when", []) or ["exact layouts", "brand identity", "source copywriting"], diff --git a/skills/artic/scripts/artic_init.py b/skills/artic/scripts/artic_init.py index 1526f31..e274c02 100644 --- a/skills/artic/scripts/artic_init.py +++ b/skills/artic/scripts/artic_init.py @@ -28,6 +28,16 @@ } DEFAULT_PRESERVE_TERMS = ["DESIGN.md", "AI-native", "Artic"] ROLE_SELECTION_MIN_SCORE = 20 +ROLE_ALLOWED_SOURCE_ROLES = { + "3d_runtime": {"implementation_reference", "behavior_reference"}, + "3d_safety_and_performance": {"qa_reference", "behavior_reference", "implementation_reference", "system_reference"}, + "trust_and_conversion": {"system_reference", "qa_reference", "behavior_reference", "copy_reference"}, + "component_restraint": {"implementation_reference", "system_reference", "behavior_reference"}, + "token_discipline": {"implementation_reference", "system_reference"}, + "mobile_confidence": {"system_reference", "visual_reference", "implementation_reference", "behavior_reference"}, + "korean_market_fit": {"system_reference", "qa_reference", "asset_source", "implementation_reference"}, + "implementation_clarity": {"implementation_reference", "system_reference", "behavior_reference"}, +} def write(path: Path, content: str) -> None: @@ -157,22 +167,58 @@ def query_from_intent(intent: dict) -> str: return " ".join(part for part in parts if part).strip() +def explicit_role_source_ids(intent: dict) -> set[str]: + explicit: set[str] = set() + for role in intent.get("reference_roles", []): + if isinstance(role, dict): + explicit.update(str(source_id) for source_id in role.get("source_ids", []) if source_id) + return explicit + + +def source_allowed_for_role(row: dict, role_name: str) -> bool: + source_role = row.get("source_role") + allowed = ROLE_ALLOWED_SOURCE_ROLES.get(role_name) + if allowed is None: + return source_role not in {"asset_source", "legal_reference"} + return source_role in allowed + + +def source_eligible_for_selection(row: dict, *, role_name: str | None, explicit_ids: set[str], fallback: bool) -> bool: + source_id = str(row.get("id") or "") + if row.get("requires_explicit_context") is True and source_id not in explicit_ids: + return False + if role_name is not None and not source_allowed_for_role(row, role_name): + return False + return True + + def select_role_grounded_sources(intent: dict, catalog_path: Path, limit: int) -> tuple[list[dict], list[dict]]: query = query_from_intent(intent) avoid_terms = terms(" ".join(str(item) for item in intent.get("avoid_facets", []) if item)) scored = search(query, catalog_path, max(limit, len(load_catalog(catalog_path))), avoid_terms=avoid_terms) by_id = {row["id"]: row for row in scored} + explicit_ids = explicit_role_source_ids(intent) selected: list[dict] = [] seen: set[str] = set() + role_rejected_ids: set[str] = set() role_assignments: list[dict] = [] for role in intent.get("reference_roles", []): if not isinstance(role, dict): continue picked: list[str] = [] + role_name = str(role.get("role") or "") for source_id in role.get("source_ids", []): row = by_id.get(source_id) - if row and row["score"] >= ROLE_SELECTION_MIN_SCORE and row["id"] not in seen: + if row and not source_allowed_for_role(row, role_name): + role_rejected_ids.add(row["id"]) + continue + if ( + row + and row["score"] >= ROLE_SELECTION_MIN_SCORE + and row["id"] not in seen + and source_eligible_for_selection(row, role_name=role_name, explicit_ids=explicit_ids, fallback=False) + ): selected.append(row) seen.add(row["id"]) picked.append(row["id"]) @@ -183,9 +229,14 @@ def select_role_grounded_sources(intent: dict, catalog_path: Path, limit: int) - for row in scored: if len(selected) >= limit: break - if row["id"] not in seen: - selected.append(row) - seen.add(row["id"]) + if row["id"] in seen: + continue + if row["id"] in role_rejected_ids: + continue + if not source_eligible_for_selection(row, role_name=None, explicit_ids=explicit_ids, fallback=True): + continue + selected.append(row) + seen.add(row["id"]) selected = selected[:limit] selected_ids = {row["id"] for row in selected} @@ -216,6 +267,9 @@ def selected_source_payload(row: dict) -> dict: "score": row["score"], "reason": "; ".join(reason_parts) or row.get("type", "reference source"), "extraction_targets": row.get("extraction_targets", row.get("use_for", [])), + "source_role": row.get("source_role", "visual_reference"), + "default_visual_reference": bool(row.get("default_visual_reference", False)), + "requires_explicit_context": bool(row.get("requires_explicit_context", False)), "url": row.get("url"), "license": row.get("license", "unknown"), } @@ -247,6 +301,9 @@ def create_init_outputs(root: Path, args: argparse.Namespace) -> dict: { "source_id": src["id"], "role": role_for_source(src["id"], role_assignments), + "source_role": src.get("source_role", "visual_reference"), + "default_visual_reference": bool(src.get("default_visual_reference", False)), + "requires_explicit_context": bool(src.get("requires_explicit_context", False)), "extract": src.get("extraction_targets", []), "transform": f"Translate {src['name']} into project-specific rules for {args.project}; keep the final layout, copy, and visual identity original.", "avoid": src.get("avoid_when", []) or ["exact layouts", "brand identity", "source copywriting"], diff --git a/tests/test_artic_package.py b/tests/test_artic_package.py index 5d79f50..47f7712 100644 --- a/tests/test_artic_package.py +++ b/tests/test_artic_package.py @@ -605,6 +605,172 @@ def test_artic_init_role_selection_rejects_low_score_default_component_sources() assert not role_selected_ids & {"shadcn-ui", "tailwind-css", "github-primer"}, references["role_assignments"] +def test_artic_init_role_selection_rejects_role_ineligible_metadata_sources(): + artic_init = importlib.import_module("artic_init") + with tempfile.TemporaryDirectory() as tmp: + catalog_path = Path(tmp) / "catalog.json" + shared = { + "type": "fixture", + "url": "https://example.test/source", + "license": "MIT", + "tags": ["homepage", "react", "components", "tokens", "trust"], + "product_fit": ["saas", "developer-tool", "web-app"], + "visual_traits": ["modern", "clear"], + "page_patterns": ["landing-page"], + "implementation_fit": ["react", "components", "tokens"], + "strengths": ["fixture source"], + "avoid_when": [], + } + catalog = [ + { + **shared, + "id": "asset-kit", + "name": "Asset Kit", + "source_role": "asset_source", + "default_visual_reference": True, + "requires_explicit_context": True, + "extraction_targets": ["icons", "illustrations"], + "use_for": ["licensed decorative assets"], + "application_guidance": "Use only when explicit asset sourcing is requested.", + }, + { + **shared, + "id": "component-kit", + "name": "Component Kit", + "source_role": "implementation_reference", + "default_visual_reference": False, + "requires_explicit_context": True, + "extraction_targets": ["component behavior", "tokens"], + "use_for": ["component restraint"], + "application_guidance": "Use for component behavior and implementation guidance.", + }, + ] + catalog_path.write_text(json.dumps(catalog, indent=2), encoding="utf-8") + intent = { + "catalog_query": "homepage react components tokens trust", + "avoid_facets": [], + "reference_roles": [ + { + "role": "component_restraint", + "source_ids": ["asset-kit", "component-kit"], + "selection_reason": "Regression fixture: role eligibility must use catalog metadata, not only score/order.", + } + ], + } + + selected, role_assignments = artic_init.select_role_grounded_sources(intent, catalog_path, 2) + + selected_ids = [row["id"] for row in selected] + assert "component-kit" in selected_ids + assert "asset-kit" not in selected_ids + assert role_assignments[0]["selected_source_ids"] == ["component-kit"] + + +def test_artic_init_fallback_skips_sources_without_explicit_context(): + artic_init = importlib.import_module("artic_init") + with tempfile.TemporaryDirectory() as tmp: + catalog_path = Path(tmp) / "catalog.json" + shared = { + "type": "fixture", + "url": "https://example.test/source", + "license": "MIT", + "tags": ["homepage", "react", "components", "tokens", "trust"], + "product_fit": ["saas", "developer-tool", "web-app"], + "visual_traits": ["modern", "clear"], + "page_patterns": ["landing-page"], + "implementation_fit": ["react", "components", "tokens"], + "extraction_targets": ["patterns"], + "strengths": ["fixture source"], + "use_for": ["fixture"], + "avoid_when": [], + "application_guidance": "Use this fixture for deterministic metadata-boundary tests.", + } + catalog = [ + { + **shared, + "id": "component-kit", + "name": "Component Kit", + "source_role": "implementation_reference", + "default_visual_reference": False, + "requires_explicit_context": True, + }, + { + **shared, + "id": "asset-kit", + "name": "Asset Kit", + "source_role": "asset_source", + "default_visual_reference": True, + "requires_explicit_context": True, + }, + { + **shared, + "id": "general-guide", + "name": "General Guide", + "source_role": "behavior_reference", + "default_visual_reference": False, + "requires_explicit_context": False, + }, + ] + catalog_path.write_text(json.dumps(catalog, indent=2), encoding="utf-8") + intent = { + "catalog_query": "homepage react components tokens trust", + "avoid_facets": [], + "reference_roles": [ + { + "role": "component_restraint", + "source_ids": ["component-kit"], + "selection_reason": "Regression fixture: fallback must not promote explicit-only asset sources.", + } + ], + } + + selected, _ = artic_init.select_role_grounded_sources(intent, catalog_path, 2) + + selected_ids = [row["id"] for row in selected] + assert "component-kit" in selected_ids + assert "general-guide" in selected_ids + assert "asset-kit" not in selected_ids + + +def test_artic_init_preserves_source_role_metadata_in_selected_sources_and_source_plan(): + with tempfile.TemporaryDirectory() as tmp: + result = subprocess.run([ + sys.executable, + str(ROOT / "skills/artic/scripts/artic_init.py"), + "--root", + tmp, + "--project", + "Korean AI Meeting Assistant", + "--audience", + "startup operators and sales teams", + "--goal", + "demo requests", + "--vibe", + "clean trustworthy mobile-first saas", + "--references", + "Linear clarity, Shopify Polaris trust, Material token discipline", + "--stack", + "React Tailwind", + "--limit", + "4", + ], check=True, capture_output=True, text=True) + payload = json.loads(result.stdout) + references = json.loads((Path(tmp) / ".artic" / "references.json").read_text(encoding="utf-8")) + + metadata_keys = {"source_role", "default_visual_reference", "requires_explicit_context"} + assert references["selected_sources"] + assert references["source_plan"] + assert all(metadata_keys <= set(row) for row in payload["selected_sources"]) + assert all(metadata_keys <= set(row) for row in references["selected_sources"]) + assert all(metadata_keys <= set(row) for row in references["source_plan"]) + + selected_by_id = {row["id"]: row for row in references["selected_sources"]} + for plan in references["source_plan"]: + source = selected_by_id[plan["source_id"]] + for key in metadata_keys: + assert plan[key] == source[key] + + def test_artic_init_generates_brief_and_reference_search_outputs(): with tempfile.TemporaryDirectory() as tmp: result = subprocess.run([