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
322 lines
11 KiB
Python
322 lines
11 KiB
Python
#!/usr/bin/env python3
|
|
# /// script
|
|
# requires-python = ">=3.10"
|
|
# dependencies = []
|
|
# ///
|
|
"""Pre-analyze raw input text to extract deterministic metrics before LLM processing.
|
|
|
|
Detects existing structure, counts lines/words/characters, finds repeated phrases,
|
|
identifies potential rhyme pairs, and estimates needed structure.
|
|
|
|
Usage:
|
|
python analyze-input.py <text-file> [options]
|
|
|
|
# Analyze input from a file
|
|
python analyze-input.py input.txt
|
|
|
|
# Analyze from text argument
|
|
python analyze-input.py --text "Some raw lyrics text"
|
|
|
|
# Output to file
|
|
python analyze-input.py input.txt -o results.json
|
|
"""
|
|
|
|
import argparse
|
|
import json
|
|
import re
|
|
import sys
|
|
from collections import Counter
|
|
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 SUNO_LYRICS_HARD_LIMIT, SUNO_LYRICS_QUALITY_BUDGET
|
|
|
|
SCRIPT_NAME = "analyze-input"
|
|
VERSION = "1.0.0"
|
|
|
|
|
|
def find_metatags(text: str) -> list[str]:
|
|
"""Find all metatag-style brackets in text."""
|
|
return re.findall(r'\[([^\]]+)\]', text)
|
|
|
|
|
|
def find_repeated_phrases(text: str, min_words: int = 3, min_count: int = 2) -> list[dict]:
|
|
"""Find exact phrase matches of min_words+ words appearing min_count+ times."""
|
|
lines = text.split('\n')
|
|
# Collect all non-empty, non-tag lines
|
|
content_lines = []
|
|
for line in lines:
|
|
stripped = line.strip()
|
|
if stripped and not re.match(r'^\[.*\]$', stripped):
|
|
content_lines.append(stripped)
|
|
|
|
# Build n-grams from all content
|
|
all_words = []
|
|
for line in content_lines:
|
|
words = re.findall(r"[a-zA-Z']+", line.lower())
|
|
all_words.extend(words)
|
|
|
|
phrases = Counter()
|
|
for n in range(min_words, min(8, len(all_words) + 1)):
|
|
for i in range(len(all_words) - n + 1):
|
|
phrase = " ".join(all_words[i:i + n])
|
|
phrases[phrase] += 1
|
|
|
|
# Filter and deduplicate (remove sub-phrases if a longer phrase has same count)
|
|
results = {}
|
|
for phrase, count in phrases.items():
|
|
if count >= min_count:
|
|
results[phrase] = count
|
|
|
|
# Remove sub-phrases where a longer phrase has the same count
|
|
filtered = {}
|
|
sorted_phrases = sorted(results.keys(), key=len, reverse=True)
|
|
for phrase in sorted_phrases:
|
|
count = results[phrase]
|
|
# Check if this is a sub-phrase of an already-kept longer phrase with same count
|
|
is_sub = False
|
|
for kept in filtered:
|
|
if phrase in kept and filtered[kept] == count:
|
|
is_sub = True
|
|
break
|
|
if not is_sub:
|
|
filtered[phrase] = count
|
|
|
|
return [{"phrase": p, "count": c} for p, c in sorted(filtered.items(), key=lambda x: -x[1])]
|
|
|
|
|
|
def find_rhyme_pairs(text: str) -> list[dict]:
|
|
"""Find potential rhyme pairs based on ending sounds (last 2-3 chars)."""
|
|
lines = text.split('\n')
|
|
content_lines = []
|
|
for line in lines:
|
|
stripped = line.strip()
|
|
if stripped and not re.match(r'^\[.*\]$', stripped):
|
|
content_lines.append(stripped)
|
|
|
|
# Extract last word of each line
|
|
line_endings = []
|
|
for i, line in enumerate(content_lines):
|
|
words = re.findall(r"[a-zA-Z']+", line)
|
|
if words:
|
|
line_endings.append((i, words[-1].lower()))
|
|
|
|
pairs = []
|
|
seen = set()
|
|
|
|
for idx in range(len(line_endings)):
|
|
# Check consecutive and alternating lines
|
|
for offset in (1, 2):
|
|
if idx + offset < len(line_endings):
|
|
i, word_a = line_endings[idx]
|
|
j, word_b = line_endings[idx + offset]
|
|
|
|
if word_a == word_b:
|
|
continue
|
|
|
|
# Check if last 2 or 3 characters match
|
|
match_len = 0
|
|
if len(word_a) >= 2 and len(word_b) >= 2 and word_a[-2:] == word_b[-2:]:
|
|
match_len = 2
|
|
if len(word_a) >= 3 and len(word_b) >= 3 and word_a[-3:] == word_b[-3:]:
|
|
match_len = 3
|
|
|
|
if match_len > 0:
|
|
pair_key = tuple(sorted([word_a, word_b]))
|
|
if pair_key not in seen:
|
|
seen.add(pair_key)
|
|
pairs.append({
|
|
"words": [word_a, word_b],
|
|
"ending_match": word_a[-match_len:],
|
|
"pattern": "consecutive" if offset == 1 else "alternating"
|
|
})
|
|
|
|
return pairs
|
|
|
|
|
|
def estimate_structure(line_count: int) -> dict:
|
|
"""Estimate structure category and needed sections from line count."""
|
|
if line_count < 16:
|
|
return {
|
|
"estimated_structure": "short",
|
|
"estimated_sections_needed": max(3, line_count // 4)
|
|
}
|
|
elif line_count <= 30:
|
|
return {
|
|
"estimated_structure": "medium",
|
|
"estimated_sections_needed": max(5, line_count // 5)
|
|
}
|
|
else:
|
|
return {
|
|
"estimated_structure": "long",
|
|
"estimated_sections_needed": max(7, line_count // 5)
|
|
}
|
|
|
|
|
|
def analyze_input(text: str) -> dict:
|
|
"""Analyze input text and extract metrics."""
|
|
lines = text.split('\n')
|
|
non_empty_lines = [line for line in lines if line.strip()]
|
|
content_lines = [line.strip() for line in lines if line.strip() and not re.match(r'^\[.*\]$', line.strip())]
|
|
|
|
# Detect metatags
|
|
existing_tags = find_metatags(text)
|
|
has_existing_structure = any(
|
|
re.match(r'^(verse|chorus|bridge|intro|outro|pre-chorus|hook|refrain|breakdown|build-up)', tag.lower())
|
|
for tag in existing_tags
|
|
)
|
|
|
|
# Counts
|
|
word_count = sum(len(line.split()) for line in content_lines)
|
|
char_count = len(text)
|
|
|
|
# Repeated phrases
|
|
repeated = find_repeated_phrases(text)
|
|
|
|
# Rhyme pairs
|
|
rhymes = find_rhyme_pairs(text)
|
|
|
|
# Structure estimate (based on content lines)
|
|
structure = estimate_structure(len(content_lines))
|
|
|
|
return {
|
|
"has_existing_structure": has_existing_structure,
|
|
"existing_tags": existing_tags,
|
|
"line_count": len(lines),
|
|
"non_empty_line_count": len(non_empty_lines),
|
|
"word_count": word_count,
|
|
"character_count": char_count,
|
|
"repeated_phrases": repeated,
|
|
"potential_rhyme_pairs": rhymes,
|
|
**structure
|
|
}
|
|
|
|
|
|
def build_report(analysis: dict, text: str, skill_path: str = "") -> dict:
|
|
"""Build the standard output report."""
|
|
findings = []
|
|
|
|
if analysis["has_existing_structure"]:
|
|
findings.append({
|
|
"severity": "info",
|
|
"category": "structure",
|
|
"issue": "Input already contains section metatags.",
|
|
"fix": "May need restructuring rather than initial structuring."
|
|
})
|
|
|
|
if analysis["character_count"] > SUNO_LYRICS_HARD_LIMIT:
|
|
findings.append({
|
|
"severity": "high",
|
|
"category": "length",
|
|
"issue": f"Character count ({analysis['character_count']}) exceeds Suno's {SUNO_LYRICS_HARD_LIMIT}-character hard limit.",
|
|
"fix": f"Trim to stay under {SUNO_LYRICS_HARD_LIMIT} characters. For best quality, aim for ~{SUNO_LYRICS_QUALITY_BUDGET}."
|
|
})
|
|
elif analysis["character_count"] > SUNO_LYRICS_QUALITY_BUDGET:
|
|
findings.append({
|
|
"severity": "medium",
|
|
"category": "length",
|
|
"issue": f"Character count ({analysis['character_count']}) exceeds the ~{SUNO_LYRICS_QUALITY_BUDGET}-character quality budget.",
|
|
"fix": f"Consider trimming — quality degrades above ~{SUNO_LYRICS_QUALITY_BUDGET} characters. Hard limit is {SUNO_LYRICS_HARD_LIMIT}."
|
|
})
|
|
|
|
severity_counts = {"critical": 0, "high": 0, "medium": 0, "low": 0, "info": 0}
|
|
for f in findings:
|
|
severity_counts[f["severity"]] = severity_counts.get(f["severity"], 0) + 1
|
|
|
|
status = "pass"
|
|
if severity_counts["medium"] > 0:
|
|
status = "info"
|
|
|
|
return {
|
|
"script": SCRIPT_NAME,
|
|
"version": VERSION,
|
|
"skill_path": skill_path,
|
|
"timestamp": datetime.now(timezone.utc).isoformat(),
|
|
"status": status,
|
|
"metrics": {
|
|
"has_existing_structure": analysis["has_existing_structure"],
|
|
"existing_tags": analysis["existing_tags"],
|
|
"line_count": analysis["line_count"],
|
|
"non_empty_line_count": analysis["non_empty_line_count"],
|
|
"word_count": analysis["word_count"],
|
|
"character_count": analysis["character_count"],
|
|
"repeated_phrases": analysis["repeated_phrases"],
|
|
"potential_rhyme_pairs": analysis["potential_rhyme_pairs"],
|
|
"estimated_structure": analysis["estimated_structure"],
|
|
"estimated_sections_needed": analysis["estimated_sections_needed"],
|
|
},
|
|
"findings": findings,
|
|
"summary": {
|
|
"total": len(findings),
|
|
**severity_counts
|
|
}
|
|
}
|
|
|
|
|
|
def main():
|
|
parser = argparse.ArgumentParser(
|
|
description="Pre-analyze raw input text to extract deterministic metrics.",
|
|
formatter_class=argparse.RawDescriptionHelpFormatter,
|
|
epilog="""
|
|
Examples:
|
|
%(prog)s input.txt
|
|
%(prog)s --text "Some raw lyrics\\nAnother line"
|
|
%(prog)s --stdin < input.txt
|
|
%(prog)s input.txt -o results.json --verbose
|
|
|
|
Metrics extracted:
|
|
- Existing metatags and structure detection
|
|
- Line, word, and character counts
|
|
- Repeated phrases (3+ words, 2+ occurrences)
|
|
- Potential rhyme pairs (shared endings)
|
|
- Estimated structure size (short/medium/long)
|
|
|
|
Exit codes: 0=pass, 1=issues, 2=error
|
|
"""
|
|
)
|
|
parser.add_argument("file", nargs="?", help="Path to text file")
|
|
parser.add_argument("--text", help="Text to analyze directly")
|
|
parser.add_argument("--stdin", action="store_true", help="Read text from stdin")
|
|
parser.add_argument("-o", "--output", help="Output file path (defaults to stdout)")
|
|
parser.add_argument("--verbose", action="store_true", help="Print diagnostics to stderr")
|
|
parser.add_argument("--skill-path", default="", help="Skill path for report context")
|
|
|
|
args = parser.parse_args()
|
|
|
|
text = ""
|
|
if args.text:
|
|
text = args.text.replace('\\n', '\n')
|
|
elif args.stdin:
|
|
text = sys.stdin.read()
|
|
elif args.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)
|
|
text = file_path.read_text()
|
|
else:
|
|
parser.print_help()
|
|
sys.exit(2)
|
|
|
|
if args.verbose:
|
|
print(f"Analyzing input ({len(text)} chars, {len(text.splitlines())} lines)...", file=sys.stderr)
|
|
|
|
analysis = analyze_input(text)
|
|
report = build_report(analysis, 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()
|