feat: design system overhaul — sidebar, AI chats, settings, brainstorm, color cleanup
All checks were successful
Deploy to Production / Build and Deploy (push) Successful in 12s
All checks were successful
Deploy to Production / Build and Deploy (push) Successful in 12s
- Sidebar: dynamic brand-accent colors, brainstorm section restyled - AI chat general: popup panel with expand/collapse, hides when contextual AI open - AI chat contextual: tabs reordered (Actions first), X close button, height fix - Settings: all tabs restyled, 6 new color presets (sage, terracotta, iron, etc.) - Global color cleanup: emerald/orange hardcoded → brand-accent dynamic - Brainstorm page: orange → brand-accent throughout - PageEntry animation component added to key pages - Floating AI button: bg-brand-accent instead of hardcoded black - i18n: all 15 locales updated with new AI/billing keys - Billing: freemium quota tracking, BYOK, stripe subscription scaffolding - Admin: integrated into new design - AGENTS.md + CLAUDE.md project rules added
This commit is contained in:
@@ -0,0 +1,448 @@
|
||||
#!/usr/bin/env python3
|
||||
# /// script
|
||||
# requires-python = ">=3.10"
|
||||
# dependencies = ["pyyaml>=6.0"]
|
||||
# ///
|
||||
|
||||
"""Validate a band profile YAML file against the expected schema.
|
||||
|
||||
Checks required fields, value constraints, tier/model consistency,
|
||||
instrumental mode, style_baseline length, and new fields (language,
|
||||
creativity_default, generation_history, studio_preferences).
|
||||
Returns structured JSON findings.
|
||||
|
||||
Also supports --derive-filename to convert a band name to kebab-case filename.
|
||||
"""
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import re
|
||||
import sys
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
|
||||
import yaml
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parent.parent.parent / "_shared"))
|
||||
from suno_constants import VALID_MODELS, VALID_TIERS, STYLE_PROMPT_LIMITS, STYLE_PROMPT_DEFAULT_MAX, FREE_TIER_MODEL
|
||||
|
||||
VALID_GENDERS = {"male", "female", "nonbinary", "any"}
|
||||
VALID_CREATIVITY = {"conservative", "balanced", "experimental"}
|
||||
STYLE_BASELINE_MAX = STYLE_PROMPT_DEFAULT_MAX
|
||||
STYLE_BASELINE_MAX_V4 = STYLE_PROMPT_LIMITS["v4 Pro"]
|
||||
MAX_GENERATION_HISTORY = 10
|
||||
|
||||
|
||||
def derive_filename(band_name: str) -> str:
|
||||
"""Convert a band name to kebab-case filename."""
|
||||
name = band_name.strip().lower()
|
||||
name = re.sub(r"[^a-z0-9\s-]", "", name)
|
||||
name = re.sub(r"[\s_]+", "-", name)
|
||||
name = re.sub(r"-+", "-", name)
|
||||
name = name.strip("-")
|
||||
return f"{name}.yaml"
|
||||
|
||||
|
||||
def validate_profile(profile_path: Path) -> dict:
|
||||
"""Validate a profile YAML file and return structured findings."""
|
||||
findings = []
|
||||
script_name = "validate-profile"
|
||||
|
||||
if not profile_path.exists():
|
||||
return {
|
||||
"script": script_name,
|
||||
"version": "2.0.0",
|
||||
"skill_path": str(profile_path),
|
||||
"timestamp": datetime.now(timezone.utc).isoformat(),
|
||||
"status": "fail",
|
||||
"findings": [{
|
||||
"severity": "critical",
|
||||
"category": "structure",
|
||||
"location": {"file": str(profile_path)},
|
||||
"issue": "Profile file does not exist",
|
||||
"fix": f"Create the profile at {profile_path}"
|
||||
}],
|
||||
"summary": {"total": 1, "critical": 1, "high": 0, "medium": 0, "low": 0}
|
||||
}
|
||||
|
||||
try:
|
||||
with open(profile_path) as f:
|
||||
profile = yaml.safe_load(f)
|
||||
except yaml.YAMLError as e:
|
||||
return {
|
||||
"script": script_name,
|
||||
"version": "2.0.0",
|
||||
"skill_path": str(profile_path),
|
||||
"timestamp": datetime.now(timezone.utc).isoformat(),
|
||||
"status": "fail",
|
||||
"findings": [{
|
||||
"severity": "critical",
|
||||
"category": "structure",
|
||||
"location": {"file": str(profile_path)},
|
||||
"issue": f"Invalid YAML: {e}",
|
||||
"fix": "Fix YAML syntax errors"
|
||||
}],
|
||||
"summary": {"total": 1, "critical": 1, "high": 0, "medium": 0, "low": 0}
|
||||
}
|
||||
|
||||
if not isinstance(profile, dict):
|
||||
return {
|
||||
"script": script_name,
|
||||
"version": "2.0.0",
|
||||
"skill_path": str(profile_path),
|
||||
"timestamp": datetime.now(timezone.utc).isoformat(),
|
||||
"status": "fail",
|
||||
"findings": [{
|
||||
"severity": "critical",
|
||||
"category": "structure",
|
||||
"location": {"file": str(profile_path)},
|
||||
"issue": "Profile is not a YAML mapping",
|
||||
"fix": "Profile must be a YAML dictionary/mapping at the top level"
|
||||
}],
|
||||
"summary": {"total": 1, "critical": 1, "high": 0, "medium": 0, "low": 0}
|
||||
}
|
||||
|
||||
is_instrumental = profile.get("instrumental", False) is True
|
||||
|
||||
# Required top-level string fields
|
||||
for field in ["name", "genre", "mood", "model_preference", "tier", "style_baseline"]:
|
||||
val = profile.get(field)
|
||||
if not val or not isinstance(val, str) or not val.strip():
|
||||
findings.append({
|
||||
"severity": "critical",
|
||||
"category": "structure",
|
||||
"location": {"file": str(profile_path), "field": field},
|
||||
"issue": f"Required field '{field}' is missing or empty",
|
||||
"fix": f"Add a non-empty '{field}' field to the profile"
|
||||
})
|
||||
|
||||
# model_preference validation
|
||||
model = profile.get("model_preference", "")
|
||||
if model and model not in VALID_MODELS:
|
||||
findings.append({
|
||||
"severity": "high",
|
||||
"category": "consistency",
|
||||
"location": {"file": str(profile_path), "field": "model_preference"},
|
||||
"issue": f"Invalid model_preference '{model}'",
|
||||
"fix": f"Must be one of: {', '.join(sorted(VALID_MODELS))}"
|
||||
})
|
||||
|
||||
# tier validation
|
||||
tier = profile.get("tier", "")
|
||||
if tier and tier not in VALID_TIERS:
|
||||
findings.append({
|
||||
"severity": "high",
|
||||
"category": "consistency",
|
||||
"location": {"file": str(profile_path), "field": "tier"},
|
||||
"issue": f"Invalid tier '{tier}'",
|
||||
"fix": f"Must be one of: {', '.join(sorted(VALID_TIERS))}"
|
||||
})
|
||||
|
||||
# style_baseline length — model-aware
|
||||
baseline = profile.get("style_baseline", "")
|
||||
if isinstance(baseline, str):
|
||||
max_len = STYLE_BASELINE_MAX_V4 if model == "v4 Pro" else STYLE_BASELINE_MAX
|
||||
if len(baseline) > max_len:
|
||||
findings.append({
|
||||
"severity": "high",
|
||||
"category": "consistency",
|
||||
"location": {"file": str(profile_path), "field": "style_baseline"},
|
||||
"issue": f"style_baseline is {len(baseline)} chars (max {max_len} for {model or 'this model'})",
|
||||
"fix": f"Trim style_baseline to {max_len} characters. Front-load essential descriptors in the first 200 chars."
|
||||
})
|
||||
|
||||
# vocal section — skip required checks if instrumental
|
||||
vocal = profile.get("vocal", {})
|
||||
if not is_instrumental:
|
||||
if not isinstance(vocal, dict):
|
||||
findings.append({
|
||||
"severity": "high",
|
||||
"category": "structure",
|
||||
"location": {"file": str(profile_path), "field": "vocal"},
|
||||
"issue": "'vocal' must be a mapping",
|
||||
"fix": "Define vocal as a YAML mapping with gender, tone, delivery, energy fields"
|
||||
})
|
||||
else:
|
||||
for vfield in ["gender", "tone", "delivery", "energy"]:
|
||||
val = vocal.get(vfield)
|
||||
if not val or not isinstance(val, str) or not val.strip():
|
||||
findings.append({
|
||||
"severity": "high",
|
||||
"category": "structure",
|
||||
"location": {"file": str(profile_path), "field": f"vocal.{vfield}"},
|
||||
"issue": f"Required vocal field '{vfield}' is missing or empty",
|
||||
"fix": f"Add a non-empty 'vocal.{vfield}' field (or set instrumental: true for instrumental projects)"
|
||||
})
|
||||
|
||||
gender = vocal.get("gender", "")
|
||||
if gender and gender not in VALID_GENDERS:
|
||||
findings.append({
|
||||
"severity": "medium",
|
||||
"category": "consistency",
|
||||
"location": {"file": str(profile_path), "field": "vocal.gender"},
|
||||
"issue": f"Invalid vocal gender '{gender}'",
|
||||
"fix": f"Must be one of: {', '.join(sorted(VALID_GENDERS))}"
|
||||
})
|
||||
elif isinstance(vocal, dict):
|
||||
# Instrumental but vocal present — validate gender if provided
|
||||
gender = vocal.get("gender", "")
|
||||
if gender and gender not in VALID_GENDERS:
|
||||
findings.append({
|
||||
"severity": "medium",
|
||||
"category": "consistency",
|
||||
"location": {"file": str(profile_path), "field": "vocal.gender"},
|
||||
"issue": f"Invalid vocal gender '{gender}'",
|
||||
"fix": f"Must be one of: {', '.join(sorted(VALID_GENDERS))}"
|
||||
})
|
||||
|
||||
# Tier-model consistency
|
||||
if tier == "free" and model and model != FREE_TIER_MODEL:
|
||||
findings.append({
|
||||
"severity": "medium",
|
||||
"category": "consistency",
|
||||
"location": {"file": str(profile_path), "field": "model_preference"},
|
||||
"issue": f"Free tier can only use '{FREE_TIER_MODEL}', but profile specifies '{model}'",
|
||||
"fix": f"Change model_preference to '{FREE_TIER_MODEL}' or upgrade tier"
|
||||
})
|
||||
|
||||
# Slider warnings for free tier
|
||||
sliders = profile.get("sliders", {})
|
||||
if tier == "free" and isinstance(sliders, dict) and sliders:
|
||||
has_values = any(
|
||||
k in ("weirdness", "style_influence") and v is not None and v != 50
|
||||
for k, v in sliders.items()
|
||||
)
|
||||
if has_values:
|
||||
findings.append({
|
||||
"severity": "medium",
|
||||
"category": "consistency",
|
||||
"location": {"file": str(profile_path), "field": "sliders"},
|
||||
"issue": "Slider values set but free tier does not support Weirdness/Style Influence sliders",
|
||||
"fix": "Remove sliders section or upgrade to Pro/Premier tier"
|
||||
})
|
||||
|
||||
# Slider range validation
|
||||
if isinstance(sliders, dict):
|
||||
for sname in ["weirdness", "style_influence", "audio_influence"]:
|
||||
sval = sliders.get(sname)
|
||||
if sval is not None:
|
||||
if not isinstance(sval, (int, float)) or sval < 0 or sval > 100:
|
||||
findings.append({
|
||||
"severity": "medium",
|
||||
"category": "consistency",
|
||||
"location": {"file": str(profile_path), "field": f"sliders.{sname}"},
|
||||
"issue": f"Slider '{sname}' value {sval} out of range",
|
||||
"fix": "Must be an integer between 0 and 100"
|
||||
})
|
||||
|
||||
# Exclusion defaults length check
|
||||
exclusions = profile.get("exclusion_defaults", [])
|
||||
if isinstance(exclusions, list):
|
||||
if len(exclusions) > 5:
|
||||
findings.append({
|
||||
"severity": "low",
|
||||
"category": "consistency",
|
||||
"location": {"file": str(profile_path), "field": "exclusion_defaults"},
|
||||
"issue": f"{len(exclusions)} exclusions defined (recommended max 5)",
|
||||
"fix": "Too many negatives can confuse the model. Prioritize the most important."
|
||||
})
|
||||
|
||||
# creativity_default validation
|
||||
creativity = profile.get("creativity_default")
|
||||
if creativity is not None:
|
||||
if not isinstance(creativity, str) or creativity not in VALID_CREATIVITY:
|
||||
findings.append({
|
||||
"severity": "medium",
|
||||
"category": "consistency",
|
||||
"location": {"file": str(profile_path), "field": "creativity_default"},
|
||||
"issue": f"Invalid creativity_default '{creativity}'",
|
||||
"fix": f"Must be one of: {', '.join(sorted(VALID_CREATIVITY))}"
|
||||
})
|
||||
|
||||
# language validation
|
||||
language = profile.get("language")
|
||||
if language is not None:
|
||||
if not isinstance(language, str) or not language.strip():
|
||||
findings.append({
|
||||
"severity": "low",
|
||||
"category": "consistency",
|
||||
"location": {"file": str(profile_path), "field": "language"},
|
||||
"issue": "language field is present but empty",
|
||||
"fix": "Provide a language value (e.g., 'English', 'Spanish') or remove the field"
|
||||
})
|
||||
|
||||
# generation_history validation
|
||||
gen_history = profile.get("generation_history")
|
||||
if gen_history is not None:
|
||||
if not isinstance(gen_history, list):
|
||||
findings.append({
|
||||
"severity": "low",
|
||||
"category": "structure",
|
||||
"location": {"file": str(profile_path), "field": "generation_history"},
|
||||
"issue": "generation_history must be a list",
|
||||
"fix": "Set generation_history to a list of snapshot entries"
|
||||
})
|
||||
elif len(gen_history) > MAX_GENERATION_HISTORY:
|
||||
findings.append({
|
||||
"severity": "low",
|
||||
"category": "consistency",
|
||||
"location": {"file": str(profile_path), "field": "generation_history"},
|
||||
"issue": f"generation_history has {len(gen_history)} entries (max {MAX_GENERATION_HISTORY})",
|
||||
"fix": f"Keep only the {MAX_GENERATION_HISTORY} most recent or significant entries"
|
||||
})
|
||||
|
||||
# studio_preferences validation — warn if not premier
|
||||
studio = profile.get("studio_preferences", {})
|
||||
if isinstance(studio, dict) and any(v is not None and v != "" for v in studio.values()):
|
||||
if tier and tier != "premier":
|
||||
findings.append({
|
||||
"severity": "medium",
|
||||
"category": "consistency",
|
||||
"location": {"file": str(profile_path), "field": "studio_preferences"},
|
||||
"issue": f"Studio preferences set but '{tier}' tier does not support Studio features",
|
||||
"fix": "Remove studio_preferences or upgrade to Premier tier"
|
||||
})
|
||||
# Validate BPM if present
|
||||
bpm = studio.get("bpm")
|
||||
if bpm is not None and not isinstance(bpm, (int, float)):
|
||||
findings.append({
|
||||
"severity": "low",
|
||||
"category": "consistency",
|
||||
"location": {"file": str(profile_path), "field": "studio_preferences.bpm"},
|
||||
"issue": f"BPM must be a number, got {type(bpm).__name__}",
|
||||
"fix": "Set bpm to a numeric value (e.g., 120)"
|
||||
})
|
||||
|
||||
# Build summary
|
||||
severity_counts = {"critical": 0, "high": 0, "medium": 0, "low": 0}
|
||||
for f in findings:
|
||||
severity_counts[f["severity"]] = severity_counts.get(f["severity"], 0) + 1
|
||||
|
||||
# Per-band playlist YAML check: if the band has any songbook entries,
|
||||
# `docs/{band-slug}-playlist.yaml` MUST exist as the canonical source of
|
||||
# truth for playlist sequencing. Multi-band projects need this to keep
|
||||
# bands independent (see playlist-sequencing-methodology.md "Per-Band
|
||||
# Playlist YAML" section).
|
||||
band_slug = profile_path.stem # e.g., docs/band-profiles/lennys-voice.yaml -> lennys-voice
|
||||
project_root = profile_path.parent.parent.parent # band-profiles -> docs -> project_root
|
||||
songbook_dir = project_root / "docs" / "songbook" / band_slug
|
||||
playlist_yaml = project_root / "docs" / f"{band_slug}-playlist.yaml"
|
||||
if songbook_dir.is_dir() and any(songbook_dir.glob("*.md")):
|
||||
if not playlist_yaml.exists():
|
||||
findings.append({
|
||||
"severity": "high",
|
||||
"category": "structure",
|
||||
"location": {"file": str(profile_path), "expected_file": str(playlist_yaml)},
|
||||
"issue": (
|
||||
f"Band has songbook entries at {songbook_dir} but no canonical "
|
||||
f"playlist YAML at {playlist_yaml}. Per-band playlist YAML is the "
|
||||
f"single source of truth for sequencing."
|
||||
),
|
||||
"fix": (
|
||||
f"Run `python3 src/skills/suno-band-profile-manager/scripts/scaffold-playlist.py "
|
||||
f"{band_slug} --from-songbook` to bootstrap from songbook entries, then fill in "
|
||||
f"audio file names and order. See profile-schema.md 'Per-Band Playlist YAML' section."
|
||||
),
|
||||
})
|
||||
|
||||
# Deprecated: in-profile `playlist:` block. Per v1.7.2 the band profile
|
||||
# should NOT carry playlist data — that lives in docs/{band-slug}-playlist.yaml.
|
||||
if "playlist" in profile and isinstance(profile["playlist"], dict):
|
||||
findings.append({
|
||||
"severity": "medium",
|
||||
"category": "deprecation",
|
||||
"location": {"file": str(profile_path), "field": "playlist"},
|
||||
"issue": (
|
||||
"The `playlist:` block in the band profile is DEPRECATED as of v1.7.2. "
|
||||
"Playlist data must live in docs/{band-slug}-playlist.yaml as the single "
|
||||
"source of truth, otherwise the two locations drift independently."
|
||||
),
|
||||
"fix": (
|
||||
f"Move authoritative track list to docs/{band_slug}-playlist.yaml (or run "
|
||||
f"scaffold-playlist.py to bootstrap), then remove the `playlist:` block "
|
||||
f"from this profile YAML. Sequencing-history narrative notes can move to "
|
||||
f"the band's playlist-ordering.md if you maintain one."
|
||||
),
|
||||
})
|
||||
|
||||
# Re-tally severity counts after the playlist checks above
|
||||
severity_counts = {"critical": 0, "high": 0, "medium": 0, "low": 0}
|
||||
for f in findings:
|
||||
sev = f.get("severity", "low")
|
||||
if sev in severity_counts:
|
||||
severity_counts[sev] += 1
|
||||
|
||||
status = "pass"
|
||||
if severity_counts["critical"] > 0:
|
||||
status = "fail"
|
||||
elif severity_counts["high"] > 0:
|
||||
status = "fail"
|
||||
elif severity_counts["medium"] > 0:
|
||||
status = "warning"
|
||||
|
||||
return {
|
||||
"script": script_name,
|
||||
"version": "2.1.0",
|
||||
"skill_path": str(profile_path),
|
||||
"timestamp": datetime.now(timezone.utc).isoformat(),
|
||||
"status": status,
|
||||
"findings": findings,
|
||||
"summary": {
|
||||
"total": len(findings),
|
||||
**severity_counts
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(
|
||||
description="Validate a band profile YAML file against the profile schema.",
|
||||
epilog="Exit codes: 0=pass, 1=fail, 2=error"
|
||||
)
|
||||
parser.add_argument("profile_path", nargs="?", help="Path to the band profile YAML file")
|
||||
parser.add_argument("-o", "--output", help="Output file (defaults to stdout)")
|
||||
parser.add_argument("--verbose", action="store_true", help="Print diagnostics to stderr")
|
||||
parser.add_argument(
|
||||
"--derive-filename",
|
||||
metavar="BAND_NAME",
|
||||
help="Convert a band name to kebab-case filename and exit"
|
||||
)
|
||||
args = parser.parse_args()
|
||||
|
||||
if args.derive_filename:
|
||||
result = {
|
||||
"band_name": args.derive_filename,
|
||||
"filename": derive_filename(args.derive_filename),
|
||||
}
|
||||
output = json.dumps(result, indent=2)
|
||||
if args.output:
|
||||
Path(args.output).write_text(output)
|
||||
else:
|
||||
print(output)
|
||||
sys.exit(0)
|
||||
|
||||
if not args.profile_path:
|
||||
parser.error("profile_path is required when not using --derive-filename")
|
||||
|
||||
profile_path = Path(args.profile_path)
|
||||
|
||||
if args.verbose:
|
||||
print(f"Validating profile: {profile_path}", file=sys.stderr)
|
||||
|
||||
result = validate_profile(profile_path)
|
||||
output = json.dumps(result, indent=2)
|
||||
|
||||
if args.output:
|
||||
Path(args.output).write_text(output)
|
||||
if args.verbose:
|
||||
print(f"Results written to {args.output}", file=sys.stderr)
|
||||
else:
|
||||
print(output)
|
||||
|
||||
if result["status"] == "fail":
|
||||
sys.exit(1)
|
||||
sys.exit(0)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
Reference in New Issue
Block a user