"""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