- Add remove_runaway_repetitions() to clean "dark, dark, dark..." patterns - Add is_garbage_output() to detect LLM generation failures - LLM mode now falls back to NLP output when garbage is detected - Limits any word to max 3 repetitions in output 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
382 lines
12 KiB
Python
382 lines
12 KiB
Python
"""Validation rules for prompt checking.
|
|
|
|
Defines patterns for detecting issues like conflicts, duplicates,
|
|
and quality problems.
|
|
"""
|
|
|
|
from typing import Dict, List, Set, Tuple
|
|
import re
|
|
|
|
|
|
# =============================================================================
|
|
# Conflict Detection Rules
|
|
# =============================================================================
|
|
|
|
# Groups of mutually exclusive terms - only one should appear
|
|
CONFLICT_GROUPS: List[Set[str]] = [
|
|
# Gender conflicts
|
|
{"woman", "man", "girl", "boy"},
|
|
{"female", "male"},
|
|
{"she", "he"},
|
|
|
|
# Age conflicts
|
|
{"young", "old", "elderly", "mature", "teen", "child", "adult"},
|
|
|
|
# Position conflicts
|
|
{"standing", "sitting", "lying", "kneeling", "crouching", "squatting"},
|
|
|
|
# Direction conflicts
|
|
{"facing viewer", "facing away", "from behind", "from side", "profile"},
|
|
|
|
# Eye state conflicts
|
|
{"open eyes", "closed eyes", "half-closed eyes"},
|
|
|
|
# Mouth state conflicts
|
|
{"open mouth", "closed mouth", "parted lips"},
|
|
|
|
# Hair length conflicts
|
|
{"long hair", "short hair", "medium hair", "bald"},
|
|
|
|
# Time of day conflicts
|
|
{"daytime", "nighttime", "sunset", "sunrise", "noon", "midnight"},
|
|
|
|
# Indoor/outdoor conflicts
|
|
{"indoor", "outdoor", "inside", "outside"},
|
|
|
|
# Lighting conflicts
|
|
{"bright", "dark", "dim"},
|
|
{"high key", "low key"},
|
|
]
|
|
|
|
|
|
def find_conflicts(tokens: List[str]) -> List[Tuple[str, str, str]]:
|
|
"""Find conflicting tokens in a list.
|
|
|
|
Args:
|
|
tokens: List of token strings
|
|
|
|
Returns:
|
|
List of (token1, token2, conflict_type) tuples
|
|
"""
|
|
conflicts = []
|
|
tokens_lower = [t.lower() for t in tokens]
|
|
|
|
for group in CONFLICT_GROUPS:
|
|
found = []
|
|
for token in tokens_lower:
|
|
# Skip long tokens (likely sentences, not individual terms)
|
|
if len(token) > 50:
|
|
continue
|
|
|
|
for term in group:
|
|
# Use word boundary matching to avoid false positives
|
|
# e.g., "cowboy" should not match "boy"
|
|
pattern = r'\b' + re.escape(term) + r'\b'
|
|
if re.search(pattern, token):
|
|
# Don't match if it's part of a compound word
|
|
if term in ["boy", "girl", "man", "woman"]:
|
|
# Check it's not part of compound like "cowboy", "showgirl"
|
|
if f"cow{term}" in token or f"show{term}" in token:
|
|
continue
|
|
found.append((token, term))
|
|
break
|
|
|
|
if len(found) > 1:
|
|
# Multiple terms from same conflict group
|
|
group_name = "/".join(sorted(group)[:3]) + "..."
|
|
for i in range(len(found)):
|
|
for j in range(i + 1, len(found)):
|
|
# Don't report if tokens are too similar (likely same phrase)
|
|
if found[i][0] == found[j][0]:
|
|
continue
|
|
conflicts.append((found[i][0], found[j][0], group_name))
|
|
|
|
return conflicts
|
|
|
|
|
|
# =============================================================================
|
|
# Duplicate Detection
|
|
# =============================================================================
|
|
|
|
# Words that are OK to repeat
|
|
REPEAT_OK_WORDS = {
|
|
"the", "a", "an", "and", "or", "with", "in", "on", "at", "to", "of",
|
|
"is", "are", "has", "have", "very", "extremely", "highly",
|
|
"her", "his", "she", "he", "they", "their", "it", "its",
|
|
"this", "that", "these", "those", "be", "been", "being",
|
|
"for", "from", "by", "as", "into", "like", "through",
|
|
}
|
|
|
|
|
|
def find_duplicates(text: str) -> List[Tuple[str, int]]:
|
|
"""Find duplicate words/phrases in text.
|
|
|
|
Args:
|
|
text: Prompt text
|
|
|
|
Returns:
|
|
List of (word, count) tuples for duplicates
|
|
"""
|
|
# Split into words
|
|
words = re.findall(r'\b\w+\b', text.lower())
|
|
|
|
# Count occurrences
|
|
counts: Dict[str, int] = {}
|
|
for word in words:
|
|
if word not in REPEAT_OK_WORDS and len(word) > 2:
|
|
counts[word] = counts.get(word, 0) + 1
|
|
|
|
# Return duplicates (count > 1)
|
|
duplicates = [(word, count) for word, count in counts.items() if count > 1]
|
|
return sorted(duplicates, key=lambda x: x[1], reverse=True)
|
|
|
|
|
|
def find_phrase_duplicates(text: str) -> List[Tuple[str, int]]:
|
|
"""Find duplicate phrases (2-3 word sequences) in text.
|
|
|
|
Args:
|
|
text: Prompt text
|
|
|
|
Returns:
|
|
List of (phrase, count) tuples for duplicates
|
|
"""
|
|
words = re.findall(r'\b\w+\b', text.lower())
|
|
phrase_counts: Dict[str, int] = {}
|
|
|
|
# Check 2-word and 3-word phrases
|
|
for n in [2, 3]:
|
|
for i in range(len(words) - n + 1):
|
|
phrase = " ".join(words[i:i + n])
|
|
# Skip if all words are common
|
|
if all(w in REPEAT_OK_WORDS for w in words[i:i + n]):
|
|
continue
|
|
phrase_counts[phrase] = phrase_counts.get(phrase, 0) + 1
|
|
|
|
duplicates = [(phrase, count) for phrase, count in phrase_counts.items() if count > 1]
|
|
return sorted(duplicates, key=lambda x: x[1], reverse=True)
|
|
|
|
|
|
def remove_duplicate_words(text: str) -> str:
|
|
"""Remove duplicate comma-separated segments while preserving sentences.
|
|
|
|
Only removes exact duplicate comma-separated phrases, not individual
|
|
words within flowing text to preserve grammar.
|
|
Preserves newlines for STRUCTURED style.
|
|
|
|
Args:
|
|
text: Prompt text
|
|
|
|
Returns:
|
|
Text with duplicate segments removed
|
|
"""
|
|
# Process each line separately to preserve newlines (for STRUCTURED style)
|
|
lines = text.split('\n')
|
|
result_lines = []
|
|
|
|
for line in lines:
|
|
# Split by commas but preserve sentence structure
|
|
segments = line.split(", ")
|
|
seen: Set[str] = set()
|
|
result = []
|
|
|
|
for segment in segments:
|
|
# Normalize for comparison
|
|
segment_normalized = segment.lower().strip()
|
|
|
|
# Skip if too short (likely incomplete)
|
|
if len(segment_normalized) < 3:
|
|
result.append(segment)
|
|
continue
|
|
|
|
# Check if this segment (or very similar) was seen
|
|
if segment_normalized not in seen:
|
|
result.append(segment)
|
|
seen.add(segment_normalized)
|
|
# Also add if it's a sentence (has verb-like structure)
|
|
elif any(c in segment for c in ".!?"):
|
|
result.append(segment)
|
|
|
|
result_lines.append(", ".join(result))
|
|
|
|
return '\n'.join(result_lines)
|
|
|
|
|
|
def remove_runaway_repetitions(text: str, max_repeats: int = 3) -> str:
|
|
"""Remove runaway repetitions in LLM output.
|
|
|
|
Detects and removes patterns like "dark, dark, dark, dark, dark..."
|
|
which indicate LLM generation failure.
|
|
|
|
Args:
|
|
text: Prompt text
|
|
max_repeats: Maximum allowed repetitions of a single word/phrase
|
|
|
|
Returns:
|
|
Cleaned text
|
|
"""
|
|
# Split into segments (comma or space separated)
|
|
segments = re.split(r',\s*|\s+', text)
|
|
|
|
# Count consecutive and total repetitions
|
|
word_counts: Dict[str, int] = {}
|
|
for seg in segments:
|
|
seg_clean = seg.lower().strip()
|
|
if seg_clean and len(seg_clean) > 1:
|
|
word_counts[seg_clean] = word_counts.get(seg_clean, 0) + 1
|
|
|
|
# Find words that are repeated excessively
|
|
excessive_words = {word for word, count in word_counts.items() if count > max_repeats}
|
|
|
|
if not excessive_words:
|
|
return text
|
|
|
|
# Remove excessive repetitions
|
|
lines = text.split('\n')
|
|
result_lines = []
|
|
|
|
for line in lines:
|
|
segments = line.split(", ")
|
|
seen_counts: Dict[str, int] = {}
|
|
result = []
|
|
|
|
for segment in segments:
|
|
seg_clean = segment.lower().strip()
|
|
|
|
# Count this segment
|
|
seen_counts[seg_clean] = seen_counts.get(seg_clean, 0) + 1
|
|
|
|
# Only keep if not excessively repeated
|
|
if seg_clean not in excessive_words or seen_counts[seg_clean] <= max_repeats:
|
|
result.append(segment)
|
|
|
|
result_lines.append(", ".join(result))
|
|
|
|
return '\n'.join(result_lines)
|
|
|
|
|
|
def is_garbage_output(text: str, threshold: int = 10) -> bool:
|
|
"""Check if text appears to be garbage LLM output.
|
|
|
|
Detects patterns that indicate generation failure:
|
|
- Excessive repetition of single words
|
|
- Very long strings of the same word
|
|
|
|
Args:
|
|
text: Text to check
|
|
threshold: Number of repetitions to consider garbage
|
|
|
|
Returns:
|
|
True if text appears to be garbage
|
|
"""
|
|
words = re.findall(r'\b\w+\b', text.lower())
|
|
if not words:
|
|
return True
|
|
|
|
# Count word occurrences
|
|
counts: Dict[str, int] = {}
|
|
for word in words:
|
|
if len(word) > 2: # Skip very short words
|
|
counts[word] = counts.get(word, 0) + 1
|
|
|
|
# Check for excessive repetition
|
|
for word, count in counts.items():
|
|
if count >= threshold:
|
|
return True
|
|
|
|
return False
|
|
|
|
|
|
# =============================================================================
|
|
# Quality Checks
|
|
# =============================================================================
|
|
|
|
# Common quality issues patterns
|
|
QUALITY_ISSUES: List[Tuple[str, str, str]] = [
|
|
# (pattern, issue_message, suggestion)
|
|
(r'\b(\w+)\s+\1\b', "Repeated word", "Remove duplicate word"),
|
|
(r',\s*,', "Double comma", "Remove extra comma"),
|
|
(r'\.\s*\.', "Double period", "Remove extra period"),
|
|
(r'\s{2,}', "Multiple spaces", "Reduce to single space"),
|
|
(r'^\s*,', "Leading comma", "Remove leading comma"),
|
|
(r',\s*$', "Trailing comma", "Remove trailing comma"),
|
|
(r'\(\s*\)', "Empty parentheses", "Remove empty parentheses"),
|
|
]
|
|
|
|
|
|
def check_quality_issues(text: str) -> List[Tuple[str, str, str]]:
|
|
"""Check for common quality issues.
|
|
|
|
Args:
|
|
text: Prompt text
|
|
|
|
Returns:
|
|
List of (matched_text, issue_message, suggestion) tuples
|
|
"""
|
|
issues = []
|
|
for pattern, message, suggestion in QUALITY_ISSUES:
|
|
matches = re.finditer(pattern, text, re.IGNORECASE)
|
|
for match in matches:
|
|
issues.append((match.group(), message, suggestion))
|
|
return issues
|
|
|
|
|
|
def fix_quality_issues(text: str) -> str:
|
|
"""Auto-fix common quality issues.
|
|
|
|
Args:
|
|
text: Prompt text
|
|
|
|
Returns:
|
|
Fixed text
|
|
"""
|
|
# Remove repeated words (but not across newlines)
|
|
text = re.sub(r'\b(\w+)[ \t]+\1\b', r'\1', text)
|
|
|
|
# Fix punctuation
|
|
text = re.sub(r',\s*,', ',', text)
|
|
text = re.sub(r'\.\s*\.', '.', text)
|
|
|
|
# Fix multiple spaces (but preserve newlines for STRUCTURED style)
|
|
text = re.sub(r'[ \t]{2,}', ' ', text) # Only horizontal whitespace
|
|
|
|
# Fix leading/trailing punctuation (per line)
|
|
lines = text.split('\n')
|
|
fixed_lines = []
|
|
for line in lines:
|
|
line = re.sub(r'^\s*,\s*', '', line)
|
|
line = re.sub(r'\s*,\s*$', '', line)
|
|
# Remove empty parentheses
|
|
line = re.sub(r'\(\s*\)', '', line)
|
|
fixed_lines.append(line.strip())
|
|
|
|
return '\n'.join(fixed_lines)
|
|
|
|
|
|
# =============================================================================
|
|
# Token Estimation
|
|
# =============================================================================
|
|
|
|
def estimate_clip_tokens(text: str) -> int:
|
|
"""Estimate CLIP token count for text.
|
|
|
|
CLIP tokenizer roughly:
|
|
- 1 token per ~4 characters for common words
|
|
- More tokens for rare words/names
|
|
|
|
Args:
|
|
text: Prompt text
|
|
|
|
Returns:
|
|
Estimated token count
|
|
"""
|
|
# Simple estimation: words + some overhead for subword tokenization
|
|
words = text.split()
|
|
base_tokens = len(words)
|
|
|
|
# Add tokens for long words (likely split)
|
|
for word in words:
|
|
if len(word) > 8:
|
|
base_tokens += (len(word) - 8) // 4
|
|
|
|
return base_tokens
|