Files
Momento/.agents/skills/suno-style-prompt-builder/scripts/validate-prompt.py
Antigravity bd495be965
All checks were successful
Deploy to Production / Build and Deploy (push) Successful in 12s
feat: design system overhaul — sidebar, AI chats, settings, brainstorm, color cleanup
- 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
2026-05-16 12:59:30 +00:00

317 lines
13 KiB
Python

#!/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()