Files
slahiriandClaude Opus 4.5 9f6f30805b feat: Z-Image prompt pipeline redesign (WIP)
Changes:
- Add Z-Image prompt style with 6-part formula support
- Add vocabulary enhancement system (984 terms)
- Add classification improvements (age, clothing, subject count)
- Add Full and Z-Image prompt styles preserving Florence prose
- Add pipeline design document (docs/COMPOSE_PIPELINE_DESIGN.md)
- Remove RAG system (not working well)
- Remove use_rag input from compose node

Key insight: Pipeline should PRESERVE information, not destroy it.
- Florence descriptions kept intact (not tokenized)
- Tags organized into Z-Image categories
- NLP mode = structured output
- LLM mode = natural prose generation

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-27 23:30:13 +05:30

213 lines
7.7 KiB
Python

"""Base classes for prompt assembly (Phase 4).
The assembler takes classified tokens from Phase 3 and combines them
into natural language prompts using templates and grammar rules.
"""
from abc import ABC, abstractmethod
from dataclasses import dataclass, field
from enum import Enum
from typing import Dict, List, Optional, Any
from ..classifier.base import ClassifiedImage, TokenClassification
from ..classifier.categories import (
CanonicalCategory,
SubjectDetailType,
CATEGORY_ORDER,
SUBJECT_DETAIL_ORDER,
CATEGORY_DISPLAY_NAMES,
)
class PromptStyle(Enum):
"""Output prompt style."""
NATURAL = "natural" # Full sentences, flowing prose (legacy)
TAGS = "tags" # Comma-separated tags (booru/SDXL style)
HYBRID = "hybrid" # Sentence intro + tag details (legacy)
STRUCTURED = "structured" # Category-segregated output with labels
FULL = "full" # Florence description + structured tags (preserves all info)
ZIMAGE = "zimage" # Z-Image optimized: Florence prose + organized attributes
@dataclass
class AssemblerConfig:
"""Configuration for prompt assembly."""
style: PromptStyle = PromptStyle.NATURAL
max_tokens_per_category: int = 50 # Default limit
include_quality_boosters: bool = True
include_technical: bool = True
min_confidence: float = 0.3
deduplicate: bool = True
use_pronouns: bool = True # Use pronouns after first subject mention
# Category-specific token limits (overrides max_tokens_per_category)
# Lower for dense categories, higher for scene-critical categories
category_limits: Dict[str, int] = field(default_factory=lambda: {
"quality_boosters": 10,
"style_medium": 8,
"subject": 5,
"subject_details": 25, # CRITICAL: Cap this to prevent overwhelming
"action_pose": 10,
"environment": 15, # Important for scene recreation
"lighting": 12, # Important for mood
"composition": 12,
"technical": 5,
})
def get_limit_for_category(self, category: CanonicalCategory) -> int:
"""Get the token limit for a specific category.
If max_tokens_per_category is set higher than the default (50),
use it as the limit for all categories (user wants more detail).
Otherwise, use the category-specific limits (user wants defaults or less).
"""
default_limit = 50 # Default value
category_limit = self.category_limits.get(category.value, default_limit)
# If user explicitly set a higher limit, use it
if self.max_tokens_per_category > default_limit:
return self.max_tokens_per_category
# If user set a lower limit, cap all categories to it
elif self.max_tokens_per_category < default_limit:
return min(category_limit, self.max_tokens_per_category)
# Default: use category-specific limits
return category_limit
@dataclass
class CategorySection:
"""A section of the assembled prompt for one category."""
category: CanonicalCategory
tokens: List[str] # The token texts included
text: str # The assembled text for this section
confidence: float # Average confidence of tokens
@dataclass
class AssembledPrompt:
"""Result from prompt assembly."""
prompt: str
style: PromptStyle
sections: List[CategorySection] = field(default_factory=list)
word_count: int = 0
token_count: int = 0
categories_used: List[str] = field(default_factory=list)
metadata: Dict[str, Any] = field(default_factory=dict)
def __post_init__(self):
self.word_count = len(self.prompt.split())
self.token_count = sum(len(s.tokens) for s in self.sections)
self.categories_used = [s.category.value for s in self.sections if s.tokens]
def get_section(self, category: CanonicalCategory) -> Optional[CategorySection]:
"""Get section for a specific category."""
for section in self.sections:
if section.category == category:
return section
return None
class BaseAssembler(ABC):
"""Base class for prompt assemblers."""
def __init__(self, config: Optional[AssemblerConfig] = None):
self.config = config or AssemblerConfig()
@abstractmethod
def assemble(self, classified: ClassifiedImage) -> AssembledPrompt:
"""Assemble classified tokens into a prompt.
Args:
classified: ClassifiedImage from the classifier
Returns:
AssembledPrompt with the generated prompt
"""
pass
def _get_tokens_for_category(
self,
classified: ClassifiedImage,
category: CanonicalCategory,
max_tokens: Optional[int] = None
) -> List[TokenClassification]:
"""Get tokens for a category, sorted by confidence.
Args:
classified: ClassifiedImage to get tokens from
category: Category to get tokens for
max_tokens: Maximum tokens to return (None = use category-specific limit)
Returns:
List of TokenClassification sorted by confidence
"""
tokens = classified.get_category(category)
if not tokens:
return []
# Filter by minimum confidence
tokens = [t for t in tokens if t.token.confidence >= self.config.min_confidence]
# For subject_details, prioritize SHORT tags over LONG sentences
# Short tags (< 30 chars) are more useful for image generation
if category == CanonicalCategory.SUBJECT_DETAILS:
# Split into short tags and long sentences
short_tags = [t for t in tokens if len(t.token.text) < 30]
long_sentences = [t for t in tokens if len(t.token.text) >= 30]
# Sort each group by confidence
short_tags.sort(key=lambda t: t.token.confidence, reverse=True)
long_sentences.sort(key=lambda t: t.token.confidence, reverse=True)
# Combine: short tags first (up to 80% of limit), then long sentences
limit = max_tokens if max_tokens else self.config.get_limit_for_category(category)
short_limit = int(limit * 0.8)
# Calculate long_limit to ensure total doesn't exceed limit
actual_short = min(len(short_tags), short_limit)
long_limit = limit - actual_short
tokens = short_tags[:actual_short] + long_sentences[:long_limit]
# Final safety check - truncate to exact limit
tokens = tokens[:limit]
else:
# Sort by confidence (highest first = most important)
tokens.sort(key=lambda t: t.token.confidence, reverse=True)
# Use category-specific limit, falling back to provided max_tokens or default
if max_tokens is not None:
limit = max_tokens
else:
limit = self.config.get_limit_for_category(category)
return tokens[:limit]
def _deduplicate_tokens(
self,
tokens: List[TokenClassification]
) -> List[TokenClassification]:
"""Remove duplicate token texts."""
seen = set()
unique = []
for t in tokens:
text_lower = t.token.text.lower().strip()
if text_lower not in seen:
seen.add(text_lower)
unique.append(t)
return unique
def _join_natural(self, items: List[str]) -> str:
"""Join items in natural language style."""
if not items:
return ""
if len(items) == 1:
return items[0]
if len(items) == 2:
return f"{items[0]} and {items[1]}"
return ', '.join(items[:-1]) + f", and {items[-1]}"
def _capitalize_first(self, text: str) -> str:
"""Capitalize first letter only."""
if not text:
return text
return text[0].upper() + text[1:]