baqu2213 commited on
Commit
cad34e4
·
verified ·
1 Parent(s): 7d605f8

Upload 5 files

Browse files
core/__init__.py ADDED
@@ -0,0 +1 @@
 
 
1
+ # NAIA-WEB Core Package
core/api_service.py ADDED
@@ -0,0 +1,388 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ NAIA-WEB API Service
3
+ NAI Image Generation API communication layer
4
+
5
+ Reference: NAIA2.0/core/api_service.py (260-460)
6
+ """
7
+
8
+ import aiohttp
9
+ import asyncio
10
+ import zipfile
11
+ import io
12
+ import json
13
+ import random
14
+ import base64
15
+ from dataclasses import dataclass
16
+ from typing import Optional, Tuple, Dict, Any, List
17
+ from PIL import Image
18
+
19
+ from utils.constants import NAI_API_URL, MODEL_ID_MAP
20
+
21
+
22
+ def process_reference_image(file_path: str) -> str:
23
+ """
24
+ Process reference image for character reference API.
25
+ Normalizes aspect ratio and encodes to base64.
26
+
27
+ Reference: NAIA2.0/modules/character_reference_module.py _file_to_base64
28
+ """
29
+ try:
30
+ original_image = Image.open(file_path)
31
+ width, height = original_image.size
32
+ aspect_ratio = width / height
33
+
34
+ # Standard aspect ratios (ratio, canvas_width, canvas_height)
35
+ ratios = {
36
+ '2:3': (2/3, 1024, 1536),
37
+ '3:2': (3/2, 1536, 1024),
38
+ '1:1': (1/1, 1472, 1472)
39
+ }
40
+
41
+ # Find closest standard ratio
42
+ closest_ratio = min(ratios.keys(), key=lambda k: abs(aspect_ratio - ratios[k][0]))
43
+ target_ratio, canvas_width, canvas_height = ratios[closest_ratio]
44
+
45
+ print(f"NAIA-WEB: Reference image {width}x{height} ({aspect_ratio:.2f}) → {closest_ratio} ({canvas_width}x{canvas_height})")
46
+
47
+ # Create black canvas
48
+ canvas = Image.new('RGB', (canvas_width, canvas_height), (0, 0, 0))
49
+
50
+ # Resize to fit canvas (preserve aspect ratio)
51
+ if width / canvas_width > height / canvas_height:
52
+ new_width = canvas_width
53
+ new_height = int(height * (canvas_width / width))
54
+ else:
55
+ new_height = canvas_height
56
+ new_width = int(width * (canvas_height / height))
57
+
58
+ resized_image = original_image.resize((new_width, new_height), Image.Resampling.LANCZOS)
59
+
60
+ # Center on canvas
61
+ x_offset = (canvas_width - new_width) // 2
62
+ y_offset = (canvas_height - new_height) // 2
63
+
64
+ # Handle RGBA transparency
65
+ if resized_image.mode == 'RGBA':
66
+ canvas = canvas.convert('RGBA')
67
+ canvas.paste(resized_image, (x_offset, y_offset), resized_image)
68
+ rgb_canvas = Image.new('RGB', (canvas_width, canvas_height), (0, 0, 0))
69
+ rgb_canvas.paste(canvas, (0, 0), canvas)
70
+ canvas = rgb_canvas
71
+ else:
72
+ canvas.paste(resized_image, (x_offset, y_offset))
73
+
74
+ # Encode to base64
75
+ buffer = io.BytesIO()
76
+ canvas.save(buffer, format="PNG", optimize=False)
77
+ return base64.b64encode(buffer.getvalue()).decode("utf-8")
78
+
79
+ except Exception as e:
80
+ print(f"NAIA-WEB: Failed to process reference image: {e}")
81
+ # Fallback: use original file bytes
82
+ with open(file_path, "rb") as f:
83
+ return base64.b64encode(f.read()).decode("utf-8")
84
+
85
+
86
+ class NAIAPIError(Exception):
87
+ """Custom exception for NAI API errors"""
88
+ def __init__(self, status_code: int, message: str, debug_info: Optional[Dict] = None):
89
+ self.status_code = status_code
90
+ self.message = message
91
+ self.debug_info = debug_info or {}
92
+ super().__init__(f"NAI API Error ({status_code}): {message}")
93
+
94
+
95
+ @dataclass
96
+ class CharacterReferenceData:
97
+ """Character reference data for NAID4.5"""
98
+ image_base64: str # Base64 encoded image
99
+ style_aware: bool = True # Include style from reference
100
+ fidelity: float = 0.75 # How closely to follow the reference (0.0-1.0)
101
+
102
+
103
+ @dataclass
104
+ class GenerationParameters:
105
+ """Parameters for image generation request"""
106
+ prompt: str
107
+ negative_prompt: str
108
+ width: int
109
+ height: int
110
+ steps: int = 28
111
+ scale: float = 5.0
112
+ cfg_rescale: float = 0.4 # NAIA2.0 default
113
+ sampler: str = "k_euler"
114
+ seed: Optional[int] = None
115
+ model: str = "NAID4.5F"
116
+ noise_schedule: str = "native"
117
+ # Character prompts: List of (prompt, negative) tuples
118
+ character_prompts: List[Tuple[str, str]] = None
119
+ # Character reference (NAID4.5 feature)
120
+ character_reference: Optional[CharacterReferenceData] = None
121
+
122
+
123
+ class NAIAPIService:
124
+ """
125
+ Service for communicating with NAI image generation API.
126
+
127
+ Handles V4.5 model API calls with proper payload structure.
128
+ """
129
+
130
+ def __init__(self):
131
+ self._session: Optional[aiohttp.ClientSession] = None
132
+ # Debug info storage
133
+ self._last_payload: Optional[Dict] = None
134
+ self._last_response_status: Optional[int] = None
135
+ self._last_response_text: Optional[str] = None
136
+
137
+ async def _get_session(self) -> aiohttp.ClientSession:
138
+ """Get or create aiohttp session"""
139
+ if self._session is None or self._session.closed:
140
+ self._session = aiohttp.ClientSession()
141
+ return self._session
142
+
143
+ async def generate_image(
144
+ self,
145
+ token: str,
146
+ params: GenerationParameters
147
+ ) -> Tuple[Image.Image, Dict[str, Any]]:
148
+ """
149
+ Call NAI API to generate an image.
150
+
151
+ Args:
152
+ token: NAI API authentication token
153
+ params: Generation parameters
154
+
155
+ Returns:
156
+ Tuple of (PIL Image, metadata dict)
157
+
158
+ Raises:
159
+ NAIAPIError: If API call fails
160
+ """
161
+ session = await self._get_session()
162
+
163
+ # Get model name from mapping
164
+ model_name = MODEL_ID_MAP.get(params.model, "nai-diffusion-4-5-full")
165
+
166
+ # Determine seed
167
+ seed = params.seed if params.seed and params.seed > 0 else random.randint(0, 2**32 - 1)
168
+
169
+ # Build V4 prompt structure
170
+ v4_prompt = {
171
+ "caption": {
172
+ "base_caption": params.prompt,
173
+ "char_captions": []
174
+ },
175
+ "use_coords": False,
176
+ "use_order": True
177
+ }
178
+
179
+ v4_negative_prompt = {
180
+ "caption": {
181
+ "base_caption": params.negative_prompt,
182
+ "char_captions": []
183
+ },
184
+ "legacy_uc": False
185
+ }
186
+
187
+ # Add character prompts if provided (NAID4.5 feature)
188
+ if params.character_prompts:
189
+ for char_prompt, char_negative in params.character_prompts:
190
+ if char_prompt.strip():
191
+ # Default center position (no 5x5 grid feature)
192
+ centers = [{"x": 0.5, "y": 0.5}]
193
+ v4_prompt["caption"]["char_captions"].append({
194
+ "char_caption": char_prompt.strip(),
195
+ "centers": centers
196
+ })
197
+ v4_negative_prompt["caption"]["char_captions"].append({
198
+ "char_caption": char_negative.strip() if char_negative else "",
199
+ "centers": centers
200
+ })
201
+ if v4_prompt["caption"]["char_captions"]:
202
+ print(f"NAIA-WEB: Added {len(v4_prompt['caption']['char_captions'])} character prompt(s)")
203
+
204
+ # Build API parameters (matching NAI V4 structure)
205
+ api_parameters = {
206
+ "width": params.width,
207
+ "height": params.height,
208
+ "n_samples": 1,
209
+ "seed": seed,
210
+ "extra_noise_seed": seed,
211
+ "sampler": params.sampler,
212
+ "steps": params.steps,
213
+ "scale": params.scale,
214
+ "cfg_rescale": params.cfg_rescale,
215
+ "noise_schedule": params.noise_schedule,
216
+ "negative_prompt": params.negative_prompt,
217
+ # V4 specific parameters
218
+ "params_version": 3,
219
+ "add_original_image": True,
220
+ "legacy": False,
221
+ "legacy_uc": False,
222
+ "autoSmea": True,
223
+ "prefer_brownian": True,
224
+ "ucPreset": 0,
225
+ "use_coords": False,
226
+ "v4_prompt": v4_prompt,
227
+ "v4_negative_prompt": v4_negative_prompt,
228
+ "skip_cfg_above_sigma": None,
229
+ }
230
+
231
+ # Add character reference if provided (NAID4.5 feature)
232
+ if params.character_reference:
233
+ ref = params.character_reference
234
+ # Build description based on style_aware setting
235
+ if ref.style_aware:
236
+ description = {
237
+ "caption": {"base_caption": "character&style", "char_captions": []},
238
+ "legacy_uc": False
239
+ }
240
+ else:
241
+ description = {
242
+ "caption": {"base_caption": "character", "char_captions": []},
243
+ "legacy_uc": False
244
+ }
245
+
246
+ api_parameters["director_reference_descriptions"] = [description]
247
+ api_parameters["director_reference_images"] = [ref.image_base64]
248
+ api_parameters["director_reference_information_extracted"] = [1]
249
+ api_parameters["director_reference_secondary_strength_values"] = [ref.fidelity]
250
+ api_parameters["director_reference_strength_values"] = [1]
251
+ api_parameters["controlnet_strength"] = 1
252
+ api_parameters["inpaintImg2ImgStrength"] = 1
253
+ api_parameters["normalize_reference_strength_multiple"] = True
254
+
255
+ print(f"NAIA-WEB: Character reference enabled (style_aware={ref.style_aware}, fidelity={ref.fidelity})")
256
+
257
+ # Build request payload
258
+ payload = {
259
+ "input": params.prompt,
260
+ "model": model_name,
261
+ "action": "generate",
262
+ "parameters": api_parameters
263
+ }
264
+
265
+ # Headers - matching NAIA2.0 (no Accept header)
266
+ headers = {
267
+ "Authorization": f"Bearer {token}",
268
+ "Content-Type": "application/json"
269
+ }
270
+
271
+ # Store for debugging
272
+ self._last_payload = payload
273
+ self._last_response_status = None
274
+ self._last_response_text = None
275
+
276
+ max_retries = 2
277
+ last_error = None
278
+
279
+ for attempt in range(max_retries):
280
+ try:
281
+ async with session.post(
282
+ NAI_API_URL,
283
+ json=payload,
284
+ headers=headers,
285
+ timeout=aiohttp.ClientTimeout(total=180) # NAIA2.0 uses 180s
286
+ ) as response:
287
+ self._last_response_status = response.status
288
+
289
+ if response.status == 200:
290
+ zip_data = await response.read()
291
+ image = self._extract_image_from_zip(zip_data)
292
+
293
+ metadata = {
294
+ "seed": seed,
295
+ "model": params.model,
296
+ "steps": params.steps,
297
+ "scale": params.scale,
298
+ "sampler": params.sampler,
299
+ "width": params.width,
300
+ "height": params.height,
301
+ }
302
+
303
+ return image, metadata
304
+ else:
305
+ error_text = await response.text()
306
+ self._last_response_text = error_text
307
+
308
+ debug_info = {
309
+ "model": model_name,
310
+ "status": response.status,
311
+ "response": error_text[:500], # Truncate long responses
312
+ "token_length": len(token) if token else 0,
313
+ "token_prefix": token[:10] + "..." if token and len(token) > 10 else token
314
+ }
315
+ last_error = NAIAPIError(response.status, error_text, debug_info)
316
+
317
+ # Don't retry on client errors (4xx)
318
+ if 400 <= response.status < 500:
319
+ raise last_error
320
+
321
+ except aiohttp.ClientError as e:
322
+ self._last_response_text = str(e)
323
+ last_error = NAIAPIError(0, f"Network error: {str(e)}")
324
+
325
+ # Wait before retry
326
+ if attempt < max_retries - 1:
327
+ await asyncio.sleep(1)
328
+
329
+ raise last_error or NAIAPIError(0, "Unknown error")
330
+
331
+ def _extract_image_from_zip(self, zip_data: bytes) -> Image.Image:
332
+ """Extract image from NAI response zip"""
333
+ with zipfile.ZipFile(io.BytesIO(zip_data)) as zf:
334
+ # Find PNG file in zip
335
+ image_files = [f for f in zf.namelist() if f.endswith('.png')]
336
+ if not image_files:
337
+ raise NAIAPIError(0, "No image found in response")
338
+
339
+ image_bytes = zf.read(image_files[0])
340
+ return Image.open(io.BytesIO(image_bytes))
341
+
342
+ async def close(self):
343
+ """Close the aiohttp session"""
344
+ if self._session and not self._session.closed:
345
+ await self._session.close()
346
+
347
+ def get_debug_info(self) -> Dict[str, Any]:
348
+ """Return debug info from last request"""
349
+ return {
350
+ "last_status": self._last_response_status,
351
+ "last_response": self._last_response_text,
352
+ "last_payload_keys": list(self._last_payload.keys()) if self._last_payload else None,
353
+ "last_model": self._last_payload.get("model") if self._last_payload else None,
354
+ }
355
+
356
+
357
+ def format_api_error(error: NAIAPIError) -> str:
358
+ """Format API error for user display with debug info"""
359
+ base_msg = ""
360
+ if error.status_code == 401:
361
+ base_msg = "Authentication failed. Please check your API token."
362
+ elif error.status_code == 402:
363
+ base_msg = "Insufficient Anlas. Please check your account balance."
364
+ elif error.status_code == 429:
365
+ base_msg = "Rate limited. Please wait before trying again."
366
+ elif error.status_code >= 500:
367
+ base_msg = "NAI server error. Please try again later."
368
+ elif error.status_code == 0:
369
+ base_msg = f"Connection error: {error.message}"
370
+ else:
371
+ base_msg = f"API Error ({error.status_code}): {error.message}"
372
+
373
+ # Add debug info if available
374
+ if error.debug_info:
375
+ debug_parts = []
376
+ if "token_length" in error.debug_info:
377
+ debug_parts.append(f"Token length: {error.debug_info['token_length']}")
378
+ if "token_prefix" in error.debug_info:
379
+ debug_parts.append(f"Token prefix: {error.debug_info['token_prefix']}")
380
+ if "model" in error.debug_info:
381
+ debug_parts.append(f"Model: {error.debug_info['model']}")
382
+ if "response" in error.debug_info:
383
+ debug_parts.append(f"Response: {error.debug_info['response']}")
384
+
385
+ if debug_parts:
386
+ base_msg += "\n\n[Debug Info]\n" + "\n".join(debug_parts)
387
+
388
+ return base_msg
core/autocomplete_service.py ADDED
@@ -0,0 +1,336 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ NAIA-WEB Autocomplete Service
3
+ Tag autocomplete functionality for prompt input fields
4
+
5
+ Reference: NAIA2.0/core/autocomplete_manager.py, NAIA2.0/core/tag_data_manager.py
6
+ """
7
+
8
+ from typing import List, Dict, Tuple, Optional
9
+ from dataclasses import dataclass
10
+ import time
11
+
12
+
13
+ @dataclass
14
+ class TagResult:
15
+ """Single tag search result"""
16
+ tag: str
17
+ count: int
18
+ category: str = "general" # general, artist, character
19
+
20
+
21
+ class AutocompleteService:
22
+ """
23
+ Autocomplete service for tag suggestions.
24
+
25
+ Provides fast tag search with:
26
+ - Prefix matching (highest priority)
27
+ - Contains matching
28
+ - Category-aware search (general, artist, character)
29
+ - Frequency-based sorting
30
+
31
+ Usage:
32
+ service = AutocompleteService()
33
+ results = service.search("blue") # Returns list of TagResult
34
+ """
35
+
36
+ _instance: Optional['AutocompleteService'] = None
37
+
38
+ def __new__(cls):
39
+ """Singleton pattern for shared data across requests"""
40
+ if cls._instance is None:
41
+ cls._instance = super().__new__(cls)
42
+ cls._instance._initialized = False
43
+ return cls._instance
44
+
45
+ def __init__(self):
46
+ if self._initialized:
47
+ return
48
+
49
+ self._generals: Dict[str, int] = {}
50
+ self._artists: Dict[str, int] = {}
51
+ self._characters: Dict[str, int] = {}
52
+ self._combined: Dict[str, Tuple[int, str]] = {} # tag -> (count, category)
53
+
54
+ self._load_data()
55
+ self._initialized = True
56
+
57
+ def _load_data(self):
58
+ """Load tag data from source files"""
59
+ print("AutocompleteService: Loading tag data...")
60
+ start_time = time.time()
61
+
62
+ # Load generals (general tags)
63
+ try:
64
+ from data.autocomplete.result_dupl import generals
65
+ self._generals = dict(generals)
66
+ print(f" - Loaded {len(self._generals):,} general tags")
67
+ except ImportError as e:
68
+ print(f" - Failed to load generals: {e}")
69
+ self._generals = {}
70
+
71
+ # Load artists
72
+ try:
73
+ from data.autocomplete.artist_dictionary import artist_dict
74
+ # artist_dict has artist names as keys with counts
75
+ self._artists = {}
76
+ for key, value in artist_dict.items():
77
+ if isinstance(value, int):
78
+ self._artists[key] = value
79
+ elif isinstance(value, (list, tuple)) and len(value) > 0:
80
+ # Some entries might be [count, ...] format
81
+ self._artists[key] = value[0] if isinstance(value[0], int) else 0
82
+ print(f" - Loaded {len(self._artists)} artists")
83
+ except ImportError as e:
84
+ print(f" - Failed to load artists: {e}")
85
+ self._artists = {}
86
+
87
+ # Load characters
88
+ try:
89
+ from data.autocomplete.danbooru_character import character_dict_count
90
+ self._characters = dict(character_dict_count)
91
+ print(f" - Loaded {len(self._characters):,} characters")
92
+ except ImportError as e:
93
+ print(f" - Failed to load characters: {e}")
94
+ self._characters = {}
95
+
96
+ # Build combined index
97
+ self._build_combined_index()
98
+
99
+ elapsed = time.time() - start_time
100
+ print(f"AutocompleteService: Loaded {len(self._combined):,} total tags in {elapsed:.2f}s")
101
+
102
+ def _build_combined_index(self):
103
+ """Build combined index with category information"""
104
+ self._combined = {}
105
+
106
+ # Add generals
107
+ for tag, count in self._generals.items():
108
+ self._combined[tag] = (count, "general")
109
+
110
+ # Add artists (may override generals with same name)
111
+ for tag, count in self._artists.items():
112
+ self._combined[tag] = (count, "artist")
113
+
114
+ # Add characters
115
+ for tag, count in self._characters.items():
116
+ if tag not in self._combined or count > self._combined[tag][0]:
117
+ self._combined[tag] = (count, "character")
118
+
119
+ def search(
120
+ self,
121
+ query: str,
122
+ limit: int = 20,
123
+ category: Optional[str] = None
124
+ ) -> List[TagResult]:
125
+ """
126
+ Search for tags matching query.
127
+
128
+ Args:
129
+ query: Search query (minimum 1 character)
130
+ limit: Maximum results to return
131
+ category: Optional filter by category ('general', 'artist', 'character')
132
+
133
+ Returns:
134
+ List of TagResult sorted by relevance and frequency
135
+ """
136
+ if not query or len(query) < 1:
137
+ return []
138
+
139
+ query_lower = query.lower().strip()
140
+
141
+ # Select data source based on category
142
+ if category == "artist":
143
+ source = {k: (v, "artist") for k, v in self._artists.items()}
144
+ elif category == "character":
145
+ source = {k: (v, "character") for k, v in self._characters.items()}
146
+ elif category == "general":
147
+ source = {k: (v, "general") for k, v in self._generals.items()}
148
+ else:
149
+ source = self._combined
150
+
151
+ # Separate matches by type
152
+ exact_matches = []
153
+ prefix_matches = []
154
+ contains_matches = []
155
+
156
+ for tag, (count, cat) in source.items():
157
+ tag_lower = tag.lower()
158
+
159
+ if tag_lower == query_lower:
160
+ exact_matches.append(TagResult(tag=tag, count=count, category=cat))
161
+ elif tag_lower.startswith(query_lower):
162
+ prefix_matches.append(TagResult(tag=tag, count=count, category=cat))
163
+ elif query_lower in tag_lower:
164
+ contains_matches.append(TagResult(tag=tag, count=count, category=cat))
165
+
166
+ # Sort each group by count (descending)
167
+ exact_matches.sort(key=lambda x: x.count, reverse=True)
168
+ prefix_matches.sort(key=lambda x: x.count, reverse=True)
169
+ contains_matches.sort(key=lambda x: x.count, reverse=True)
170
+
171
+ # Combine: exact > prefix > contains
172
+ results = exact_matches + prefix_matches + contains_matches
173
+
174
+ return results[:limit]
175
+
176
+ def search_artists(self, query: str, limit: int = 20) -> List[TagResult]:
177
+ """Search only artists"""
178
+ return self.search(query, limit=limit, category="artist")
179
+
180
+ def search_characters(self, query: str, limit: int = 20) -> List[TagResult]:
181
+ """Search only characters"""
182
+ return self.search(query, limit=limit, category="character")
183
+
184
+ def search_generals(self, query: str, limit: int = 20) -> List[TagResult]:
185
+ """Search only general tags"""
186
+ return self.search(query, limit=limit, category="general")
187
+
188
+ def get_popular_tags(self, limit: int = 100, category: Optional[str] = None) -> List[TagResult]:
189
+ """Get most popular tags"""
190
+ if category == "artist":
191
+ source = [(k, v, "artist") for k, v in self._artists.items()]
192
+ elif category == "character":
193
+ source = [(k, v, "character") for k, v in self._characters.items()]
194
+ elif category == "general":
195
+ source = [(k, v, "general") for k, v in self._generals.items()]
196
+ else:
197
+ source = [(k, v, c) for k, (v, c) in self._combined.items()]
198
+
199
+ # Sort by count
200
+ source.sort(key=lambda x: x[1], reverse=True)
201
+
202
+ return [TagResult(tag=t, count=c, category=cat) for t, c, cat in source[:limit]]
203
+
204
+ def get_stats(self) -> Dict[str, int]:
205
+ """Get statistics about loaded data"""
206
+ return {
207
+ "generals": len(self._generals),
208
+ "artists": len(self._artists),
209
+ "characters": len(self._characters),
210
+ "total": len(self._combined)
211
+ }
212
+
213
+
214
+ # Convenience function for simple usage
215
+ def search_tags(query: str, limit: int = 20) -> List[Dict]:
216
+ """
217
+ Simple function to search tags.
218
+
219
+ Returns list of dicts: [{"tag": str, "count": int, "category": str}, ...]
220
+ """
221
+ service = AutocompleteService()
222
+ results = service.search(query, limit=limit)
223
+ return [{"tag": r.tag, "count": r.count, "category": r.category} for r in results]
224
+
225
+
226
+ def get_autocomplete_service() -> AutocompleteService:
227
+ """Get the singleton AutocompleteService instance"""
228
+ return AutocompleteService()
229
+
230
+
231
+ # =============================================================================
232
+ # Gradio API Functions
233
+ # =============================================================================
234
+
235
+ def gradio_search_tags(query: str, limit: int = 20) -> List[List]:
236
+ """
237
+ Search tags for Gradio Dataframe component.
238
+
239
+ Args:
240
+ query: Search query
241
+ limit: Maximum results
242
+
243
+ Returns:
244
+ List of [tag, count, category] for Dataframe display
245
+ """
246
+ if not query or len(query.strip()) < 1:
247
+ return []
248
+
249
+ service = get_autocomplete_service()
250
+ results = service.search(query.strip(), limit=limit)
251
+
252
+ return [[r.tag, r.count, r.category] for r in results]
253
+
254
+
255
+ def gradio_search_tags_json(query: str, limit: int = 20) -> List[Dict]:
256
+ """
257
+ Search tags and return as JSON-serializable list.
258
+
259
+ For JavaScript consumption via Gradio's js parameter.
260
+
261
+ Returns:
262
+ [{"tag": str, "count": int, "category": str}, ...]
263
+ """
264
+ if not query or len(query.strip()) < 1:
265
+ return []
266
+
267
+ service = get_autocomplete_service()
268
+ results = service.search(query.strip(), limit=limit)
269
+
270
+ return [
271
+ {"tag": r.tag, "count": r.count, "category": r.category}
272
+ for r in results
273
+ ]
274
+
275
+
276
+ def gradio_get_completion(current_text: str, cursor_position: int, limit: int = 10) -> List[Dict]:
277
+ """
278
+ Get autocomplete suggestions based on current cursor position.
279
+
280
+ Extracts the current token (word being typed) and returns suggestions.
281
+
282
+ Args:
283
+ current_text: Full text content
284
+ cursor_position: Cursor position in text
285
+ limit: Maximum suggestions
286
+
287
+ Returns:
288
+ List of suggestions with metadata
289
+ """
290
+ if not current_text:
291
+ return []
292
+
293
+ # Extract current token (word at cursor)
294
+ # Tokens are separated by commas
295
+ text_before_cursor = current_text[:cursor_position]
296
+
297
+ # Find the start of current token (after last comma)
298
+ last_comma = text_before_cursor.rfind(',')
299
+ token_start = last_comma + 1 if last_comma >= 0 else 0
300
+
301
+ # Extract and clean the current token
302
+ current_token = text_before_cursor[token_start:].strip()
303
+
304
+ if len(current_token) < 1:
305
+ return []
306
+
307
+ # Search for matches
308
+ service = get_autocomplete_service()
309
+ results = service.search(current_token, limit=limit)
310
+
311
+ return [
312
+ {
313
+ "tag": r.tag,
314
+ "count": r.count,
315
+ "category": r.category,
316
+ "token_start": token_start,
317
+ "token_end": cursor_position
318
+ }
319
+ for r in results
320
+ ]
321
+
322
+
323
+ def preload_autocomplete_data() -> Dict:
324
+ """
325
+ Preload autocomplete data and return statistics.
326
+ Call this at app startup to warm up the cache.
327
+
328
+ Returns:
329
+ Statistics about loaded data
330
+ """
331
+ service = get_autocomplete_service()
332
+ stats = service.get_stats()
333
+ return {
334
+ "status": "loaded",
335
+ "stats": stats
336
+ }
core/generation_service.py ADDED
@@ -0,0 +1,161 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ NAIA-WEB Generation Service
3
+ High-level orchestration of prompt processing and API calls
4
+
5
+ Reference: NAIA2.0/core/generation_controller.py
6
+ """
7
+
8
+ from dataclasses import dataclass, field
9
+ from typing import Optional, Set, List, Tuple
10
+ from PIL import Image
11
+
12
+ from .api_service import (
13
+ NAIAPIService,
14
+ NAIAPIError,
15
+ GenerationParameters,
16
+ CharacterReferenceData,
17
+ format_api_error
18
+ )
19
+ from .prompt_processor import PromptProcessor, PromptContext
20
+
21
+
22
+ @dataclass
23
+ class GenerationRequest:
24
+ """User-facing generation request"""
25
+ positive_prompt: str
26
+ negative_prompt: str
27
+ resolution: str # e.g., "832 x 1216"
28
+ model: str # e.g., "NAID4.5F"
29
+ steps: int = 28
30
+ scale: float = 5.0
31
+ cfg_rescale: float = 0.4 # NAIA2.0 default
32
+ sampler: str = "k_euler"
33
+ seed: Optional[int] = None
34
+
35
+ # Prompt processing options
36
+ use_quality_tags: bool = True
37
+ pre_prompt: str = ""
38
+ post_prompt: str = ""
39
+ auto_hide_tags: Set[str] = field(default_factory=set)
40
+
41
+ # Character prompt data (NAID4.5 feature)
42
+ # List of (prompt, negative) tuples for each active character
43
+ character_prompts: List[Tuple[str, str]] = field(default_factory=list)
44
+
45
+ # Character reference (NAID4.5 feature)
46
+ character_reference: Optional[CharacterReferenceData] = None
47
+
48
+
49
+ @dataclass
50
+ class GenerationResult:
51
+ """Result of generation attempt"""
52
+ success: bool
53
+ image: Optional[Image.Image] = None
54
+ seed_used: int = 0
55
+ error_message: str = ""
56
+ processed_prompt: str = ""
57
+ processed_negative: str = ""
58
+ metadata: dict = field(default_factory=dict)
59
+
60
+
61
+ class GenerationService:
62
+ """
63
+ High-level service coordinating prompt processing and image generation.
64
+
65
+ This service:
66
+ 1. Parses user input into structured parameters
67
+ 2. Runs prompt through processing pipeline
68
+ 3. Calls NAI API
69
+ 4. Returns processed result
70
+ """
71
+
72
+ def __init__(self):
73
+ self.api_service = NAIAPIService()
74
+ self.prompt_processor = PromptProcessor()
75
+
76
+ def _parse_resolution(self, resolution: str) -> tuple[int, int]:
77
+ """Parse resolution string like '832 x 1216' into (width, height)"""
78
+ try:
79
+ parts = resolution.lower().replace(' ', '').split('x')
80
+ if len(parts) != 2:
81
+ raise ValueError()
82
+ return int(parts[0]), int(parts[1])
83
+ except (ValueError, IndexError):
84
+ # Default resolution
85
+ return 832, 1216
86
+
87
+ async def generate(
88
+ self,
89
+ token: str,
90
+ request: GenerationRequest,
91
+ ) -> GenerationResult:
92
+ """
93
+ Execute a generation request.
94
+
95
+ Args:
96
+ token: NAI API token
97
+ request: Generation parameters
98
+
99
+ Returns:
100
+ GenerationResult with image or error information
101
+ """
102
+ try:
103
+ # Parse resolution
104
+ width, height = self._parse_resolution(request.resolution)
105
+
106
+ # Process prompts through pipeline
107
+ context = PromptContext(
108
+ positive_prompt=request.positive_prompt,
109
+ negative_prompt=request.negative_prompt,
110
+ use_quality_tags=request.use_quality_tags,
111
+ pre_prompt=request.pre_prompt,
112
+ post_prompt=request.post_prompt,
113
+ auto_hide_tags=request.auto_hide_tags,
114
+ )
115
+ processed_context = self.prompt_processor.process(context)
116
+
117
+ # Build API parameters
118
+ params = GenerationParameters(
119
+ prompt=processed_context.positive_prompt,
120
+ negative_prompt=processed_context.negative_prompt,
121
+ width=width,
122
+ height=height,
123
+ steps=request.steps,
124
+ scale=request.scale,
125
+ cfg_rescale=request.cfg_rescale,
126
+ sampler=request.sampler,
127
+ seed=request.seed,
128
+ model=request.model,
129
+ character_prompts=request.character_prompts if request.character_prompts else None,
130
+ character_reference=request.character_reference,
131
+ )
132
+
133
+ # Call API
134
+ image, metadata = await self.api_service.generate_image(
135
+ token=token,
136
+ params=params
137
+ )
138
+
139
+ return GenerationResult(
140
+ success=True,
141
+ image=image,
142
+ seed_used=metadata.get("seed", 0),
143
+ processed_prompt=processed_context.positive_prompt,
144
+ processed_negative=processed_context.negative_prompt,
145
+ metadata=metadata
146
+ )
147
+
148
+ except NAIAPIError as e:
149
+ return GenerationResult(
150
+ success=False,
151
+ error_message=format_api_error(e)
152
+ )
153
+ except Exception as e:
154
+ return GenerationResult(
155
+ success=False,
156
+ error_message=f"Unexpected error: {str(e)}"
157
+ )
158
+
159
+ async def close(self):
160
+ """Cleanup resources"""
161
+ await self.api_service.close()
core/prompt_processor.py ADDED
@@ -0,0 +1,323 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ NAIA-WEB Prompt Processor
3
+ Pipeline-based prompt processing with hooks
4
+
5
+ Reference: NAIA2.0/core/prompt_processor.py, NAIA2.0/modules/prompt_engineering_module.py
6
+ """
7
+
8
+ import re
9
+ from dataclasses import dataclass, field
10
+ from typing import List, Set, Tuple
11
+
12
+ from utils.constants import QUALITY_TAGS_POSITIVE, QUALITY_TAGS_NEGATIVE
13
+
14
+
15
+ @dataclass
16
+ class PromptContext:
17
+ """
18
+ Context passed through the prompt processing pipeline.
19
+
20
+ Carries all prompt-related data and settings through each stage.
21
+ """
22
+ positive_prompt: str
23
+ negative_prompt: str
24
+
25
+ # Processing flags
26
+ use_quality_tags: bool = True
27
+
28
+ # Pre/Post prompt additions
29
+ pre_prompt: str = ""
30
+ post_prompt: str = ""
31
+
32
+ # Auto hide tags (tags to remove) - supports patterns
33
+ auto_hide_tags: Set[str] = field(default_factory=set)
34
+
35
+ # Removed tags tracking
36
+ removed_tags: List[str] = field(default_factory=list)
37
+
38
+ # Processing log for debugging
39
+ processing_log: List[str] = field(default_factory=list)
40
+
41
+
42
+ class PromptProcessor:
43
+ """
44
+ Pipeline-based prompt processor.
45
+
46
+ Processing order:
47
+ 1. Add pre-prompt
48
+ 2. Main prompt
49
+ 3. Add post-prompt
50
+ 4. Inject quality tags (if enabled)
51
+ 5. Remove auto-hide tags
52
+ 6. Clean up formatting
53
+ """
54
+
55
+ def process(self, context: PromptContext) -> PromptContext:
56
+ """
57
+ Run the full processing pipeline on a prompt context.
58
+
59
+ Args:
60
+ context: Initial prompt context
61
+
62
+ Returns:
63
+ Processed prompt context
64
+ """
65
+ # Step 1: Build positive prompt with pre/post
66
+ context = self._build_positive_prompt(context)
67
+
68
+ # Step 2: Inject quality tags
69
+ if context.use_quality_tags:
70
+ context = self._inject_quality_tags(context)
71
+
72
+ # Step 3: Remove auto-hide tags
73
+ if context.auto_hide_tags:
74
+ context = self._remove_auto_hide_tags(context)
75
+
76
+ # Step 4: Clean up formatting
77
+ context = self._cleanup_prompt(context)
78
+
79
+ return context
80
+
81
+ # Person tag sets for reordering (from NAIA2.0)
82
+ PERSON_TAGS = {
83
+ "boys": {"1boy", "2boys", "3boys", "4boys", "5boys", "6+boys"},
84
+ "girls": {"1girl", "2girls", "3girls", "4girls", "5girls", "6+girls"},
85
+ "others": {"1other", "2others", "3others", "4others", "5others", "6+others"}
86
+ }
87
+ ALL_PERSON_TAGS = PERSON_TAGS["boys"] | PERSON_TAGS["girls"] | PERSON_TAGS["others"]
88
+
89
+ def _build_positive_prompt(self, context: PromptContext) -> PromptContext:
90
+ """
91
+ Combine pre-prompt, main prompt, and post-prompt.
92
+
93
+ Person tags (1girl, 2boys, etc.) are extracted from main prompt
94
+ and moved to the front in order: boys -> girls -> others.
95
+
96
+ Final order: [person tags], [pre-prompt], [main prompt], [post-prompt]
97
+ """
98
+ # Parse main prompt into tags
99
+ main_tags = [t.strip() for t in context.positive_prompt.split(',') if t.strip()]
100
+
101
+ # Extract person tags from main prompt
102
+ person_tags_found = []
103
+ other_main_tags = []
104
+
105
+ for tag in main_tags:
106
+ if tag.lower() in {pt.lower() for pt in self.ALL_PERSON_TAGS}:
107
+ person_tags_found.append(tag)
108
+ else:
109
+ other_main_tags.append(tag)
110
+
111
+ # Sort person tags: boys -> girls -> others
112
+ sorted_person_tags = sorted(
113
+ person_tags_found,
114
+ key=lambda tag: (
115
+ 0 if tag.lower() in {pt.lower() for pt in self.PERSON_TAGS["boys"]} else
116
+ 1 if tag.lower() in {pt.lower() for pt in self.PERSON_TAGS["girls"]} else 2
117
+ )
118
+ )
119
+
120
+ if sorted_person_tags:
121
+ context.processing_log.append(f"Person tags moved to front: {', '.join(sorted_person_tags)}")
122
+
123
+ # Build final prompt: [person tags], [pre-prompt], [main prompt], [post-prompt]
124
+ parts = []
125
+
126
+ # 1. Person tags (extracted from main prompt)
127
+ if sorted_person_tags:
128
+ parts.append(", ".join(sorted_person_tags))
129
+
130
+ # 2. Pre-prompt
131
+ if context.pre_prompt.strip():
132
+ parts.append(context.pre_prompt.strip())
133
+ context.processing_log.append("Added pre-prompt")
134
+
135
+ # 3. Main prompt (without person tags)
136
+ if other_main_tags:
137
+ parts.append(", ".join(other_main_tags))
138
+
139
+ # 4. Post-prompt
140
+ if context.post_prompt.strip():
141
+ parts.append(context.post_prompt.strip())
142
+ context.processing_log.append("Added post-prompt")
143
+
144
+ context.positive_prompt = ", ".join(parts)
145
+ return context
146
+
147
+ def _inject_quality_tags(self, context: PromptContext) -> PromptContext:
148
+ """
149
+ Inject quality tags if enabled.
150
+
151
+ Positive quality tags are only appended to the END of the prompt
152
+ if the user's post_prompt does NOT contain "quality".
153
+ This allows users to customize quality tags via post_prompt.
154
+
155
+ Negative quality tags are always appended.
156
+ """
157
+ # Check if post_prompt contains "quality" (case-insensitive)
158
+ has_quality_in_post = "quality" in context.post_prompt.lower()
159
+
160
+ # Append positive quality tags only if post_prompt doesn't have "quality"
161
+ if not has_quality_in_post:
162
+ if context.positive_prompt:
163
+ context.positive_prompt = f"{context.positive_prompt}, {QUALITY_TAGS_POSITIVE}"
164
+ else:
165
+ context.positive_prompt = QUALITY_TAGS_POSITIVE
166
+ context.processing_log.append("Appended positive quality tags (post_prompt has no 'quality')")
167
+ else:
168
+ context.processing_log.append("Skipped positive quality tags (post_prompt has 'quality')")
169
+
170
+ # Append quality tags to negative prompt (always)
171
+ if context.negative_prompt:
172
+ context.negative_prompt = f"{context.negative_prompt}, {QUALITY_TAGS_NEGATIVE}"
173
+ else:
174
+ context.negative_prompt = QUALITY_TAGS_NEGATIVE
175
+
176
+ context.processing_log.append("Injected negative quality tags")
177
+ return context
178
+
179
+ def _remove_auto_hide_tags(self, context: PromptContext) -> PromptContext:
180
+ """
181
+ Remove auto-hide tags from the prompt with pattern support.
182
+
183
+ Pattern syntax (from NAIA2.0):
184
+ - `tag`: Exact match removal
185
+ - `_pattern_`: Remove tags containing 'pattern' (e.g., _hair_ → blonde hair)
186
+ - `_pattern`: Remove tags ending with 'pattern'
187
+ - `pattern_`: Remove tags starting with 'pattern'
188
+ - `~keyword`: Protect keyword from removal
189
+ """
190
+ if not context.auto_hide_tags:
191
+ return context
192
+
193
+ # Parse tags from positive prompt
194
+ tags = [t.strip() for t in context.positive_prompt.split(',') if t.strip()]
195
+
196
+ # Separate protected keywords (starting with ~) and patterns
197
+ protected_keywords = set()
198
+ auto_hide_patterns = []
199
+
200
+ for item in context.auto_hide_tags:
201
+ item = item.strip()
202
+ if not item:
203
+ continue
204
+ if item.startswith('~'):
205
+ # Protected keyword
206
+ protected_keywords.add(item[1:].strip().lower())
207
+ else:
208
+ auto_hide_patterns.append(item)
209
+
210
+ # Build removal list
211
+ to_remove = set()
212
+
213
+ for pattern in auto_hide_patterns:
214
+ pattern_lower = pattern.lower()
215
+
216
+ # Pattern matching logic from NAIA2.0
217
+ if pattern.startswith('__') and pattern.endswith('__') and len(pattern) > 4:
218
+ # __pattern__: contains match (double underscore)
219
+ # Remove all underscores for search
220
+ search_term = pattern[2:-2].replace('_', '')
221
+ for tag in tags:
222
+ if search_term.lower() in tag.lower().replace(' ', ''):
223
+ to_remove.add(tag)
224
+
225
+ elif pattern.startswith('_') and pattern.endswith('_') and len(pattern) > 2:
226
+ # _pattern_: contains match (single underscore, space-based)
227
+ search_term = pattern[1:-1].replace('_', ' ')
228
+ for tag in tags:
229
+ if search_term.lower() in tag.lower():
230
+ to_remove.add(tag)
231
+
232
+ elif pattern.startswith('_') and not pattern.endswith('_'):
233
+ # _pattern: ends with match
234
+ search_term = pattern[1:].replace('_', ' ')
235
+ for tag in tags:
236
+ if tag.lower().endswith(search_term.lower()):
237
+ to_remove.add(tag)
238
+
239
+ elif pattern.endswith('_') and not pattern.startswith('_'):
240
+ # pattern_: starts with match
241
+ search_term = pattern[:-1].replace('_', ' ')
242
+ for tag in tags:
243
+ if tag.lower().startswith(search_term.lower()):
244
+ to_remove.add(tag)
245
+
246
+ else:
247
+ # Exact match
248
+ for tag in tags:
249
+ if tag.lower() == pattern_lower:
250
+ to_remove.add(tag)
251
+
252
+ # Remove protected keywords from removal list
253
+ if protected_keywords:
254
+ protected_to_keep = set()
255
+ for tag in to_remove:
256
+ tag_lower = tag.lower()
257
+ for protected in protected_keywords:
258
+ if protected in tag_lower or tag_lower == protected:
259
+ protected_to_keep.add(tag)
260
+ break
261
+ to_remove -= protected_to_keep
262
+
263
+ if protected_to_keep:
264
+ context.processing_log.append(f"Protected tags: {', '.join(protected_to_keep)}")
265
+
266
+ # Apply removal
267
+ filtered = [t for t in tags if t not in to_remove]
268
+ context.removed_tags = list(to_remove)
269
+
270
+ context.positive_prompt = ", ".join(filtered)
271
+
272
+ if to_remove:
273
+ context.processing_log.append(f"Auto-hide removed {len(to_remove)} tags: {', '.join(sorted(to_remove))}")
274
+ else:
275
+ context.processing_log.append("Auto-hide: no tags matched")
276
+
277
+ return context
278
+
279
+ def _cleanup_prompt(self, context: PromptContext) -> PromptContext:
280
+ """Clean up prompt formatting"""
281
+ # Process positive prompt
282
+ context.positive_prompt = self._clean_text(context.positive_prompt)
283
+
284
+ # Process negative prompt
285
+ context.negative_prompt = self._clean_text(context.negative_prompt)
286
+
287
+ context.processing_log.append("Cleaned up formatting")
288
+ return context
289
+
290
+ def _clean_text(self, text: str) -> str:
291
+ """Clean a single text string"""
292
+ if not text:
293
+ return ""
294
+
295
+ # Remove extra whitespace
296
+ text = ' '.join(text.split())
297
+
298
+ # Remove duplicate commas
299
+ text = re.sub(r',\s*,+', ',', text)
300
+
301
+ # Remove spaces around commas
302
+ text = re.sub(r'\s*,\s*', ', ', text)
303
+
304
+ # Strip leading/trailing commas and whitespace
305
+ text = text.strip(' ,')
306
+
307
+ return text
308
+
309
+
310
+ def parse_tags_from_text(text: str) -> List[str]:
311
+ """
312
+ Parse comma-separated tags from text.
313
+
314
+ Args:
315
+ text: Comma-separated tag string
316
+
317
+ Returns:
318
+ List of individual tags (stripped)
319
+ """
320
+ if not text:
321
+ return []
322
+
323
+ return [t.strip() for t in text.split(',') if t.strip()]