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,224 @@
|
||||
#!/usr/bin/env python3
|
||||
# /// script
|
||||
# requires-python = ">=3.10"
|
||||
# dependencies = ["pytest>=7.0"]
|
||||
# ///
|
||||
"""Tests for validate-prompt.py"""
|
||||
|
||||
import importlib.util
|
||||
import json
|
||||
import subprocess
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
# Load the script as a module
|
||||
SCRIPT_PATH = Path(__file__).parent.parent / "validate-prompt.py"
|
||||
spec = importlib.util.spec_from_file_location("validate_prompt", SCRIPT_PATH)
|
||||
validate_prompt = importlib.util.module_from_spec(spec)
|
||||
spec.loader.exec_module(validate_prompt)
|
||||
|
||||
|
||||
class TestValidateStylePrompt:
|
||||
"""Tests for style prompt validation."""
|
||||
|
||||
def test_valid_prompt_passes(self):
|
||||
"""A well-formed prompt under the limit should pass."""
|
||||
prompt = "indie folk-rock, melancholic warmth, acoustic guitar over ambient pads, breathy male vocal, intimate lo-fi mix"
|
||||
findings = validate_prompt.validate_style_prompt(prompt)
|
||||
critical = [f for f in findings if f["severity"] == "critical"]
|
||||
assert len(critical) == 0
|
||||
|
||||
def test_over_1000_chars_is_critical(self):
|
||||
"""Prompts over 1,000 chars should produce a critical finding."""
|
||||
prompt = "rock, " * 200 # ~1200 chars
|
||||
findings = validate_prompt.validate_style_prompt(prompt)
|
||||
critical = [f for f in findings if f["severity"] == "critical"]
|
||||
assert len(critical) == 1
|
||||
assert "1,000" in critical[0]["issue"]
|
||||
|
||||
def test_v4_pro_200_char_limit(self):
|
||||
"""v4 Pro should have a 200-char limit, not 1,000."""
|
||||
prompt = "rock, warm vocals, gentle acoustic guitar, melancholic mood, wide stereo field, intimate mix, layered harmonies, subtle percussion" * 2
|
||||
assert len(prompt) > 200
|
||||
assert len(prompt) < 1000
|
||||
findings = validate_prompt.validate_style_prompt(prompt, model="v4 Pro")
|
||||
critical = [f for f in findings if f["severity"] == "critical"]
|
||||
assert len(critical) == 1
|
||||
assert "200" in critical[0]["issue"]
|
||||
assert "v4 Pro" in critical[0]["issue"]
|
||||
|
||||
def test_v5_pro_uses_1000_limit(self):
|
||||
"""v5 Pro should use 1,000-char limit."""
|
||||
prompt = "rock " + "x" * 300
|
||||
findings = validate_prompt.validate_style_prompt(prompt, model="v5 Pro")
|
||||
critical = [f for f in findings if f["severity"] == "critical"]
|
||||
assert len(critical) == 0
|
||||
|
||||
def test_critical_zone_warning(self):
|
||||
"""Prompts with substantial content beyond 200 chars should warn about critical zone."""
|
||||
prompt = "rock, warm vocals, " + "x" * 350
|
||||
findings = validate_prompt.validate_style_prompt(prompt)
|
||||
zone_warnings = [f for f in findings if "critical zone" in f.get("issue", "").lower()]
|
||||
assert len(zone_warnings) == 1
|
||||
|
||||
def test_near_limit_is_low(self):
|
||||
"""Prompts at 90-100% of limit should produce a low finding."""
|
||||
prompt = "x" * 950
|
||||
# Add a genre keyword to avoid the front-loading warning
|
||||
prompt = "rock " + "x" * 945
|
||||
findings = validate_prompt.validate_style_prompt(prompt)
|
||||
low = [f for f in findings if f["severity"] == "low" and "near" in f.get("issue", "").lower()]
|
||||
assert len(low) == 1
|
||||
|
||||
def test_empty_prompt_is_critical(self):
|
||||
"""Empty prompts should be critical."""
|
||||
findings = validate_prompt.validate_style_prompt("")
|
||||
critical = [f for f in findings if f["severity"] == "critical"]
|
||||
assert len(critical) == 1
|
||||
|
||||
def test_whitespace_only_is_critical(self):
|
||||
"""Whitespace-only prompts should be critical."""
|
||||
findings = validate_prompt.validate_style_prompt(" \n ")
|
||||
critical = [f for f in findings if f["severity"] == "critical"]
|
||||
assert len(critical) == 1
|
||||
|
||||
def test_no_genre_frontloading_warning(self):
|
||||
"""Prompts without genre in first 200 chars should warn."""
|
||||
prompt = "warm and beautiful with layered textures and organic feel throughout the production"
|
||||
findings = validate_prompt.validate_style_prompt(prompt)
|
||||
medium = [f for f in findings if f["severity"] == "medium" and "genre" in f["issue"].lower()]
|
||||
assert len(medium) == 1
|
||||
|
||||
def test_genre_present_no_warning(self):
|
||||
"""Prompts with genre early should not warn about front-loading."""
|
||||
prompt = "indie rock, melancholic, warm production"
|
||||
findings = validate_prompt.validate_style_prompt(prompt)
|
||||
genre_warnings = [f for f in findings if "genre" in f.get("issue", "").lower()]
|
||||
assert len(genre_warnings) == 0
|
||||
|
||||
def test_lyric_metatags_detected(self):
|
||||
"""Section tags in style prompts should be flagged."""
|
||||
prompt = "indie rock [Verse] warm vocals [Chorus] big harmonies"
|
||||
findings = validate_prompt.validate_style_prompt(prompt)
|
||||
high = [f for f in findings if f["severity"] == "high"]
|
||||
assert len(high) >= 1
|
||||
assert "metatag" in high[0]["issue"].lower() or "lyric" in high[0]["issue"].lower()
|
||||
|
||||
def test_asterisks_detected(self):
|
||||
"""Asterisks in style prompts should be flagged."""
|
||||
prompt = "indie rock, *bold vocals*, warm production"
|
||||
findings = validate_prompt.validate_style_prompt(prompt)
|
||||
asterisk = [f for f in findings if "asterisk" in f["issue"].lower()]
|
||||
assert len(asterisk) == 1
|
||||
|
||||
|
||||
class TestValidateExclusionPrompt:
|
||||
"""Tests for exclusion prompt validation."""
|
||||
|
||||
def test_empty_exclusion_is_info(self):
|
||||
"""Empty exclusion prompts should produce an info finding (optional)."""
|
||||
findings = validate_prompt.validate_exclusion_prompt("")
|
||||
assert len(findings) == 1
|
||||
assert findings[0]["severity"] == "info"
|
||||
|
||||
def test_valid_exclusion_passes(self):
|
||||
"""A reasonable exclusion prompt should pass cleanly."""
|
||||
findings = validate_prompt.validate_exclusion_prompt("no autotune, no screaming")
|
||||
high_or_critical = [f for f in findings if f["severity"] in ("critical", "high")]
|
||||
assert len(high_or_critical) == 0
|
||||
|
||||
def test_very_long_exclusion_is_high(self):
|
||||
"""Exclusion prompts over 300 chars should produce a high finding."""
|
||||
prompt = "no " + ", no ".join([f"thing{i}" for i in range(60)])
|
||||
findings = validate_prompt.validate_exclusion_prompt(prompt)
|
||||
high = [f for f in findings if f["severity"] == "high"]
|
||||
assert len(high) >= 1
|
||||
|
||||
def test_too_many_items_warns(self):
|
||||
"""More than 5 exclusion items should produce a medium warning."""
|
||||
prompt = "no guitar, no piano, no drums, no bass, no synth, no vocals"
|
||||
findings = validate_prompt.validate_exclusion_prompt(prompt)
|
||||
medium = [f for f in findings if f["severity"] == "medium" and "many" in f["issue"].lower()]
|
||||
assert len(medium) == 1
|
||||
|
||||
def test_vague_terms_caught(self):
|
||||
"""Vague exclusion terms should be flagged."""
|
||||
prompt = "no instruments, nothing bad"
|
||||
findings = validate_prompt.validate_exclusion_prompt(prompt)
|
||||
vague = [f for f in findings if "vague" in f["issue"].lower()]
|
||||
assert len(vague) >= 1
|
||||
|
||||
|
||||
class TestBuildReport:
|
||||
"""Tests for report generation."""
|
||||
|
||||
def test_report_structure(self):
|
||||
"""Report should have all required fields."""
|
||||
report = validate_prompt.build_report([], [], "test", "", "/test/path")
|
||||
assert report["script"] == "validate-prompt"
|
||||
assert report["version"] == "1.1.0"
|
||||
assert report["status"] == "pass"
|
||||
assert "findings" in report
|
||||
assert "summary" in report
|
||||
assert "metrics" in report
|
||||
|
||||
def test_critical_finding_sets_fail(self):
|
||||
"""Critical findings should set status to fail."""
|
||||
findings = [{"severity": "critical", "category": "structure", "issue": "test", "fix": "test"}]
|
||||
report = validate_prompt.build_report(findings, [], "test", "")
|
||||
assert report["status"] == "fail"
|
||||
|
||||
def test_high_finding_sets_warning(self):
|
||||
"""High findings (without critical) should set status to warning."""
|
||||
findings = [{"severity": "high", "category": "structure", "issue": "test", "fix": "test"}]
|
||||
report = validate_prompt.build_report(findings, [], "test", "")
|
||||
assert report["status"] == "warning"
|
||||
|
||||
def test_metrics_include_char_counts(self):
|
||||
"""Metrics should include character counts."""
|
||||
report = validate_prompt.build_report([], [], "hello world", "no guitar")
|
||||
assert report["metrics"]["style_prompt_chars"] == 11
|
||||
assert report["metrics"]["exclusion_prompt_chars"] == 9
|
||||
|
||||
|
||||
class TestCLI:
|
||||
"""Tests for command-line interface."""
|
||||
|
||||
def test_help_flag(self):
|
||||
"""--help should exit 0 with usage info."""
|
||||
result = subprocess.run(
|
||||
[sys.executable, str(SCRIPT_PATH), "--help"],
|
||||
capture_output=True, text=True
|
||||
)
|
||||
assert result.returncode == 0
|
||||
assert "validate" in result.stdout.lower()
|
||||
|
||||
def test_style_flag_produces_json(self):
|
||||
"""--style should produce valid JSON output."""
|
||||
result = subprocess.run(
|
||||
[sys.executable, str(SCRIPT_PATH), "--style", "indie rock, warm vocals"],
|
||||
capture_output=True, text=True
|
||||
)
|
||||
output = json.loads(result.stdout)
|
||||
assert output["script"] == "validate-prompt"
|
||||
assert "findings" in output
|
||||
|
||||
def test_model_flag_v4_pro(self):
|
||||
"""--model 'v4 Pro' should apply 200-char limit."""
|
||||
prompt = "rock, " * 40 # ~240 chars, over 200 but under 1000
|
||||
result = subprocess.run(
|
||||
[sys.executable, str(SCRIPT_PATH), "--style", prompt, "--model", "v4 Pro"],
|
||||
capture_output=True, text=True
|
||||
)
|
||||
output = json.loads(result.stdout)
|
||||
critical = [f for f in output["findings"] if f["severity"] == "critical"]
|
||||
assert len(critical) >= 1
|
||||
assert "200" in critical[0]["issue"]
|
||||
|
||||
def test_no_args_exits_2(self):
|
||||
"""No arguments should exit with code 2."""
|
||||
result = subprocess.run(
|
||||
[sys.executable, str(SCRIPT_PATH)],
|
||||
capture_output=True, text=True
|
||||
)
|
||||
assert result.returncode == 2
|
||||
@@ -0,0 +1,316 @@
|
||||
#!/usr/bin/env python3
|
||||
# /// script
|
||||
# requires-python = ">=3.10"
|
||||
# dependencies = []
|
||||
# ///
|
||||
"""
|
||||
Validate Suno style prompt output for character limits and structure.
|
||||
|
||||
Validates:
|
||||
- Style prompt character count (model-specific: v4 Pro=200, v4.5+/v5=1,000)
|
||||
- Critical zone check (first 200 chars should contain all essentials)
|
||||
- Exclusion prompt character count (recommended max ~200)
|
||||
- Required fields present in prompt package
|
||||
- Front-loading check (genre/mood should appear early)
|
||||
|
||||
Usage:
|
||||
python validate-prompt.py <prompt-file-or-text> [options]
|
||||
|
||||
# Validate a prompt text directly
|
||||
python validate-prompt.py --style "indie folk-rock, warm..." --exclude "no autotune"
|
||||
|
||||
# Validate with model-specific limits
|
||||
python validate-prompt.py --style "indie folk-rock..." --model "v4 Pro"
|
||||
|
||||
# Validate from a file (expects YAML with style_prompt and exclusion_prompt fields)
|
||||
python validate-prompt.py prompt-output.yaml
|
||||
|
||||
# Output to file
|
||||
python validate-prompt.py --style "..." -o results.json
|
||||
"""
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import sys
|
||||
import re
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parent.parent.parent / "_shared"))
|
||||
from suno_constants import STYLE_PROMPT_LIMITS, STYLE_PROMPT_DEFAULT_MAX, CRITICAL_ZONE, EXCLUSION_RECOMMENDED_MAX, EXCLUSION_HARD_MAX
|
||||
|
||||
SCRIPT_NAME = "validate-prompt"
|
||||
VERSION = "1.1.0"
|
||||
|
||||
|
||||
def get_limit_for_model(model: str) -> int:
|
||||
"""Return the style prompt character limit for a given Suno model."""
|
||||
return STYLE_PROMPT_LIMITS.get(model, STYLE_PROMPT_DEFAULT_MAX)
|
||||
|
||||
|
||||
def validate_style_prompt(text: str, model: str = "") -> list[dict]:
|
||||
"""Validate a style prompt and return findings."""
|
||||
findings = []
|
||||
char_count = len(text)
|
||||
limit = get_limit_for_model(model) if model else STYLE_PROMPT_DEFAULT_MAX
|
||||
|
||||
# Character limit check (model-specific)
|
||||
if char_count > limit:
|
||||
findings.append({
|
||||
"severity": "critical",
|
||||
"category": "structure",
|
||||
"issue": f"Style prompt exceeds {limit:,} character limit for {model or 'default'} ({char_count} chars). Suno will silently truncate.",
|
||||
"fix": f"Trim {char_count - limit} characters. Cut from the end — genre/mood at the start are most important.",
|
||||
"data": {"char_count": char_count, "limit": limit, "over_by": char_count - limit, "model": model}
|
||||
})
|
||||
elif char_count > limit * 0.9:
|
||||
findings.append({
|
||||
"severity": "low",
|
||||
"category": "structure",
|
||||
"issue": f"Style prompt is near the {limit:,} character limit ({char_count} chars). Limited room for iteration.",
|
||||
"fix": "Consider trimming less essential descriptors to leave room for refinement.",
|
||||
"data": {"char_count": char_count, "limit": limit}
|
||||
})
|
||||
|
||||
# Critical zone check — first 200 chars have strongest influence
|
||||
if char_count > CRITICAL_ZONE:
|
||||
first_segment = text[:CRITICAL_ZONE]
|
||||
remaining = text[CRITICAL_ZONE:]
|
||||
# Warn if substantial content exists beyond the critical zone
|
||||
if len(remaining.strip()) > 100:
|
||||
findings.append({
|
||||
"severity": "low",
|
||||
"category": "consistency",
|
||||
"issue": f"Style prompt has {len(remaining.strip())} chars beyond the critical zone (first {CRITICAL_ZONE} chars). Front-loaded terms have strongest influence on generation. Content beyond ~200 chars is supplementary but not wasted — v5.5 may interpret more of the prompt effectively.",
|
||||
"fix": "Ensure essential genre, mood, and vocal descriptors appear within the first 200 characters. Content beyond this zone adds nuance. This is a priority guide, not a character limit.",
|
||||
"data": {"critical_zone": CRITICAL_ZONE, "beyond_zone_chars": len(remaining.strip())}
|
||||
})
|
||||
|
||||
# Empty check
|
||||
if not text.strip():
|
||||
findings.append({
|
||||
"severity": "critical",
|
||||
"category": "structure",
|
||||
"issue": "Style prompt is empty.",
|
||||
"fix": "Provide at minimum a genre and mood description."
|
||||
})
|
||||
return findings
|
||||
|
||||
# Front-loading check — genre/mood keywords should appear in first 200 chars
|
||||
first_segment = text[:200].lower()
|
||||
genre_signals = ["rock", "pop", "folk", "jazz", "blues", "electronic", "hip hop", "r&b",
|
||||
"country", "classical", "metal", "punk", "indie", "soul", "funk",
|
||||
"ambient", "lo-fi", "lofi", "dance", "edm", "house", "techno",
|
||||
"rap", "acoustic", "orchestral", "cinematic", "reggae", "latin",
|
||||
"alternative", "grunge", "shoegaze", "post-punk", "synth", "disco"]
|
||||
has_genre = any(g in first_segment for g in genre_signals)
|
||||
if not has_genre:
|
||||
findings.append({
|
||||
"severity": "medium",
|
||||
"category": "consistency",
|
||||
"issue": "No obvious genre keyword found in the first 200 characters. Genre should be front-loaded.",
|
||||
"fix": "Move genre and mood descriptors to the beginning of the style prompt."
|
||||
})
|
||||
|
||||
# Style cue contamination check (things that belong in lyrics, not style prompt)
|
||||
style_contamination = re.findall(r'\[(?:Verse|Chorus|Bridge|Intro|Outro|Pre-Chorus)\]', text, re.IGNORECASE)
|
||||
if style_contamination:
|
||||
findings.append({
|
||||
"severity": "high",
|
||||
"category": "structure",
|
||||
"issue": f"Lyric metatags found in style prompt: {style_contamination}. These belong in lyrics, not the style prompt.",
|
||||
"fix": "Remove all section tags ([Verse], [Chorus], etc.) from the style prompt. These go in the lyrics input."
|
||||
})
|
||||
|
||||
# Asterisk check
|
||||
if '*' in text:
|
||||
findings.append({
|
||||
"severity": "medium",
|
||||
"category": "structure",
|
||||
"issue": "Asterisks found in style prompt. Suno does not use markdown formatting in style prompts.",
|
||||
"fix": "Remove all asterisks from the style prompt."
|
||||
})
|
||||
|
||||
return findings
|
||||
|
||||
|
||||
def validate_exclusion_prompt(text: str) -> list[dict]:
|
||||
"""Validate an exclusion prompt and return findings."""
|
||||
findings = []
|
||||
|
||||
if not text.strip():
|
||||
findings.append({
|
||||
"severity": "info",
|
||||
"category": "structure",
|
||||
"issue": "No exclusion prompt provided. This is optional but can improve results.",
|
||||
"fix": "Consider adding 2-3 specific exclusions to prevent unwanted elements."
|
||||
})
|
||||
return findings
|
||||
|
||||
char_count = len(text)
|
||||
|
||||
if char_count > EXCLUSION_HARD_MAX:
|
||||
findings.append({
|
||||
"severity": "high",
|
||||
"category": "structure",
|
||||
"issue": f"Exclusion prompt is very long ({char_count} chars). Too many negatives can confuse the model.",
|
||||
"fix": "Trim to 2-3 most important exclusions. Prioritize the elements you most want to avoid.",
|
||||
"data": {"char_count": char_count, "recommended_max": EXCLUSION_RECOMMENDED_MAX}
|
||||
})
|
||||
elif char_count > EXCLUSION_RECOMMENDED_MAX:
|
||||
findings.append({
|
||||
"severity": "low",
|
||||
"category": "structure",
|
||||
"issue": f"Exclusion prompt is above recommended length ({char_count} chars, recommended ~{EXCLUSION_RECOMMENDED_MAX}).",
|
||||
"fix": "Consider trimming to the most impactful exclusions.",
|
||||
"data": {"char_count": char_count, "recommended_max": EXCLUSION_RECOMMENDED_MAX}
|
||||
})
|
||||
|
||||
# Count exclusion items
|
||||
items = [i.strip() for i in re.split(r'[,;]', text) if i.strip()]
|
||||
if len(items) > 5:
|
||||
findings.append({
|
||||
"severity": "medium",
|
||||
"category": "consistency",
|
||||
"issue": f"Too many exclusion items ({len(items)}). More than 3-5 exclusions can confuse the model.",
|
||||
"fix": "Reduce to 2-3 most critical exclusions."
|
||||
})
|
||||
|
||||
# Vagueness check
|
||||
vague_terms = ["no music", "no sound", "no instruments", "no singing", "nothing bad"]
|
||||
for term in vague_terms:
|
||||
if term.lower() in text.lower():
|
||||
findings.append({
|
||||
"severity": "medium",
|
||||
"category": "consistency",
|
||||
"issue": f"Vague exclusion term found: '{term}'. Be specific about what to exclude.",
|
||||
"fix": "Replace with specific terms: 'no electric guitar' instead of 'no instruments'."
|
||||
})
|
||||
|
||||
return findings
|
||||
|
||||
|
||||
def build_report(style_findings: list, exclusion_findings: list, style_text: str, exclusion_text: str, skill_path: str = "") -> dict:
|
||||
"""Build the standard output report."""
|
||||
all_findings = []
|
||||
for f in style_findings:
|
||||
f["location"] = {"field": "style_prompt"}
|
||||
all_findings.append(f)
|
||||
for f in exclusion_findings:
|
||||
f["location"] = {"field": "exclusion_prompt"}
|
||||
all_findings.append(f)
|
||||
|
||||
severity_counts = {"critical": 0, "high": 0, "medium": 0, "low": 0, "info": 0}
|
||||
for f in all_findings:
|
||||
severity_counts[f["severity"]] = severity_counts.get(f["severity"], 0) + 1
|
||||
|
||||
status = "pass"
|
||||
if severity_counts["critical"] > 0:
|
||||
status = "fail"
|
||||
elif severity_counts["high"] > 0:
|
||||
status = "warning"
|
||||
|
||||
return {
|
||||
"script": SCRIPT_NAME,
|
||||
"version": VERSION,
|
||||
"skill_path": skill_path,
|
||||
"timestamp": datetime.now(timezone.utc).isoformat(),
|
||||
"status": status,
|
||||
"metrics": {
|
||||
"style_prompt_chars": len(style_text),
|
||||
"style_prompt_limit": STYLE_PROMPT_DEFAULT_MAX,
|
||||
"critical_zone": CRITICAL_ZONE,
|
||||
"exclusion_prompt_chars": len(exclusion_text) if exclusion_text else 0,
|
||||
"exclusion_recommended_max": EXCLUSION_RECOMMENDED_MAX
|
||||
},
|
||||
"findings": all_findings,
|
||||
"summary": {
|
||||
"total": len(all_findings),
|
||||
**severity_counts
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(
|
||||
description="Validate Suno style prompt output for character limits and structure.",
|
||||
formatter_class=argparse.RawDescriptionHelpFormatter,
|
||||
epilog="""
|
||||
Examples:
|
||||
%(prog)s --style "indie folk-rock, warm analog..." --exclude "no autotune"
|
||||
%(prog)s prompt-output.yaml
|
||||
%(prog)s --style "..." -o results.json --verbose
|
||||
"""
|
||||
)
|
||||
parser.add_argument("file", nargs="?", help="YAML file with style_prompt and exclusion_prompt fields")
|
||||
parser.add_argument("--style", help="Style prompt text to validate")
|
||||
parser.add_argument("--exclude", default="", help="Exclusion prompt text to validate")
|
||||
parser.add_argument("--model", default="", help="Suno model name for model-specific limits (e.g., 'v4 Pro', 'v5 Pro')")
|
||||
parser.add_argument("-o", "--output", help="Output file path (defaults to stdout)")
|
||||
parser.add_argument("--verbose", action="store_true", help="Include debug information")
|
||||
parser.add_argument("--skill-path", default="", help="Skill path for report context")
|
||||
|
||||
args = parser.parse_args()
|
||||
|
||||
style_text = ""
|
||||
exclusion_text = ""
|
||||
|
||||
if args.file:
|
||||
# Read from YAML file
|
||||
file_path = Path(args.file)
|
||||
if not file_path.exists():
|
||||
print(f"Error: File not found: {args.file}", file=sys.stderr)
|
||||
sys.exit(2)
|
||||
try:
|
||||
import yaml
|
||||
except ImportError:
|
||||
# Fallback: simple key-value parsing for basic YAML
|
||||
content = file_path.read_text()
|
||||
for line in content.splitlines():
|
||||
if line.startswith("style_prompt:"):
|
||||
style_text = line.split(":", 1)[1].strip().strip('"').strip("'")
|
||||
elif line.startswith("exclusion_prompt:"):
|
||||
exclusion_text = line.split(":", 1)[1].strip().strip('"').strip("'")
|
||||
else:
|
||||
data = yaml.safe_load(file_path.read_text())
|
||||
style_text = data.get("style_prompt", "")
|
||||
exclusion_text = data.get("exclusion_prompt", "")
|
||||
elif args.style:
|
||||
style_text = args.style
|
||||
exclusion_text = args.exclude
|
||||
else:
|
||||
parser.print_help()
|
||||
sys.exit(2)
|
||||
|
||||
if args.verbose:
|
||||
print(f"Validating style prompt ({len(style_text)} chars)...", file=sys.stderr)
|
||||
if exclusion_text:
|
||||
print(f"Validating exclusion prompt ({len(exclusion_text)} chars)...", file=sys.stderr)
|
||||
|
||||
model = args.model
|
||||
if not model and args.file:
|
||||
# Try to extract model from YAML file
|
||||
try:
|
||||
if 'data' in dir() and isinstance(data, dict):
|
||||
model = data.get("model", "")
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
style_findings = validate_style_prompt(style_text, model=model)
|
||||
exclusion_findings = validate_exclusion_prompt(exclusion_text)
|
||||
report = build_report(style_findings, exclusion_findings, style_text, exclusion_text, args.skill_path)
|
||||
|
||||
output_json = json.dumps(report, indent=2)
|
||||
|
||||
if args.output:
|
||||
Path(args.output).write_text(output_json)
|
||||
if args.verbose:
|
||||
print(f"Report written to {args.output}", file=sys.stderr)
|
||||
else:
|
||||
print(output_json)
|
||||
|
||||
sys.exit(0 if report["status"] == "pass" else 1)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
Reference in New Issue
Block a user