diff --git a/docs/plans/2026-01-30-suitbuilder-design.md b/docs/plans/2026-01-30-suitbuilder-design.md index 716e4b63..c44af224 100644 --- a/docs/plans/2026-01-30-suitbuilder-design.md +++ b/docs/plans/2026-01-30-suitbuilder-design.md @@ -222,3 +222,31 @@ No changes needed - ItemPreFilter used at line 969. | Task 5: Add Armor Level Scoring | ✅ Complete | Added armor_score = total_armor // 100 | | Task 6: Add Item Pre-Filtering | ✅ Already Working | No changes needed | | Task 7: AccessorySearcher | Not Started | Future | +| Task 8: Fix SSE Streaming Proxy | ✅ Complete | Added dedicated streaming endpoint in main.py | + +--- + +## Bug Fixes Applied + +### SSE Streaming Proxy Fix (2026-01-30) + +**Problem:** The generic inventory proxy at `/inv/{path:path}` used `httpx.request()` which buffers the entire response before returning. For SSE streams like suitbuilder search, this caused: +- No progress updates reaching the frontend +- "Gateway Time-out" after buffering the full 5-minute search + +**Solution:** Added dedicated streaming proxy endpoint `/inv/suitbuilder/search` in `main.py`: +```python +@app.post("/inv/suitbuilder/search") +async def proxy_suitbuilder_search(request: Request): + async def stream_response(): + async with httpx.AsyncClient.stream(...) as response: + async for chunk in response.aiter_bytes(): + yield chunk + return StreamingResponse( + stream_response(), + media_type="text/event-stream", + headers={"X-Accel-Buffering": "no"} # Disable nginx buffering + ) +``` + +**Result:** Suits now stream to frontend in real-time with scores visible as they're found. diff --git a/inventory-service/main.py b/inventory-service/main.py index 3512f6d8..564e31dc 100644 --- a/inventory-service/main.py +++ b/inventory-service/main.py @@ -2702,7 +2702,7 @@ async def search_items( # Handle NULLS for optional fields nulls_clause = "NULLS LAST" if sort_direction == "ASC" else "NULLS FIRST" - query_parts.append(f"ORDER BY {sort_field} {sort_direction} {nulls_clause}") + query_parts.append(f"ORDER BY {sort_field} {sort_direction} {nulls_clause}, character_name, db_item_id") # Add pagination offset = (page - 1) * limit diff --git a/inventory-service/suitbuilder.py b/inventory-service/suitbuilder.py index 27394c0d..0f192e37 100644 --- a/inventory-service/suitbuilder.py +++ b/inventory-service/suitbuilder.py @@ -253,14 +253,20 @@ class ItemBucket: is_required: bool = False # Some slots might be required by constraints def sort_items(self): - """Sort items by priority based on slot type.""" + """Sort items by priority based on slot type. + + All sorts include (character_name, name) as stable tiebreakers + to ensure deterministic ordering for reproducible search results. + """ if self.slot in ['Shirt', 'Pants']: # Underclothes: damage_rating first, ignore armor_level (buffed armor irrelevant) self.items.sort( key=lambda item: ( item.ratings.get('damage_rating', 0), len(item.spell_names), - sum(r for k, r in item.ratings.items() if k != 'damage_rating') + sum(r for k, r in item.ratings.items() if k != 'damage_rating'), + item.character_name, + item.name ), reverse=True ) @@ -271,7 +277,9 @@ class ItemBucket: item.armor_level, item.ratings.get('crit_damage_rating', 0), len(item.spell_names), - sum(item.ratings.values()) + sum(item.ratings.values()), + item.character_name, + item.name ), reverse=True ) @@ -280,7 +288,9 @@ class ItemBucket: self.items.sort( key=lambda item: ( len(item.spell_names), - sum(item.ratings.values()) + sum(item.ratings.values()), + item.character_name, + item.name ), reverse=True ) @@ -407,13 +417,19 @@ class ScoringWeights(BaseModel): damage_rating_3: int = 30 # DR3 on clothes +class LockedSlotInfo(BaseModel): + """Information about a locked slot.""" + set_id: Optional[int] = None + spells: List[str] = [] + + class SearchConstraints(BaseModel): """User-defined search constraints.""" characters: List[str] primary_set: Optional[int] = None secondary_set: Optional[int] = None required_spells: List[str] = field(default_factory=list) - locked_items: Dict[str, int] = field(default_factory=dict) # slot -> item_id + locked_slots: Dict[str, LockedSlotInfo] = field(default_factory=dict) # slot -> lock info include_equipped: bool = True include_inventory: bool = True min_armor: Optional[int] = None @@ -595,7 +611,12 @@ class ConstraintSatisfactionSolver: self.scoring_weights = constraints.scoring_weights or ScoringWeights() self.search_completed = False self.is_cancelled = is_cancelled # Callback to check if search should stop - + + # Branch pruning: track best suit found so far (Mag-SuitBuilder style) + self.best_suit_item_count = 0 + self.highest_armor_count_suit_built = 0 # Track highest armor piece count seen + self.total_armor_buckets_with_items = 0 # Will be set during bucket creation + # Pre-compute needed spell bitmap self.needed_spell_bitmap = self.spell_index.get_bitmap(constraints.required_spells) logger.info(f"[SPELL_CONSTRAINTS_DEBUG] Required spells: {constraints.required_spells}") @@ -604,47 +625,167 @@ class ConstraintSatisfactionSolver: async def search(self) -> AsyncGenerator[SearchResult, None]: """Main search entry point with streaming results.""" try: + # Phase 1: Loading items + yield SearchResult(type="phase", data={ + "phase": "loading", + "message": "Loading items from database...", + "phase_number": 1, + "total_phases": 5 + }) + # Load and preprocess items items = await self.load_items() logger.info(f"Loaded {len(items)} items for optimization") + + yield SearchResult(type="phase", data={ + "phase": "loaded", + "message": f"Loaded {len(items)} items", + "items_count": len(items), + "phase_number": 1, + "total_phases": 5 + }) + yield SearchResult(type="log", data={ + "level": "info", + "message": f"Loaded {len(items)} items from {len(self.constraints.characters)} characters", + "timestamp": time.time() - self.start_time + }) + if not items: yield SearchResult(type="error", data={"message": "No items found for specified characters"}) return - + + # Phase 2: Creating buckets + yield SearchResult(type="phase", data={ + "phase": "buckets", + "message": "Creating equipment buckets...", + "phase_number": 2, + "total_phases": 5 + }) + # Create buckets buckets = self.create_buckets(items) logger.info(f"Created {len(buckets)} equipment buckets") - + + # Build bucket summary + bucket_summary = {b.slot: len(b.items) for b in buckets} + yield SearchResult(type="phase", data={ + "phase": "buckets_done", + "message": f"Created {len(buckets)} buckets", + "bucket_count": len(buckets), + "bucket_summary": bucket_summary, + "phase_number": 2, + "total_phases": 5 + }) + # Log bucket details + bucket_details = ", ".join([f"{b.slot}: {len(b.items)}" for b in buckets[:5]]) + yield SearchResult(type="log", data={ + "level": "info", + "message": f"Buckets created: {bucket_details}{'...' if len(buckets) > 5 else ''}", + "timestamp": time.time() - self.start_time + }) + + # Phase 3: Applying reduction rules + yield SearchResult(type="phase", data={ + "phase": "reducing", + "message": "Applying armor reduction rules...", + "phase_number": 3, + "total_phases": 5 + }) + # Apply armor reduction rules buckets = self.apply_reduction_options(buckets) - + + # Phase 4: Sorting buckets + yield SearchResult(type="phase", data={ + "phase": "sorting", + "message": "Optimizing search order...", + "phase_number": 4, + "total_phases": 5 + }) + # Sort buckets buckets = self.sort_buckets(buckets) - + # Start recursive search initial_state = SuitState() - - # Apply locked items - for slot, item_id in self.constraints.locked_items.items(): - # Find the locked item - for bucket in buckets: - if bucket.slot == slot: - for item in bucket.items: - if item.id == item_id: - item.is_locked = True - initial_state.push(item) - break - - # Start search + + # Handle locked slots - filter out locked slots from buckets + if self.constraints.locked_slots: + # Debug: log what we received + logger.info(f"[LOCKED_SLOTS] Received locked_slots: {self.constraints.locked_slots}") + for slot, lock_info in self.constraints.locked_slots.items(): + logger.info(f"[LOCKED_SLOTS] Slot '{slot}': set_id={lock_info.set_id}, spells={lock_info.spells}") + + locked_slot_names = set(self.constraints.locked_slots.keys()) + original_bucket_count = len(buckets) + buckets = [b for b in buckets if b.slot not in locked_slot_names] + logger.info(f"Filtered out {original_bucket_count - len(buckets)} locked slots: {locked_slot_names}") + + # Calculate locked set contributions (using numeric set IDs) + self.locked_set_counts = {} + for slot, lock_info in self.constraints.locked_slots.items(): + if lock_info.set_id: + # Use numeric set_id for consistency with state.set_counts + self.locked_set_counts[lock_info.set_id] = self.locked_set_counts.get(lock_info.set_id, 0) + 1 + logger.info(f"[LOCKED_SLOTS] Added set_id {lock_info.set_id} from slot {slot}") + logger.info(f"Locked set contributions (by ID): {self.locked_set_counts}") + logger.info(f"[LOCKED_SLOTS] Primary set ID: {self.constraints.primary_set}, locked count for it: {self.locked_set_counts.get(self.constraints.primary_set, 0)}") + + # Calculate locked spells to exclude from required + self.locked_spells = set() + for lock_info in self.constraints.locked_slots.values(): + self.locked_spells.update(lock_info.spells) + logger.info(f"Locked spells (already covered): {self.locked_spells}") + + # Log locked slots info + yield SearchResult(type="log", data={ + "level": "info", + "message": f"Locked {len(locked_slot_names)} slots: {', '.join(locked_slot_names)}", + "timestamp": time.time() - self.start_time + }) + else: + self.locked_set_counts = {} + self.locked_spells = set() + + # Calculate effective set requirements (subtract locked pieces) + self.effective_primary_needed = 5 # Default for primary set + self.effective_secondary_needed = 4 # Default for secondary set + + if self.constraints.primary_set: + locked_primary = self.locked_set_counts.get(self.constraints.primary_set, 0) + self.effective_primary_needed = max(0, 5 - locked_primary) + + if self.constraints.secondary_set: + locked_secondary = self.locked_set_counts.get(self.constraints.secondary_set, 0) + self.effective_secondary_needed = max(0, 4 - locked_secondary) + + logger.info(f"Effective requirements: {self.effective_primary_needed} primary, {self.effective_secondary_needed} secondary (after locked)") + + # Log effective requirements + if self.locked_set_counts: + yield SearchResult(type="log", data={ + "level": "info", + "message": f"Need: {self.effective_primary_needed} primary + {self.effective_secondary_needed} secondary pieces", + "timestamp": time.time() - self.start_time + }) + + # Phase 5: Searching logger.info(f"Starting recursive search with {len(buckets)} buckets") - yield SearchResult(type="progress", data={ - "message": "Search started", - "buckets": len(buckets), - "evaluated": 0, - "found": 0, - "elapsed": 0.0 + yield SearchResult(type="phase", data={ + "phase": "searching", + "message": "Searching for optimal suits...", + "total_buckets": len(buckets), + "phase_number": 5, + "total_phases": 5 }) - + # Log search start summary + total_items = sum(len(b.items) for b in buckets) + yield SearchResult(type="log", data={ + "level": "info", + "message": f"Starting search: {len(buckets)} buckets, {total_items} total items", + "timestamp": time.time() - self.start_time + }) + logger.info("Starting async iteration over recursive search") async for result in self.recursive_search(buckets, 0, initial_state): yield result @@ -855,27 +996,33 @@ class ConstraintSatisfactionSolver: return base_params all_jewelry_items = [] - - # Fetch jewelry items (object_class=4) - all jewelry for now - logger.info("[DEBUG] Building jewelry API parameters") - jewelry_params = build_base_params() - jewelry_params.append("object_class=4") # Jewelry - # Note: has_spells filter doesn't seem to work, so load all jewelry for now - jewelry_url = f"http://localhost:8000/search/items?{'&'.join(jewelry_params)}" - logger.info(f"[DEBUG] Constructed jewelry URL: {jewelry_url}") - - logger.info(f"Fetching jewelry items with {equipment_status_log}") - logger.info(f"Fetching jewelry from: {jewelry_url}") - - try: - with urllib.request.urlopen(jewelry_url) as response: - data = json_module.load(response) - jewelry_items = data.get('items', []) - logger.info(f"Jewelry items with {equipment_status_log}: {len(jewelry_items)} items returned") - all_jewelry_items.extend(jewelry_items) - except Exception as e: - logger.error(f"Error fetching jewelry: {e}") - + + # Fetch each jewelry type separately using slot_names filter + # This ensures we get all rings, bracelets, etc. instead of just the first page of all jewelry + jewelry_slot_types = [ + ("Ring", "rings"), + ("Bracelet", "bracelets"), + ("Neck", "necklaces/amulets"), + ("Trinket", "trinkets") + ] + + for slot_filter, slot_description in jewelry_slot_types: + jewelry_params = build_base_params() + jewelry_params.append("jewelry_only=true") + jewelry_params.append(f"slot_names={slot_filter}") + jewelry_url = f"http://localhost:8000/search/items?{'&'.join(jewelry_params)}" + + logger.info(f"Fetching {slot_description} with {equipment_status_log}") + + try: + with urllib.request.urlopen(jewelry_url) as response: + data = json_module.load(response) + items = data.get('items', []) + logger.info(f"Fetched {len(items)} {slot_description}") + all_jewelry_items.extend(items) + except Exception as e: + logger.error(f"Error fetching {slot_description}: {e}") + logger.info(f"Total jewelry items fetched: {len(all_jewelry_items)}") return all_jewelry_items @@ -895,6 +1042,26 @@ class ConstraintSatisfactionSolver: logger.info(f"Total items from inventory API: {len(all_api_items)}") + # Helper function to normalize spell_names to list format + def normalize_spell_names(spell_data): + """Convert spell_names to list format regardless of input type. + + The API may return spell_names as: + - A list of spell names (correct format) + - A comma-separated string of spell names or IDs + - None or empty + + This ensures we always work with a list. + """ + if spell_data is None: + return [] + if isinstance(spell_data, list): + return spell_data + if isinstance(spell_data, str) and spell_data.strip(): + # Split comma-separated values and clean up + return [s.strip() for s in spell_data.split(',') if s.strip()] + return [] + # Convert to SuitItem objects items = [] for api_item in all_api_items: @@ -934,7 +1101,7 @@ class ConstraintSatisfactionSolver: 'vitality_rating': api_item.get('vitality_rating') if api_item.get('vitality_rating') is not None else 0 }, spell_bitmap=0, # Will calculate if needed - spell_names=api_item.get('spell_names', []), + spell_names=normalize_spell_names(api_item.get('spell_names')), material=api_item.get('material_name', '') ) items.append(suit_item) @@ -969,28 +1136,62 @@ class ConstraintSatisfactionSolver: filtered_items = ItemPreFilter.remove_surpassed_items(items) # Sort items for optimal search order - armor_items = [item for item in filtered_items if item.slot in { + # Define slot sets + armor_slot_set = { "Head", "Chest", "Upper Arms", "Lower Arms", "Hands", "Abdomen", "Upper Legs", "Lower Legs", "Feet" - }] - jewelry_items = [item for item in filtered_items if item.slot in { + } + jewelry_slot_set = { "Neck", "Left Ring", "Right Ring", "Left Wrist", "Right Wrist", "Trinket" - }] - clothing_items = [item for item in filtered_items if item.slot in {"Shirt", "Pants"}] + } + # Also match generic jewelry slot names that might come from API + jewelry_fallback_slots = {"Ring", "Bracelet", "Jewelry", "Necklace", "Amulet"} + clothing_slot_set = {"Shirt", "Pants"} + + # Helper to check if item matches any slot in a set (handles multi-slot items) + def matches_slot_set(item_slot: str, slot_set: set, fallback_set: set = None) -> bool: + if item_slot in slot_set: + return True + # Handle multi-slot items like "Left Wrist, Right Wrist" + if ', ' in item_slot: + return any(s.strip() in slot_set for s in item_slot.split(', ')) + # Check fallback set for generic names like "Ring", "Jewelry" + if fallback_set and item_slot in fallback_set: + return True + return False + + armor_items = [item for item in filtered_items if matches_slot_set(item.slot, armor_slot_set)] + jewelry_items = [item for item in filtered_items if matches_slot_set(item.slot, jewelry_slot_set, jewelry_fallback_slots)] + clothing_items = [item for item in filtered_items if matches_slot_set(item.slot, clothing_slot_set)] # Sort armor by spell count (most spells first) since armor level deprioritized - armor_items.sort(key=lambda x: len(x.spell_names), reverse=True) + # Include (character_name, name) as stable tiebreakers for deterministic ordering + armor_items.sort(key=lambda x: (len(x.spell_names), x.character_name, x.name), reverse=True) - # Sort jewelry by spell count (most spells first) - jewelry_items.sort(key=lambda x: len(x.spell_names), reverse=True) + # Sort jewelry by spell count (most spells first) + jewelry_items.sort(key=lambda x: (len(x.spell_names), x.character_name, x.name), reverse=True) # Sort clothing by damage rating (highest first) - clothing_items.sort(key=lambda x: x.ratings.get('damage_rating', 0), reverse=True) + clothing_items.sort(key=lambda x: (x.ratings.get('damage_rating', 0), x.character_name, x.name), reverse=True) # Recombine in optimized order optimized_items = armor_items + jewelry_items + clothing_items + # DETERMINISM CHECK - Log first 5 items of each type to verify consistent ordering + logger.info("DETERMINISM CHECK - First 5 armor items:") + for i, item in enumerate(armor_items[:5]): + logger.info(f" {i}: {item.character_name}/{item.name} spells={len(item.spell_names)}") + logger.info("DETERMINISM CHECK - First 5 jewelry items:") + for i, item in enumerate(jewelry_items[:5]): + logger.info(f" {i}: {item.character_name}/{item.name} spells={len(item.spell_names)}") + logger.info(f"ITEM SORTING: {len(armor_items)} armor, {len(jewelry_items)} jewelry, {len(clothing_items)} clothing") + + # Debug: Log jewelry items with spells + jewelry_with_spells = [item for item in jewelry_items if item.spell_names] + logger.info(f"JEWELRY WITH SPELLS: {len(jewelry_with_spells)} items") + for item in jewelry_with_spells[:5]: # Log first 5 + logger.info(f" - {item.name} (slot: {item.slot}): {item.spell_names}") return optimized_items @@ -1074,19 +1275,25 @@ class ConstraintSatisfactionSolver: else: logger.warning(f"Multi-slot item {item.name} with slots '{item.slot}' couldn't be mapped to any valid slots") else: - # Check for complex slot patterns that might not use comma separation - # Handle items with complex slot descriptions that the SQL computed incorrectly + # Handle generic jewelry slot names that need expansion + generic_jewelry_expansion = { + "Ring": ["Left Ring", "Right Ring"], + "Bracelet": ["Left Wrist", "Right Wrist"], + "Jewelry": ["Neck", "Left Ring", "Right Ring", "Left Wrist", "Right Wrist", "Trinket"], + "Necklace": ["Neck"], + "Amulet": ["Neck"], + } + mapped_slots = [] - - # Check if any of our known slots are mentioned in the slot string - for known_slot in all_slots: - if known_slot.lower() in item.slot.lower(): - # Create a single-slot variant of the item + + # Check for generic jewelry slot names first + if item.slot in generic_jewelry_expansion: + for target_slot in generic_jewelry_expansion[item.slot]: single_slot_item = SuitItem( id=item.id, name=item.name, character_name=item.character_name, - slot=known_slot, # Single slot assignment + slot=target_slot, coverage=item.coverage, set_id=item.set_id, armor_level=item.armor_level, @@ -1095,13 +1302,35 @@ class ConstraintSatisfactionSolver: spell_names=item.spell_names.copy(), material=item.material ) - slot_items[known_slot].append(single_slot_item) - mapped_slots.append(known_slot) - - if mapped_slots: - logger.debug(f"Complex slot item {item.name} split into slots: {mapped_slots} (original: '{item.slot}')") + slot_items[target_slot].append(single_slot_item) + mapped_slots.append(target_slot) + logger.debug(f"Generic jewelry slot '{item.slot}' expanded to: {mapped_slots} for {item.name}") else: - logger.warning(f"Unknown slot '{item.slot}' for item {item.name} - could not map to any known slots") + # Check for complex slot patterns that might not use comma separation + # Handle items with complex slot descriptions that the SQL computed incorrectly + for known_slot in all_slots: + if known_slot.lower() in item.slot.lower(): + # Create a single-slot variant of the item + single_slot_item = SuitItem( + id=item.id, + name=item.name, + character_name=item.character_name, + slot=known_slot, # Single slot assignment + coverage=item.coverage, + set_id=item.set_id, + armor_level=item.armor_level, + ratings=item.ratings.copy(), + spell_bitmap=item.spell_bitmap, + spell_names=item.spell_names.copy(), + material=item.material + ) + slot_items[known_slot].append(single_slot_item) + mapped_slots.append(known_slot) + + if mapped_slots: + logger.debug(f"Complex slot item {item.name} split into slots: {mapped_slots} (original: '{item.slot}')") + else: + logger.warning(f"Unknown slot '{item.slot}' for item {item.name} - could not map to any known slots") # Create buckets - CRITICAL: Create ALL buckets even if empty (MagSuitBuilder behavior) buckets = [] @@ -1131,7 +1360,21 @@ class ConstraintSatisfactionSolver: logger.info(f"CREATED {len(buckets)} total buckets (including {len([b for b in buckets if len(b.items) == 0])} empty)") logger.info(f"BUCKET ORDER: {[f'{b.slot}({len(b.items)})' for b in buckets]}") - + + # Calculate total armor buckets with items for Mag-SuitBuilder pruning + self.total_armor_buckets_with_items = sum( + 1 for b in buckets if b.is_armor and len(b.items) > 0 + ) + logger.info(f"ARMOR BUCKETS WITH ITEMS: {self.total_armor_buckets_with_items}") + + # Log first 3 items of first non-empty bucket for determinism verification + for bucket in buckets: + if len(bucket.items) > 0: + logger.info(f"FIRST BUCKET ({bucket.slot}) - First 3 items:") + for i, item in enumerate(bucket.items[:3]): + logger.info(f" {i}: {item.character_name}/{item.name}") + break + return buckets def apply_reduction_options(self, buckets: List[ItemBucket]) -> List[ItemBucket]: @@ -1283,60 +1526,108 @@ class ConstraintSatisfactionSolver: if self.is_cancelled and await self.is_cancelled(): logger.info("Search cancelled by client") return - - # No timeout - search continuously until user stops - + # Early success detection - stop when user's set goals are achieved primary_count = state.set_counts.get(self.constraints.primary_set, 0) if self.constraints.primary_set else 0 secondary_count = state.set_counts.get(self.constraints.secondary_set, 0) if self.constraints.secondary_set else 0 - - # REMOVED: Don't stop early - we need to search deeper for complete suits - # Let the search continue to find better combinations - - # REMOVED: Aggressive pruning that prevents finding complete suits - # This pruning was cutting off valid search branches too early - # We need to search deeper to find 9-piece complete suits - + + # Branch pruning - Mag-SuitBuilder style aggressive pruning (ArmorSearcher.cs:138) + # Formula: if (builder.Count + 1 < highestArmorCountSuitBuilt - (totalArmorBucketsWithItems - min(index, totalArmorBucketsWithItems))) + # This prunes branches where the best possible suit can't compete with what we've found + if self.highest_armor_count_suit_built > 0: + current_count = len(state.items) + # Calculate remaining armor slots that could be filled + remaining_armor_potential = self.total_armor_buckets_with_items - min(bucket_idx, self.total_armor_buckets_with_items) + # Minimum required: highest seen minus potential remaining (gives us 1-piece buffer) + min_required = self.highest_armor_count_suit_built - remaining_armor_potential + # If we can't even hit minimum required with 1 more piece, prune + if current_count + 1 < min_required: + return + + # Also keep the simpler max-items pruning as backup + remaining_buckets = len(buckets) - bucket_idx + max_possible_items = len(state.items) + remaining_buckets + if self.best_suit_item_count > 0 and max_possible_items < self.best_suit_item_count: + return + # Base case: all buckets processed if bucket_idx >= len(buckets): - logger.info(f"[DEBUG] BASE CASE: All {len(buckets)} buckets processed, state has {len(state.items)} items") + logger.debug(f"[DEBUG] BASE CASE: All {len(buckets)} buckets processed, state has {len(state.items)} items") suit = self.finalize_suit(state) if suit: - logger.info(f"[DEBUG] Suit created with score {suit.score}, {len(suit.items)} items") + logger.debug(f"[DEBUG] Suit created with score {suit.score}, {len(suit.items)} items") if self.is_better_than_existing(suit): - logger.info(f"[DEBUG] Suit ACCEPTED: score {suit.score} is better than existing") + logger.debug(f"[DEBUG] Suit ACCEPTED: score {suit.score} is better than existing") logger.info(f"Found suit with score {suit.score}: {len(suit.items)} items") self.best_suits.append(suit) + # Update best suit item count for pruning + if len(suit.items) > self.best_suit_item_count: + self.best_suit_item_count = len(suit.items) + # Update armor piece count for Mag-SuitBuilder style pruning + armor_slots = {"Head", "Chest", "Upper Arms", "Lower Arms", "Hands", + "Abdomen", "Upper Legs", "Lower Legs", "Feet"} + armor_piece_count = sum(1 for slot in suit.items.keys() if slot in armor_slots) + if armor_piece_count > self.highest_armor_count_suit_built: + self.highest_armor_count_suit_built = armor_piece_count + logger.info(f"[PRUNING] New highest armor count: {armor_piece_count}") self.best_suits.sort(key=lambda s: s.score, reverse=True) self.best_suits = self.best_suits[:self.constraints.max_results] - # Pass constraint info to to_dict for proper set counts - FIXED: use translated names + # Pass constraint info to to_dict for proper set counts suit_data = suit.to_dict() from main import translate_equipment_set_id primary_set_name = translate_equipment_set_id(str(self.constraints.primary_set)) if self.constraints.primary_set else None secondary_set_name = translate_equipment_set_id(str(self.constraints.secondary_set)) if self.constraints.secondary_set else None - - suit_data['stats']['primary_set_count'] = suit.set_counts.get(primary_set_name, 0) if primary_set_name else 0 - suit_data['stats']['secondary_set_count'] = suit.set_counts.get(secondary_set_name, 0) if secondary_set_name else 0 + + # FIXED: set_counts uses numeric keys, not string names + primary_count = suit.set_counts.get(self.constraints.primary_set, 0) if self.constraints.primary_set else 0 + secondary_count = suit.set_counts.get(self.constraints.secondary_set, 0) if self.constraints.secondary_set else 0 + + # Add locked slot contributions to the counts + if hasattr(self, 'locked_set_counts'): + primary_count += self.locked_set_counts.get(self.constraints.primary_set, 0) if self.constraints.primary_set else 0 + secondary_count += self.locked_set_counts.get(self.constraints.secondary_set, 0) if self.constraints.secondary_set else 0 + + # Get locked counts for breakdown + locked_primary = self.locked_set_counts.get(self.constraints.primary_set, 0) if hasattr(self, 'locked_set_counts') and self.constraints.primary_set else 0 + locked_secondary = self.locked_set_counts.get(self.constraints.secondary_set, 0) if hasattr(self, 'locked_set_counts') and self.constraints.secondary_set else 0 + + suit_data['stats']['primary_set_count'] = primary_count # Total (found + locked) + suit_data['stats']['secondary_set_count'] = secondary_count # Total (found + locked) suit_data['stats']['primary_set'] = primary_set_name suit_data['stats']['secondary_set'] = secondary_set_name + suit_data['stats']['locked_slots'] = len(self.constraints.locked_slots) if self.constraints.locked_slots else 0 + suit_data['stats']['primary_locked'] = locked_primary + suit_data['stats']['secondary_locked'] = locked_secondary yield SearchResult(type="suit", data=suit_data) + + # Send log event for suit found + yield SearchResult(type="log", data={ + "level": "success", + "message": f"Found suit #{len(self.best_suits)} with score {suit.score} ({len(suit.items)} items)", + "timestamp": time.time() - self.start_time + }) else: - logger.info(f"[DEBUG] Suit REJECTED: score {suit.score} not better than existing") + logger.debug(f"[DEBUG] Suit REJECTED: score {suit.score} not better than existing") else: - logger.info(f"[DEBUG] No suit created from current state") + logger.debug(f"[DEBUG] No suit created from current state") return - + # Progress update and debug info self.suits_evaluated += 1 - if self.suits_evaluated % 10 == 0: # Every 10 evaluations for better granularity + if self.suits_evaluated % 100 == 0: # Every 100 evaluations for reduced log spam # Check for cancellation during progress update if self.is_cancelled and await self.is_cancelled(): logger.info("Search cancelled during progress update") return - - logger.info(f"Search progress: evaluated {self.suits_evaluated}, depth {bucket_idx}/{len(buckets)}, found {len(self.best_suits)} suits, current state: {len(state.items)} items") + + elapsed = time.time() - self.start_time + rate = round(self.suits_evaluated / elapsed, 1) if elapsed > 0 else 0 + best_score = self.best_suits[0].score if self.best_suits else 0 + current_bucket_name = buckets[bucket_idx].slot if bucket_idx < len(buckets) else None + + logger.info(f"Search progress: evaluated {self.suits_evaluated}, depth {bucket_idx}/{len(buckets)}, found {len(self.best_suits)} suits, best has {self.best_suit_item_count} items") yield SearchResult( type="progress", data={ @@ -1345,61 +1636,57 @@ class ConstraintSatisfactionSolver: "current_depth": bucket_idx, "total_buckets": len(buckets), "current_items": len(state.items), - "elapsed": time.time() - self.start_time + "elapsed": elapsed, + "rate": rate, + "current_bucket": current_bucket_name, + "best_score": best_score } ) - + + # Send verbose log every 500 evaluations + if self.suits_evaluated % 500 == 0: + yield SearchResult(type="log", data={ + "level": "info", + "message": f"Evaluated {self.suits_evaluated:,} combinations | Bucket: {current_bucket_name} ({bucket_idx+1}/{len(buckets)}) | Rate: {rate}/s", + "timestamp": elapsed + }) + bucket = buckets[bucket_idx] - - # Implement Mag-SuitBuilder parallel processing for first bucket (ArmorSearcher.cs:192-210) - if bucket_idx == 0 and len(bucket.items) > 10: # Only parallelize if enough items - # For first bucket, we could use asyncio.gather for parallel processing - # but for now, keep sequential to avoid complexity with async generators - pass - - # DEBUG: Log bucket processing - logger.info(f"[DEBUG] Processing bucket {bucket_idx}: {bucket.slot} with {len(bucket.items)} items") - - # Continue searching to find more combinations - no aggressive pruning - + + # DEBUG: Log bucket processing (reduced verbosity) + logger.debug(f"[DEBUG] Processing bucket {bucket_idx}: {bucket.slot} with {len(bucket.items)} items") + # Try each item in current bucket items_tried = 0 items_accepted = 0 for item in bucket.items: items_tried += 1 - logger.info(f"[DEBUG] Trying item {items_tried}/{len(bucket.items)} in {bucket.slot}: {item.name}") - + logger.debug(f"[DEBUG] Trying item {items_tried}/{len(bucket.items)} in {bucket.slot}: {item.name}") + if self.can_add_item(item, state): items_accepted += 1 - logger.info(f"[DEBUG] Item ACCEPTED: {item.name} (#{items_accepted})") - + logger.debug(f"[DEBUG] Item ACCEPTED: {item.name} (#{items_accepted})") + # Add item to state state.push(item) - logger.info(f"[DEBUG] State after push: {len(state.items)} items, going to bucket {bucket_idx + 1}") - + # Continue search with next bucket - recursion_count = 0 async for result in self.recursive_search(buckets, bucket_idx + 1, state): - recursion_count += 1 - logger.info(f"[DEBUG] Received result #{recursion_count} from recursion") yield result - + # Remove item from state (backtrack) state.pop(item.slot) - logger.info(f"[DEBUG] Backtracked from {item.name}, state now: {len(state.items)} items") else: - logger.info(f"[DEBUG] Item REJECTED: {item.name}") + logger.debug(f"[DEBUG] Item REJECTED: {item.name}") - logger.info(f"[DEBUG] Bucket {bucket.slot} summary: {items_tried} tried, {items_accepted} accepted") - - # ALWAYS try skipping buckets to allow incomplete suits - logger.info(f"[DEBUG] Trying skip bucket {bucket.slot} (bucket {bucket_idx})") - skip_recursion_count = 0 - async for result in self.recursive_search(buckets, bucket_idx + 1, state): - skip_recursion_count += 1 - logger.info(f"[DEBUG] Skip-bucket result #{skip_recursion_count}") - yield result - logger.info(f"[DEBUG] Skip bucket {bucket.slot} yielded {skip_recursion_count} results") + logger.debug(f"[DEBUG] Bucket {bucket.slot} summary: {items_tried} tried, {items_accepted} accepted") + + # Only skip if no items were accepted (allows incomplete suits when no valid items exist) + # If items were accepted, we already explored those paths - don't also explore skip + if items_accepted == 0: + logger.debug(f"[DEBUG] No items accepted for {bucket.slot}, trying skip") + async for result in self.recursive_search(buckets, bucket_idx + 1, state): + yield result def can_add_item(self, item: SuitItem, state: SuitState) -> bool: """Check if item can be added without violating constraints.""" @@ -1415,75 +1702,80 @@ class ConstraintSatisfactionSolver: # 1. Slot availability if item.slot in state.occupied_slots: - logger.info(f"[DEBUG] REJECT {item.name}: slot {item.slot} already occupied") + logger.debug(f"[DEBUG] REJECT {item.name}: slot {item.slot} already occupied") return False - + # 2. Item uniqueness - same physical item can't be used in multiple slots for existing_item in state.items.values(): if existing_item.id == item.id: - logger.info(f"[DEBUG] REJECT {item.name}: item already used (duplicate ID)") + logger.debug(f"[DEBUG] REJECT {item.name}: item already used (duplicate ID)") return False - - # 3. Set piece validation - FIXED: Use numeric IDs consistently + + # 3. Set piece validation - Use EFFECTIVE limits (account for locked slots) if item.set_id: # Convert item.set_id to numeric for comparison (it might be string or int) try: item_set_numeric = int(item.set_id) if isinstance(item.set_id, str) and item.set_id.isdigit() else item.set_id except: item_set_numeric = item.set_id - + current_count = state.set_counts.get(item_set_numeric, 0) - + + # Use effective limits which account for locked slots + eff_primary = getattr(self, 'effective_primary_needed', 5) + eff_secondary = getattr(self, 'effective_secondary_needed', 4) + if item_set_numeric == self.constraints.primary_set: - # Primary set: max 5 pieces - if current_count >= 5: - logger.info(f"[DEBUG] REJECT {item.name}: primary set {item_set_numeric} already has {current_count} pieces (max 5)") + # Primary set: use effective limit (accounts for locked pieces) + if current_count >= eff_primary: + logger.info(f"[SET_LIMIT] REJECT {item.name}: primary set {item_set_numeric} already has {current_count} pieces (effective max {eff_primary})") return False elif item_set_numeric == self.constraints.secondary_set: - # Secondary set: max 4 pieces - if current_count >= 4: - logger.info(f"[DEBUG] REJECT {item.name}: secondary set {item_set_numeric} already has {current_count} pieces (max 4)") + # Secondary set: use effective limit (accounts for locked pieces) + if current_count >= eff_secondary: + logger.info(f"[SET_LIMIT] REJECT {item.name}: secondary set {item_set_numeric} already has {current_count} pieces (effective max {eff_secondary})") return False else: - # Check if this is a jewelry item - always allow jewelry even if from other sets + # Check if this is a jewelry item - only allow if it contributes required spells jewelry_slots = {"Neck", "Left Ring", "Right Ring", "Left Wrist", "Right Wrist", "Trinket"} if item.slot in jewelry_slots: - # Always allow jewelry items regardless of set ID (they provide spells) - logger.info(f"[DEBUG] ACCEPT {item.name}: jewelry item from set '{item_set_numeric}', allowed for spells") + # Jewelry MUST contribute to required spells to be accepted + if not self._jewelry_contributes_required_spell(item, state): + logger.debug(f"[DEBUG] REJECT {item.name}: jewelry doesn't contribute any required spells") + return False + logger.debug(f"[DEBUG] ACCEPT {item.name}: jewelry from set '{item_set_numeric}' contributes required spells") else: # STRICT: Reject armor items from other sets # Only allow armor from the two user-selected sets - logger.info(f"[DEBUG] REJECT {item.name}: armor from other set '{item_set_numeric}', only primary '{self.constraints.primary_set}' and secondary '{self.constraints.secondary_set}' allowed") + logger.debug(f"[DEBUG] REJECT {item.name}: armor from other set '{item_set_numeric}', only primary '{self.constraints.primary_set}' and secondary '{self.constraints.secondary_set}' allowed") return False else: # For set optimization, reject items with no set ID unless they're clothing or jewelry jewelry_slots = {"Neck", "Left Ring", "Right Ring", "Left Wrist", "Right Wrist", "Trinket"} if item.slot in ['Shirt', 'Pants']: # Allow clothing items even without set ID - logger.info(f"[DEBUG] ACCEPT {item.name}: clothing item without set ID, allowed") + logger.debug(f"[DEBUG] ACCEPT {item.name}: clothing item without set ID, allowed") elif item.slot in jewelry_slots: - # Allow jewelry items even without set ID (they provide cantrips/wards) - logger.info(f"[DEBUG] ACCEPT {item.name}: jewelry item without set ID, allowed") + # Jewelry MUST contribute to required spells to be accepted + if not self._jewelry_contributes_required_spell(item, state): + logger.debug(f"[DEBUG] REJECT {item.name}: jewelry doesn't contribute any required spells") + return False + logger.debug(f"[DEBUG] ACCEPT {item.name}: jewelry without set ID contributes required spells") else: # Reject armor items without set ID for set optimization - logger.info(f"[DEBUG] REJECT {item.name}: no set ID and not clothing/jewelry") + logger.debug(f"[DEBUG] REJECT {item.name}: no set ID and not clothing/jewelry") return False - - # 4. Spell overlap constraints - FIXED: Only reject if item adds no value + + # 4. Spell overlap constraints - STRICT: Reject items that don't contribute new spells if self.constraints.required_spells and item.spell_names: - # Check if item would add any beneficial spells if not self._can_get_beneficial_spell_from(item, state): - # Additional check: item might still be valuable for armor/ratings - if (item.armor_level < 300 and # Low armor - item.ratings.get('crit_damage_rating', 0) == 0 and # No crit damage - item.ratings.get('damage_rating', 0) == 0 and # No damage rating - item.set_id not in [self.constraints.primary_set, self.constraints.secondary_set]): # Not from target sets - logger.info(f"[DEBUG] REJECT {item.name}: no beneficial spells and low stats") - return False - else: - logger.info(f"[DEBUG] ACCEPT {item.name}: overlapping spells but good stats/set") - - logger.info(f"[DEBUG] ACCEPT {item.name}: passed all constraints") + # STRICT MODE: No fallback for target sets or good stats + # Items with ALL duplicate spells are rejected + # (Items with SOME new spells + some duplicates are accepted by _can_get_beneficial_spell_from) + logger.debug(f"[DEBUG] REJECT {item.name}: all spells are duplicates") + return False + + logger.debug(f"[DEBUG] ACCEPT {item.name}: passed all constraints") return True def _is_double_spell_acceptable(self, item: SuitItem, overlap: int) -> bool: @@ -1512,14 +1804,23 @@ class ConstraintSatisfactionSolver: # Determine fulfilled and missing spells fulfilled_spells = [] missing_spells = [] - + if self.constraints.required_spells: needed_bitmap = self.needed_spell_bitmap fulfilled_bitmap = state.spell_bitmap & needed_bitmap missing_bitmap = needed_bitmap & ~state.spell_bitmap - + fulfilled_spells = self.spell_index.get_spell_names(fulfilled_bitmap) missing_spells = self.spell_index.get_spell_names(missing_bitmap) + + # Add locked spells to fulfilled and remove from missing + if hasattr(self, 'locked_spells') and self.locked_spells: + for spell in self.locked_spells: + if spell in missing_spells: + missing_spells.remove(spell) + fulfilled_spells.append(spell) + elif spell not in fulfilled_spells: + fulfilled_spells.append(spell) return CompletedSuit( items=state.items.copy(), @@ -1547,36 +1848,60 @@ class ConstraintSatisfactionSolver: logger.debug(f"[SCORING] Looking for primary set: {primary_set_name} (ID: {self.constraints.primary_set})") logger.debug(f"[SCORING] Looking for secondary set: {secondary_set_name} (ID: {self.constraints.secondary_set})") - - # FIXED: set_counts uses numeric IDs, not translated names - primary_count = state.set_counts.get(self.constraints.primary_set, 0) if self.constraints.primary_set else 0 - secondary_count = state.set_counts.get(self.constraints.secondary_set, 0) if self.constraints.secondary_set else 0 - - logger.debug(f"[SCORING] Primary set {self.constraints.primary_set} has {primary_count} pieces") - logger.debug(f"[SCORING] Secondary set {self.constraints.secondary_set} has {secondary_count} pieces") - - # Complete set bonuses: +1000 for complete sets only - if primary_count >= 5: + + # Get FOUND counts (items in this suit, not including locked) + found_primary = state.set_counts.get(self.constraints.primary_set, 0) if self.constraints.primary_set else 0 + found_secondary = state.set_counts.get(self.constraints.secondary_set, 0) if self.constraints.secondary_set else 0 + + # Get locked counts for display + locked_primary = self.locked_set_counts.get(self.constraints.primary_set, 0) if hasattr(self, 'locked_set_counts') and self.constraints.primary_set else 0 + locked_secondary = self.locked_set_counts.get(self.constraints.secondary_set, 0) if hasattr(self, 'locked_set_counts') and self.constraints.secondary_set else 0 + + # Total counts (found + locked) for display + total_primary = found_primary + locked_primary + total_secondary = found_secondary + locked_secondary + + # Get effective requirements with fallback + eff_primary = getattr(self, 'effective_primary_needed', 5) + eff_secondary = getattr(self, 'effective_secondary_needed', 4) + + logger.debug(f"[SCORING] Primary: {found_primary} found + {locked_primary} locked = {total_primary} total (need {eff_primary} more)") + logger.debug(f"[SCORING] Secondary: {found_secondary} found + {locked_secondary} locked = {total_secondary} total (need {eff_secondary} more)") + + # Complete set bonuses: compare FOUND against EFFECTIVE requirements + if found_primary >= eff_primary: score += weights.armor_set_complete logger.debug(f"[SCORING] Primary set complete: +{weights.armor_set_complete}") + # Penalty for EXCESS primary pieces (should have gone to secondary) + if found_primary > eff_primary: + excess = found_primary - eff_primary + excess_penalty = excess * 500 # STRONG penalty per excess piece + score -= excess_penalty + logger.debug(f"[SCORING] Primary set EXCESS ({excess} extra): -{excess_penalty}") else: # Missing set penalty: -200 per missing piece - if self.constraints.primary_set and primary_count > 0: - missing_pieces = 5 - primary_count + if self.constraints.primary_set and found_primary > 0: + missing_pieces = eff_primary - found_primary penalty = missing_pieces * weights.missing_set_penalty score += penalty # negative penalty - logger.debug(f"[SCORING] Primary set incomplete ({primary_count}/5): {penalty}") - - if secondary_count >= 4: + logger.debug(f"[SCORING] Primary set incomplete ({found_primary}/{eff_primary}): {penalty}") + + if found_secondary >= eff_secondary: score += weights.armor_set_complete logger.debug(f"[SCORING] Secondary set complete: +{weights.armor_set_complete}") + # Penalty for EXCESS secondary pieces + if found_secondary > eff_secondary: + excess = found_secondary - eff_secondary + excess_penalty = excess * 500 # STRONG penalty + score -= excess_penalty + logger.debug(f"[SCORING] Secondary set EXCESS ({excess} extra): -{excess_penalty}") else: # Missing set penalty: -200 per missing piece - if self.constraints.secondary_set and secondary_count > 0: - missing_pieces = 4 - secondary_count + if self.constraints.secondary_set and found_secondary > 0: + missing_pieces = eff_secondary - found_secondary penalty = missing_pieces * weights.missing_set_penalty score += penalty # negative penalty - logger.debug(f"[SCORING] Secondary set incomplete ({secondary_count}/4): {penalty}") + logger.debug(f"[SCORING] Secondary set incomplete ({found_secondary}/{eff_secondary}): {penalty}") # 2. Crit Damage Rating: CD1 = +10, CD2 = +20 per piece for item in state.items.values(): @@ -1638,16 +1963,30 @@ class ConstraintSatisfactionSolver: return result def _has_room_for_armor_set(self, item: SuitItem, state: SuitState) -> bool: - """Check if adding this armor piece violates set limits (Mag-SuitBuilder HasRoomForArmorSet).""" + """Check if adding this armor piece violates set limits (Mag-SuitBuilder HasRoomForArmorSet). + + Uses effective limits which account for locked slots. + """ if not item.set_id: return True # Non-set items don't count against limits - + current_count = state.set_counts.get(item.set_id, 0) - + + # Use effective limits (which account for locked slots) + eff_primary = getattr(self, 'effective_primary_needed', 5) + eff_secondary = getattr(self, 'effective_secondary_needed', 4) + if item.set_id == self.constraints.primary_set: - return current_count < 5 + # Hard limit: don't add more primary set pieces than needed + has_room = current_count < eff_primary + if not has_room: + logger.debug(f"[SET_LIMIT] Rejecting {item.name} - already have {current_count}/{eff_primary} primary set pieces") + return has_room elif item.set_id == self.constraints.secondary_set: - return current_count < 4 + has_room = current_count < eff_secondary + if not has_room: + logger.debug(f"[SET_LIMIT] Rejecting {item.name} - already have {current_count}/{eff_secondary} secondary set pieces") + return has_room else: # STRICT: Other sets not allowed for armor # Only jewelry can be from other sets @@ -1682,35 +2021,64 @@ class ConstraintSatisfactionSolver: return name_to_id.get(set_name) def _can_get_beneficial_spell_from(self, item: SuitItem, state: SuitState) -> bool: - """Check if item provides beneficial spells (Mag-SuitBuilder CanGetBeneficialSpellFrom).""" + """Check if item provides beneficial spells without duplicates. + + STRICT MODE: Reject items that only have duplicate spells, even from target sets. + This prevents wasted spell slots (e.g., Flame Ward on 3 armor pieces). + """ + # Non-spell items are always beneficial (armor/ratings only) if not item.spell_names: - return True # Non-spell items are always beneficial for armor/ratings - - # If no spell constraints, any spell item is beneficial + return True + + # If no spell constraints specified, allow any item if not self.constraints.required_spells: return True - - # FIXED: Items are beneficial for multiple reasons, not just spells - - # 1. Items from requested armor sets are always beneficial (set completion) - if (item.set_id == self.constraints.primary_set or - item.set_id == self.constraints.secondary_set): - return True - - # 2. Items with good ratings are beneficial even if spells overlap - if (item.ratings.get('crit_damage_rating', 0) > 0 or - item.ratings.get('damage_rating', 0) > 0 or - item.armor_level > 500): # High armor items are valuable - return True - - # 3. Check if item provides any needed spells not already covered + + # STRICT: Check if item provides ANY new required spell not already covered needed_bitmap = self.needed_spell_bitmap current_bitmap = state.spell_bitmap item_bitmap = item.spell_bitmap - - # Item is beneficial if it provides any needed spell we don't have - beneficial_spells = item_bitmap & needed_bitmap & ~current_bitmap - return beneficial_spells != 0 + + # Item MUST provide at least one new required spell + new_beneficial_spells = item_bitmap & needed_bitmap & ~current_bitmap + + if new_beneficial_spells != 0: + return True # Has new required spells - beneficial + + # STRICT REJECTION: No new required spells = reject + # This applies even to primary/secondary set pieces + # Rationale: Duplicate spells waste valuable spell slots + return False + + def _jewelry_contributes_required_spell(self, item: SuitItem, state: SuitState) -> bool: + """Check if jewelry item contributes at least one required spell not already covered. + + Jewelry should ONLY be added if it fulfills spell constraints. Empty slots are + preferred over jewelry that doesn't contribute to required spells. + """ + # If no spell constraints, don't add jewelry (nothing to contribute) + if not self.constraints.required_spells: + logger.debug(f"[JEWELRY] REJECT {item.name}: no required spells specified") + return False + + # Item must have spells to contribute + if not item.spell_names: + logger.debug(f"[JEWELRY] REJECT {item.name}: item has no spells") + return False + + # Check if item has ANY required spell that's not already in the suit + needed_bitmap = self.needed_spell_bitmap + current_bitmap = state.spell_bitmap + + for spell in item.spell_names: + spell_bit = self.spell_index.get_bitmap([spell]) + # Check if this spell is required AND not already covered + if (spell_bit & needed_bitmap) and not (current_bitmap & spell_bit): + logger.debug(f"[JEWELRY] ACCEPT {item.name}: contributes uncovered spell '{spell}'") + return True + + logger.debug(f"[JEWELRY] REJECT {item.name}: no new required spells contributed") + return False # API Endpoints diff --git a/main.py b/main.py index d2821afd..f71f4dd5 100644 --- a/main.py +++ b/main.py @@ -18,7 +18,7 @@ import socket import struct from fastapi import FastAPI, Header, HTTPException, Query, WebSocket, WebSocketDisconnect, Request -from fastapi.responses import JSONResponse, Response +from fastapi.responses import JSONResponse, Response, StreamingResponse from fastapi.routing import APIRoute from fastapi.staticfiles import StaticFiles from fastapi.encoders import jsonable_encoder @@ -2268,13 +2268,50 @@ async def test_inventory_route(): """Test route to verify inventory proxy is working""" return {"message": "Inventory proxy route is working"} +@app.post("/inv/suitbuilder/search") +async def proxy_suitbuilder_search(request: Request): + """Stream suitbuilder search results - SSE requires streaming proxy.""" + inventory_service_url = os.getenv('INVENTORY_SERVICE_URL', 'http://inventory-service:8000') + logger.info(f"Streaming proxy to suitbuilder search") + + # Read body BEFORE creating generator (request context needed) + body = await request.body() + + async def stream_response(): + try: + # Use streaming request with long timeout for searches + async with httpx.AsyncClient(timeout=httpx.Timeout(300.0, connect=10.0)) as client: + async with client.stream( + method="POST", + url=f"{inventory_service_url}/suitbuilder/search", + content=body, + headers={"Content-Type": "application/json"} + ) as response: + async for chunk in response.aiter_bytes(): + yield chunk + except httpx.ReadTimeout: + yield b"event: error\ndata: {\"message\": \"Search timeout\"}\n\n" + except Exception as e: + logger.error(f"Streaming proxy error: {e}") + yield f"event: error\ndata: {{\"message\": \"Proxy error: {str(e)}\"}}\n\n".encode() + + return StreamingResponse( + stream_response(), + media_type="text/event-stream", + headers={ + "Cache-Control": "no-cache", + "Connection": "keep-alive", + "X-Accel-Buffering": "no" # Disable nginx buffering + } + ) + @app.api_route("/inv/{path:path}", methods=["GET", "POST"]) async def proxy_inventory_service(path: str, request: Request): """Proxy all inventory service requests""" try: inventory_service_url = os.getenv('INVENTORY_SERVICE_URL', 'http://inventory-service:8000') logger.info(f"Proxying to inventory service: {inventory_service_url}/{path}") - + # Forward the request to inventory service (60s timeout for large queries) async with httpx.AsyncClient(timeout=60.0) as client: response = await client.request(