Spaces:
Running
on
Zero
Running
on
Zero
Upload 18 files
Browse filesADD Find by Description function and update recommendation accuracy
- app.py +95 -14
- bonus_penalty_engine.py +596 -0
- breed_recommendation_enhanced.py +640 -0
- config_manager.py +554 -0
- constraint_manager.py +852 -0
- dimension_score_calculator.py +782 -0
- dynamic_scoring_config.py +410 -0
- multi_head_scorer.py +763 -0
- natural_language_processor.py +488 -0
- query_understanding.py +464 -0
- recommendation_formatter.py +321 -0
- recommendation_html_format.py +260 -544
- recommendation_html_formatter.py +1025 -0
- score_calibrator.py +477 -0
- score_integration_manager.py +805 -0
- scoring_calculation_system.py +0 -0
- semantic_breed_recommender.py +0 -0
- styles.py +138 -43
app.py
CHANGED
@@ -10,6 +10,11 @@ from torchvision.ops import nms, box_iou
|
|
10 |
import torch.nn.functional as F
|
11 |
from torchvision import transforms
|
12 |
from PIL import Image, ImageDraw, ImageFont, ImageFilter
|
|
|
|
|
|
|
|
|
|
|
13 |
from breed_health_info import breed_health_info
|
14 |
from breed_noise_info import breed_noise_info
|
15 |
from dog_database import get_dog_description
|
@@ -20,7 +25,7 @@ from search_history import create_history_tab, create_history_component
|
|
20 |
from styles import get_css_styles
|
21 |
from breed_detection import create_detection_tab
|
22 |
from breed_comparison import create_comparison_tab
|
23 |
-
from
|
24 |
from breed_visualization import create_visualization_tab
|
25 |
from style_transfer import DogStyleTransfer, create_style_transfer_tab
|
26 |
from html_templates import (
|
@@ -36,23 +41,24 @@ from html_templates import (
|
|
36 |
get_akc_breeds_link
|
37 |
)
|
38 |
from model_architecture import BaseModel, dog_breeds
|
39 |
-
|
40 |
-
from ultralytics import YOLO
|
41 |
-
import asyncio
|
42 |
-
import traceback
|
43 |
|
44 |
history_manager = UserHistoryManager()
|
45 |
|
46 |
class ModelManager:
|
47 |
"""
|
48 |
-
Singleton class for managing model instances and device allocation
|
49 |
specifically designed for Hugging Face Spaces deployment.
|
|
|
50 |
"""
|
51 |
_instance = None
|
52 |
_initialized = False
|
53 |
_yolo_model = None
|
54 |
_breed_model = None
|
55 |
_device = None
|
|
|
|
|
|
|
56 |
|
57 |
def __new__(cls):
|
58 |
if cls._instance is None:
|
@@ -64,6 +70,9 @@ class ModelManager:
|
|
64 |
self._device = torch.device('cuda' if torch.cuda.is_available() else 'cpu')
|
65 |
ModelManager._initialized = True
|
66 |
|
|
|
|
|
|
|
67 |
@property
|
68 |
def device(self):
|
69 |
if self._device is None:
|
@@ -83,12 +92,12 @@ class ModelManager:
|
|
83 |
num_classes=len(dog_breeds),
|
84 |
device=self.device
|
85 |
).to(self.device)
|
86 |
-
|
87 |
checkpoint = torch.load(
|
88 |
'ConvNextV2Base_best_model.pth',
|
89 |
map_location=self.device
|
90 |
)
|
91 |
-
|
92 |
# Try to load with model_state_dict first, then base_model
|
93 |
if 'model_state_dict' in checkpoint:
|
94 |
self._breed_model.load_state_dict(checkpoint['model_state_dict'], strict=False)
|
@@ -98,10 +107,81 @@ class ModelManager:
|
|
98 |
# If neither key exists, raise a descriptive error
|
99 |
available_keys = list(checkpoint.keys()) if isinstance(checkpoint, dict) else "not a dictionary"
|
100 |
raise KeyError(f"Model checkpoint does not contain 'model_state_dict' or 'base_model' keys. Available keys: {available_keys}")
|
101 |
-
|
102 |
self._breed_model.eval()
|
103 |
return self._breed_model
|
104 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
105 |
# Initialize model manager
|
106 |
model_manager = ModelManager()
|
107 |
|
@@ -197,7 +277,7 @@ def detect_multiple_dogs(image, conf_threshold=0.3, iou_threshold=0.3):
|
|
197 |
})
|
198 |
|
199 |
if not detected_boxes:
|
200 |
-
return [(image, 1, [0, 0, img_width, img_height], False)]
|
201 |
|
202 |
# Phase 2: Analysis of detection relationships
|
203 |
avg_height = sum(box['height'] for box in detected_boxes) / len(detected_boxes)
|
@@ -211,7 +291,7 @@ def detect_multiple_dogs(image, conf_threshold=0.3, iou_threshold=0.3):
|
|
211 |
y2 = min(box1['coords'][3], box2['coords'][3])
|
212 |
|
213 |
if x2 <= x1 or y2 <= y1:
|
214 |
-
return 0
|
215 |
|
216 |
intersection = (x2 - x1) * (y2 - y1)
|
217 |
area1 = box1['area']
|
@@ -328,7 +408,7 @@ def predict(image):
|
|
328 |
print(f" Is dog: {is_dog}")
|
329 |
print(f" Detection confidence: {detection_confidence:.4f}")
|
330 |
|
331 |
-
#
|
332 |
if is_dog:
|
333 |
top1_prob, topk_breeds, relative_probs = predict_single_dog(cropped_image)
|
334 |
print(f" Breed prediction - Top probability: {top1_prob:.4f}")
|
@@ -515,7 +595,8 @@ def main():
|
|
515 |
with gr.Tab("Style Transfer"):
|
516 |
style_transfer_components = create_style_transfer_tab(dog_style_transfer)
|
517 |
|
518 |
-
|
|
|
519 |
create_history_tab(history_component)
|
520 |
|
521 |
# Footer
|
@@ -549,4 +630,4 @@ def main():
|
|
549 |
|
550 |
if __name__ == "__main__":
|
551 |
iface = main()
|
552 |
-
iface.launch()
|
|
|
10 |
import torch.nn.functional as F
|
11 |
from torchvision import transforms
|
12 |
from PIL import Image, ImageDraw, ImageFont, ImageFilter
|
13 |
+
from sentence_transformers import SentenceTransformer
|
14 |
+
from urllib.parse import quote
|
15 |
+
from ultralytics import YOLO
|
16 |
+
import asyncio
|
17 |
+
import traceback
|
18 |
from breed_health_info import breed_health_info
|
19 |
from breed_noise_info import breed_noise_info
|
20 |
from dog_database import get_dog_description
|
|
|
25 |
from styles import get_css_styles
|
26 |
from breed_detection import create_detection_tab
|
27 |
from breed_comparison import create_comparison_tab
|
28 |
+
from breed_recommendation_enhanced import create_recommendation_tab
|
29 |
from breed_visualization import create_visualization_tab
|
30 |
from style_transfer import DogStyleTransfer, create_style_transfer_tab
|
31 |
from html_templates import (
|
|
|
41 |
get_akc_breeds_link
|
42 |
)
|
43 |
from model_architecture import BaseModel, dog_breeds
|
44 |
+
|
|
|
|
|
|
|
45 |
|
46 |
history_manager = UserHistoryManager()
|
47 |
|
48 |
class ModelManager:
|
49 |
"""
|
50 |
+
Enhanced Singleton class for managing model instances and device allocation
|
51 |
specifically designed for Hugging Face Spaces deployment.
|
52 |
+
Includes support for multi-dimensional recommendation system.
|
53 |
"""
|
54 |
_instance = None
|
55 |
_initialized = False
|
56 |
_yolo_model = None
|
57 |
_breed_model = None
|
58 |
_device = None
|
59 |
+
_sbert_model = None
|
60 |
+
_config_manager = None
|
61 |
+
_enhanced_system_initialized = False
|
62 |
|
63 |
def __new__(cls):
|
64 |
if cls._instance is None:
|
|
|
70 |
self._device = torch.device('cuda' if torch.cuda.is_available() else 'cpu')
|
71 |
ModelManager._initialized = True
|
72 |
|
73 |
+
# Initialize enhanced recommendation system
|
74 |
+
self._initialize_enhanced_system()
|
75 |
+
|
76 |
@property
|
77 |
def device(self):
|
78 |
if self._device is None:
|
|
|
92 |
num_classes=len(dog_breeds),
|
93 |
device=self.device
|
94 |
).to(self.device)
|
95 |
+
|
96 |
checkpoint = torch.load(
|
97 |
'ConvNextV2Base_best_model.pth',
|
98 |
map_location=self.device
|
99 |
)
|
100 |
+
|
101 |
# Try to load with model_state_dict first, then base_model
|
102 |
if 'model_state_dict' in checkpoint:
|
103 |
self._breed_model.load_state_dict(checkpoint['model_state_dict'], strict=False)
|
|
|
107 |
# If neither key exists, raise a descriptive error
|
108 |
available_keys = list(checkpoint.keys()) if isinstance(checkpoint, dict) else "not a dictionary"
|
109 |
raise KeyError(f"Model checkpoint does not contain 'model_state_dict' or 'base_model' keys. Available keys: {available_keys}")
|
110 |
+
|
111 |
self._breed_model.eval()
|
112 |
return self._breed_model
|
113 |
|
114 |
+
def _initialize_enhanced_system(self):
|
115 |
+
"""Initialize enhanced multi-dimensional recommendation system"""
|
116 |
+
if ModelManager._enhanced_system_initialized:
|
117 |
+
return
|
118 |
+
|
119 |
+
try:
|
120 |
+
# Initialize SBERT model for semantic analysis
|
121 |
+
try:
|
122 |
+
# Use default model configuration
|
123 |
+
model_name = 'all-MiniLM-L6-v2'
|
124 |
+
fallback_models = ['all-mpnet-base-v2', 'all-MiniLM-L12-v2']
|
125 |
+
|
126 |
+
for model_name_attempt in [model_name] + fallback_models:
|
127 |
+
try:
|
128 |
+
self._sbert_model = SentenceTransformer(model_name_attempt)
|
129 |
+
print(f"Initialized SBERT model: {model_name_attempt}")
|
130 |
+
break
|
131 |
+
except Exception as e:
|
132 |
+
print(f"Failed to load SBERT model {model_name_attempt}: {str(e)}")
|
133 |
+
continue
|
134 |
+
|
135 |
+
if self._sbert_model is None:
|
136 |
+
print("All SBERT models failed to load, enhanced system will use keyword-only analysis")
|
137 |
+
|
138 |
+
except Exception as e:
|
139 |
+
print(f"SBERT initialization failed: {str(e)}")
|
140 |
+
self._sbert_model = None
|
141 |
+
|
142 |
+
ModelManager._enhanced_system_initialized = True
|
143 |
+
print("Enhanced recommendation system initialization completed")
|
144 |
+
|
145 |
+
except ImportError as e:
|
146 |
+
print(f"Enhanced modules not available: {str(e)}")
|
147 |
+
ModelManager._enhanced_system_initialized = True # Mark as attempted
|
148 |
+
except Exception as e:
|
149 |
+
print(f"Enhanced system initialization failed: {str(e)}")
|
150 |
+
print(traceback.format_exc())
|
151 |
+
ModelManager._enhanced_system_initialized = True # Mark as attempted
|
152 |
+
|
153 |
+
@property
|
154 |
+
def sbert_model(self):
|
155 |
+
"""Get SBERT model for semantic analysis"""
|
156 |
+
if not ModelManager._enhanced_system_initialized:
|
157 |
+
self._initialize_enhanced_system()
|
158 |
+
return self._sbert_model
|
159 |
+
|
160 |
+
@property
|
161 |
+
def config_manager(self):
|
162 |
+
"""Get configuration manager (simplified)"""
|
163 |
+
if not ModelManager._enhanced_system_initialized:
|
164 |
+
self._initialize_enhanced_system()
|
165 |
+
return None # Simplified - no config manager needed
|
166 |
+
|
167 |
+
@property
|
168 |
+
def enhanced_system_available(self):
|
169 |
+
"""Check if enhanced recommendation system is available"""
|
170 |
+
return (ModelManager._enhanced_system_initialized and
|
171 |
+
self._sbert_model is not None)
|
172 |
+
|
173 |
+
def get_system_status(self):
|
174 |
+
"""Get status of all managed models and systems"""
|
175 |
+
return {
|
176 |
+
'device': str(self.device),
|
177 |
+
'yolo_model_loaded': self._yolo_model is not None,
|
178 |
+
'breed_model_loaded': self._breed_model is not None,
|
179 |
+
'sbert_model_loaded': self._sbert_model is not None,
|
180 |
+
'config_manager_available': False, # Simplified system
|
181 |
+
'enhanced_system_initialized': ModelManager._enhanced_system_initialized,
|
182 |
+
'enhanced_system_available': self.enhanced_system_available
|
183 |
+
}
|
184 |
+
|
185 |
# Initialize model manager
|
186 |
model_manager = ModelManager()
|
187 |
|
|
|
277 |
})
|
278 |
|
279 |
if not detected_boxes:
|
280 |
+
return [(image, 1.0, [0, 0, img_width, img_height], False)]
|
281 |
|
282 |
# Phase 2: Analysis of detection relationships
|
283 |
avg_height = sum(box['height'] for box in detected_boxes) / len(detected_boxes)
|
|
|
291 |
y2 = min(box1['coords'][3], box2['coords'][3])
|
292 |
|
293 |
if x2 <= x1 or y2 <= y1:
|
294 |
+
return 0.0
|
295 |
|
296 |
intersection = (x2 - x1) * (y2 - y1)
|
297 |
area1 = box1['area']
|
|
|
408 |
print(f" Is dog: {is_dog}")
|
409 |
print(f" Detection confidence: {detection_confidence:.4f}")
|
410 |
|
411 |
+
# 如果是狗且進行品種預測
|
412 |
if is_dog:
|
413 |
top1_prob, topk_breeds, relative_probs = predict_single_dog(cropped_image)
|
414 |
print(f" Breed prediction - Top probability: {top1_prob:.4f}")
|
|
|
595 |
with gr.Tab("Style Transfer"):
|
596 |
style_transfer_components = create_style_transfer_tab(dog_style_transfer)
|
597 |
|
598 |
+
|
599 |
+
# 6. History Search
|
600 |
create_history_tab(history_component)
|
601 |
|
602 |
# Footer
|
|
|
630 |
|
631 |
if __name__ == "__main__":
|
632 |
iface = main()
|
633 |
+
iface.launch()
|
bonus_penalty_engine.py
ADDED
@@ -0,0 +1,596 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
import math
|
2 |
+
from typing import Dict, Any
|
3 |
+
from dataclasses import dataclass
|
4 |
+
|
5 |
+
@dataclass
|
6 |
+
class UserPreferences:
|
7 |
+
"""使用者偏好設定的資料結構"""
|
8 |
+
living_space: str # "apartment", "house_small", "house_large"
|
9 |
+
yard_access: str # "no_yard", "shared_yard", "private_yard"
|
10 |
+
exercise_time: int # minutes per day
|
11 |
+
exercise_type: str # "light_walks", "moderate_activity", "active_training"
|
12 |
+
grooming_commitment: str # "low", "medium", "high"
|
13 |
+
experience_level: str # "beginner", "intermediate", "advanced"
|
14 |
+
time_availability: str # "limited", "moderate", "flexible"
|
15 |
+
has_children: bool
|
16 |
+
children_age: str # "toddler", "school_age", "teenager"
|
17 |
+
noise_tolerance: str # "low", "medium", "high"
|
18 |
+
space_for_play: bool
|
19 |
+
other_pets: bool
|
20 |
+
climate: str # "cold", "moderate", "hot"
|
21 |
+
health_sensitivity: str = "medium"
|
22 |
+
barking_acceptance: str = None
|
23 |
+
size_preference: str = "no_preference" # "no_preference", "small", "medium", "large", "giant"
|
24 |
+
training_commitment: str = "medium" # "low", "medium", "high" - 訓練投入程度
|
25 |
+
living_environment: str = "ground_floor" # "ground_floor", "with_elevator", "walk_up" - 居住環境細節
|
26 |
+
|
27 |
+
def __post_init__(self):
|
28 |
+
if self.barking_acceptance is None:
|
29 |
+
self.barking_acceptance = self.noise_tolerance
|
30 |
+
|
31 |
+
|
32 |
+
class BonusPenaltyEngine:
|
33 |
+
"""
|
34 |
+
加分扣分引擎類別
|
35 |
+
負責處理所有品種加分機制、額外評估因素和分數分布優化
|
36 |
+
"""
|
37 |
+
|
38 |
+
def __init__(self):
|
39 |
+
"""初始化加分扣分引擎"""
|
40 |
+
pass
|
41 |
+
|
42 |
+
@staticmethod
|
43 |
+
def calculate_breed_bonus(breed_info: dict, user_prefs: 'UserPreferences') -> float:
|
44 |
+
"""
|
45 |
+
計算品種額外加分
|
46 |
+
|
47 |
+
Args:
|
48 |
+
breed_info: 品種資訊字典
|
49 |
+
user_prefs: 使用者偏好設定
|
50 |
+
|
51 |
+
Returns:
|
52 |
+
float: 品種加分 (-0.25 到 0.5 之間)
|
53 |
+
"""
|
54 |
+
bonus = 0.0
|
55 |
+
temperament = breed_info.get('Temperament', '').lower()
|
56 |
+
|
57 |
+
# 1. 壽命加分(最高0.05)
|
58 |
+
try:
|
59 |
+
lifespan = breed_info.get('Lifespan', '10-12 years')
|
60 |
+
years = [int(x) for x in lifespan.split('-')[0].split()[0:1]]
|
61 |
+
longevity_bonus = min(0.05, (max(years) - 10) * 0.01)
|
62 |
+
bonus += longevity_bonus
|
63 |
+
except:
|
64 |
+
pass
|
65 |
+
|
66 |
+
# 2. 性格特徵加分(最高0.15)
|
67 |
+
positive_traits = {
|
68 |
+
'friendly': 0.05,
|
69 |
+
'gentle': 0.05,
|
70 |
+
'patient': 0.05,
|
71 |
+
'intelligent': 0.04,
|
72 |
+
'adaptable': 0.04,
|
73 |
+
'affectionate': 0.04,
|
74 |
+
'easy-going': 0.03,
|
75 |
+
'calm': 0.03
|
76 |
+
}
|
77 |
+
|
78 |
+
negative_traits = {
|
79 |
+
'aggressive': -0.08,
|
80 |
+
'stubborn': -0.06,
|
81 |
+
'dominant': -0.06,
|
82 |
+
'aloof': -0.04,
|
83 |
+
'nervous': -0.05,
|
84 |
+
'protective': -0.04
|
85 |
+
}
|
86 |
+
|
87 |
+
personality_score = sum(value for trait, value in positive_traits.items() if trait in temperament)
|
88 |
+
personality_score += sum(value for trait, value in negative_traits.items() if trait in temperament)
|
89 |
+
bonus += max(-0.15, min(0.15, personality_score))
|
90 |
+
|
91 |
+
# 3. 適應性加分(最高0.1)
|
92 |
+
adaptability_bonus = 0.0
|
93 |
+
if breed_info.get('Size') == "Small" and user_prefs.living_space == "apartment":
|
94 |
+
adaptability_bonus += 0.05
|
95 |
+
if 'adaptable' in temperament or 'versatile' in temperament:
|
96 |
+
adaptability_bonus += 0.05
|
97 |
+
bonus += min(0.1, adaptability_bonus)
|
98 |
+
|
99 |
+
# 4. 家庭相容性(最高0.15)
|
100 |
+
if user_prefs.has_children:
|
101 |
+
family_traits = {
|
102 |
+
'good with children': 0.06,
|
103 |
+
'patient': 0.05,
|
104 |
+
'gentle': 0.05,
|
105 |
+
'tolerant': 0.04,
|
106 |
+
'playful': 0.03
|
107 |
+
}
|
108 |
+
unfriendly_traits = {
|
109 |
+
'aggressive': -0.08,
|
110 |
+
'nervous': -0.07,
|
111 |
+
'protective': -0.06,
|
112 |
+
'territorial': -0.05
|
113 |
+
}
|
114 |
+
|
115 |
+
# 年齡評估
|
116 |
+
age_adjustments = {
|
117 |
+
'toddler': {'bonus_mult': 0.7, 'penalty_mult': 1.3},
|
118 |
+
'school_age': {'bonus_mult': 1.0, 'penalty_mult': 1.0},
|
119 |
+
'teenager': {'bonus_mult': 1.2, 'penalty_mult': 0.8}
|
120 |
+
}
|
121 |
+
|
122 |
+
adj = age_adjustments.get(user_prefs.children_age,
|
123 |
+
{'bonus_mult': 1.0, 'penalty_mult': 1.0})
|
124 |
+
|
125 |
+
family_bonus = sum(value for trait, value in family_traits.items()
|
126 |
+
if trait in temperament) * adj['bonus_mult']
|
127 |
+
family_penalty = sum(value for trait, value in unfriendly_traits.items()
|
128 |
+
if trait in temperament) * adj['penalty_mult']
|
129 |
+
|
130 |
+
bonus += min(0.15, max(-0.2, family_bonus + family_penalty))
|
131 |
+
|
132 |
+
# 5. 專門技能加分(最高0.1)
|
133 |
+
skill_bonus = 0.0
|
134 |
+
special_abilities = {
|
135 |
+
'working': 0.03,
|
136 |
+
'herding': 0.03,
|
137 |
+
'hunting': 0.03,
|
138 |
+
'tracking': 0.03,
|
139 |
+
'agility': 0.02
|
140 |
+
}
|
141 |
+
for ability, value in special_abilities.items():
|
142 |
+
if ability in temperament.lower():
|
143 |
+
skill_bonus += value
|
144 |
+
bonus += min(0.1, skill_bonus)
|
145 |
+
|
146 |
+
# 6. 適應性評估(增強版)
|
147 |
+
adaptability_bonus = 0.0
|
148 |
+
if breed_info.get('Size') == "Small" and user_prefs.living_space == "apartment":
|
149 |
+
adaptability_bonus += 0.08 # 小型犬更適合公寓
|
150 |
+
|
151 |
+
# 環境適應性評估
|
152 |
+
if 'adaptable' in temperament or 'versatile' in temperament:
|
153 |
+
if user_prefs.living_space == "apartment":
|
154 |
+
adaptability_bonus += 0.10 # 適應性在公寓環境更重要
|
155 |
+
else:
|
156 |
+
adaptability_bonus += 0.05 # 其他環境仍有加分
|
157 |
+
|
158 |
+
# 氣候適應性
|
159 |
+
description = breed_info.get('Description', '').lower()
|
160 |
+
climate = user_prefs.climate
|
161 |
+
if climate == 'hot':
|
162 |
+
if 'heat tolerant' in description or 'warm climate' in description:
|
163 |
+
adaptability_bonus += 0.08
|
164 |
+
elif 'thick coat' in description or 'cold climate' in description:
|
165 |
+
adaptability_bonus -= 0.10
|
166 |
+
elif climate == 'cold':
|
167 |
+
if 'thick coat' in description or 'cold climate' in description:
|
168 |
+
adaptability_bonus += 0.08
|
169 |
+
elif 'heat tolerant' in description or 'short coat' in description:
|
170 |
+
adaptability_bonus -= 0.10
|
171 |
+
|
172 |
+
bonus += min(0.15, adaptability_bonus)
|
173 |
+
|
174 |
+
return min(0.5, max(-0.25, bonus))
|
175 |
+
|
176 |
+
@staticmethod
|
177 |
+
def calculate_additional_factors(breed_info: dict, user_prefs: 'UserPreferences') -> dict:
|
178 |
+
"""
|
179 |
+
計算額外的評估因素,結合品種特性與使用者需求的全面評估系統
|
180 |
+
|
181 |
+
1. 多功能性評估 - 品種的多樣化能力
|
182 |
+
2. 訓練性評估 - 學習和服從能力
|
183 |
+
3. 能量水平評估 - 活力和運動需求
|
184 |
+
4. 美容需求評估 - 護理和維護需求
|
185 |
+
5. 社交需求評估 - 與人互動的需求程度
|
186 |
+
6. 氣候適應性 - 對環境的適應能力
|
187 |
+
7. 運動類型匹配 - 與使用者運動習慣的契合度
|
188 |
+
8. 生活方式適配 - 與使用者日常生活的匹配度
|
189 |
+
"""
|
190 |
+
factors = {
|
191 |
+
'versatility': 0.0, # 多功能性
|
192 |
+
'trainability': 0.0, # 可訓練度
|
193 |
+
'energy_level': 0.0, # 能量水平
|
194 |
+
'grooming_needs': 0.0, # 美容需求
|
195 |
+
'social_needs': 0.0, # 社交需求
|
196 |
+
'weather_adaptability': 0.0,# 氣候適應性
|
197 |
+
'exercise_match': 0.0, # 運動匹配度
|
198 |
+
'lifestyle_fit': 0.0 # 生活方式適配度
|
199 |
+
}
|
200 |
+
|
201 |
+
temperament = breed_info.get('Temperament', '').lower()
|
202 |
+
description = breed_info.get('Description', '').lower()
|
203 |
+
size = breed_info.get('Size', 'Medium')
|
204 |
+
|
205 |
+
# 1. 多功能性評估 - 加強品種用途評估
|
206 |
+
versatile_traits = {
|
207 |
+
'intelligent': 0.25,
|
208 |
+
'adaptable': 0.25,
|
209 |
+
'trainable': 0.20,
|
210 |
+
'athletic': 0.15,
|
211 |
+
'versatile': 0.15
|
212 |
+
}
|
213 |
+
|
214 |
+
working_roles = {
|
215 |
+
'working': 0.20,
|
216 |
+
'herding': 0.15,
|
217 |
+
'hunting': 0.15,
|
218 |
+
'sporting': 0.15,
|
219 |
+
'companion': 0.10
|
220 |
+
}
|
221 |
+
|
222 |
+
# 計算特質分數
|
223 |
+
trait_score = sum(value for trait, value in versatile_traits.items()
|
224 |
+
if trait in temperament)
|
225 |
+
|
226 |
+
# 計算角色分數
|
227 |
+
role_score = sum(value for role, value in working_roles.items()
|
228 |
+
if role in description)
|
229 |
+
|
230 |
+
# 根據使用者需求調整多功能性評分
|
231 |
+
purpose_traits = {
|
232 |
+
'light_walks': ['calm', 'gentle', 'easy-going'],
|
233 |
+
'moderate_activity': ['adaptable', 'balanced', 'versatile'],
|
234 |
+
'active_training': ['intelligent', 'trainable', 'working']
|
235 |
+
}
|
236 |
+
|
237 |
+
if user_prefs.exercise_type in purpose_traits:
|
238 |
+
matching_traits = sum(1 for trait in purpose_traits[user_prefs.exercise_type]
|
239 |
+
if trait in temperament)
|
240 |
+
trait_score += matching_traits * 0.15
|
241 |
+
|
242 |
+
factors['versatility'] = min(1.0, trait_score + role_score)
|
243 |
+
|
244 |
+
# 2. 訓練性評估
|
245 |
+
trainable_traits = {
|
246 |
+
'intelligent': 0.3,
|
247 |
+
'eager to please': 0.3,
|
248 |
+
'trainable': 0.2,
|
249 |
+
'quick learner': 0.2,
|
250 |
+
'obedient': 0.2
|
251 |
+
}
|
252 |
+
|
253 |
+
base_trainability = sum(value for trait, value in trainable_traits.items()
|
254 |
+
if trait in temperament)
|
255 |
+
|
256 |
+
# 根據使用者經驗調整訓練性評分
|
257 |
+
experience_multipliers = {
|
258 |
+
'beginner': 1.2, # 新手更需要容易訓練的狗
|
259 |
+
'intermediate': 1.0,
|
260 |
+
'advanced': 0.8 # 專家能處理較難訓���的狗
|
261 |
+
}
|
262 |
+
|
263 |
+
factors['trainability'] = min(1.0, base_trainability *
|
264 |
+
experience_multipliers.get(user_prefs.experience_level, 1.0))
|
265 |
+
|
266 |
+
# 3. 能量水平評估
|
267 |
+
exercise_needs = breed_info.get('Exercise Needs', 'MODERATE').upper()
|
268 |
+
energy_levels = {
|
269 |
+
'VERY HIGH': {
|
270 |
+
'score': 1.0,
|
271 |
+
'min_exercise': 120,
|
272 |
+
'ideal_exercise': 150
|
273 |
+
},
|
274 |
+
'HIGH': {
|
275 |
+
'score': 0.8,
|
276 |
+
'min_exercise': 90,
|
277 |
+
'ideal_exercise': 120
|
278 |
+
},
|
279 |
+
'MODERATE': {
|
280 |
+
'score': 0.6,
|
281 |
+
'min_exercise': 60,
|
282 |
+
'ideal_exercise': 90
|
283 |
+
},
|
284 |
+
'LOW': {
|
285 |
+
'score': 0.4,
|
286 |
+
'min_exercise': 30,
|
287 |
+
'ideal_exercise': 60
|
288 |
+
}
|
289 |
+
}
|
290 |
+
|
291 |
+
breed_energy = energy_levels.get(exercise_needs, energy_levels['MODERATE'])
|
292 |
+
|
293 |
+
# 計算運動時間匹配度
|
294 |
+
if user_prefs.exercise_time >= breed_energy['ideal_exercise']:
|
295 |
+
energy_score = breed_energy['score']
|
296 |
+
else:
|
297 |
+
# 如果運動時間不足,按比例降低分數
|
298 |
+
deficit_ratio = max(0.4, user_prefs.exercise_time / breed_energy['ideal_exercise'])
|
299 |
+
energy_score = breed_energy['score'] * deficit_ratio
|
300 |
+
|
301 |
+
factors['energy_level'] = energy_score
|
302 |
+
|
303 |
+
# 4. 美容需求評估
|
304 |
+
grooming_needs = breed_info.get('Grooming Needs', 'MODERATE').upper()
|
305 |
+
grooming_levels = {
|
306 |
+
'HIGH': 1.0,
|
307 |
+
'MODERATE': 0.6,
|
308 |
+
'LOW': 0.3
|
309 |
+
}
|
310 |
+
|
311 |
+
# 特殊毛髮類型評估
|
312 |
+
coat_adjustments = 0
|
313 |
+
if 'long coat' in description:
|
314 |
+
coat_adjustments += 0.2
|
315 |
+
if 'double coat' in description:
|
316 |
+
coat_adjustments += 0.15
|
317 |
+
if 'curly' in description:
|
318 |
+
coat_adjustments += 0.15
|
319 |
+
|
320 |
+
# 根據使用者承諾度調整
|
321 |
+
commitment_multipliers = {
|
322 |
+
'low': 1.5, # 低承諾度時加重美容需求的影響
|
323 |
+
'medium': 1.0,
|
324 |
+
'high': 0.8 # 高承諾度時降低美容需求的影響
|
325 |
+
}
|
326 |
+
|
327 |
+
base_grooming = grooming_levels.get(grooming_needs, 0.6) + coat_adjustments
|
328 |
+
factors['grooming_needs'] = min(1.0, base_grooming *
|
329 |
+
commitment_multipliers.get(user_prefs.grooming_commitment, 1.0))
|
330 |
+
|
331 |
+
# 5. 社交需求評估
|
332 |
+
social_traits = {
|
333 |
+
'friendly': 0.25,
|
334 |
+
'social': 0.25,
|
335 |
+
'affectionate': 0.20,
|
336 |
+
'people-oriented': 0.20
|
337 |
+
}
|
338 |
+
|
339 |
+
antisocial_traits = {
|
340 |
+
'independent': -0.20,
|
341 |
+
'aloof': -0.20,
|
342 |
+
'reserved': -0.15
|
343 |
+
}
|
344 |
+
|
345 |
+
social_score = sum(value for trait, value in social_traits.items()
|
346 |
+
if trait in temperament)
|
347 |
+
antisocial_score = sum(value for trait, value in antisocial_traits.items()
|
348 |
+
if trait in temperament)
|
349 |
+
|
350 |
+
# 家庭情況調整
|
351 |
+
if user_prefs.has_children:
|
352 |
+
child_friendly_bonus = 0.2 if 'good with children' in temperament else 0
|
353 |
+
social_score += child_friendly_bonus
|
354 |
+
|
355 |
+
factors['social_needs'] = min(1.0, max(0.0, social_score + antisocial_score))
|
356 |
+
|
357 |
+
# 6. 氣候適應性評估 - 更細緻的環境適應評估
|
358 |
+
climate_traits = {
|
359 |
+
'cold': {
|
360 |
+
'positive': ['thick coat', 'winter', 'cold climate'],
|
361 |
+
'negative': ['short coat', 'heat sensitive']
|
362 |
+
},
|
363 |
+
'hot': {
|
364 |
+
'positive': ['short coat', 'heat tolerant', 'warm climate'],
|
365 |
+
'negative': ['thick coat', 'cold climate']
|
366 |
+
},
|
367 |
+
'moderate': {
|
368 |
+
'positive': ['adaptable', 'all climate'],
|
369 |
+
'negative': []
|
370 |
+
}
|
371 |
+
}
|
372 |
+
|
373 |
+
climate_score = 0.4 # 基礎分數
|
374 |
+
if user_prefs.climate in climate_traits:
|
375 |
+
# 正面特質加分
|
376 |
+
climate_score += sum(0.2 for term in climate_traits[user_prefs.climate]['positive']
|
377 |
+
if term in description)
|
378 |
+
# 負面特質減分
|
379 |
+
climate_score -= sum(0.2 for term in climate_traits[user_prefs.climate]['negative']
|
380 |
+
if term in description)
|
381 |
+
|
382 |
+
factors['weather_adaptability'] = min(1.0, max(0.0, climate_score))
|
383 |
+
|
384 |
+
# 7. 運動類型匹配評估
|
385 |
+
exercise_type_traits = {
|
386 |
+
'light_walks': ['calm', 'gentle'],
|
387 |
+
'moderate_activity': ['adaptable', 'balanced'],
|
388 |
+
'active_training': ['athletic', 'energetic']
|
389 |
+
}
|
390 |
+
|
391 |
+
if user_prefs.exercise_type in exercise_type_traits:
|
392 |
+
match_score = sum(0.25 for trait in exercise_type_traits[user_prefs.exercise_type]
|
393 |
+
if trait in temperament)
|
394 |
+
factors['exercise_match'] = min(1.0, match_score + 0.5) # 基礎分0.5
|
395 |
+
|
396 |
+
# 8. 生活方式適配評估
|
397 |
+
lifestyle_score = 0.5 # 基礎分數
|
398 |
+
|
399 |
+
# 空間適配
|
400 |
+
if user_prefs.living_space == 'apartment':
|
401 |
+
if size == 'Small':
|
402 |
+
lifestyle_score += 0.2
|
403 |
+
elif size == 'Large':
|
404 |
+
lifestyle_score -= 0.2
|
405 |
+
elif user_prefs.living_space == 'house_large':
|
406 |
+
if size in ['Large', 'Giant']:
|
407 |
+
lifestyle_score += 0.2
|
408 |
+
|
409 |
+
# 時間可用性適配
|
410 |
+
time_availability_bonus = {
|
411 |
+
'limited': -0.1,
|
412 |
+
'moderate': 0,
|
413 |
+
'flexible': 0.1
|
414 |
+
}
|
415 |
+
lifestyle_score += time_availability_bonus.get(user_prefs.time_availability, 0)
|
416 |
+
|
417 |
+
factors['lifestyle_fit'] = min(1.0, max(0.0, lifestyle_score))
|
418 |
+
|
419 |
+
return factors
|
420 |
+
|
421 |
+
def amplify_score_extreme(self, score: float) -> float:
|
422 |
+
"""
|
423 |
+
優化分數分布,提供更有意義的評分範圍。
|
424 |
+
純粹進行數學轉換,不依賴外部資訊。
|
425 |
+
|
426 |
+
Parameters:
|
427 |
+
score: 原始評分(0-1之間的浮點數)
|
428 |
+
|
429 |
+
Returns:
|
430 |
+
float: 調整後的評分(0-1之間的浮點數)
|
431 |
+
"""
|
432 |
+
def smooth_curve(x: float, steepness: float = 12) -> float:
|
433 |
+
"""創建平滑的S型曲線用於分數轉換"""
|
434 |
+
return 1 / (1 + math.exp(-steepness * (x - 0.5)))
|
435 |
+
|
436 |
+
# 90-100分的轉換(極佳匹配)
|
437 |
+
if score >= 0.90:
|
438 |
+
position = (score - 0.90) / 0.10
|
439 |
+
return 0.96 + (position * 0.04)
|
440 |
+
|
441 |
+
# 80-90分的轉換(優秀匹配)
|
442 |
+
elif score >= 0.80:
|
443 |
+
position = (score - 0.80) / 0.10
|
444 |
+
return 0.90 + (position * 0.06)
|
445 |
+
|
446 |
+
# 70-80分的轉換(良好匹配)
|
447 |
+
elif score >= 0.70:
|
448 |
+
position = (score - 0.70) / 0.10
|
449 |
+
return 0.82 + (position * 0.08)
|
450 |
+
|
451 |
+
# 50-70分的轉換(可接受匹配)
|
452 |
+
elif score >= 0.50:
|
453 |
+
position = (score - 0.50) / 0.20
|
454 |
+
return 0.75 + (smooth_curve(position) * 0.07)
|
455 |
+
|
456 |
+
# 50分以下的轉換(較差匹配)
|
457 |
+
else:
|
458 |
+
position = score / 0.50
|
459 |
+
return 0.70 + (smooth_curve(position) * 0.05)
|
460 |
+
|
461 |
+
def apply_special_case_adjustments(self, score: float, user_prefs: UserPreferences, breed_info: dict) -> float:
|
462 |
+
"""
|
463 |
+
處理特殊情況和極端案例的評分調整。這個函數特別關注:
|
464 |
+
1. 條件組合的協同效應
|
465 |
+
2. 品種特性的獨特需求
|
466 |
+
3. 極端情況的合理處理
|
467 |
+
|
468 |
+
這個函數就像是一個細心的裁判,會考慮到各種特殊情況,
|
469 |
+
並根據具體場景做出合理的評分調整。
|
470 |
+
|
471 |
+
Parameters:
|
472 |
+
score: 初始評分
|
473 |
+
user_prefs: 使用者偏好
|
474 |
+
breed_info: 品種資訊
|
475 |
+
Returns:
|
476 |
+
float: 調整後的評分(0.2-1.0之間)
|
477 |
+
"""
|
478 |
+
severity_multiplier = 1.0
|
479 |
+
|
480 |
+
def evaluate_spatial_exercise_combination() -> float:
|
481 |
+
"""
|
482 |
+
評估空間與運動需求的組合效應。
|
483 |
+
|
484 |
+
這個函數不再過分懲罰大型犬,而是更多地考慮品種的實際特性。
|
485 |
+
就像評估一個運動員是否適合在特定場地訓練一樣,我們需要考慮
|
486 |
+
場地大小和運動需求的整體匹配度。
|
487 |
+
"""
|
488 |
+
multiplier = 1.0
|
489 |
+
|
490 |
+
if user_prefs.living_space == 'apartment':
|
491 |
+
temperament = breed_info.get('Temperament', '').lower()
|
492 |
+
description = breed_info.get('Description', '').lower()
|
493 |
+
|
494 |
+
# 檢查品種是否有利於公寓生活的特徵
|
495 |
+
apartment_friendly = any(trait in temperament or trait in description
|
496 |
+
for trait in ['calm', 'adaptable', 'quiet'])
|
497 |
+
|
498 |
+
# 大型犬的特殊處理
|
499 |
+
if breed_info['Size'] in ['Large', 'Giant']:
|
500 |
+
if apartment_friendly:
|
501 |
+
multiplier *= 0.85 # 從0.7提升到0.85,降低懲罰
|
502 |
+
else:
|
503 |
+
multiplier *= 0.75 # 從0.5提升到0.75
|
504 |
+
|
505 |
+
# 檢查運動需求的匹配度
|
506 |
+
exercise_needs = breed_info.get('Exercise Needs', 'MODERATE').upper()
|
507 |
+
exercise_time = user_prefs.exercise_time
|
508 |
+
|
509 |
+
if exercise_needs in ['HIGH', 'VERY HIGH']:
|
510 |
+
if exercise_time >= 120: # 高運動量可以部分補償空間限制
|
511 |
+
multiplier *= 1.1
|
512 |
+
|
513 |
+
return multiplier
|
514 |
+
|
515 |
+
def evaluate_experience_combination() -> float:
|
516 |
+
"""
|
517 |
+
評估經驗需求的複合影響。
|
518 |
+
|
519 |
+
這個函數就像是評估一個工作崗位與應聘者經驗的匹配度,
|
520 |
+
需要綜合考慮工作難度和應聘者能力。
|
521 |
+
"""
|
522 |
+
multiplier = 1.0
|
523 |
+
temperament = breed_info.get('Temperament', '').lower()
|
524 |
+
care_level = breed_info.get('Care Level', 'MODERATE')
|
525 |
+
|
526 |
+
# 新手飼主的特殊考慮,更寬容的評估標準
|
527 |
+
if user_prefs.experience_level == 'beginner':
|
528 |
+
if care_level == 'HIGH':
|
529 |
+
if user_prefs.has_children:
|
530 |
+
multiplier *= 0.7 # 從0.5提升到0.7
|
531 |
+
else:
|
532 |
+
multiplier *= 0.8 # 從0.6提升到0.8
|
533 |
+
|
534 |
+
# 性格特徵影響,降低懲罰程度
|
535 |
+
challenging_traits = {
|
536 |
+
'stubborn': -0.10, # 從-0.15降低
|
537 |
+
'independent': -0.08, # 從-0.12降低
|
538 |
+
'dominant': -0.08, # 從-0.12降低
|
539 |
+
'protective': -0.06, # 從-0.10降低
|
540 |
+
'aggressive': -0.15 # 保持較高懲罰因安全考慮
|
541 |
+
}
|
542 |
+
|
543 |
+
for trait, penalty in challenging_traits.items():
|
544 |
+
if trait in temperament:
|
545 |
+
multiplier *= (1 + penalty)
|
546 |
+
|
547 |
+
return multiplier
|
548 |
+
|
549 |
+
def evaluate_breed_specific_requirements() -> float:
|
550 |
+
"""
|
551 |
+
評估品種特定需求。
|
552 |
+
|
553 |
+
這個函數就像是為每個品種量身定制評估標準,
|
554 |
+
考慮其獨特的特性和需求。
|
555 |
+
"""
|
556 |
+
multiplier = 1.0
|
557 |
+
exercise_time = user_prefs.exercise_time
|
558 |
+
exercise_type = user_prefs.exercise_type
|
559 |
+
|
560 |
+
# 檢查品種特性
|
561 |
+
temperament = breed_info.get('Temperament', '').lower()
|
562 |
+
description = breed_info.get('Description', '').lower()
|
563 |
+
exercise_needs = breed_info.get('Exercise Needs', 'MODERATE').upper()
|
564 |
+
|
565 |
+
# 運動需求匹配度評估,更合理的標準
|
566 |
+
if exercise_needs == 'LOW':
|
567 |
+
if exercise_time > 120:
|
568 |
+
multiplier *= 0.85 # 從0.5提升到0.85
|
569 |
+
elif exercise_needs == 'VERY HIGH':
|
570 |
+
if exercise_time < 60:
|
571 |
+
multiplier *= 0.7 # 從0.5提升到0.7
|
572 |
+
|
573 |
+
# 特殊品種類型的考慮
|
574 |
+
if 'sprint' in temperament:
|
575 |
+
if exercise_time > 120 and exercise_type != 'active_training':
|
576 |
+
multiplier *= 0.85 # 從0.7提升到0.85
|
577 |
+
|
578 |
+
if any(trait in temperament for trait in ['working', 'herding']):
|
579 |
+
if exercise_time < 90 or exercise_type == 'light_walks':
|
580 |
+
multiplier *= 0.8 # 從0.7提升到0.8
|
581 |
+
|
582 |
+
return multiplier
|
583 |
+
|
584 |
+
# 計算各項調整
|
585 |
+
space_exercise_mult = evaluate_spatial_exercise_combination()
|
586 |
+
experience_mult = evaluate_experience_combination()
|
587 |
+
breed_specific_mult = evaluate_breed_specific_requirements()
|
588 |
+
|
589 |
+
# 整合所有調整因素
|
590 |
+
severity_multiplier *= space_exercise_mult
|
591 |
+
severity_multiplier *= experience_mult
|
592 |
+
severity_multiplier *= breed_specific_mult
|
593 |
+
|
594 |
+
# 應用最終調整,確保分數在合理範圍內
|
595 |
+
final_score = score * severity_multiplier
|
596 |
+
return max(0.2, min(1.0, final_score))
|
breed_recommendation_enhanced.py
ADDED
@@ -0,0 +1,640 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
import gradio as gr
|
2 |
+
from typing import Dict, List, Any, Optional
|
3 |
+
import traceback
|
4 |
+
from semantic_breed_recommender import get_breed_recommendations_by_description, get_enhanced_recommendations_with_unified_scoring
|
5 |
+
from natural_language_processor import get_nlp_processor
|
6 |
+
from recommendation_html_format import format_unified_recommendation_html
|
7 |
+
|
8 |
+
def create_description_examples():
|
9 |
+
"""Create HTML for description examples with dynamic visibility"""
|
10 |
+
return """
|
11 |
+
<div style='
|
12 |
+
background: linear-gradient(135deg, rgba(66, 153, 225, 0.1) 0%, rgba(72, 187, 120, 0.1) 100%);
|
13 |
+
border-radius: 12px;
|
14 |
+
padding: 20px;
|
15 |
+
margin: 15px 0;
|
16 |
+
border-left: 4px solid #4299e1;
|
17 |
+
display: block;
|
18 |
+
'>
|
19 |
+
<h4 style='
|
20 |
+
color: #2d3748;
|
21 |
+
margin: 0 0 15px 0;
|
22 |
+
font-size: 1.1em;
|
23 |
+
font-weight: 600;
|
24 |
+
'>💡 Example Descriptions - Try These Expression Styles:</h4>
|
25 |
+
|
26 |
+
<div style='
|
27 |
+
display: grid;
|
28 |
+
grid-template-columns: 1fr 1fr;
|
29 |
+
gap: 15px;
|
30 |
+
margin-top: 10px;
|
31 |
+
'>
|
32 |
+
<div style='
|
33 |
+
background: white;
|
34 |
+
padding: 12px;
|
35 |
+
border-radius: 8px;
|
36 |
+
border: 1px solid #e2e8f0;
|
37 |
+
box-shadow: 0 1px 3px rgba(0,0,0,0.1);
|
38 |
+
'>
|
39 |
+
<strong style='color: #4299e1;'>🏠 Living Environment:</strong><br>
|
40 |
+
<span style='color: #4a5568; font-size: 0.9em;'>
|
41 |
+
"I live in an apartment and need a quiet, small dog that's good with children"
|
42 |
+
</span>
|
43 |
+
</div>
|
44 |
+
|
45 |
+
<div style='
|
46 |
+
background: white;
|
47 |
+
padding: 12px;
|
48 |
+
border-radius: 8px;
|
49 |
+
border: 1px solid #e2e8f0;
|
50 |
+
box-shadow: 0 1px 3px rgba(0,0,0,0.1);
|
51 |
+
'>
|
52 |
+
<strong style='color: #48bb78;'>🎾 Activity Preferences:</strong><br>
|
53 |
+
<span style='color: #4a5568; font-size: 0.9em;'>
|
54 |
+
"I want an active medium to large dog for hiking and outdoor activities"
|
55 |
+
</span>
|
56 |
+
</div>
|
57 |
+
|
58 |
+
<div style='
|
59 |
+
background: white;
|
60 |
+
padding: 12px;
|
61 |
+
border-radius: 8px;
|
62 |
+
border: 1px solid #e2e8f0;
|
63 |
+
box-shadow: 0 1px 3px rgba(0,0,0,0.1);
|
64 |
+
'>
|
65 |
+
<strong style='color: #ed8936;'>❤️ Breed Preferences:</strong><br>
|
66 |
+
<span style='color: #4a5568; font-size: 0.9em;'>
|
67 |
+
"I love Border Collies most, then Golden Retrievers, followed by Pugs"
|
68 |
+
</span>
|
69 |
+
</div>
|
70 |
+
|
71 |
+
<div style='
|
72 |
+
background: white;
|
73 |
+
padding: 12px;
|
74 |
+
border-radius: 8px;
|
75 |
+
border: 1px solid #e2e8f0;
|
76 |
+
box-shadow: 0 1px 3px rgba(0,0,0,0.1);
|
77 |
+
'>
|
78 |
+
<strong style='color: #9f7aea;'>👥 Family Situation:</strong><br>
|
79 |
+
<span style='color: #4a5568; font-size: 0.9em;'>
|
80 |
+
"Looking for a calm, low-maintenance companion dog for elderly person"
|
81 |
+
</span>
|
82 |
+
</div>
|
83 |
+
</div>
|
84 |
+
|
85 |
+
<div style='
|
86 |
+
margin-top: 15px;
|
87 |
+
padding: 12px;
|
88 |
+
background: rgba(255, 255, 255, 0.8);
|
89 |
+
border-radius: 8px;
|
90 |
+
border: 1px solid #e2e8f0;
|
91 |
+
'>
|
92 |
+
<strong style='color: #2d3748;'>🔍 Tips:</strong>
|
93 |
+
<span style='color: #4a5568; font-size: 0.9em;'>
|
94 |
+
Please describe in English, including living environment, preferred breeds, family situation, activity needs, etc. The more detailed your description, the more accurate the recommendations!
|
95 |
+
</span>
|
96 |
+
</div>
|
97 |
+
</div>
|
98 |
+
"""
|
99 |
+
|
100 |
+
|
101 |
+
def create_recommendation_tab(
|
102 |
+
UserPreferences,
|
103 |
+
get_breed_recommendations,
|
104 |
+
format_recommendation_html,
|
105 |
+
history_component
|
106 |
+
):
|
107 |
+
"""Create the enhanced breed recommendation tab with natural language support"""
|
108 |
+
|
109 |
+
with gr.TabItem("Breed Recommendation"):
|
110 |
+
with gr.Tabs():
|
111 |
+
# --------------------------
|
112 |
+
# Tab 1: Find by Criteria
|
113 |
+
# --------------------------
|
114 |
+
with gr.Tab("Find by Criteria"):
|
115 |
+
gr.HTML("""
|
116 |
+
<div style='
|
117 |
+
text-align: center;
|
118 |
+
position: relative;
|
119 |
+
padding: 20px 0;
|
120 |
+
margin: 15px 0;
|
121 |
+
background: linear-gradient(to right, rgba(66, 153, 225, 0.1), rgba(72, 187, 120, 0.1));
|
122 |
+
border-radius: 10px;
|
123 |
+
'>
|
124 |
+
|
125 |
+
<p style='
|
126 |
+
font-size: 1.2em;
|
127 |
+
margin: 0;
|
128 |
+
padding: 0 20px;
|
129 |
+
line-height: 1.5;
|
130 |
+
background: linear-gradient(90deg, #4299e1, #48bb78);
|
131 |
+
-webkit-background-clip: text;
|
132 |
+
-webkit-text-fill-color: transparent;
|
133 |
+
font-weight: 600;
|
134 |
+
'>
|
135 |
+
Tell us about your lifestyle, and we'll recommend the perfect dog breeds for you!
|
136 |
+
</p>
|
137 |
+
<div style='
|
138 |
+
margin-top: 15px;
|
139 |
+
padding: 10px 20px;
|
140 |
+
background: linear-gradient(to right, rgba(66, 153, 225, 0.15), rgba(72, 187, 120, 0.15));
|
141 |
+
border-radius: 8px;
|
142 |
+
font-size: 0.9em;
|
143 |
+
color: #2D3748;
|
144 |
+
display: flex;
|
145 |
+
align-items: center;
|
146 |
+
justify-content: center;
|
147 |
+
gap: 8px;
|
148 |
+
'>
|
149 |
+
<span style="font-size: 1.2em;">🔬</span>
|
150 |
+
<span style="
|
151 |
+
letter-spacing: 0.3px;
|
152 |
+
line-height: 1.4;
|
153 |
+
">The matching algorithm is continuously improving. Results are for reference only.</span>
|
154 |
+
</div>
|
155 |
+
</div>
|
156 |
+
""")
|
157 |
+
|
158 |
+
with gr.Row():
|
159 |
+
with gr.Column():
|
160 |
+
living_space = gr.Radio(
|
161 |
+
choices=["apartment", "house_small", "house_large"],
|
162 |
+
label="What type of living space do you have?",
|
163 |
+
info="Choose your current living situation",
|
164 |
+
value="apartment"
|
165 |
+
)
|
166 |
+
|
167 |
+
yard_access = gr.Radio(
|
168 |
+
choices=["no_yard", "shared_yard", "private_yard"],
|
169 |
+
label="Yard Access Type",
|
170 |
+
info="Available outdoor space",
|
171 |
+
value="no_yard"
|
172 |
+
)
|
173 |
+
|
174 |
+
exercise_time = gr.Slider(
|
175 |
+
minimum=0,
|
176 |
+
maximum=180,
|
177 |
+
value=60,
|
178 |
+
label="Daily exercise time (minutes)",
|
179 |
+
info="Consider walks, play time, and training"
|
180 |
+
)
|
181 |
+
|
182 |
+
exercise_type = gr.Radio(
|
183 |
+
choices=["light_walks", "moderate_activity", "active_training"],
|
184 |
+
label="Exercise Style",
|
185 |
+
info="What kind of activities do you prefer?",
|
186 |
+
value="moderate_activity"
|
187 |
+
)
|
188 |
+
|
189 |
+
grooming_commitment = gr.Radio(
|
190 |
+
choices=["low", "medium", "high"],
|
191 |
+
label="Grooming commitment level",
|
192 |
+
info="Low: monthly, Medium: weekly, High: daily",
|
193 |
+
value="medium"
|
194 |
+
)
|
195 |
+
|
196 |
+
with gr.Column():
|
197 |
+
size_preference = gr.Radio(
|
198 |
+
choices=["no_preference", "small", "medium", "large", "giant"],
|
199 |
+
label="Preference Dog Size",
|
200 |
+
info="Select your preferred dog size - this will strongly filter the recommendations",
|
201 |
+
value="no_preference"
|
202 |
+
)
|
203 |
+
experience_level = gr.Radio(
|
204 |
+
choices=["beginner", "intermediate", "advanced"],
|
205 |
+
label="Dog ownership experience",
|
206 |
+
info="Be honest - this helps find the right match",
|
207 |
+
value="beginner"
|
208 |
+
)
|
209 |
+
|
210 |
+
time_availability = gr.Radio(
|
211 |
+
choices=["limited", "moderate", "flexible"],
|
212 |
+
label="Time Availability",
|
213 |
+
info="Time available for dog care daily",
|
214 |
+
value="moderate"
|
215 |
+
)
|
216 |
+
|
217 |
+
has_children = gr.Checkbox(
|
218 |
+
label="Have children at home",
|
219 |
+
info="Helps recommend child-friendly breeds"
|
220 |
+
)
|
221 |
+
|
222 |
+
children_age = gr.Radio(
|
223 |
+
choices=["toddler", "school_age", "teenager"],
|
224 |
+
label="Children's Age Group",
|
225 |
+
info="Helps match with age-appropriate breeds",
|
226 |
+
visible=False
|
227 |
+
)
|
228 |
+
|
229 |
+
noise_tolerance = gr.Radio(
|
230 |
+
choices=["low", "medium", "high"],
|
231 |
+
label="Noise tolerance level",
|
232 |
+
info="Some breeds are more vocal than others",
|
233 |
+
value="medium"
|
234 |
+
)
|
235 |
+
|
236 |
+
def update_children_age_visibility(has_children_val):
|
237 |
+
"""Update children age visibility based on has_children checkbox"""
|
238 |
+
return gr.update(visible=has_children_val)
|
239 |
+
|
240 |
+
has_children.change(
|
241 |
+
fn=update_children_age_visibility,
|
242 |
+
inputs=[has_children],
|
243 |
+
outputs=[children_age]
|
244 |
+
)
|
245 |
+
|
246 |
+
# --------- 條件搜尋---------
|
247 |
+
def find_breed_matches(
|
248 |
+
living_space, yard_access, exercise_time, exercise_type,
|
249 |
+
grooming_commitment, size_preference, experience_level,
|
250 |
+
time_availability, has_children, children_age, noise_tolerance
|
251 |
+
):
|
252 |
+
"""Process criteria-based breed matching and persist history"""
|
253 |
+
try:
|
254 |
+
# 1) 建立偏好
|
255 |
+
user_prefs = UserPreferences(
|
256 |
+
living_space=living_space,
|
257 |
+
yard_access=yard_access,
|
258 |
+
exercise_time=exercise_time,
|
259 |
+
exercise_type=exercise_type,
|
260 |
+
grooming_commitment=grooming_commitment,
|
261 |
+
size_preference=size_preference,
|
262 |
+
experience_level=experience_level,
|
263 |
+
time_availability=time_availability,
|
264 |
+
has_children=has_children,
|
265 |
+
children_age=children_age if has_children else None,
|
266 |
+
noise_tolerance=noise_tolerance,
|
267 |
+
# 其他欄位依原始設計
|
268 |
+
space_for_play=(living_space != "apartment"),
|
269 |
+
other_pets=False,
|
270 |
+
climate="moderate",
|
271 |
+
health_sensitivity="medium",
|
272 |
+
barking_acceptance=noise_tolerance
|
273 |
+
)
|
274 |
+
|
275 |
+
# 2) 取得推薦
|
276 |
+
recommendations = get_breed_recommendations(user_prefs)
|
277 |
+
print(f"[CRITERIA] generated={len(recommendations) if recommendations else 0}")
|
278 |
+
|
279 |
+
if not recommendations:
|
280 |
+
return format_recommendation_html([], is_description_search=False)
|
281 |
+
|
282 |
+
# 3) 準備歷史資料(final_score / overall_score 同步)
|
283 |
+
history_results = []
|
284 |
+
for idx, rec in enumerate(recommendations, start=1):
|
285 |
+
final_score = rec.get("final_score", rec.get("overall_score", 0))
|
286 |
+
overall_score = final_score # Ensure consistency
|
287 |
+
history_results.append({
|
288 |
+
"breed": rec.get("breed", "Unknown"),
|
289 |
+
"rank": rec.get("rank", idx),
|
290 |
+
"final_score": final_score,
|
291 |
+
"overall_score": overall_score,
|
292 |
+
"base_score": rec.get("base_score", 0),
|
293 |
+
"bonus_score": rec.get("bonus_score", 0),
|
294 |
+
"scores": rec.get("scores", {})
|
295 |
+
})
|
296 |
+
|
297 |
+
prefs_dict = user_prefs.__dict__ if hasattr(user_prefs, "__dict__") else user_prefs
|
298 |
+
|
299 |
+
# 4) 寫入歷史(criteria)
|
300 |
+
try:
|
301 |
+
ok = history_component.save_search(
|
302 |
+
user_preferences=prefs_dict,
|
303 |
+
results=history_results,
|
304 |
+
search_type="criteria",
|
305 |
+
description=None
|
306 |
+
)
|
307 |
+
print(f"[CRITERIA SAVE] ok={ok}, saved={len(history_results)}")
|
308 |
+
except Exception as e:
|
309 |
+
print(f"[CRITERIA SAVE][ERROR] {str(e)}")
|
310 |
+
|
311 |
+
# 5) 顯示結果
|
312 |
+
return format_recommendation_html(recommendations, is_description_search=False)
|
313 |
+
|
314 |
+
except Exception as e:
|
315 |
+
print(f"[CRITERIA][ERROR] {str(e)}")
|
316 |
+
print(traceback.format_exc())
|
317 |
+
return f"""
|
318 |
+
<div style="text-align: center; padding: 20px; color: #e53e3e;">
|
319 |
+
<h3>⚠️ Error generating recommendations</h3>
|
320 |
+
<p>We encountered an issue while processing your preferences.</p>
|
321 |
+
<p><strong>Error details:</strong> {str(e)}</p>
|
322 |
+
</div>
|
323 |
+
"""
|
324 |
+
|
325 |
+
find_button = gr.Button("🔍 Find My Perfect Match!", elem_id="find-match-btn", size="lg")
|
326 |
+
criteria_results = gr.HTML(label="Breed Recommendations")
|
327 |
+
find_button.click(
|
328 |
+
fn=find_breed_matches,
|
329 |
+
inputs=[living_space, yard_access, exercise_time, exercise_type,
|
330 |
+
grooming_commitment, size_preference, experience_level,
|
331 |
+
time_availability, has_children, children_age, noise_tolerance],
|
332 |
+
outputs=criteria_results
|
333 |
+
)
|
334 |
+
|
335 |
+
# --------------------------
|
336 |
+
# Tab 2: Find by Description
|
337 |
+
# --------------------------
|
338 |
+
with gr.Tab("Find by Description") as description_tab:
|
339 |
+
gr.HTML("""
|
340 |
+
<div style='
|
341 |
+
text-align: center;
|
342 |
+
position: relative;
|
343 |
+
padding: 20px 0;
|
344 |
+
margin: 15px 0;
|
345 |
+
background: linear-gradient(to right, rgba(66, 153, 225, 0.1), rgba(72, 187, 120, 0.1));
|
346 |
+
border-radius: 10px;
|
347 |
+
'>
|
348 |
+
<div style='
|
349 |
+
position: absolute;
|
350 |
+
top: 10px;
|
351 |
+
right: 20px;
|
352 |
+
background: linear-gradient(90deg, #ff6b6b, #feca57);
|
353 |
+
color: white;
|
354 |
+
padding: 4px 12px;
|
355 |
+
border-radius: 15px;
|
356 |
+
font-size: 0.85em;
|
357 |
+
font-weight: 600;
|
358 |
+
letter-spacing: 1px;
|
359 |
+
box-shadow: 0 2px 4px rgba(0,0,0,0.1);
|
360 |
+
'>NEW</div>
|
361 |
+
<p style='
|
362 |
+
font-size: 1.2em;
|
363 |
+
margin: 0;
|
364 |
+
padding: 0 20px;
|
365 |
+
line-height: 1.5;
|
366 |
+
background: linear-gradient(90deg, #4299e1, #48bb78);
|
367 |
+
-webkit-background-clip: text;
|
368 |
+
-webkit-text-fill-color: transparent;
|
369 |
+
font-weight: 600;
|
370 |
+
'>
|
371 |
+
Describe your needs in natural language, and AI will find the most suitable breeds!
|
372 |
+
</p>
|
373 |
+
<div style='
|
374 |
+
margin-top: 15px;
|
375 |
+
padding: 10px 20px;
|
376 |
+
background: linear-gradient(to right, rgba(255, 107, 107, 0.15), rgba(254, 202, 87, 0.15));
|
377 |
+
border-radius: 8px;
|
378 |
+
font-size: 0.9em;
|
379 |
+
color: #2D3748;
|
380 |
+
display: flex;
|
381 |
+
align-items: center;
|
382 |
+
justify-content: center;
|
383 |
+
gap: 8px;
|
384 |
+
'>
|
385 |
+
<span style="font-size: 1.2em;">🚀</span>
|
386 |
+
<span style="
|
387 |
+
letter-spacing: 0.3px;
|
388 |
+
line-height: 1.4;
|
389 |
+
"><strong>New Feature:</strong> Based on advanced semantic understanding technology, making search more aligned with your real needs!</span>
|
390 |
+
</div>
|
391 |
+
</div>
|
392 |
+
""")
|
393 |
+
|
394 |
+
examples_display = gr.HTML(create_description_examples())
|
395 |
+
|
396 |
+
description_input = gr.Textbox(
|
397 |
+
label="🗣️ Please describe your needs",
|
398 |
+
placeholder=("Example: I live in an apartment and need a quiet, small dog that's good with children. "
|
399 |
+
"I prefer Border Collies and Golden Retrievers..."),
|
400 |
+
lines=4,
|
401 |
+
max_lines=6,
|
402 |
+
elem_classes=["description-input"]
|
403 |
+
)
|
404 |
+
|
405 |
+
validation_status = gr.HTML(visible=False)
|
406 |
+
|
407 |
+
# Accuracy disclaimer
|
408 |
+
gr.HTML("""
|
409 |
+
<div style='
|
410 |
+
background: linear-gradient(135deg, rgba(34, 197, 94, 0.08) 0%, rgba(59, 130, 246, 0.08) 100%);
|
411 |
+
border: 1px solid rgba(34, 197, 94, 0.2);
|
412 |
+
border-radius: 10px;
|
413 |
+
padding: 16px 20px;
|
414 |
+
margin: 16px 0 20px 0;
|
415 |
+
display: flex;
|
416 |
+
align-items: center;
|
417 |
+
gap: 12px;
|
418 |
+
'>
|
419 |
+
<div style='
|
420 |
+
background: linear-gradient(135deg, #22c55e, #3b82f6);
|
421 |
+
border-radius: 50%;
|
422 |
+
width: 32px;
|
423 |
+
height: 32px;
|
424 |
+
display: flex;
|
425 |
+
align-items: center;
|
426 |
+
justify-content: center;
|
427 |
+
flex-shrink: 0;
|
428 |
+
'>
|
429 |
+
<span style='color: white; font-size: 16px; font-weight: bold;'>ⓘ</span>
|
430 |
+
</div>
|
431 |
+
<div style='flex: 1;'>
|
432 |
+
<div style='
|
433 |
+
color: #1f2937;
|
434 |
+
font-weight: 600;
|
435 |
+
font-size: 0.95em;
|
436 |
+
margin-bottom: 4px;
|
437 |
+
line-height: 1.4;
|
438 |
+
'>
|
439 |
+
Accuracy Continuously Improving - Use as Reference Guide
|
440 |
+
</div>
|
441 |
+
<div style='
|
442 |
+
color: #4b5563;
|
443 |
+
font-size: 0.88em;
|
444 |
+
line-height: 1.5;
|
445 |
+
letter-spacing: 0.2px;
|
446 |
+
'>
|
447 |
+
The AI recommendation system is constantly learning and improving. Use these recommendations as a helpful reference for your pet adoption.
|
448 |
+
</div>
|
449 |
+
</div>
|
450 |
+
</div>
|
451 |
+
""")
|
452 |
+
|
453 |
+
def validate_description_input(text):
|
454 |
+
"""Validate description input"""
|
455 |
+
try:
|
456 |
+
nlp = get_nlp_processor()
|
457 |
+
validation = nlp.validate_input(text)
|
458 |
+
if validation.get("is_valid", True):
|
459 |
+
return gr.update(visible=False), True
|
460 |
+
else:
|
461 |
+
error_html = f"""
|
462 |
+
<div style='
|
463 |
+
background: #fed7d7;
|
464 |
+
border: 1px solid #fc8181;
|
465 |
+
color: #c53030;
|
466 |
+
padding: 10px;
|
467 |
+
border-radius: 8px;
|
468 |
+
margin: 10px 0;
|
469 |
+
'>
|
470 |
+
<strong>⚠️ {validation.get('error', 'Invalid input')}</strong><br>
|
471 |
+
{"<br>".join(f"• {s}" for s in validation.get('suggestions', []))}
|
472 |
+
</div>
|
473 |
+
"""
|
474 |
+
return gr.update(value=error_html, visible=True), False
|
475 |
+
except Exception as e:
|
476 |
+
# 無 NLP 驗證也可放行
|
477 |
+
print(f"[DESC][VALIDATE][WARN] {str(e)}")
|
478 |
+
return gr.update(visible=False), True
|
479 |
+
|
480 |
+
def find_breeds_by_description(description_text):
|
481 |
+
"""Find breeds based on description and persist history"""
|
482 |
+
try:
|
483 |
+
if not description_text or not description_text.strip():
|
484 |
+
return """
|
485 |
+
<div style="text-align: center; padding: 20px; color: #718096;">
|
486 |
+
<p>Please enter your description to get personalized recommendations</p>
|
487 |
+
</div>
|
488 |
+
"""
|
489 |
+
|
490 |
+
# 驗證(若可用)
|
491 |
+
try:
|
492 |
+
nlp = get_nlp_processor()
|
493 |
+
validation = nlp.validate_input(description_text)
|
494 |
+
if not validation.get("is_valid", True):
|
495 |
+
return f"""
|
496 |
+
<div style="text-align: center; padding: 20px; color: #e53e3e;">
|
497 |
+
<h3>⚠️ Input validation failed</h3>
|
498 |
+
<p>{validation.get('error','Invalid input')}</p>
|
499 |
+
<ul style="text-align: left; display: inline-block;">
|
500 |
+
{"".join(f"<li>{s}</li>" for s in validation.get('suggestions', []))}
|
501 |
+
</ul>
|
502 |
+
</div>
|
503 |
+
"""
|
504 |
+
except Exception as e:
|
505 |
+
print(f"[DESC][VALIDATE][WARN] {str(e)} (skip validation)")
|
506 |
+
|
507 |
+
# 取得增強語意推薦
|
508 |
+
recommendations = get_enhanced_recommendations_with_unified_scoring(
|
509 |
+
user_description=description_text,
|
510 |
+
top_k=15
|
511 |
+
)
|
512 |
+
print(f"[DESC] generated={len(recommendations) if recommendations else 0}")
|
513 |
+
|
514 |
+
if not recommendations:
|
515 |
+
return """
|
516 |
+
<div style="text-align: center; padding: 20px; color: #e53e3e;">
|
517 |
+
<h3>😔 No matching breeds found</h3>
|
518 |
+
<p>No dog breeds match your specific requirements. Please try:</p>
|
519 |
+
<ul style="text-align: left; display: inline-block; color: #4a5568;">
|
520 |
+
<li>Providing a more general description</li>
|
521 |
+
<li>Relaxing some specific requirements</li>
|
522 |
+
<li>Including different breed preferences</li>
|
523 |
+
</ul>
|
524 |
+
</div>
|
525 |
+
"""
|
526 |
+
|
527 |
+
# 準備歷史資料
|
528 |
+
def _to_float(x, default=0.0):
|
529 |
+
try:
|
530 |
+
return float(x)
|
531 |
+
except Exception:
|
532 |
+
return default
|
533 |
+
|
534 |
+
history_results = []
|
535 |
+
for i, rec in enumerate(recommendations, start=1):
|
536 |
+
final_score = _to_float(rec.get("final_score", rec.get("overall_score", 0)))
|
537 |
+
overall_score = final_score # Ensure consistency between final_score and overall_score
|
538 |
+
history_results.append({
|
539 |
+
"breed": str(rec.get("breed", "Unknown")),
|
540 |
+
"rank": int(rec.get("rank", i)),
|
541 |
+
"final_score": final_score,
|
542 |
+
"overall_score": overall_score,
|
543 |
+
"semantic_score": _to_float(rec.get("semantic_score", 0)),
|
544 |
+
"comparative_bonus": _to_float(rec.get("comparative_bonus", 0)),
|
545 |
+
"lifestyle_bonus": _to_float(rec.get("lifestyle_bonus", 0)),
|
546 |
+
"size": str(rec.get("size", "Unknown")),
|
547 |
+
"scores": rec.get("scores", {})
|
548 |
+
})
|
549 |
+
|
550 |
+
# 寫入歷史(description)
|
551 |
+
try:
|
552 |
+
ok = history_component.save_search(
|
553 |
+
user_preferences=None,
|
554 |
+
results=history_results,
|
555 |
+
search_type="description",
|
556 |
+
description=description_text
|
557 |
+
)
|
558 |
+
print(f"[DESC SAVE] ok={ok}, saved={len(history_results)}")
|
559 |
+
except Exception as e:
|
560 |
+
print(f"[DESC SAVE][ERROR] {str(e)}")
|
561 |
+
|
562 |
+
# 使用統一HTML格式化器顯示增強推薦結果
|
563 |
+
html_output = format_unified_recommendation_html(recommendations, is_description_search=True)
|
564 |
+
return html_output
|
565 |
+
|
566 |
+
except RuntimeError as e:
|
567 |
+
error_msg = str(e)
|
568 |
+
print(f"[DESC][RUNTIME_ERROR] {error_msg}")
|
569 |
+
return f"""
|
570 |
+
<div style="text-align: center; padding: 20px; color: #e53e3e;">
|
571 |
+
<h3>🔧 System Configuration Issue</h3>
|
572 |
+
<p style="color: #4a5568; text-align: left; max-width: 600px; margin: 0 auto;">
|
573 |
+
{error_msg.replace(chr(10), '<br>').replace('•', '•')}
|
574 |
+
</p>
|
575 |
+
<div style="margin-top: 15px; padding: 10px; background-color: #f7fafc; border-radius: 8px;">
|
576 |
+
<p style="color: #2d3748; font-weight: 500;">💡 What you can try:</p>
|
577 |
+
<ul style="text-align: left; color: #4a5568; margin: 10px 0;">
|
578 |
+
<li>Restart the application</li>
|
579 |
+
<li>Use the "Find by Criteria" tab instead</li>
|
580 |
+
<li>Contact support if the issue persists</li>
|
581 |
+
</ul>
|
582 |
+
</div>
|
583 |
+
</div>
|
584 |
+
"""
|
585 |
+
except ValueError as e:
|
586 |
+
error_msg = str(e)
|
587 |
+
print(f"[DESC][VALUE_ERROR] {error_msg}")
|
588 |
+
return f"""
|
589 |
+
<div style="text-align: center; padding: 20px; color: #e53e3e;">
|
590 |
+
<h3>🔍 No Matching Results</h3>
|
591 |
+
<p style="color: #4a5568; text-align: left; max-width: 600px; margin: 0 auto;">
|
592 |
+
{error_msg}
|
593 |
+
</p>
|
594 |
+
<div style="margin-top: 15px; padding: 10px; background-color: #f0f9ff; border-radius: 8px;">
|
595 |
+
<p style="color: #2d3748; font-weight: 500;">💡 Suggestions to get better results:</p>
|
596 |
+
<ul style="text-align: left; color: #4a5568; margin: 10px 0;">
|
597 |
+
<li>Try describing your lifestyle more generally</li>
|
598 |
+
<li>Mention multiple breed preferences</li>
|
599 |
+
<li>Include both what you want and what you can accommodate</li>
|
600 |
+
<li>Consider using the "Find by Criteria" tab for structured search</li>
|
601 |
+
</ul>
|
602 |
+
</div>
|
603 |
+
</div>
|
604 |
+
"""
|
605 |
+
except Exception as e:
|
606 |
+
error_msg = str(e)
|
607 |
+
print(f"[DESC][ERROR] {error_msg}")
|
608 |
+
print(traceback.format_exc())
|
609 |
+
return f"""
|
610 |
+
<div style="text-align: center; padding: 20px; color: #e53e3e;">
|
611 |
+
<h3>⚠️ Unexpected Error</h3>
|
612 |
+
<p style="color: #4a5568;">An unexpected error occurred while processing your description.</p>
|
613 |
+
<details style="margin-top: 15px; text-align: left; max-width: 600px; margin-left: auto; margin-right: auto;">
|
614 |
+
<summary style="cursor: pointer; color: #2d3748; font-weight: 500;">Show technical details</summary>
|
615 |
+
<pre style="background-color: #f7fafc; padding: 10px; border-radius: 4px; font-size: 12px; color: #4a5568; margin-top: 10px; overflow: auto;">{error_msg}</pre>
|
616 |
+
</details>
|
617 |
+
<p style="margin-top: 15px; color: #4a5568; font-size: 14px;">Please try the "Find by Criteria" tab or contact support.</p>
|
618 |
+
</div>
|
619 |
+
"""
|
620 |
+
|
621 |
+
description_input.change(
|
622 |
+
fn=lambda x: validate_description_input(x)[0],
|
623 |
+
inputs=[description_input],
|
624 |
+
outputs=[validation_status]
|
625 |
+
)
|
626 |
+
|
627 |
+
description_button = gr.Button("🤖 Smart Breed Recommendation", elem_id="find-by-description-btn", size="lg")
|
628 |
+
description_results = gr.HTML(label="AI Breed Recommendations")
|
629 |
+
|
630 |
+
description_button.click(
|
631 |
+
fn=find_breeds_by_description,
|
632 |
+
inputs=[description_input],
|
633 |
+
outputs=[description_results]
|
634 |
+
)
|
635 |
+
|
636 |
+
return {
|
637 |
+
'criteria_results': locals().get('criteria_results'),
|
638 |
+
'description_results': locals().get('description_results'),
|
639 |
+
'description_input': locals().get('description_input')
|
640 |
+
}
|
config_manager.py
ADDED
@@ -0,0 +1,554 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
import json
|
2 |
+
import sqlite3
|
3 |
+
import numpy as np
|
4 |
+
from typing import Dict, List, Tuple, Any, Optional, Union
|
5 |
+
from dataclasses import dataclass, field, asdict
|
6 |
+
from enum import Enum
|
7 |
+
import os
|
8 |
+
import traceback
|
9 |
+
from dog_database import get_dog_description
|
10 |
+
from breed_health_info import breed_health_info
|
11 |
+
from breed_noise_info import breed_noise_info
|
12 |
+
|
13 |
+
class DataQuality(Enum):
|
14 |
+
"""資料品質等級"""
|
15 |
+
HIGH = "high" # 完整且可靠的資料
|
16 |
+
MEDIUM = "medium" # 部分資料或推斷資料
|
17 |
+
LOW = "low" # 不完整或不確定的資料
|
18 |
+
UNKNOWN = "unknown" # 未知或缺失資料
|
19 |
+
|
20 |
+
@dataclass
|
21 |
+
class BreedStandardization:
|
22 |
+
"""品種標準化資料結構"""
|
23 |
+
canonical_name: str
|
24 |
+
display_name: str
|
25 |
+
aliases: List[str] = field(default_factory=list)
|
26 |
+
size_category: int = 1 # 1=tiny, 2=small, 3=medium, 4=large, 5=giant
|
27 |
+
exercise_level: int = 2 # 1=low, 2=moderate, 3=high, 4=very_high
|
28 |
+
noise_level: int = 2 # 1=low, 2=moderate, 3=high
|
29 |
+
care_complexity: int = 2 # 1=low, 2=moderate, 3=high
|
30 |
+
child_compatibility: float = 0.5 # 0=no, 0.5=unknown, 1=yes
|
31 |
+
data_quality_scores: Dict[str, DataQuality] = field(default_factory=dict)
|
32 |
+
confidence_flags: Dict[str, float] = field(default_factory=dict)
|
33 |
+
|
34 |
+
@dataclass
|
35 |
+
class ConfigurationSettings:
|
36 |
+
"""配置設定結構"""
|
37 |
+
scoring_weights: Dict[str, float] = field(default_factory=dict)
|
38 |
+
calibration_settings: Dict[str, Any] = field(default_factory=dict)
|
39 |
+
constraint_thresholds: Dict[str, float] = field(default_factory=dict)
|
40 |
+
semantic_model_config: Dict[str, Any] = field(default_factory=dict)
|
41 |
+
data_imputation_rules: Dict[str, Any] = field(default_factory=dict)
|
42 |
+
debug_mode: bool = False
|
43 |
+
version: str = "1.0.0"
|
44 |
+
|
45 |
+
class ConfigManager:
|
46 |
+
"""
|
47 |
+
中央化配置和資料標準化管理系統
|
48 |
+
處理品種資料標準化、配置管理和資料品質評估
|
49 |
+
"""
|
50 |
+
|
51 |
+
def __init__(self, config_file: Optional[str] = None):
|
52 |
+
"""初始化配置管理器"""
|
53 |
+
self.config_file = config_file or "config.json"
|
54 |
+
self.breed_standardization = {}
|
55 |
+
self.configuration = ConfigurationSettings()
|
56 |
+
self.breed_aliases = {}
|
57 |
+
self._load_default_configuration()
|
58 |
+
self._initialize_breed_standardization()
|
59 |
+
|
60 |
+
# 嘗試載入自定義配置
|
61 |
+
if os.path.exists(self.config_file):
|
62 |
+
self._load_configuration()
|
63 |
+
|
64 |
+
def _load_default_configuration(self):
|
65 |
+
"""載入預設配置"""
|
66 |
+
self.configuration = ConfigurationSettings(
|
67 |
+
scoring_weights={
|
68 |
+
'activity_compatibility': 0.35,
|
69 |
+
'noise_compatibility': 0.25,
|
70 |
+
'spatial_compatibility': 0.15,
|
71 |
+
'family_compatibility': 0.10,
|
72 |
+
'maintenance_compatibility': 0.10,
|
73 |
+
'size_compatibility': 0.05
|
74 |
+
},
|
75 |
+
calibration_settings={
|
76 |
+
'target_range_min': 0.45,
|
77 |
+
'target_range_max': 0.95,
|
78 |
+
'min_effective_range': 0.3,
|
79 |
+
'auto_calibration': True,
|
80 |
+
'tie_breaking_enabled': True
|
81 |
+
},
|
82 |
+
constraint_thresholds={
|
83 |
+
'apartment_size_limit': 3, # 最大允許尺寸 (medium)
|
84 |
+
'high_exercise_threshold': 3, # 高運動需求閾值
|
85 |
+
'quiet_noise_limit': 2, # 安靜環境噪音限制
|
86 |
+
'child_safety_threshold': 0.8 # 兒童安全最低分數
|
87 |
+
},
|
88 |
+
semantic_model_config={
|
89 |
+
'model_name': 'all-MiniLM-L6-v2',
|
90 |
+
'fallback_models': ['all-mpnet-base-v2', 'all-MiniLM-L12-v2'],
|
91 |
+
'similarity_threshold': 0.5,
|
92 |
+
'cache_embeddings': True
|
93 |
+
},
|
94 |
+
data_imputation_rules={
|
95 |
+
'noise_level_defaults': {
|
96 |
+
'terrier': 'high',
|
97 |
+
'hound': 'high',
|
98 |
+
'herding': 'moderate',
|
99 |
+
'toy': 'moderate',
|
100 |
+
'working': 'moderate',
|
101 |
+
'sporting': 'moderate',
|
102 |
+
'non_sporting': 'low',
|
103 |
+
'unknown': 'moderate'
|
104 |
+
},
|
105 |
+
'exercise_level_defaults': {
|
106 |
+
'working': 'high',
|
107 |
+
'sporting': 'high',
|
108 |
+
'herding': 'high',
|
109 |
+
'terrier': 'moderate',
|
110 |
+
'hound': 'moderate',
|
111 |
+
'toy': 'low',
|
112 |
+
'non_sporting': 'moderate',
|
113 |
+
'unknown': 'moderate'
|
114 |
+
}
|
115 |
+
},
|
116 |
+
debug_mode=False,
|
117 |
+
version="1.0.0"
|
118 |
+
)
|
119 |
+
|
120 |
+
def _initialize_breed_standardization(self):
|
121 |
+
"""初始化品種標準化"""
|
122 |
+
try:
|
123 |
+
# 獲取所有品種
|
124 |
+
breeds = self._get_all_breeds()
|
125 |
+
|
126 |
+
for breed in breeds:
|
127 |
+
standardized = self._standardize_breed_data(breed)
|
128 |
+
self.breed_standardization[breed] = standardized
|
129 |
+
|
130 |
+
# 建立別名映射
|
131 |
+
for alias in standardized.aliases:
|
132 |
+
self.breed_aliases[alias.lower()] = breed
|
133 |
+
|
134 |
+
print(f"Initialized standardization for {len(self.breed_standardization)} breeds")
|
135 |
+
|
136 |
+
except Exception as e:
|
137 |
+
print(f"Error initializing breed standardization: {str(e)}")
|
138 |
+
print(traceback.format_exc())
|
139 |
+
|
140 |
+
def _get_all_breeds(self) -> List[str]:
|
141 |
+
"""獲取所有品種清單"""
|
142 |
+
try:
|
143 |
+
conn = sqlite3.connect('animal_detector.db')
|
144 |
+
cursor = conn.cursor()
|
145 |
+
cursor.execute("SELECT DISTINCT Breed FROM AnimalCatalog")
|
146 |
+
breeds = [row[0] for row in cursor.fetchall()]
|
147 |
+
cursor.close()
|
148 |
+
conn.close()
|
149 |
+
return breeds
|
150 |
+
except Exception as e:
|
151 |
+
print(f"Error getting breed list: {str(e)}")
|
152 |
+
return []
|
153 |
+
|
154 |
+
def _standardize_breed_data(self, breed: str) -> BreedStandardization:
|
155 |
+
"""標準化品種資料"""
|
156 |
+
try:
|
157 |
+
# 基本資訊
|
158 |
+
breed_info = get_dog_description(breed) or {}
|
159 |
+
health_info = breed_health_info.get(breed, {})
|
160 |
+
noise_info = breed_noise_info.get(breed, {})
|
161 |
+
|
162 |
+
# 建立標準化結構
|
163 |
+
canonical_name = breed
|
164 |
+
display_name = breed.replace('_', ' ')
|
165 |
+
aliases = self._generate_breed_aliases(breed)
|
166 |
+
|
167 |
+
# 標準化分類數據
|
168 |
+
size_category = self._standardize_size(breed_info.get('Size', ''))
|
169 |
+
exercise_level = self._standardize_exercise_needs(breed_info.get('Exercise Needs', ''))
|
170 |
+
noise_level = self._standardize_noise_level(noise_info.get('noise_level', ''))
|
171 |
+
care_complexity = self._standardize_care_level(breed_info.get('Care Level', ''))
|
172 |
+
child_compatibility = self._standardize_child_compatibility(
|
173 |
+
breed_info.get('Good with Children', '')
|
174 |
+
)
|
175 |
+
|
176 |
+
# 評估資料品質
|
177 |
+
data_quality_scores = self._assess_data_quality(breed_info, health_info, noise_info)
|
178 |
+
confidence_flags = self._calculate_confidence_flags(breed_info, health_info, noise_info)
|
179 |
+
|
180 |
+
return BreedStandardization(
|
181 |
+
canonical_name=canonical_name,
|
182 |
+
display_name=display_name,
|
183 |
+
aliases=aliases,
|
184 |
+
size_category=size_category,
|
185 |
+
exercise_level=exercise_level,
|
186 |
+
noise_level=noise_level,
|
187 |
+
care_complexity=care_complexity,
|
188 |
+
child_compatibility=child_compatibility,
|
189 |
+
data_quality_scores=data_quality_scores,
|
190 |
+
confidence_flags=confidence_flags
|
191 |
+
)
|
192 |
+
|
193 |
+
except Exception as e:
|
194 |
+
print(f"Error standardizing breed {breed}: {str(e)}")
|
195 |
+
return BreedStandardization(
|
196 |
+
canonical_name=breed,
|
197 |
+
display_name=breed.replace('_', ' '),
|
198 |
+
aliases=self._generate_breed_aliases(breed)
|
199 |
+
)
|
200 |
+
|
201 |
+
def _generate_breed_aliases(self, breed: str) -> List[str]:
|
202 |
+
"""生成品種別名"""
|
203 |
+
aliases = []
|
204 |
+
display_name = breed.replace('_', ' ')
|
205 |
+
|
206 |
+
# 基本別名
|
207 |
+
aliases.append(display_name.lower())
|
208 |
+
aliases.append(breed.lower())
|
209 |
+
|
210 |
+
# 常見縮寫和變體
|
211 |
+
breed_aliases_map = {
|
212 |
+
'German_Shepherd': ['gsd', 'german shepherd dog', 'alsatian'],
|
213 |
+
'Labrador_Retriever': ['lab', 'labrador', 'retriever'],
|
214 |
+
'Golden_Retriever': ['golden', 'goldie'],
|
215 |
+
'Border_Collie': ['border', 'collie'],
|
216 |
+
'Yorkshire_Terrier': ['yorkie', 'york', 'yorkshire'],
|
217 |
+
'French_Bulldog': ['frenchie', 'french bull', 'bouledogue français'],
|
218 |
+
'Boston_Terrier': ['boston bull', 'american gentleman'],
|
219 |
+
'Cavalier_King_Charles_Spaniel': ['cavalier', 'ckcs', 'king charles'],
|
220 |
+
'American_Staffordshire_Terrier': ['amstaff', 'american staff'],
|
221 |
+
'Jack_Russell_Terrier': ['jrt', 'jack russell', 'parson russell'],
|
222 |
+
'Shih_Tzu': ['shih tzu', 'lion dog'],
|
223 |
+
'Bichon_Frise': ['bichon', 'powder puff'],
|
224 |
+
'Cocker_Spaniel': ['cocker', 'english cocker', 'american cocker']
|
225 |
+
}
|
226 |
+
|
227 |
+
if breed in breed_aliases_map:
|
228 |
+
aliases.extend(breed_aliases_map[breed])
|
229 |
+
|
230 |
+
# 移除重複
|
231 |
+
return list(set(aliases))
|
232 |
+
|
233 |
+
def _standardize_size(self, size_str: str) -> int:
|
234 |
+
"""標準化體型分類"""
|
235 |
+
size_mapping = {
|
236 |
+
'tiny': 1, 'toy': 1,
|
237 |
+
'small': 2, 'little': 2, 'compact': 2,
|
238 |
+
'medium': 3, 'moderate': 3, 'average': 3,
|
239 |
+
'large': 4, 'big': 4,
|
240 |
+
'giant': 5, 'huge': 5, 'extra large': 5
|
241 |
+
}
|
242 |
+
|
243 |
+
size_lower = size_str.lower()
|
244 |
+
for key, value in size_mapping.items():
|
245 |
+
if key in size_lower:
|
246 |
+
return value
|
247 |
+
|
248 |
+
return 3 # 預設為 medium
|
249 |
+
|
250 |
+
def _standardize_exercise_needs(self, exercise_str: str) -> int:
|
251 |
+
"""標準化運動需求"""
|
252 |
+
exercise_mapping = {
|
253 |
+
'low': 1, 'minimal': 1, 'light': 1,
|
254 |
+
'moderate': 2, 'average': 2, 'medium': 2, 'regular': 2,
|
255 |
+
'high': 3, 'active': 3, 'vigorous': 3,
|
256 |
+
'very high': 4, 'extreme': 4, 'intense': 4
|
257 |
+
}
|
258 |
+
|
259 |
+
exercise_lower = exercise_str.lower()
|
260 |
+
for key, value in exercise_mapping.items():
|
261 |
+
if key in exercise_lower:
|
262 |
+
return value
|
263 |
+
|
264 |
+
return 2 # 預設為 moderate
|
265 |
+
|
266 |
+
def _standardize_noise_level(self, noise_str: str) -> int:
|
267 |
+
"""標準化噪音水平"""
|
268 |
+
noise_mapping = {
|
269 |
+
'low': 1, 'quiet': 1, 'silent': 1, 'minimal': 1,
|
270 |
+
'moderate': 2, 'average': 2, 'medium': 2, 'occasional': 2,
|
271 |
+
'high': 3, 'loud': 3, 'vocal': 3, 'frequent': 3
|
272 |
+
}
|
273 |
+
|
274 |
+
noise_lower = noise_str.lower()
|
275 |
+
for key, value in noise_mapping.items():
|
276 |
+
if key in noise_lower:
|
277 |
+
return value
|
278 |
+
|
279 |
+
return 2 # 預設為 moderate
|
280 |
+
|
281 |
+
def _standardize_care_level(self, care_str: str) -> int:
|
282 |
+
"""標準化護理複雜度"""
|
283 |
+
care_mapping = {
|
284 |
+
'low': 1, 'easy': 1, 'simple': 1, 'minimal': 1,
|
285 |
+
'moderate': 2, 'average': 2, 'medium': 2, 'regular': 2,
|
286 |
+
'high': 3, 'complex': 3, 'intensive': 3, 'demanding': 3
|
287 |
+
}
|
288 |
+
|
289 |
+
care_lower = care_str.lower()
|
290 |
+
for key, value in care_mapping.items():
|
291 |
+
if key in care_lower:
|
292 |
+
return value
|
293 |
+
|
294 |
+
return 2 # 預設為 moderate
|
295 |
+
|
296 |
+
def _standardize_child_compatibility(self, child_str: str) -> float:
|
297 |
+
"""標準化兒童相容性"""
|
298 |
+
if child_str.lower() == 'yes':
|
299 |
+
return 1.0
|
300 |
+
elif child_str.lower() == 'no':
|
301 |
+
return 0.0
|
302 |
+
else:
|
303 |
+
return 0.5 # 未知或不確定
|
304 |
+
|
305 |
+
def _assess_data_quality(self, breed_info: Dict, health_info: Dict,
|
306 |
+
noise_info: Dict) -> Dict[str, DataQuality]:
|
307 |
+
"""評估資料品質"""
|
308 |
+
quality_scores = {}
|
309 |
+
|
310 |
+
# 基本資訊品質
|
311 |
+
if breed_info:
|
312 |
+
required_fields = ['Size', 'Exercise Needs', 'Temperament', 'Good with Children']
|
313 |
+
complete_fields = sum(1 for field in required_fields if breed_info.get(field))
|
314 |
+
|
315 |
+
if complete_fields >= 4:
|
316 |
+
quality_scores['basic_info'] = DataQuality.HIGH
|
317 |
+
elif complete_fields >= 2:
|
318 |
+
quality_scores['basic_info'] = DataQuality.MEDIUM
|
319 |
+
else:
|
320 |
+
quality_scores['basic_info'] = DataQuality.LOW
|
321 |
+
else:
|
322 |
+
quality_scores['basic_info'] = DataQuality.UNKNOWN
|
323 |
+
|
324 |
+
# 健康資訊品質
|
325 |
+
if health_info and health_info.get('health_notes'):
|
326 |
+
quality_scores['health_info'] = DataQuality.HIGH
|
327 |
+
elif health_info:
|
328 |
+
quality_scores['health_info'] = DataQuality.MEDIUM
|
329 |
+
else:
|
330 |
+
quality_scores['health_info'] = DataQuality.UNKNOWN
|
331 |
+
|
332 |
+
# 噪音資訊品質
|
333 |
+
if noise_info and noise_info.get('noise_level'):
|
334 |
+
quality_scores['noise_info'] = DataQuality.HIGH
|
335 |
+
else:
|
336 |
+
quality_scores['noise_info'] = DataQuality.LOW
|
337 |
+
|
338 |
+
return quality_scores
|
339 |
+
|
340 |
+
def _calculate_confidence_flags(self, breed_info: Dict, health_info: Dict,
|
341 |
+
noise_info: Dict) -> Dict[str, float]:
|
342 |
+
"""計算信心度標記"""
|
343 |
+
confidence_flags = {}
|
344 |
+
|
345 |
+
# 基本資訊信心度
|
346 |
+
basic_confidence = 0.8 if breed_info else 0.2
|
347 |
+
if breed_info and breed_info.get('Description'):
|
348 |
+
basic_confidence += 0.1
|
349 |
+
confidence_flags['basic_info'] = min(1.0, basic_confidence)
|
350 |
+
|
351 |
+
# 健康資訊信心度
|
352 |
+
health_confidence = 0.7 if health_info else 0.3
|
353 |
+
confidence_flags['health_info'] = health_confidence
|
354 |
+
|
355 |
+
# 噪音資訊信心度
|
356 |
+
noise_confidence = 0.8 if noise_info else 0.4
|
357 |
+
confidence_flags['noise_info'] = noise_confidence
|
358 |
+
|
359 |
+
# 整體信心度
|
360 |
+
confidence_flags['overall'] = np.mean(list(confidence_flags.values()))
|
361 |
+
|
362 |
+
return confidence_flags
|
363 |
+
|
364 |
+
def get_standardized_breed_data(self, breed: str) -> Optional[BreedStandardization]:
|
365 |
+
"""獲取標準化品種資料"""
|
366 |
+
# 嘗試直接匹配
|
367 |
+
if breed in self.breed_standardization:
|
368 |
+
return self.breed_standardization[breed]
|
369 |
+
|
370 |
+
# 嘗試別名匹配
|
371 |
+
breed_lower = breed.lower()
|
372 |
+
if breed_lower in self.breed_aliases:
|
373 |
+
canonical_breed = self.breed_aliases[breed_lower]
|
374 |
+
return self.breed_standardization.get(canonical_breed)
|
375 |
+
|
376 |
+
# 模糊匹配
|
377 |
+
for alias, canonical_breed in self.breed_aliases.items():
|
378 |
+
if breed_lower in alias or alias in breed_lower:
|
379 |
+
return self.breed_standardization.get(canonical_breed)
|
380 |
+
|
381 |
+
return None
|
382 |
+
|
383 |
+
def apply_data_imputation(self, breed: str) -> BreedStandardization:
|
384 |
+
"""應用資料插補規則"""
|
385 |
+
try:
|
386 |
+
standardized = self.get_standardized_breed_data(breed)
|
387 |
+
if not standardized:
|
388 |
+
return BreedStandardization(canonical_name=breed, display_name=breed.replace('_', ' '))
|
389 |
+
|
390 |
+
imputation_rules = self.configuration.data_imputation_rules
|
391 |
+
|
392 |
+
# 噪音水平插補
|
393 |
+
if standardized.noise_level == 2: # moderate (可能是預設值)
|
394 |
+
breed_group = self._determine_breed_group(breed)
|
395 |
+
noise_defaults = imputation_rules.get('noise_level_defaults', {})
|
396 |
+
if breed_group in noise_defaults:
|
397 |
+
imputed_noise = self._standardize_noise_level(noise_defaults[breed_group])
|
398 |
+
standardized.noise_level = imputed_noise
|
399 |
+
standardized.confidence_flags['noise_info'] *= 0.7 # 降低信心度
|
400 |
+
|
401 |
+
# 運動需求插補
|
402 |
+
if standardized.exercise_level == 2: # moderate (可能是預設值)
|
403 |
+
breed_group = self._determine_breed_group(breed)
|
404 |
+
exercise_defaults = imputation_rules.get('exercise_level_defaults', {})
|
405 |
+
if breed_group in exercise_defaults:
|
406 |
+
imputed_exercise = self._standardize_exercise_needs(exercise_defaults[breed_group])
|
407 |
+
standardized.exercise_level = imputed_exercise
|
408 |
+
standardized.confidence_flags['basic_info'] *= 0.8 # 降低信心度
|
409 |
+
|
410 |
+
return standardized
|
411 |
+
|
412 |
+
except Exception as e:
|
413 |
+
print(f"Error applying data imputation for {breed}: {str(e)}")
|
414 |
+
return self.get_standardized_breed_data(breed) or BreedStandardization(
|
415 |
+
canonical_name=breed, display_name=breed.replace('_', ' ')
|
416 |
+
)
|
417 |
+
|
418 |
+
def _determine_breed_group(self, breed: str) -> str:
|
419 |
+
"""確定品種群組"""
|
420 |
+
breed_lower = breed.lower()
|
421 |
+
|
422 |
+
if 'terrier' in breed_lower:
|
423 |
+
return 'terrier'
|
424 |
+
elif 'hound' in breed_lower:
|
425 |
+
return 'hound'
|
426 |
+
elif any(word in breed_lower for word in ['shepherd', 'collie', 'cattle', 'sheepdog']):
|
427 |
+
return 'herding'
|
428 |
+
elif any(word in breed_lower for word in ['retriever', 'pointer', 'setter', 'spaniel']):
|
429 |
+
return 'sporting'
|
430 |
+
elif any(word in breed_lower for word in ['mastiff', 'great', 'rottweiler', 'akita']):
|
431 |
+
return 'working'
|
432 |
+
elif any(word in breed_lower for word in ['toy', 'pug', 'chihuahua', 'papillon']):
|
433 |
+
return 'toy'
|
434 |
+
else:
|
435 |
+
return 'unknown'
|
436 |
+
|
437 |
+
def _load_configuration(self):
|
438 |
+
"""載入配置檔案"""
|
439 |
+
try:
|
440 |
+
with open(self.config_file, 'r', encoding='utf-8') as f:
|
441 |
+
config_data = json.load(f)
|
442 |
+
|
443 |
+
# 更新配置
|
444 |
+
if 'scoring_weights' in config_data:
|
445 |
+
self.configuration.scoring_weights.update(config_data['scoring_weights'])
|
446 |
+
if 'calibration_settings' in config_data:
|
447 |
+
self.configuration.calibration_settings.update(config_data['calibration_settings'])
|
448 |
+
if 'constraint_thresholds' in config_data:
|
449 |
+
self.configuration.constraint_thresholds.update(config_data['constraint_thresholds'])
|
450 |
+
if 'semantic_model_config' in config_data:
|
451 |
+
self.configuration.semantic_model_config.update(config_data['semantic_model_config'])
|
452 |
+
if 'data_imputation_rules' in config_data:
|
453 |
+
self.configuration.data_imputation_rules.update(config_data['data_imputation_rules'])
|
454 |
+
if 'debug_mode' in config_data:
|
455 |
+
self.configuration.debug_mode = config_data['debug_mode']
|
456 |
+
|
457 |
+
print(f"Configuration loaded from {self.config_file}")
|
458 |
+
|
459 |
+
except Exception as e:
|
460 |
+
print(f"Error loading configuration: {str(e)}")
|
461 |
+
|
462 |
+
def save_configuration(self):
|
463 |
+
"""儲存配置檔案"""
|
464 |
+
try:
|
465 |
+
config_data = asdict(self.configuration)
|
466 |
+
|
467 |
+
with open(self.config_file, 'w', encoding='utf-8') as f:
|
468 |
+
json.dump(config_data, f, indent=2, ensure_ascii=False)
|
469 |
+
|
470 |
+
print(f"Configuration saved to {self.config_file}")
|
471 |
+
|
472 |
+
except Exception as e:
|
473 |
+
print(f"Error saving configuration: {str(e)}")
|
474 |
+
|
475 |
+
def get_configuration(self) -> ConfigurationSettings:
|
476 |
+
"""獲取當前配置"""
|
477 |
+
return self.configuration
|
478 |
+
|
479 |
+
def update_configuration(self, updates: Dict[str, Any]):
|
480 |
+
"""更新配置"""
|
481 |
+
try:
|
482 |
+
for key, value in updates.items():
|
483 |
+
if hasattr(self.configuration, key):
|
484 |
+
setattr(self.configuration, key, value)
|
485 |
+
|
486 |
+
print(f"Configuration updated: {list(updates.keys())}")
|
487 |
+
|
488 |
+
except Exception as e:
|
489 |
+
print(f"Error updating configuration: {str(e)}")
|
490 |
+
|
491 |
+
def get_breed_mapping_summary(self) -> Dict[str, Any]:
|
492 |
+
"""獲取品種映射摘要"""
|
493 |
+
try:
|
494 |
+
total_breeds = len(self.breed_standardization)
|
495 |
+
total_aliases = len(self.breed_aliases)
|
496 |
+
|
497 |
+
# 資料品質分布
|
498 |
+
quality_distribution = {}
|
499 |
+
for breed_data in self.breed_standardization.values():
|
500 |
+
for category, quality in breed_data.data_quality_scores.items():
|
501 |
+
if category not in quality_distribution:
|
502 |
+
quality_distribution[category] = {}
|
503 |
+
quality_name = quality.value
|
504 |
+
quality_distribution[category][quality_name] = (
|
505 |
+
quality_distribution[category].get(quality_name, 0) + 1
|
506 |
+
)
|
507 |
+
|
508 |
+
# 信心度統計
|
509 |
+
confidence_stats = {}
|
510 |
+
for breed_data in self.breed_standardization.values():
|
511 |
+
for category, confidence in breed_data.confidence_flags.items():
|
512 |
+
if category not in confidence_stats:
|
513 |
+
confidence_stats[category] = []
|
514 |
+
confidence_stats[category].append(confidence)
|
515 |
+
|
516 |
+
confidence_averages = {
|
517 |
+
category: np.mean(values) for category, values in confidence_stats.items()
|
518 |
+
}
|
519 |
+
|
520 |
+
return {
|
521 |
+
'total_breeds': total_breeds,
|
522 |
+
'total_aliases': total_aliases,
|
523 |
+
'quality_distribution': quality_distribution,
|
524 |
+
'confidence_averages': confidence_averages,
|
525 |
+
'configuration_version': self.configuration.version
|
526 |
+
}
|
527 |
+
|
528 |
+
except Exception as e:
|
529 |
+
print(f"Error generating breed mapping summary: {str(e)}")
|
530 |
+
return {'error': str(e)}
|
531 |
+
|
532 |
+
_config_manager = None
|
533 |
+
|
534 |
+
def get_config_manager() -> ConfigManager:
|
535 |
+
"""獲取全局配置管理器"""
|
536 |
+
global _config_manager
|
537 |
+
if _config_manager is None:
|
538 |
+
_config_manager = ConfigManager()
|
539 |
+
return _config_manager
|
540 |
+
|
541 |
+
def get_standardized_breed_data(breed: str) -> Optional[BreedStandardization]:
|
542 |
+
"""獲取標準化品種資料"""
|
543 |
+
manager = get_config_manager()
|
544 |
+
return manager.get_standardized_breed_data(breed)
|
545 |
+
|
546 |
+
def get_breed_with_imputation(breed: str) -> BreedStandardization:
|
547 |
+
"""獲取應用補進後的品種資料"""
|
548 |
+
manager = get_config_manager()
|
549 |
+
return manager.apply_data_imputation(breed)
|
550 |
+
|
551 |
+
def get_system_configuration() -> ConfigurationSettings:
|
552 |
+
"""系統配置"""
|
553 |
+
manager = get_config_manager()
|
554 |
+
return manager.get_configuration()
|
constraint_manager.py
ADDED
@@ -0,0 +1,852 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
import sqlite3
|
2 |
+
import json
|
3 |
+
import numpy as np
|
4 |
+
from typing import List, Dict, Tuple, Set, Optional, Any
|
5 |
+
from dataclasses import dataclass, field
|
6 |
+
from enum import Enum
|
7 |
+
import traceback
|
8 |
+
from dog_database import get_dog_description
|
9 |
+
from dynamic_scoring_config import get_scoring_config
|
10 |
+
from breed_health_info import breed_health_info
|
11 |
+
from breed_noise_info import breed_noise_info
|
12 |
+
from query_understanding import QueryDimensions
|
13 |
+
|
14 |
+
class ConstraintPriority(Enum):
|
15 |
+
"""Constraint priority definitions"""
|
16 |
+
CRITICAL = 1 # Critical constraints (safety, space)
|
17 |
+
HIGH = 2 # High priority (activity level, noise)
|
18 |
+
MODERATE = 3 # Moderate priority (maintenance, experience)
|
19 |
+
FLEXIBLE = 4 # Flexible constraints (other preferences)
|
20 |
+
|
21 |
+
@dataclass
|
22 |
+
class ConstraintRule:
|
23 |
+
"""Constraint rule structure"""
|
24 |
+
name: str
|
25 |
+
priority: ConstraintPriority
|
26 |
+
description: str
|
27 |
+
filter_function: str # Function name
|
28 |
+
relaxation_allowed: bool = True
|
29 |
+
safety_critical: bool = False
|
30 |
+
|
31 |
+
@dataclass
|
32 |
+
class FilterResult:
|
33 |
+
"""Filter result structure"""
|
34 |
+
passed_breeds: Set[str]
|
35 |
+
filtered_breeds: Dict[str, str] # breed -> reason
|
36 |
+
applied_constraints: List[str]
|
37 |
+
relaxed_constraints: List[str] = field(default_factory=list)
|
38 |
+
warnings: List[str] = field(default_factory=list)
|
39 |
+
|
40 |
+
class ConstraintManager:
|
41 |
+
"""
|
42 |
+
Hierarchical constraint management system
|
43 |
+
Implements priority-based constraint filtering with progressive constraint relaxation
|
44 |
+
"""
|
45 |
+
|
46 |
+
def __init__(self):
|
47 |
+
"""Initialize constraint manager"""
|
48 |
+
self.breed_list = self._load_breed_list()
|
49 |
+
self.breed_cache = {} # Breed information cache
|
50 |
+
self.constraint_rules = self._initialize_constraint_rules()
|
51 |
+
self._warm_cache()
|
52 |
+
|
53 |
+
def _load_breed_list(self) -> List[str]:
|
54 |
+
"""Load breed list from database"""
|
55 |
+
try:
|
56 |
+
conn = sqlite3.connect('animal_detector.db')
|
57 |
+
cursor = conn.cursor()
|
58 |
+
cursor.execute("SELECT DISTINCT Breed FROM AnimalCatalog")
|
59 |
+
breeds = [row[0] for row in cursor.fetchall()]
|
60 |
+
cursor.close()
|
61 |
+
conn.close()
|
62 |
+
return breeds
|
63 |
+
except Exception as e:
|
64 |
+
print(f"Error loading breed list: {str(e)}")
|
65 |
+
return ['Labrador_Retriever', 'German_Shepherd', 'Golden_Retriever',
|
66 |
+
'Bulldog', 'Poodle', 'Beagle', 'Border_Collie', 'Yorkshire_Terrier']
|
67 |
+
|
68 |
+
def _warm_cache(self):
|
69 |
+
"""Warm up breed information cache"""
|
70 |
+
for breed in self.breed_list:
|
71 |
+
self.breed_cache[breed] = self._get_breed_info(breed)
|
72 |
+
|
73 |
+
def _get_breed_info(self, breed: str) -> Dict[str, Any]:
|
74 |
+
"""Get comprehensive breed information"""
|
75 |
+
if breed in self.breed_cache:
|
76 |
+
return self.breed_cache[breed]
|
77 |
+
|
78 |
+
try:
|
79 |
+
# Basic breed information
|
80 |
+
breed_info = get_dog_description(breed) or {}
|
81 |
+
|
82 |
+
# Health information
|
83 |
+
health_info = breed_health_info.get(breed, {})
|
84 |
+
|
85 |
+
# Noise information
|
86 |
+
noise_info = breed_noise_info.get(breed, {})
|
87 |
+
|
88 |
+
# Combine all information
|
89 |
+
combined_info = {
|
90 |
+
'breed_name': breed,
|
91 |
+
'display_name': breed.replace('_', ' '),
|
92 |
+
'size': breed_info.get('Size', '').lower(),
|
93 |
+
'exercise_needs': breed_info.get('Exercise Needs', '').lower(),
|
94 |
+
'grooming_needs': breed_info.get('Grooming Needs', '').lower(),
|
95 |
+
'temperament': breed_info.get('Temperament', '').lower(),
|
96 |
+
'good_with_children': breed_info.get('Good with Children', 'Yes'),
|
97 |
+
'care_level': breed_info.get('Care Level', '').lower(),
|
98 |
+
'lifespan': breed_info.get('Lifespan', '10-12 years'),
|
99 |
+
'noise_level': noise_info.get('noise_level', 'moderate').lower(),
|
100 |
+
'health_issues': health_info.get('health_notes', ''),
|
101 |
+
'raw_breed_info': breed_info,
|
102 |
+
'raw_health_info': health_info,
|
103 |
+
'raw_noise_info': noise_info
|
104 |
+
}
|
105 |
+
|
106 |
+
self.breed_cache[breed] = combined_info
|
107 |
+
return combined_info
|
108 |
+
|
109 |
+
except Exception as e:
|
110 |
+
print(f"Error getting breed info for {breed}: {str(e)}")
|
111 |
+
return {'breed_name': breed, 'display_name': breed.replace('_', ' ')}
|
112 |
+
|
113 |
+
def _initialize_constraint_rules(self) -> List[ConstraintRule]:
|
114 |
+
"""Initialize constraint rules"""
|
115 |
+
return [
|
116 |
+
# Priority 1: Critical constraints (cannot be violated)
|
117 |
+
ConstraintRule(
|
118 |
+
name="apartment_size_constraint",
|
119 |
+
priority=ConstraintPriority.CRITICAL,
|
120 |
+
description="Apartment living space size restrictions",
|
121 |
+
filter_function="filter_apartment_size",
|
122 |
+
relaxation_allowed=False,
|
123 |
+
safety_critical=True
|
124 |
+
),
|
125 |
+
ConstraintRule(
|
126 |
+
name="child_safety_constraint",
|
127 |
+
priority=ConstraintPriority.CRITICAL,
|
128 |
+
description="Child safety compatibility",
|
129 |
+
filter_function="filter_child_safety",
|
130 |
+
relaxation_allowed=False,
|
131 |
+
safety_critical=True
|
132 |
+
),
|
133 |
+
ConstraintRule(
|
134 |
+
name="severe_allergy_constraint",
|
135 |
+
priority=ConstraintPriority.CRITICAL,
|
136 |
+
description="Severe allergy restrictions",
|
137 |
+
filter_function="filter_severe_allergies",
|
138 |
+
relaxation_allowed=False,
|
139 |
+
safety_critical=True
|
140 |
+
),
|
141 |
+
|
142 |
+
# Priority 2: High priority constraints
|
143 |
+
ConstraintRule(
|
144 |
+
name="exercise_constraint",
|
145 |
+
priority=ConstraintPriority.HIGH,
|
146 |
+
description="Exercise requirement mismatch",
|
147 |
+
filter_function="filter_exercise_mismatch",
|
148 |
+
relaxation_allowed=False,
|
149 |
+
safety_critical=False
|
150 |
+
),
|
151 |
+
ConstraintRule(
|
152 |
+
name="size_bias_correction",
|
153 |
+
priority=ConstraintPriority.MODERATE,
|
154 |
+
description="Correct size bias in moderate lifestyle matches",
|
155 |
+
filter_function="filter_size_bias",
|
156 |
+
relaxation_allowed=True,
|
157 |
+
safety_critical=False
|
158 |
+
),
|
159 |
+
ConstraintRule(
|
160 |
+
name="low_activity_constraint",
|
161 |
+
priority=ConstraintPriority.HIGH,
|
162 |
+
description="Low activity level restrictions",
|
163 |
+
filter_function="filter_low_activity",
|
164 |
+
relaxation_allowed=True
|
165 |
+
),
|
166 |
+
ConstraintRule(
|
167 |
+
name="quiet_requirement_constraint",
|
168 |
+
priority=ConstraintPriority.HIGH,
|
169 |
+
description="Quiet environment requirements",
|
170 |
+
filter_function="filter_quiet_requirements",
|
171 |
+
relaxation_allowed=True
|
172 |
+
),
|
173 |
+
ConstraintRule(
|
174 |
+
name="space_compatibility_constraint",
|
175 |
+
priority=ConstraintPriority.HIGH,
|
176 |
+
description="Living space compatibility",
|
177 |
+
filter_function="filter_space_compatibility",
|
178 |
+
relaxation_allowed=True
|
179 |
+
),
|
180 |
+
|
181 |
+
# Priority 3: Moderate constraints
|
182 |
+
ConstraintRule(
|
183 |
+
name="grooming_preference_constraint",
|
184 |
+
priority=ConstraintPriority.MODERATE,
|
185 |
+
description="Grooming maintenance preferences",
|
186 |
+
filter_function="filter_grooming_preferences",
|
187 |
+
relaxation_allowed=True
|
188 |
+
),
|
189 |
+
ConstraintRule(
|
190 |
+
name="experience_level_constraint",
|
191 |
+
priority=ConstraintPriority.MODERATE,
|
192 |
+
description="Ownership experience requirements",
|
193 |
+
filter_function="filter_experience_level",
|
194 |
+
relaxation_allowed=True
|
195 |
+
),
|
196 |
+
|
197 |
+
# Priority 4: Flexible constraints
|
198 |
+
ConstraintRule(
|
199 |
+
name="size_preference_constraint",
|
200 |
+
priority=ConstraintPriority.FLEXIBLE,
|
201 |
+
description="Size preferences",
|
202 |
+
filter_function="filter_size_preferences",
|
203 |
+
relaxation_allowed=True
|
204 |
+
)
|
205 |
+
]
|
206 |
+
|
207 |
+
def apply_constraints(self, dimensions: QueryDimensions,
|
208 |
+
min_candidates: int = 12) -> FilterResult:
|
209 |
+
"""
|
210 |
+
Apply constraint filtering
|
211 |
+
|
212 |
+
Args:
|
213 |
+
dimensions: Query dimensions
|
214 |
+
min_candidates: Minimum number of candidate breeds
|
215 |
+
|
216 |
+
Returns:
|
217 |
+
FilterResult: Filtering results
|
218 |
+
"""
|
219 |
+
try:
|
220 |
+
# Start with all breeds
|
221 |
+
candidates = set(self.breed_list)
|
222 |
+
filtered_breeds = {}
|
223 |
+
applied_constraints = []
|
224 |
+
relaxed_constraints = []
|
225 |
+
warnings = []
|
226 |
+
|
227 |
+
# Apply constraints in priority order
|
228 |
+
for priority in [ConstraintPriority.CRITICAL, ConstraintPriority.HIGH,
|
229 |
+
ConstraintPriority.MODERATE, ConstraintPriority.FLEXIBLE]:
|
230 |
+
|
231 |
+
# Get constraint rules for this priority level
|
232 |
+
priority_rules = [rule for rule in self.constraint_rules
|
233 |
+
if rule.priority == priority]
|
234 |
+
|
235 |
+
for rule in priority_rules:
|
236 |
+
# Check if this constraint should be applied
|
237 |
+
if self._should_apply_constraint(rule, dimensions):
|
238 |
+
# Apply constraint
|
239 |
+
before_count = len(candidates)
|
240 |
+
filter_func = getattr(self, rule.filter_function)
|
241 |
+
new_filtered = filter_func(candidates, dimensions)
|
242 |
+
|
243 |
+
# Update candidate list
|
244 |
+
candidates -= set(new_filtered.keys())
|
245 |
+
filtered_breeds.update(new_filtered)
|
246 |
+
applied_constraints.append(rule.name)
|
247 |
+
|
248 |
+
print(f"Applied {rule.name}: {before_count} -> {len(candidates)} candidates")
|
249 |
+
|
250 |
+
# Check if constraint relaxation is needed
|
251 |
+
if (len(candidates) < min_candidates and
|
252 |
+
rule.relaxation_allowed and not rule.safety_critical):
|
253 |
+
|
254 |
+
# Constraint relaxation
|
255 |
+
# candidates.update(new_filtered.keys())
|
256 |
+
relaxed_constraints.append(rule.name)
|
257 |
+
warnings.append(f"Relaxed {rule.description} to maintain diversity")
|
258 |
+
|
259 |
+
print(f"Relaxed {rule.name}: restored to {len(candidates)} candidates")
|
260 |
+
|
261 |
+
# If too few candidates after critical constraints, warn but don't relax
|
262 |
+
if (priority == ConstraintPriority.CRITICAL and
|
263 |
+
len(candidates) < min_candidates):
|
264 |
+
warnings.append(f"Critical constraints resulted in only {len(candidates)} candidates")
|
265 |
+
|
266 |
+
# Final safety net: ensure at least some candidate breeds
|
267 |
+
if len(candidates) == 0:
|
268 |
+
warnings.append("All breeds filtered out, returning top safe breeds")
|
269 |
+
candidates = self._get_emergency_candidates()
|
270 |
+
|
271 |
+
return FilterResult(
|
272 |
+
passed_breeds=candidates,
|
273 |
+
filtered_breeds=filtered_breeds,
|
274 |
+
applied_constraints=applied_constraints,
|
275 |
+
relaxed_constraints=relaxed_constraints,
|
276 |
+
warnings=warnings
|
277 |
+
)
|
278 |
+
|
279 |
+
except Exception as e:
|
280 |
+
print(f"Error applying constraints: {str(e)}")
|
281 |
+
print(traceback.format_exc())
|
282 |
+
return FilterResult(
|
283 |
+
passed_breeds=set(self.breed_list[:min_candidates]),
|
284 |
+
filtered_breeds={},
|
285 |
+
applied_constraints=[],
|
286 |
+
warnings=[f"Constraint application failed: {str(e)}"]
|
287 |
+
)
|
288 |
+
|
289 |
+
def _should_apply_constraint(self, rule: ConstraintRule,
|
290 |
+
dimensions: QueryDimensions) -> bool:
|
291 |
+
"""Enhanced constraint application logic"""
|
292 |
+
|
293 |
+
# Always apply size constraints when space is mentioned
|
294 |
+
if rule.name == "apartment_size_constraint":
|
295 |
+
return any(term in dimensions.spatial_constraints
|
296 |
+
for term in ['apartment', 'small', 'studio', 'condo'])
|
297 |
+
|
298 |
+
# Apply exercise constraints when activity level is specified
|
299 |
+
if rule.name == "exercise_constraint":
|
300 |
+
return len(dimensions.activity_level) > 0 or \
|
301 |
+
any(term in str(dimensions.spatial_constraints)
|
302 |
+
for term in ['apartment', 'small'])
|
303 |
+
|
304 |
+
# Child safety constraint
|
305 |
+
if rule.name == "child_safety_constraint":
|
306 |
+
return 'children' in dimensions.family_context
|
307 |
+
|
308 |
+
# Severe allergy constraint
|
309 |
+
if rule.name == "severe_allergy_constraint":
|
310 |
+
return 'hypoallergenic' in dimensions.special_requirements
|
311 |
+
|
312 |
+
# Low activity constraint
|
313 |
+
if rule.name == "low_activity_constraint":
|
314 |
+
return 'low' in dimensions.activity_level
|
315 |
+
|
316 |
+
# Quiet requirement constraint
|
317 |
+
if rule.name == "quiet_requirement_constraint":
|
318 |
+
return 'low' in dimensions.noise_preferences
|
319 |
+
|
320 |
+
# Space compatibility constraint
|
321 |
+
if rule.name == "space_compatibility_constraint":
|
322 |
+
return ('apartment' in dimensions.spatial_constraints or
|
323 |
+
'house' in dimensions.spatial_constraints)
|
324 |
+
|
325 |
+
# Grooming preference constraint
|
326 |
+
if rule.name == "grooming_preference_constraint":
|
327 |
+
return len(dimensions.maintenance_level) > 0
|
328 |
+
|
329 |
+
# Experience level constraint
|
330 |
+
if rule.name == "experience_level_constraint":
|
331 |
+
return 'first_time' in dimensions.special_requirements
|
332 |
+
|
333 |
+
# Size preference constraint
|
334 |
+
if rule.name == "size_preference_constraint":
|
335 |
+
return len(dimensions.size_preferences) > 0
|
336 |
+
|
337 |
+
return False
|
338 |
+
|
339 |
+
def filter_apartment_size(self, candidates: Set[str],
|
340 |
+
dimensions: QueryDimensions) -> Dict[str, str]:
|
341 |
+
"""Enhanced apartment size filtering with strict enforcement"""
|
342 |
+
filtered = {}
|
343 |
+
|
344 |
+
# Extract living space type with better pattern matching
|
345 |
+
living_space = self._extract_living_space(dimensions)
|
346 |
+
space_requirements = self._get_space_requirements(living_space)
|
347 |
+
|
348 |
+
for breed in list(candidates):
|
349 |
+
breed_info = self.breed_cache.get(breed, {})
|
350 |
+
breed_size = self._normalize_breed_size(breed_info.get('size', 'Medium'))
|
351 |
+
exercise_needs = self._normalize_exercise_level(breed_info.get('exercise_needs', 'Moderate'))
|
352 |
+
|
353 |
+
# Dynamic space compatibility check
|
354 |
+
compatibility_score = self._calculate_space_compatibility(
|
355 |
+
breed_size, exercise_needs, space_requirements
|
356 |
+
)
|
357 |
+
|
358 |
+
# Apply threshold-based filtering
|
359 |
+
if compatibility_score < 0.3: # Strict threshold for poor matches
|
360 |
+
reason = self._generate_filter_reason(breed_size, exercise_needs, living_space)
|
361 |
+
filtered[breed] = reason
|
362 |
+
continue
|
363 |
+
|
364 |
+
return filtered
|
365 |
+
|
366 |
+
def _extract_living_space(self, dimensions: QueryDimensions) -> str:
|
367 |
+
"""Extract living space type from dimensions"""
|
368 |
+
spatial_text = ' '.join(dimensions.spatial_constraints).lower()
|
369 |
+
|
370 |
+
if any(term in spatial_text for term in ['apartment', 'small apartment', 'studio', 'condo']):
|
371 |
+
return 'apartment'
|
372 |
+
elif any(term in spatial_text for term in ['small house', 'townhouse']):
|
373 |
+
return 'small_house'
|
374 |
+
elif any(term in spatial_text for term in ['medium house', 'medium-sized']):
|
375 |
+
return 'medium_house'
|
376 |
+
elif any(term in spatial_text for term in ['large house', 'big house']):
|
377 |
+
return 'large_house'
|
378 |
+
else:
|
379 |
+
return 'medium_house' # Default assumption
|
380 |
+
|
381 |
+
def _get_space_requirements(self, living_space: str) -> Dict[str, float]:
|
382 |
+
"""Get space requirements for different living situations"""
|
383 |
+
requirements = {
|
384 |
+
'apartment': {'min_space': 1.0, 'yard_bonus': 0.0, 'exercise_penalty': 1.5},
|
385 |
+
'small_house': {'min_space': 1.5, 'yard_bonus': 0.2, 'exercise_penalty': 1.2},
|
386 |
+
'medium_house': {'min_space': 2.0, 'yard_bonus': 0.3, 'exercise_penalty': 1.0},
|
387 |
+
'large_house': {'min_space': 3.0, 'yard_bonus': 0.5, 'exercise_penalty': 0.8}
|
388 |
+
}
|
389 |
+
return requirements.get(living_space, requirements['medium_house'])
|
390 |
+
|
391 |
+
def _normalize_breed_size(self, size: str) -> str:
|
392 |
+
"""Normalize breed size to standard categories"""
|
393 |
+
size_lower = size.lower()
|
394 |
+
if any(term in size_lower for term in ['toy', 'tiny']):
|
395 |
+
return 'toy'
|
396 |
+
elif 'small' in size_lower:
|
397 |
+
return 'small'
|
398 |
+
elif 'medium' in size_lower:
|
399 |
+
return 'medium'
|
400 |
+
elif 'large' in size_lower:
|
401 |
+
return 'large'
|
402 |
+
elif any(term in size_lower for term in ['giant', 'extra large']):
|
403 |
+
return 'giant'
|
404 |
+
else:
|
405 |
+
return 'medium' # Default
|
406 |
+
|
407 |
+
def _normalize_exercise_level(self, exercise: str) -> str:
|
408 |
+
"""Normalize exercise level to standard categories"""
|
409 |
+
exercise_lower = exercise.lower()
|
410 |
+
if any(term in exercise_lower for term in ['very high', 'extreme', 'intense']):
|
411 |
+
return 'very_high'
|
412 |
+
elif 'high' in exercise_lower:
|
413 |
+
return 'high'
|
414 |
+
elif 'moderate' in exercise_lower:
|
415 |
+
return 'moderate'
|
416 |
+
elif any(term in exercise_lower for term in ['low', 'minimal']):
|
417 |
+
return 'low'
|
418 |
+
else:
|
419 |
+
return 'moderate' # Default
|
420 |
+
|
421 |
+
def _calculate_space_compatibility(self, breed_size: str, exercise_level: str, space_req: Dict[str, float]) -> float:
|
422 |
+
"""Calculate dynamic space compatibility score"""
|
423 |
+
# Size-space compatibility matrix (dynamic, not hardcoded)
|
424 |
+
size_factors = {
|
425 |
+
'toy': 0.5, 'small': 1.0, 'medium': 1.5, 'large': 2.5, 'giant': 4.0
|
426 |
+
}
|
427 |
+
|
428 |
+
exercise_factors = {
|
429 |
+
'low': 1.0, 'moderate': 1.3, 'high': 1.8, 'very_high': 2.5
|
430 |
+
}
|
431 |
+
|
432 |
+
breed_space_need = size_factors[breed_size] * exercise_factors[exercise_level]
|
433 |
+
available_space = space_req['min_space']
|
434 |
+
|
435 |
+
# Calculate compatibility ratio
|
436 |
+
compatibility = available_space / breed_space_need
|
437 |
+
|
438 |
+
# Apply exercise penalty for high-energy breeds in small spaces
|
439 |
+
if exercise_level in ['high', 'very_high'] and available_space < 2.0:
|
440 |
+
compatibility *= (1.0 - space_req['exercise_penalty'] * 0.3)
|
441 |
+
|
442 |
+
return max(0.0, min(1.0, compatibility))
|
443 |
+
|
444 |
+
def _generate_filter_reason(self, breed_size: str, exercise_level: str, living_space: str) -> str:
|
445 |
+
"""Generate dynamic filtering reason"""
|
446 |
+
if breed_size in ['giant', 'large'] and living_space == 'apartment':
|
447 |
+
return f"{breed_size.title()} breed not suitable for apartment living"
|
448 |
+
elif exercise_level in ['high', 'very_high'] and living_space in ['apartment', 'small_house']:
|
449 |
+
return f"High-energy breed needs more space than {living_space.replace('_', ' ')}"
|
450 |
+
else:
|
451 |
+
return f"Space and exercise requirements exceed {living_space.replace('_', ' ')} capacity"
|
452 |
+
|
453 |
+
def filter_child_safety(self, candidates: Set[str],
|
454 |
+
dimensions: QueryDimensions) -> Dict[str, str]:
|
455 |
+
"""Child safety filtering"""
|
456 |
+
filtered = {}
|
457 |
+
|
458 |
+
for breed in list(candidates):
|
459 |
+
breed_info = self.breed_cache.get(breed, {})
|
460 |
+
good_with_children = breed_info.get('good_with_children', 'Yes')
|
461 |
+
size = breed_info.get('size', '')
|
462 |
+
temperament = breed_info.get('temperament', '')
|
463 |
+
|
464 |
+
# Breeds explicitly not suitable for children
|
465 |
+
if good_with_children == 'No':
|
466 |
+
filtered[breed] = "Not suitable for children"
|
467 |
+
# Large breeds without clear child compatibility indicators should be cautious
|
468 |
+
elif ('large' in size and good_with_children != 'Yes' and
|
469 |
+
any(trait in temperament for trait in ['aggressive', 'dominant', 'protective'])):
|
470 |
+
filtered[breed] = "Large breed with uncertain child compatibility"
|
471 |
+
|
472 |
+
return filtered
|
473 |
+
|
474 |
+
def filter_severe_allergies(self, candidates: Set[str],
|
475 |
+
dimensions: QueryDimensions) -> Dict[str, str]:
|
476 |
+
"""Severe allergy filtering"""
|
477 |
+
filtered = {}
|
478 |
+
|
479 |
+
# High shedding breed list (should be adjusted based on actual database)
|
480 |
+
high_shedding_breeds = {
|
481 |
+
'German_Shepherd', 'Golden_Retriever', 'Labrador_Retriever',
|
482 |
+
'Husky', 'Akita', 'Bernese_Mountain_Dog'
|
483 |
+
}
|
484 |
+
|
485 |
+
for breed in list(candidates):
|
486 |
+
if breed in high_shedding_breeds:
|
487 |
+
filtered[breed] = "High shedding breed not suitable for allergies"
|
488 |
+
|
489 |
+
return filtered
|
490 |
+
|
491 |
+
def filter_low_activity(self, candidates: Set[str],
|
492 |
+
dimensions: QueryDimensions) -> Dict[str, str]:
|
493 |
+
"""Low activity level filtering"""
|
494 |
+
filtered = {}
|
495 |
+
|
496 |
+
for breed in list(candidates):
|
497 |
+
breed_info = self.breed_cache.get(breed, {})
|
498 |
+
exercise_needs = breed_info.get('exercise_needs', '')
|
499 |
+
temperament = breed_info.get('temperament', '')
|
500 |
+
|
501 |
+
# High exercise requirement breeds
|
502 |
+
if 'high' in exercise_needs or 'very high' in exercise_needs:
|
503 |
+
filtered[breed] = "High exercise requirements unsuitable for low activity lifestyle"
|
504 |
+
# Working dogs, sporting dogs, herding dogs typically need substantial exercise
|
505 |
+
elif any(trait in temperament for trait in ['working', 'sporting', 'herding', 'energetic']):
|
506 |
+
filtered[breed] = "High-energy breed requiring substantial daily exercise"
|
507 |
+
|
508 |
+
return filtered
|
509 |
+
|
510 |
+
def filter_quiet_requirements(self, candidates: Set[str],
|
511 |
+
dimensions: QueryDimensions) -> Dict[str, str]:
|
512 |
+
"""Quiet requirement filtering"""
|
513 |
+
filtered = {}
|
514 |
+
|
515 |
+
for breed in list(candidates):
|
516 |
+
breed_info = self.breed_cache.get(breed, {})
|
517 |
+
noise_level = breed_info.get('noise_level', 'moderate').lower()
|
518 |
+
temperament = breed_info.get('temperament', '')
|
519 |
+
|
520 |
+
# High noise level breeds
|
521 |
+
if 'high' in noise_level or 'loud' in noise_level:
|
522 |
+
filtered[breed] = "High noise level unsuitable for quiet requirements"
|
523 |
+
# Terriers and hounds are typically more vocal
|
524 |
+
elif ('terrier' in breed.lower() or 'hound' in breed.lower() or
|
525 |
+
'vocal' in temperament):
|
526 |
+
filtered[breed] = "Breed group typically more vocal than desired"
|
527 |
+
|
528 |
+
return filtered
|
529 |
+
|
530 |
+
def filter_space_compatibility(self, candidates: Set[str],
|
531 |
+
dimensions: QueryDimensions) -> Dict[str, str]:
|
532 |
+
"""Space compatibility filtering"""
|
533 |
+
filtered = {}
|
534 |
+
|
535 |
+
# This function provides more refined space matching
|
536 |
+
for breed in list(candidates):
|
537 |
+
breed_info = self.breed_cache.get(breed, {})
|
538 |
+
size = breed_info.get('size', '')
|
539 |
+
exercise_needs = breed_info.get('exercise_needs', '')
|
540 |
+
|
541 |
+
# If house is specified but breed is too small, may not be optimal choice (soft constraint)
|
542 |
+
if ('house' in dimensions.spatial_constraints and
|
543 |
+
'tiny' in size and 'guard' in dimensions.special_requirements):
|
544 |
+
filtered[breed] = "Very small breed may not meet guard dog requirements for house"
|
545 |
+
|
546 |
+
return filtered
|
547 |
+
|
548 |
+
def filter_grooming_preferences(self, candidates: Set[str],
|
549 |
+
dimensions: QueryDimensions) -> Dict[str, str]:
|
550 |
+
"""Grooming preference filtering"""
|
551 |
+
filtered = {}
|
552 |
+
|
553 |
+
for breed in list(candidates):
|
554 |
+
breed_info = self.breed_cache.get(breed, {})
|
555 |
+
grooming_needs = breed_info.get('grooming_needs', '')
|
556 |
+
|
557 |
+
# Low maintenance needed but breed requires high maintenance
|
558 |
+
if ('low' in dimensions.maintenance_level and
|
559 |
+
'high' in grooming_needs):
|
560 |
+
filtered[breed] = "High grooming requirements exceed maintenance preferences"
|
561 |
+
# High maintenance preference but breed is too simple (rarely applicable)
|
562 |
+
elif ('high' in dimensions.maintenance_level and
|
563 |
+
'low' in grooming_needs):
|
564 |
+
# Usually don't filter out, as low maintenance is always good
|
565 |
+
pass
|
566 |
+
|
567 |
+
return filtered
|
568 |
+
|
569 |
+
def filter_experience_level(self, candidates: Set[str],
|
570 |
+
dimensions: QueryDimensions) -> Dict[str, str]:
|
571 |
+
"""Experience level filtering"""
|
572 |
+
filtered = {}
|
573 |
+
|
574 |
+
for breed in list(candidates):
|
575 |
+
breed_info = self.breed_cache.get(breed, {})
|
576 |
+
care_level = breed_info.get('care_level', '')
|
577 |
+
temperament = breed_info.get('temperament', '')
|
578 |
+
|
579 |
+
# Beginners not suitable for high maintenance or difficult breeds
|
580 |
+
if 'first_time' in dimensions.special_requirements:
|
581 |
+
if ('high' in care_level or 'expert' in care_level or
|
582 |
+
any(trait in temperament for trait in
|
583 |
+
['stubborn', 'independent', 'dominant', 'challenging'])):
|
584 |
+
filtered[breed] = "High care requirements unsuitable for first-time owners"
|
585 |
+
|
586 |
+
return filtered
|
587 |
+
|
588 |
+
def filter_size_preferences(self, candidates: Set[str],
|
589 |
+
dimensions: QueryDimensions) -> Dict[str, str]:
|
590 |
+
"""Size preference filtering"""
|
591 |
+
filtered = {}
|
592 |
+
|
593 |
+
# This is a soft constraint, usually won't completely exclude
|
594 |
+
size_preferences = dimensions.size_preferences
|
595 |
+
|
596 |
+
if not size_preferences:
|
597 |
+
return filtered
|
598 |
+
|
599 |
+
for breed in list(candidates):
|
600 |
+
breed_info = self.breed_cache.get(breed, {})
|
601 |
+
breed_size = breed_info.get('size', '')
|
602 |
+
|
603 |
+
# Check if matches preferences
|
604 |
+
size_match = False
|
605 |
+
for preferred_size in size_preferences:
|
606 |
+
if preferred_size in breed_size:
|
607 |
+
size_match = True
|
608 |
+
break
|
609 |
+
|
610 |
+
# Since this is a flexible constraint, usually won't filter out, only reflected in scores
|
611 |
+
# But if user is very explicit (e.g., only wants small dogs), can filter
|
612 |
+
if not size_match and len(size_preferences) == 1:
|
613 |
+
# Only filter when user has very explicit preference for single size
|
614 |
+
preferred = size_preferences[0]
|
615 |
+
if ((preferred == 'small' and 'large' in breed_size) or
|
616 |
+
(preferred == 'large' and 'small' in breed_size)):
|
617 |
+
filtered[breed] = f"Size mismatch: prefer {preferred} but breed is {breed_size}"
|
618 |
+
|
619 |
+
return filtered
|
620 |
+
|
621 |
+
def filter_exercise_mismatch(self, candidates: Set[str],
|
622 |
+
dimensions: QueryDimensions) -> Dict[str, str]:
|
623 |
+
"""Filter breeds with severe exercise mismatches using dynamic thresholds"""
|
624 |
+
filtered = {}
|
625 |
+
|
626 |
+
# Extract user exercise profile dynamically
|
627 |
+
user_profile = self._extract_exercise_profile(dimensions)
|
628 |
+
compatibility_threshold = self._get_exercise_threshold(user_profile)
|
629 |
+
|
630 |
+
for breed in candidates:
|
631 |
+
breed_info = self.breed_cache.get(breed, {})
|
632 |
+
breed_exercise_level = self._normalize_exercise_level(breed_info.get('exercise_needs', 'Moderate'))
|
633 |
+
|
634 |
+
# Calculate exercise compatibility score
|
635 |
+
compatibility = self._calculate_exercise_compatibility(
|
636 |
+
user_profile, breed_exercise_level
|
637 |
+
)
|
638 |
+
|
639 |
+
# Apply threshold-based filtering
|
640 |
+
if compatibility < compatibility_threshold:
|
641 |
+
reason = self._generate_exercise_filter_reason(user_profile, breed_exercise_level)
|
642 |
+
filtered[breed] = reason
|
643 |
+
|
644 |
+
return filtered
|
645 |
+
|
646 |
+
def _extract_exercise_profile(self, dimensions: QueryDimensions) -> Dict[str, str]:
|
647 |
+
"""Extract comprehensive user exercise profile"""
|
648 |
+
activity_text = ' '.join(dimensions.activity_level).lower()
|
649 |
+
spatial_text = ' '.join(dimensions.spatial_constraints).lower()
|
650 |
+
|
651 |
+
# Determine exercise level
|
652 |
+
if any(term in activity_text for term in ['don\'t exercise', 'minimal', 'low', 'light walks']):
|
653 |
+
level = 'low'
|
654 |
+
elif any(term in activity_text for term in ['hiking', 'running', 'active', 'athletic']):
|
655 |
+
level = 'high'
|
656 |
+
elif any(term in activity_text for term in ['30 minutes', 'moderate', 'balanced']):
|
657 |
+
level = 'moderate'
|
658 |
+
else:
|
659 |
+
# Infer from living space
|
660 |
+
if 'apartment' in spatial_text:
|
661 |
+
level = 'low_moderate'
|
662 |
+
else:
|
663 |
+
level = 'moderate'
|
664 |
+
|
665 |
+
# Determine time commitment
|
666 |
+
if any(term in activity_text for term in ['30 minutes', 'half hour']):
|
667 |
+
time = 'limited'
|
668 |
+
elif any(term in activity_text for term in ['hiking', 'outdoor activities']):
|
669 |
+
time = 'extensive'
|
670 |
+
else:
|
671 |
+
time = 'moderate'
|
672 |
+
|
673 |
+
return {'level': level, 'time': time}
|
674 |
+
|
675 |
+
def _get_exercise_threshold(self, user_profile: Dict[str, str]) -> float:
|
676 |
+
"""Get dynamic threshold based on user profile"""
|
677 |
+
base_threshold = 0.4
|
678 |
+
|
679 |
+
# Adjust threshold based on user constraints
|
680 |
+
if user_profile['level'] == 'low':
|
681 |
+
base_threshold = 0.6 # Stricter for low-activity users
|
682 |
+
elif user_profile['level'] == 'high':
|
683 |
+
base_threshold = 0.3 # More lenient for active users
|
684 |
+
|
685 |
+
return base_threshold
|
686 |
+
|
687 |
+
def _calculate_exercise_compatibility(self, user_profile: Dict[str, str], breed_level: str) -> float:
|
688 |
+
"""Calculate dynamic exercise compatibility"""
|
689 |
+
# Exercise level compatibility matrix
|
690 |
+
compatibility_matrix = {
|
691 |
+
'low': {'low': 1.0, 'moderate': 0.7, 'high': 0.3, 'very_high': 0.1},
|
692 |
+
'low_moderate': {'low': 0.9, 'moderate': 1.0, 'high': 0.5, 'very_high': 0.2},
|
693 |
+
'moderate': {'low': 0.8, 'moderate': 1.0, 'high': 0.8, 'very_high': 0.4},
|
694 |
+
'high': {'low': 0.5, 'moderate': 0.8, 'high': 1.0, 'very_high': 0.9}
|
695 |
+
}
|
696 |
+
|
697 |
+
user_level = user_profile['level']
|
698 |
+
base_compatibility = compatibility_matrix.get(user_level, {}).get(breed_level, 0.5)
|
699 |
+
|
700 |
+
# Adjust for time commitment
|
701 |
+
if user_profile['time'] == 'limited' and breed_level in ['high', 'very_high']:
|
702 |
+
base_compatibility *= 0.7
|
703 |
+
elif user_profile['time'] == 'extensive' and breed_level == 'low':
|
704 |
+
base_compatibility *= 0.8
|
705 |
+
|
706 |
+
return base_compatibility
|
707 |
+
|
708 |
+
def _generate_exercise_filter_reason(self, user_profile: Dict[str, str], breed_level: str) -> str:
|
709 |
+
"""Generate dynamic exercise filtering reason"""
|
710 |
+
user_level = user_profile['level']
|
711 |
+
|
712 |
+
if user_level == 'low' and breed_level in ['high', 'very_high']:
|
713 |
+
return f"High-energy breed unsuitable for low-activity lifestyle"
|
714 |
+
elif user_level == 'high' and breed_level == 'low':
|
715 |
+
return f"Low-energy breed may not match active lifestyle requirements"
|
716 |
+
else:
|
717 |
+
return f"Exercise requirements mismatch: {user_level} user with {breed_level} breed"
|
718 |
+
|
719 |
+
def filter_size_bias(self, candidates: Set[str], dimensions: QueryDimensions) -> Dict[str, str]:
|
720 |
+
"""Filter to correct size bias for moderate lifestyle users"""
|
721 |
+
filtered = {}
|
722 |
+
|
723 |
+
# Detect moderate lifestyle indicators
|
724 |
+
activity_text = ' '.join(dimensions.activity_level).lower()
|
725 |
+
is_moderate_lifestyle = any(term in activity_text for term in
|
726 |
+
['moderate', 'balanced', '30 minutes', 'medium-sized house'])
|
727 |
+
|
728 |
+
if not is_moderate_lifestyle:
|
729 |
+
return filtered # No filtering needed
|
730 |
+
|
731 |
+
# Count size distribution in candidates
|
732 |
+
size_counts = {'toy': 0, 'small': 0, 'medium': 0, 'large': 0, 'giant': 0}
|
733 |
+
total_candidates = len(candidates)
|
734 |
+
|
735 |
+
for breed in candidates:
|
736 |
+
breed_info = self.breed_cache.get(breed, {})
|
737 |
+
breed_size = self._normalize_breed_size(breed_info.get('size', 'Medium'))
|
738 |
+
size_counts[breed_size] += 1
|
739 |
+
|
740 |
+
# Check for size bias (too many large/giant breeds)
|
741 |
+
large_giant_ratio = (size_counts['large'] + size_counts['giant']) / max(total_candidates, 1)
|
742 |
+
|
743 |
+
if large_giant_ratio > 0.6: # More than 60% large/giant breeds
|
744 |
+
# Filter some large/giant breeds to balance distribution
|
745 |
+
large_giant_filtered = 0
|
746 |
+
target_reduction = int((large_giant_ratio - 0.4) * total_candidates)
|
747 |
+
|
748 |
+
for breed in list(candidates):
|
749 |
+
if large_giant_filtered >= target_reduction:
|
750 |
+
break
|
751 |
+
|
752 |
+
breed_info = self.breed_cache.get(breed, {})
|
753 |
+
breed_size = self._normalize_breed_size(breed_info.get('size', 'Medium'))
|
754 |
+
|
755 |
+
if breed_size in ['large', 'giant']:
|
756 |
+
# Check if breed has additional compatibility issues
|
757 |
+
exercise_level = self._normalize_exercise_level(
|
758 |
+
breed_info.get('exercise_needs', 'Moderate')
|
759 |
+
)
|
760 |
+
|
761 |
+
if breed_size == 'giant' or exercise_level == 'very_high':
|
762 |
+
filtered[breed] = f"Size bias correction: {breed_size} breed less suitable for moderate lifestyle"
|
763 |
+
large_giant_filtered += 1
|
764 |
+
|
765 |
+
return filtered
|
766 |
+
|
767 |
+
def _get_emergency_candidates(self) -> Set[str]:
|
768 |
+
"""Get emergency candidate breeds (safest choices)"""
|
769 |
+
safe_breeds = {
|
770 |
+
'Labrador_Retriever', 'Golden_Retriever', 'Cavalier_King_Charles_Spaniel',
|
771 |
+
'Bichon_Frise', 'French_Bulldog', 'Boston_Terrier', 'Pug'
|
772 |
+
}
|
773 |
+
|
774 |
+
# Only return breeds that exist in the database
|
775 |
+
available_safe_breeds = safe_breeds.intersection(set(self.breed_list))
|
776 |
+
|
777 |
+
if not available_safe_breeds:
|
778 |
+
# If even safe breeds are not available, return first few breeds
|
779 |
+
return set(self.breed_list[:5])
|
780 |
+
|
781 |
+
return available_safe_breeds
|
782 |
+
|
783 |
+
def get_constraint_summary(self, filter_result: FilterResult) -> Dict[str, Any]:
|
784 |
+
"""Get constraint application summary"""
|
785 |
+
return {
|
786 |
+
'total_breeds': len(self.breed_list),
|
787 |
+
'passed_breeds': len(filter_result.passed_breeds),
|
788 |
+
'filtered_breeds': len(filter_result.filtered_breeds),
|
789 |
+
'applied_constraints': filter_result.applied_constraints,
|
790 |
+
'relaxed_constraints': filter_result.relaxed_constraints,
|
791 |
+
'warnings': filter_result.warnings,
|
792 |
+
'pass_rate': len(filter_result.passed_breeds) / len(self.breed_list),
|
793 |
+
'filter_breakdown': self._get_filter_breakdown(filter_result)
|
794 |
+
}
|
795 |
+
|
796 |
+
def _get_filter_breakdown(self, filter_result: FilterResult) -> Dict[str, int]:
|
797 |
+
"""Get filtering reason breakdown"""
|
798 |
+
breakdown = {}
|
799 |
+
|
800 |
+
for breed, reason in filter_result.filtered_breeds.items():
|
801 |
+
# Simplify reason categorization
|
802 |
+
if 'apartment' in reason.lower() or 'large' in reason.lower():
|
803 |
+
category = 'Size/Space Issues'
|
804 |
+
elif 'child' in reason.lower():
|
805 |
+
category = 'Child Safety'
|
806 |
+
elif 'allerg' in reason.lower() or 'shed' in reason.lower():
|
807 |
+
category = 'Allergy Concerns'
|
808 |
+
elif 'exercise' in reason.lower() or 'activity' in reason.lower():
|
809 |
+
category = 'Exercise/Activity Mismatch'
|
810 |
+
elif 'noise' in reason.lower() or 'bark' in reason.lower():
|
811 |
+
category = 'Noise Issues'
|
812 |
+
elif 'groom' in reason.lower() or 'maintenance' in reason.lower():
|
813 |
+
category = 'Maintenance Requirements'
|
814 |
+
elif 'experience' in reason.lower() or 'first-time' in reason.lower():
|
815 |
+
category = 'Experience Level'
|
816 |
+
else:
|
817 |
+
category = 'Other'
|
818 |
+
|
819 |
+
breakdown[category] = breakdown.get(category, 0) + 1
|
820 |
+
|
821 |
+
return breakdown
|
822 |
+
|
823 |
+
def apply_breed_constraints(dimensions: QueryDimensions,
|
824 |
+
min_candidates: int = 12) -> FilterResult:
|
825 |
+
"""
|
826 |
+
Convenience function: Apply breed constraint filtering
|
827 |
+
|
828 |
+
Args:
|
829 |
+
dimensions: Query dimensions
|
830 |
+
min_candidates: Minimum number of candidate breeds
|
831 |
+
|
832 |
+
Returns:
|
833 |
+
FilterResult: Filtering results
|
834 |
+
"""
|
835 |
+
manager = ConstraintManager()
|
836 |
+
return manager.apply_constraints(dimensions, min_candidates)
|
837 |
+
|
838 |
+
def get_filtered_breeds(dimensions: QueryDimensions) -> Tuple[List[str], Dict[str, Any]]:
|
839 |
+
"""
|
840 |
+
Convenience function: Get filtered breed list and summary
|
841 |
+
|
842 |
+
Args:
|
843 |
+
dimensions: Query dimensions
|
844 |
+
|
845 |
+
Returns:
|
846 |
+
Tuple: (Filtered breed list, filtering summary)
|
847 |
+
"""
|
848 |
+
manager = ConstraintManager()
|
849 |
+
result = manager.apply_constraints(dimensions)
|
850 |
+
summary = manager.get_constraint_summary(result)
|
851 |
+
|
852 |
+
return list(result.passed_breeds), summary
|
dimension_score_calculator.py
ADDED
@@ -0,0 +1,782 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
import traceback
|
2 |
+
from typing import Dict, Any
|
3 |
+
from breed_health_info import breed_health_info
|
4 |
+
from breed_noise_info import breed_noise_info
|
5 |
+
|
6 |
+
class DimensionScoreCalculator:
|
7 |
+
"""
|
8 |
+
維度評分計算器類別
|
9 |
+
負責計算各個維度的具體評分,包含空間、運動、美容、經驗、健康和噪音等維度
|
10 |
+
"""
|
11 |
+
|
12 |
+
def __init__(self):
|
13 |
+
"""初始化維度評分計算器"""
|
14 |
+
pass
|
15 |
+
|
16 |
+
def calculate_space_score(self, size: str, living_space: str, has_yard: bool, exercise_needs: str) -> float:
|
17 |
+
"""
|
18 |
+
計算空間適配性評分
|
19 |
+
|
20 |
+
完整實現原始版本的空間計算邏輯,包含:
|
21 |
+
1. 動態的基礎分數矩陣
|
22 |
+
2. 強化空間品質評估
|
23 |
+
3. 增加極端情況處理
|
24 |
+
4. 考慮不同空間組合的協同效應
|
25 |
+
"""
|
26 |
+
def get_base_score():
|
27 |
+
# 基礎分數矩陣 - 更極端的分數分配
|
28 |
+
base_matrix = {
|
29 |
+
"Small": {
|
30 |
+
"apartment": {
|
31 |
+
"no_yard": 0.85, # 小型犬在公寓仍然適合
|
32 |
+
"shared_yard": 0.90, # 共享院子提供額外活動空間
|
33 |
+
"private_yard": 0.95 # 私人院子最理想
|
34 |
+
},
|
35 |
+
"house_small": {
|
36 |
+
"no_yard": 0.80,
|
37 |
+
"shared_yard": 0.85,
|
38 |
+
"private_yard": 0.90
|
39 |
+
},
|
40 |
+
"house_large": {
|
41 |
+
"no_yard": 0.75,
|
42 |
+
"shared_yard": 0.80,
|
43 |
+
"private_yard": 0.85
|
44 |
+
}
|
45 |
+
},
|
46 |
+
"Medium": {
|
47 |
+
"apartment": {
|
48 |
+
"no_yard": 0.75,
|
49 |
+
"shared_yard": 0.85,
|
50 |
+
"private_yard": 0.90
|
51 |
+
},
|
52 |
+
"house_small": {
|
53 |
+
"no_yard": 0.80,
|
54 |
+
"shared_yard": 0.90,
|
55 |
+
"private_yard": 0.90
|
56 |
+
},
|
57 |
+
"house_large": {
|
58 |
+
"no_yard": 0.85,
|
59 |
+
"shared_yard": 0.90,
|
60 |
+
"private_yard": 0.95
|
61 |
+
}
|
62 |
+
},
|
63 |
+
"Large": {
|
64 |
+
"apartment": {
|
65 |
+
"no_yard": 0.70,
|
66 |
+
"shared_yard": 0.80,
|
67 |
+
"private_yard": 0.85
|
68 |
+
},
|
69 |
+
"house_small": {
|
70 |
+
"no_yard": 0.75,
|
71 |
+
"shared_yard": 0.85,
|
72 |
+
"private_yard": 0.90
|
73 |
+
},
|
74 |
+
"house_large": {
|
75 |
+
"no_yard": 0.85,
|
76 |
+
"shared_yard": 0.90,
|
77 |
+
"private_yard": 1.0
|
78 |
+
}
|
79 |
+
},
|
80 |
+
"Giant": {
|
81 |
+
"apartment": {
|
82 |
+
"no_yard": 0.65,
|
83 |
+
"shared_yard": 0.75,
|
84 |
+
"private_yard": 0.80
|
85 |
+
},
|
86 |
+
"house_small": {
|
87 |
+
"no_yard": 0.70,
|
88 |
+
"shared_yard": 0.80,
|
89 |
+
"private_yard": 0.85
|
90 |
+
},
|
91 |
+
"house_large": {
|
92 |
+
"no_yard": 0.80,
|
93 |
+
"shared_yard": 0.90,
|
94 |
+
"private_yard": 1.0
|
95 |
+
}
|
96 |
+
}
|
97 |
+
}
|
98 |
+
|
99 |
+
yard_type = "private_yard" if has_yard else "no_yard"
|
100 |
+
return base_matrix.get(size, base_matrix["Medium"])[living_space][yard_type]
|
101 |
+
|
102 |
+
def calculate_exercise_adjustment():
|
103 |
+
# 運動需求對空間評分的影響
|
104 |
+
exercise_impact = {
|
105 |
+
"Very High": {
|
106 |
+
"apartment": -0.10,
|
107 |
+
"house_small": -0.05,
|
108 |
+
"house_large": 0
|
109 |
+
},
|
110 |
+
"High": {
|
111 |
+
"apartment": -0.08,
|
112 |
+
"house_small": -0.05,
|
113 |
+
"house_large": 0
|
114 |
+
},
|
115 |
+
"Moderate": {
|
116 |
+
"apartment": -0.05,
|
117 |
+
"house_small": -0.02,
|
118 |
+
"house_large": 0
|
119 |
+
},
|
120 |
+
"Low": {
|
121 |
+
"apartment": 0.10,
|
122 |
+
"house_small": 0.05,
|
123 |
+
"house_large": 0
|
124 |
+
}
|
125 |
+
}
|
126 |
+
|
127 |
+
return exercise_impact.get(exercise_needs, exercise_impact["Moderate"])[living_space]
|
128 |
+
|
129 |
+
def calculate_yard_bonus():
|
130 |
+
# 院子效益評估更加細緻
|
131 |
+
if not has_yard:
|
132 |
+
return 0
|
133 |
+
|
134 |
+
yard_benefits = {
|
135 |
+
"Giant": {
|
136 |
+
"Very High": 0.25,
|
137 |
+
"High": 0.20,
|
138 |
+
"Moderate": 0.15,
|
139 |
+
"Low": 0.10
|
140 |
+
},
|
141 |
+
"Large": {
|
142 |
+
"Very High": 0.20,
|
143 |
+
"High": 0.15,
|
144 |
+
"Moderate": 0.10,
|
145 |
+
"Low": 0.05
|
146 |
+
},
|
147 |
+
"Medium": {
|
148 |
+
"Very High": 0.15,
|
149 |
+
"High": 0.10,
|
150 |
+
"Moderate": 0.08,
|
151 |
+
"Low": 0.05
|
152 |
+
},
|
153 |
+
"Small": {
|
154 |
+
"Very High": 0.10,
|
155 |
+
"High": 0.08,
|
156 |
+
"Moderate": 0.05,
|
157 |
+
"Low": 0.03
|
158 |
+
}
|
159 |
+
}
|
160 |
+
|
161 |
+
size_benefits = yard_benefits.get(size, yard_benefits["Medium"])
|
162 |
+
return size_benefits.get(exercise_needs, size_benefits["Moderate"])
|
163 |
+
|
164 |
+
def apply_extreme_case_adjustments(score):
|
165 |
+
# 處理極端情況
|
166 |
+
if size == "Giant" and living_space == "apartment":
|
167 |
+
return score * 0.85
|
168 |
+
|
169 |
+
if size == "Large" and living_space == "apartment" and exercise_needs == "Very High":
|
170 |
+
return score * 0.85
|
171 |
+
|
172 |
+
if size == "Small" and living_space == "house_large" and exercise_needs == "Low":
|
173 |
+
return score * 0.9 # 低運動需求的小型犬在大房子可能過於寬敞
|
174 |
+
|
175 |
+
return score
|
176 |
+
|
177 |
+
# 計算最終分數
|
178 |
+
base_score = get_base_score()
|
179 |
+
exercise_adj = calculate_exercise_adjustment()
|
180 |
+
yard_bonus = calculate_yard_bonus()
|
181 |
+
|
182 |
+
# 整合所有評分因素
|
183 |
+
initial_score = base_score + exercise_adj + yard_bonus
|
184 |
+
|
185 |
+
# 應用極端情況調整
|
186 |
+
final_score = apply_extreme_case_adjustments(initial_score)
|
187 |
+
|
188 |
+
# 確保分數在有效範圍內,但允許更極端的結果
|
189 |
+
return max(0.05, min(1.0, final_score))
|
190 |
+
|
191 |
+
def calculate_exercise_score(self, breed_needs: str, exercise_time: int, exercise_type: str, breed_size: str, living_space: str, breed_info: dict = None) -> float:
|
192 |
+
"""
|
193 |
+
計算品種運動需求與使用者運動條件的匹配度。此函數特別著重:
|
194 |
+
1. 不同品種的運動耐受度差異
|
195 |
+
2. 運動時間與類型的匹配度
|
196 |
+
3. 極端運動量的嚴格限制
|
197 |
+
|
198 |
+
Parameters:
|
199 |
+
breed_needs: 品種的運動需求等級
|
200 |
+
exercise_time: 使用者計劃的運動時間(分鐘)
|
201 |
+
exercise_type: 運動類型(輕度/中度/高度)
|
202 |
+
|
203 |
+
Returns:
|
204 |
+
float: 0.1到1.0之間的匹配分數
|
205 |
+
"""
|
206 |
+
# 定義每個運動需求等級的具體參數
|
207 |
+
exercise_levels = {
|
208 |
+
'VERY HIGH': {
|
209 |
+
'min': 120, # 最低需求
|
210 |
+
'ideal': 150, # 理想運動量
|
211 |
+
'max': 180, # 最大建議量
|
212 |
+
'type_weights': { # 不同運動類型的權重
|
213 |
+
'active_training': 1.0,
|
214 |
+
'moderate_activity': 0.6,
|
215 |
+
'light_walks': 0.3
|
216 |
+
}
|
217 |
+
},
|
218 |
+
'HIGH': {
|
219 |
+
'min': 90,
|
220 |
+
'ideal': 120,
|
221 |
+
'max': 150,
|
222 |
+
'type_weights': {
|
223 |
+
'active_training': 0.9,
|
224 |
+
'moderate_activity': 0.8,
|
225 |
+
'light_walks': 0.4
|
226 |
+
}
|
227 |
+
},
|
228 |
+
'MODERATE': {
|
229 |
+
'min': 45,
|
230 |
+
'ideal': 60,
|
231 |
+
'max': 90,
|
232 |
+
'type_weights': {
|
233 |
+
'active_training': 0.7,
|
234 |
+
'moderate_activity': 1.0,
|
235 |
+
'light_walks': 0.8
|
236 |
+
}
|
237 |
+
},
|
238 |
+
'LOW': {
|
239 |
+
'min': 15,
|
240 |
+
'ideal': 30,
|
241 |
+
'max': 45,
|
242 |
+
'type_weights': {
|
243 |
+
'active_training': 0.5,
|
244 |
+
'moderate_activity': 0.8,
|
245 |
+
'light_walks': 1.0
|
246 |
+
}
|
247 |
+
}
|
248 |
+
}
|
249 |
+
|
250 |
+
# 獲取品種的運動參數
|
251 |
+
breed_level = exercise_levels.get(breed_needs.upper(), exercise_levels['MODERATE'])
|
252 |
+
|
253 |
+
# 計算時間匹配度
|
254 |
+
def calculate_time_score():
|
255 |
+
"""計算運動時間的匹配度,特別處理過度運動的情況"""
|
256 |
+
if exercise_time < breed_level['min']:
|
257 |
+
# 運動不足的嚴格懲罰
|
258 |
+
deficit_ratio = exercise_time / breed_level['min']
|
259 |
+
return max(0.1, deficit_ratio * 0.4)
|
260 |
+
|
261 |
+
elif exercise_time <= breed_level['ideal']:
|
262 |
+
# 理想範圍內的漸進提升
|
263 |
+
progress = (exercise_time - breed_level['min']) / (breed_level['ideal'] - breed_level['min'])
|
264 |
+
return 0.6 + (progress * 0.4)
|
265 |
+
|
266 |
+
elif exercise_time <= breed_level['max']:
|
267 |
+
# 理想到最大範圍的平緩下降
|
268 |
+
excess_ratio = (exercise_time - breed_level['ideal']) / (breed_level['max'] - breed_level['ideal'])
|
269 |
+
return 1.0 - (excess_ratio * 0.2)
|
270 |
+
|
271 |
+
else:
|
272 |
+
# 過度運動的顯著懲罰
|
273 |
+
excess = (exercise_time - breed_level['max']) / breed_level['max']
|
274 |
+
# 低運動需求品種的過度運動懲罰更嚴重
|
275 |
+
penalty_factor = 1.5 if breed_needs.upper() == 'LOW' else 1.0
|
276 |
+
return max(0.1, 0.8 - (excess * 0.5 * penalty_factor))
|
277 |
+
|
278 |
+
# 計算運動類型匹配度
|
279 |
+
def calculate_type_score():
|
280 |
+
"""評估運動類型的適合度,考慮品種特性"""
|
281 |
+
base_type_score = breed_level['type_weights'].get(exercise_type, 0.5)
|
282 |
+
|
283 |
+
# 特殊情況處理
|
284 |
+
if breed_needs.upper() == 'LOW' and exercise_type == 'active_training':
|
285 |
+
# 低運動需求品種不適合高強度運動
|
286 |
+
base_type_score *= 0.5
|
287 |
+
elif breed_needs.upper() == 'VERY HIGH' and exercise_type == 'light_walks':
|
288 |
+
# 高運動需求品種需要更多強度
|
289 |
+
base_type_score *= 0.6
|
290 |
+
|
291 |
+
return base_type_score
|
292 |
+
|
293 |
+
# 計算最終分數
|
294 |
+
time_score = calculate_time_score()
|
295 |
+
type_score = calculate_type_score()
|
296 |
+
|
297 |
+
# 根據運動需求等級調整權重
|
298 |
+
if breed_needs.upper() == 'LOW':
|
299 |
+
# 低運動需求品種更重視運動類型的合適性
|
300 |
+
final_score = (time_score * 0.6) + (type_score * 0.4)
|
301 |
+
elif breed_needs.upper() == 'VERY HIGH':
|
302 |
+
# 高運動需求品種更重視運動時間的充足性
|
303 |
+
final_score = (time_score * 0.7) + (type_score * 0.3)
|
304 |
+
else:
|
305 |
+
final_score = (time_score * 0.65) + (type_score * 0.35)
|
306 |
+
|
307 |
+
if breed_size in ['Large', 'Giant'] and living_space == 'apartment':
|
308 |
+
if exercise_time >= 120:
|
309 |
+
final_score = min(1.0, final_score * 1.2)
|
310 |
+
|
311 |
+
# 極端情況的最終調整
|
312 |
+
if breed_needs.upper() == 'LOW' and exercise_time > breed_level['max'] * 2:
|
313 |
+
# 低運動需求品種的過度運動顯著降分
|
314 |
+
final_score *= 0.6
|
315 |
+
elif breed_needs.upper() == 'VERY HIGH' and exercise_time < breed_level['min'] * 0.5:
|
316 |
+
# 高運動需求品種運動嚴重不足降分
|
317 |
+
final_score *= 0.5
|
318 |
+
|
319 |
+
return max(0.1, min(1.0, final_score))
|
320 |
+
|
321 |
+
def calculate_grooming_score(self, breed_needs: str, user_commitment: str, breed_size: str) -> float:
|
322 |
+
"""
|
323 |
+
計算美容需求分數,強化美容維護需求與使用者承諾度的匹配評估。
|
324 |
+
這個函數特別注意品種大小對美容工作的影響,以及不同程度的美容需求對時間投入的要求。
|
325 |
+
"""
|
326 |
+
# 重新設計基礎分數矩陣,讓美容需求的差異更加明顯
|
327 |
+
base_scores = {
|
328 |
+
"High": {
|
329 |
+
"low": 0.20, # 高需求對低承諾極不合適,顯著降低初始分數
|
330 |
+
"medium": 0.65, # 中等承諾仍有挑戰
|
331 |
+
"high": 1.0 # 高承諾最適合
|
332 |
+
},
|
333 |
+
"Moderate": {
|
334 |
+
"low": 0.45, # 中等需求對低承諾有困難
|
335 |
+
"medium": 0.85, # 較好的匹配
|
336 |
+
"high": 0.95 # 高承諾會有餘力
|
337 |
+
},
|
338 |
+
"Low": {
|
339 |
+
"low": 0.90, # 低需求對低承諾很合適
|
340 |
+
"medium": 0.85, # 略微降低以反映可能過度投入
|
341 |
+
"high": 0.80 # 可能造成資源浪費
|
342 |
+
}
|
343 |
+
}
|
344 |
+
|
345 |
+
# 取得基礎分數
|
346 |
+
base_score = base_scores.get(breed_needs, base_scores["Moderate"])[user_commitment]
|
347 |
+
|
348 |
+
# 根據品種大小調整美容工作量
|
349 |
+
size_adjustments = {
|
350 |
+
"Giant": {
|
351 |
+
"low": -0.20, # 大型犬的美容工作量顯著增加
|
352 |
+
"medium": -0.10,
|
353 |
+
"high": -0.05
|
354 |
+
},
|
355 |
+
"Large": {
|
356 |
+
"low": -0.15,
|
357 |
+
"medium": -0.05,
|
358 |
+
"high": 0
|
359 |
+
},
|
360 |
+
"Medium": {
|
361 |
+
"low": -0.10,
|
362 |
+
"medium": -0.05,
|
363 |
+
"high": 0
|
364 |
+
},
|
365 |
+
"Small": {
|
366 |
+
"low": -0.05,
|
367 |
+
"medium": 0,
|
368 |
+
"high": 0
|
369 |
+
}
|
370 |
+
}
|
371 |
+
|
372 |
+
# 應用體型調整
|
373 |
+
size_adjustment = size_adjustments.get(breed_size, size_adjustments["Medium"])[user_commitment]
|
374 |
+
current_score = base_score + size_adjustment
|
375 |
+
|
376 |
+
# 特殊毛髮類型的額外調整
|
377 |
+
def get_coat_adjustment(breed_description: str, commitment: str) -> float:
|
378 |
+
"""評估特殊毛髮類型所需的額外維護工作"""
|
379 |
+
adjustments = 0
|
380 |
+
|
381 |
+
# 長毛品種需要更多維護
|
382 |
+
if 'long coat' in breed_description.lower():
|
383 |
+
coat_penalties = {
|
384 |
+
'low': -0.20,
|
385 |
+
'medium': -0.15,
|
386 |
+
'high': -0.05
|
387 |
+
}
|
388 |
+
adjustments += coat_penalties[commitment]
|
389 |
+
|
390 |
+
# 雙層毛的品種掉毛量更大
|
391 |
+
if 'double coat' in breed_description.lower():
|
392 |
+
double_coat_penalties = {
|
393 |
+
'low': -0.15,
|
394 |
+
'medium': -0.10,
|
395 |
+
'high': -0.05
|
396 |
+
}
|
397 |
+
adjustments += double_coat_penalties[commitment]
|
398 |
+
|
399 |
+
# 捲毛品種需要定期專業修剪
|
400 |
+
if 'curly' in breed_description.lower():
|
401 |
+
curly_penalties = {
|
402 |
+
'low': -0.15,
|
403 |
+
'medium': -0.10,
|
404 |
+
'high': -0.05
|
405 |
+
}
|
406 |
+
adjustments += curly_penalties[commitment]
|
407 |
+
|
408 |
+
return adjustments
|
409 |
+
|
410 |
+
# 季節性考量
|
411 |
+
def get_seasonal_adjustment(breed_description: str, commitment: str) -> float:
|
412 |
+
"""評估季節性掉毛對美容需求的影響"""
|
413 |
+
if 'seasonal shedding' in breed_description.lower():
|
414 |
+
seasonal_penalties = {
|
415 |
+
'low': -0.15,
|
416 |
+
'medium': -0.10,
|
417 |
+
'high': -0.05
|
418 |
+
}
|
419 |
+
return seasonal_penalties[commitment]
|
420 |
+
return 0
|
421 |
+
|
422 |
+
# 專業美容需求評估
|
423 |
+
def get_professional_grooming_adjustment(breed_description: str, commitment: str) -> float:
|
424 |
+
"""評估需要專業美容服務的影響"""
|
425 |
+
if 'professional grooming' in breed_description.lower():
|
426 |
+
grooming_penalties = {
|
427 |
+
'low': -0.20,
|
428 |
+
'medium': -0.15,
|
429 |
+
'high': -0.05
|
430 |
+
}
|
431 |
+
return grooming_penalties[commitment]
|
432 |
+
return 0
|
433 |
+
|
434 |
+
# 應用所有額外調整
|
435 |
+
coat_adjustment = get_coat_adjustment("", user_commitment)
|
436 |
+
seasonal_adjustment = get_seasonal_adjustment("", user_commitment)
|
437 |
+
professional_adjustment = get_professional_grooming_adjustment("", user_commitment)
|
438 |
+
|
439 |
+
final_score = current_score + coat_adjustment + seasonal_adjustment + professional_adjustment
|
440 |
+
|
441 |
+
# 確保分數在有意義的範圍內,但允許更大的差異
|
442 |
+
return max(0.1, min(1.0, final_score))
|
443 |
+
|
444 |
+
def calculate_experience_score(self, care_level: str, user_experience: str, temperament: str) -> float:
|
445 |
+
"""
|
446 |
+
計算使用者經驗與品種需求的匹配分數,更平衡的經驗等級影響
|
447 |
+
|
448 |
+
改進重點:
|
449 |
+
1. 提高初學者的基礎分數
|
450 |
+
2. 縮小經驗等級間的差距
|
451 |
+
3. 保持適度的區分度
|
452 |
+
"""
|
453 |
+
# 基礎分數矩陣 - 更合理的分數分配
|
454 |
+
base_scores = {
|
455 |
+
"High": {
|
456 |
+
"beginner": 0.55, # 提高起始分,讓新手也有機會
|
457 |
+
"intermediate": 0.80, # 中級玩家有不錯的勝任能力
|
458 |
+
"advanced": 0.95 # 資深者幾乎完全勝任
|
459 |
+
},
|
460 |
+
"Moderate": {
|
461 |
+
"beginner": 0.65, # 適中難度對新手更友善
|
462 |
+
"intermediate": 0.85, # 中級玩家相當適合
|
463 |
+
"advanced": 0.90 # 資深者完全勝任
|
464 |
+
},
|
465 |
+
"Low": {
|
466 |
+
"beginner": 0.85, # 新手友善品種維持高分
|
467 |
+
"intermediate": 0.90, # 中級玩家幾乎完全勝任
|
468 |
+
"advanced": 0.90 # 資深者完全勝任
|
469 |
+
}
|
470 |
+
}
|
471 |
+
|
472 |
+
# 取得基礎分數
|
473 |
+
score = base_scores.get(care_level, base_scores["Moderate"])[user_experience]
|
474 |
+
|
475 |
+
# 性格評估的權重也需要調整
|
476 |
+
temperament_lower = temperament.lower()
|
477 |
+
temperament_adjustments = 0.0
|
478 |
+
|
479 |
+
# 根據經驗等級設定不同的特徵評估標準,降低懲罰程度
|
480 |
+
if user_experience == "beginner":
|
481 |
+
difficult_traits = {
|
482 |
+
'stubborn': -0.15, # 降低懲罰程度
|
483 |
+
'independent': -0.12,
|
484 |
+
'dominant': -0.12,
|
485 |
+
'strong-willed': -0.10,
|
486 |
+
'protective': -0.10,
|
487 |
+
'aloof': -0.08,
|
488 |
+
'energetic': -0.08,
|
489 |
+
'aggressive': -0.20 # 保持較高懲罰,因為安全考慮
|
490 |
+
}
|
491 |
+
|
492 |
+
easy_traits = {
|
493 |
+
'gentle': 0.08, # 提高獎勵以平衡
|
494 |
+
'friendly': 0.08,
|
495 |
+
'eager to please': 0.10,
|
496 |
+
'patient': 0.08,
|
497 |
+
'adaptable': 0.08,
|
498 |
+
'calm': 0.08
|
499 |
+
}
|
500 |
+
|
501 |
+
# 計算特徵調整
|
502 |
+
for trait, penalty in difficult_traits.items():
|
503 |
+
if trait in temperament_lower:
|
504 |
+
temperament_adjustments += penalty
|
505 |
+
|
506 |
+
for trait, bonus in easy_traits.items():
|
507 |
+
if trait in temperament_lower:
|
508 |
+
temperament_adjustments += bonus
|
509 |
+
|
510 |
+
# 品種類型特殊評估,降低懲罰程度
|
511 |
+
if 'terrier' in temperament_lower:
|
512 |
+
temperament_adjustments -= 0.10 # 降低懲罰
|
513 |
+
elif 'working' in temperament_lower:
|
514 |
+
temperament_adjustments -= 0.12
|
515 |
+
elif 'guard' in temperament_lower:
|
516 |
+
temperament_adjustments -= 0.12
|
517 |
+
|
518 |
+
elif user_experience == "intermediate":
|
519 |
+
moderate_traits = {
|
520 |
+
'stubborn': -0.08,
|
521 |
+
'independent': -0.05,
|
522 |
+
'intelligent': 0.10,
|
523 |
+
'athletic': 0.08,
|
524 |
+
'versatile': 0.08,
|
525 |
+
'protective': -0.05
|
526 |
+
}
|
527 |
+
|
528 |
+
for trait, adjustment in moderate_traits.items():
|
529 |
+
if trait in temperament_lower:
|
530 |
+
temperament_adjustments += adjustment
|
531 |
+
|
532 |
+
else: # advanced
|
533 |
+
advanced_traits = {
|
534 |
+
'stubborn': 0.05,
|
535 |
+
'independent': 0.05,
|
536 |
+
'intelligent': 0.10,
|
537 |
+
'protective': 0.05,
|
538 |
+
'strong-willed': 0.05
|
539 |
+
}
|
540 |
+
|
541 |
+
for trait, bonus in advanced_traits.items():
|
542 |
+
if trait in temperament_lower:
|
543 |
+
temperament_adjustments += bonus
|
544 |
+
|
545 |
+
# 確保最終分數範圍合理
|
546 |
+
final_score = max(0.15, min(1.0, score + temperament_adjustments))
|
547 |
+
|
548 |
+
return final_score
|
549 |
+
|
550 |
+
def calculate_health_score(self, breed_name: str, health_sensitivity: str) -> float:
|
551 |
+
"""
|
552 |
+
計算品種健康分數,加強健康問題的影響力和與使用者敏感度的連結
|
553 |
+
|
554 |
+
1. 根據使用者的健康敏感度調整分數
|
555 |
+
2. 更嚴格的健康問題評估
|
556 |
+
3. 考慮多重健康問題的累積效應
|
557 |
+
4. 加入遺傳疾病的特別考量
|
558 |
+
"""
|
559 |
+
try:
|
560 |
+
if breed_name not in breed_health_info:
|
561 |
+
return 0.5
|
562 |
+
except ImportError:
|
563 |
+
return 0.5
|
564 |
+
|
565 |
+
health_notes = breed_health_info[breed_name]['health_notes'].lower()
|
566 |
+
|
567 |
+
# 嚴重健康問題 - 加重扣分
|
568 |
+
severe_conditions = {
|
569 |
+
'hip dysplasia': -0.20, # 髖關節發育不良,影響生活品質
|
570 |
+
'heart disease': -0.15, # 心臟疾病,需要長期治療
|
571 |
+
'progressive retinal atrophy': -0.15, # 進行性視網膜萎縮,導致失明
|
572 |
+
'bloat': -0.18, # 胃扭轉,致命風險
|
573 |
+
'epilepsy': -0.15, # 癲癇,需要長期藥物控制
|
574 |
+
'degenerative myelopathy': -0.15, # 脊髓退化,影響行動能力
|
575 |
+
'von willebrand disease': -0.12 # 血液凝固障礙
|
576 |
+
}
|
577 |
+
|
578 |
+
# 中度健康問題 - 適度扣分
|
579 |
+
moderate_conditions = {
|
580 |
+
'allergies': -0.12, # 過敏問題,需要持續關注
|
581 |
+
'eye problems': -0.15, # 眼睛問題,可能需要手術
|
582 |
+
'joint problems': -0.15, # 關節問題,影響運動能力
|
583 |
+
'hypothyroidism': -0.12, # 甲狀腺功能低下,需要藥物治療
|
584 |
+
'ear infections': -0.10, # 耳道感染,需要定期清理
|
585 |
+
'skin issues': -0.12 # 皮膚問題,需要特殊護理
|
586 |
+
}
|
587 |
+
|
588 |
+
# 輕微健康問題 - 輕微扣分
|
589 |
+
minor_conditions = {
|
590 |
+
'dental issues': -0.08, # 牙齒問題,需要定期護理
|
591 |
+
'weight gain tendency': -0.08, # 易胖體質,需要控制飲食
|
592 |
+
'minor allergies': -0.06, # 輕微過敏,可控制
|
593 |
+
'seasonal allergies': -0.06 # 季節性過敏
|
594 |
+
}
|
595 |
+
|
596 |
+
# 計算基礎健康分數
|
597 |
+
health_score = 1.0
|
598 |
+
|
599 |
+
# 健康問題累積效應計算
|
600 |
+
condition_counts = {
|
601 |
+
'severe': 0,
|
602 |
+
'moderate': 0,
|
603 |
+
'minor': 0
|
604 |
+
}
|
605 |
+
|
606 |
+
# 計算各等級健康問題的數量和影響
|
607 |
+
for condition, penalty in severe_conditions.items():
|
608 |
+
if condition in health_notes:
|
609 |
+
health_score += penalty
|
610 |
+
condition_counts['severe'] += 1
|
611 |
+
|
612 |
+
for condition, penalty in moderate_conditions.items():
|
613 |
+
if condition in health_notes:
|
614 |
+
health_score += penalty
|
615 |
+
condition_counts['moderate'] += 1
|
616 |
+
|
617 |
+
for condition, penalty in minor_conditions.items():
|
618 |
+
if condition in health_notes:
|
619 |
+
health_score += penalty
|
620 |
+
condition_counts['minor'] += 1
|
621 |
+
|
622 |
+
# 多重問題的額外懲罰(累積效應)
|
623 |
+
if condition_counts['severe'] > 1:
|
624 |
+
health_score *= (0.85 ** (condition_counts['severe'] - 1))
|
625 |
+
if condition_counts['moderate'] > 2:
|
626 |
+
health_score *= (0.90 ** (condition_counts['moderate'] - 2))
|
627 |
+
|
628 |
+
# 根據使用者健康敏感度調整分數
|
629 |
+
sensitivity_multipliers = {
|
630 |
+
'low': 1.1, # 較不在意健康問題
|
631 |
+
'medium': 1.0, # 標準評估
|
632 |
+
'high': 0.85 # 非常注重健康問題
|
633 |
+
}
|
634 |
+
|
635 |
+
health_score *= sensitivity_multipliers.get(health_sensitivity, 1.0)
|
636 |
+
|
637 |
+
# 壽命影響評估
|
638 |
+
try:
|
639 |
+
lifespan = breed_health_info[breed_name].get('average_lifespan', '10-12')
|
640 |
+
years = float(lifespan.split('-')[0])
|
641 |
+
if years < 8:
|
642 |
+
health_score *= 0.85 # 短壽命顯著降低分數
|
643 |
+
elif years < 10:
|
644 |
+
health_score *= 0.92 # 較短壽命輕微降低分數
|
645 |
+
elif years > 13:
|
646 |
+
health_score *= 1.1 # 長壽命適度加分
|
647 |
+
except:
|
648 |
+
pass
|
649 |
+
|
650 |
+
# 特殊健康優勢
|
651 |
+
if 'generally healthy' in health_notes or 'hardy breed' in health_notes:
|
652 |
+
health_score *= 1.15
|
653 |
+
elif 'robust health' in health_notes or 'few health issues' in health_notes:
|
654 |
+
health_score *= 1.1
|
655 |
+
|
656 |
+
# 確保分數在合理範圍內,但允許更大的分數差異
|
657 |
+
return max(0.1, min(1.0, health_score))
|
658 |
+
|
659 |
+
def calculate_noise_score(self, breed_name: str, noise_tolerance: str, living_space: str, has_children: bool, children_age: str) -> float:
|
660 |
+
"""
|
661 |
+
計算品種噪音分數,特別加強噪音程度與生活環境的關聯性評估,很多人棄養就是因為叫聲
|
662 |
+
"""
|
663 |
+
try:
|
664 |
+
if breed_name not in breed_noise_info:
|
665 |
+
return 0.5
|
666 |
+
except ImportError:
|
667 |
+
return 0.5
|
668 |
+
|
669 |
+
noise_info = breed_noise_info[breed_name]
|
670 |
+
noise_level = noise_info['noise_level'].lower()
|
671 |
+
noise_notes = noise_info['noise_notes'].lower()
|
672 |
+
|
673 |
+
# 重新設計基礎噪音分數矩陣,考慮不同情境下的接受度
|
674 |
+
base_scores = {
|
675 |
+
'low': {
|
676 |
+
'low': 1.0, # 安靜的狗對低容忍完美匹配
|
677 |
+
'medium': 0.95, # 安靜的狗對一般容忍很好
|
678 |
+
'high': 0.90 # 安靜的狗對高容忍當然可以
|
679 |
+
},
|
680 |
+
'medium': {
|
681 |
+
'low': 0.60, # 一般吠叫對低容忍較困難
|
682 |
+
'medium': 0.90, # 一般吠叫對一般容忍可接受
|
683 |
+
'high': 0.95 # 一般吠叫對高容忍很好
|
684 |
+
},
|
685 |
+
'high': {
|
686 |
+
'low': 0.25, # 愛叫的狗對低容忍極不適合
|
687 |
+
'medium': 0.65, # 愛叫的狗對一般容忍有挑戰
|
688 |
+
'high': 0.90 # 愛叫的狗對高容忍可以接受
|
689 |
+
},
|
690 |
+
'varies': {
|
691 |
+
'low': 0.50, # 不確定的情況對低容忍風險較大
|
692 |
+
'medium': 0.75, # 不確定的情況對一般容忍可嘗試
|
693 |
+
'high': 0.85 # 不確定的情況對高容忍問題較小
|
694 |
+
}
|
695 |
+
}
|
696 |
+
|
697 |
+
# 取得基礎分數
|
698 |
+
base_score = base_scores.get(noise_level, {'low': 0.6, 'medium': 0.75, 'high': 0.85})[noise_tolerance]
|
699 |
+
|
700 |
+
# 吠叫原因評估,根據環境調整懲罰程度
|
701 |
+
barking_penalties = {
|
702 |
+
'separation anxiety': {
|
703 |
+
'apartment': -0.30, # 在公寓對鄰居影響更大
|
704 |
+
'house_small': -0.25,
|
705 |
+
'house_large': -0.20
|
706 |
+
},
|
707 |
+
'excessive barking': {
|
708 |
+
'apartment': -0.25,
|
709 |
+
'house_small': -0.20,
|
710 |
+
'house_large': -0.15
|
711 |
+
},
|
712 |
+
'territorial': {
|
713 |
+
'apartment': -0.20, # 在公寓更容易被觸發
|
714 |
+
'house_small': -0.15,
|
715 |
+
'house_large': -0.10
|
716 |
+
},
|
717 |
+
'alert barking': {
|
718 |
+
'apartment': -0.15, # 公寓環境刺激較多
|
719 |
+
'house_small': -0.10,
|
720 |
+
'house_large': -0.08
|
721 |
+
},
|
722 |
+
'attention seeking': {
|
723 |
+
'apartment': -0.15,
|
724 |
+
'house_small': -0.12,
|
725 |
+
'house_large': -0.10
|
726 |
+
}
|
727 |
+
}
|
728 |
+
|
729 |
+
# 計算環境相關的吠叫懲罰
|
730 |
+
barking_penalty = 0
|
731 |
+
for trigger, penalties in barking_penalties.items():
|
732 |
+
if trigger in noise_notes:
|
733 |
+
barking_penalty += penalties.get(living_space, -0.15)
|
734 |
+
|
735 |
+
# 特殊情況評估
|
736 |
+
special_adjustments = 0
|
737 |
+
if has_children:
|
738 |
+
# 孩童年齡相關調整
|
739 |
+
child_age_adjustments = {
|
740 |
+
'toddler': {
|
741 |
+
'high': -0.20, # 幼童對吵鬧更敏感
|
742 |
+
'medium': -0.15,
|
743 |
+
'low': -0.05
|
744 |
+
},
|
745 |
+
'school_age': {
|
746 |
+
'high': -0.15,
|
747 |
+
'medium': -0.10,
|
748 |
+
'low': -0.05
|
749 |
+
},
|
750 |
+
'teenager': {
|
751 |
+
'high': -0.10,
|
752 |
+
'medium': -0.05,
|
753 |
+
'low': -0.02
|
754 |
+
}
|
755 |
+
}
|
756 |
+
|
757 |
+
# 根據孩童年齡和噪音等級調整
|
758 |
+
age_adj = child_age_adjustments.get(children_age,
|
759 |
+
child_age_adjustments['school_age'])
|
760 |
+
special_adjustments += age_adj.get(noise_level, -0.10)
|
761 |
+
|
762 |
+
# 訓練性補償評估
|
763 |
+
trainability_bonus = 0
|
764 |
+
if 'responds well to training' in noise_notes:
|
765 |
+
trainability_bonus = 0.12
|
766 |
+
elif 'can be trained' in noise_notes:
|
767 |
+
trainability_bonus = 0.08
|
768 |
+
elif 'difficult to train' in noise_notes:
|
769 |
+
trainability_bonus = 0.02
|
770 |
+
|
771 |
+
# 夜間吠叫特別考量
|
772 |
+
if 'night barking' in noise_notes or 'howls' in noise_notes:
|
773 |
+
if living_space == 'apartment':
|
774 |
+
special_adjustments -= 0.15
|
775 |
+
elif living_space == 'house_small':
|
776 |
+
special_adjustments -= 0.10
|
777 |
+
else:
|
778 |
+
special_adjustments -= 0.05
|
779 |
+
|
780 |
+
# 計算最終分數,確保更大的分數範圍
|
781 |
+
final_score = base_score + barking_penalty + special_adjustments + trainability_bonus
|
782 |
+
return max(0.1, min(1.0, final_score))
|
dynamic_scoring_config.py
ADDED
@@ -0,0 +1,410 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
from typing import Dict, List, Any, Optional
|
2 |
+
from dataclasses import dataclass
|
3 |
+
import json
|
4 |
+
import os
|
5 |
+
|
6 |
+
@dataclass
|
7 |
+
class DimensionConfig:
|
8 |
+
"""維度配置"""
|
9 |
+
name: str
|
10 |
+
base_weight: float
|
11 |
+
priority_multiplier: Dict[str, float]
|
12 |
+
compatibility_matrix: Dict[str, Dict[str, float]]
|
13 |
+
threshold_values: Dict[str, float]
|
14 |
+
description: str
|
15 |
+
|
16 |
+
|
17 |
+
@dataclass
|
18 |
+
class ConstraintConfig:
|
19 |
+
"""約束配置"""
|
20 |
+
name: str
|
21 |
+
condition_keywords: List[str]
|
22 |
+
elimination_threshold: float
|
23 |
+
penalty_factors: Dict[str, float]
|
24 |
+
exemption_conditions: List[str]
|
25 |
+
description: str
|
26 |
+
|
27 |
+
|
28 |
+
@dataclass
|
29 |
+
class ScoringProfile:
|
30 |
+
"""評分配置檔"""
|
31 |
+
profile_name: str
|
32 |
+
dimensions: List[DimensionConfig]
|
33 |
+
constraints: List[ConstraintConfig]
|
34 |
+
normalization_method: str
|
35 |
+
bias_correction_rules: Dict[str, Any]
|
36 |
+
ui_preferences: Dict[str, Any]
|
37 |
+
|
38 |
+
|
39 |
+
class DynamicScoringConfig:
|
40 |
+
"""動態評分配置管理器"""
|
41 |
+
|
42 |
+
def __init__(self, config_path: Optional[str] = None):
|
43 |
+
self.config_path = config_path or self._get_default_config_path()
|
44 |
+
self.current_profile = self._load_default_profile()
|
45 |
+
self.custom_profiles = {}
|
46 |
+
|
47 |
+
def _get_default_config_path(self) -> str:
|
48 |
+
"""獲取默認配置路徑"""
|
49 |
+
return os.path.join(os.path.dirname(__file__), 'scoring_configs')
|
50 |
+
|
51 |
+
def _load_default_profile(self) -> ScoringProfile:
|
52 |
+
"""載入預設評分配置"""
|
53 |
+
# 空間相容性維度配置
|
54 |
+
space_dimension = DimensionConfig(
|
55 |
+
name="space_compatibility",
|
56 |
+
base_weight=0.30,
|
57 |
+
priority_multiplier={
|
58 |
+
"apartment_living": 1.5,
|
59 |
+
"first_time_owner": 1.2,
|
60 |
+
"limited_space": 1.4
|
61 |
+
},
|
62 |
+
compatibility_matrix={
|
63 |
+
"apartment": {
|
64 |
+
"toy": 0.95, "small": 0.90, "medium": 0.50,
|
65 |
+
"large": 0.15, "giant": 0.05
|
66 |
+
},
|
67 |
+
"house_small": {
|
68 |
+
"toy": 0.85, "small": 0.90, "medium": 0.85,
|
69 |
+
"large": 0.60, "giant": 0.30
|
70 |
+
},
|
71 |
+
"house_medium": {
|
72 |
+
"toy": 0.80, "small": 0.85, "medium": 0.95,
|
73 |
+
"large": 0.85, "giant": 0.60
|
74 |
+
},
|
75 |
+
"house_large": {
|
76 |
+
"toy": 0.75, "small": 0.80, "medium": 0.90,
|
77 |
+
"large": 0.95, "giant": 0.95
|
78 |
+
}
|
79 |
+
},
|
80 |
+
threshold_values={
|
81 |
+
"elimination_threshold": 0.20,
|
82 |
+
"warning_threshold": 0.40,
|
83 |
+
"good_threshold": 0.70
|
84 |
+
},
|
85 |
+
description="Evaluates breed size compatibility with living space"
|
86 |
+
)
|
87 |
+
|
88 |
+
# 運動相容性維度配置
|
89 |
+
exercise_dimension = DimensionConfig(
|
90 |
+
name="exercise_compatibility",
|
91 |
+
base_weight=0.25,
|
92 |
+
priority_multiplier={
|
93 |
+
"low_activity": 1.6,
|
94 |
+
"high_activity": 1.3,
|
95 |
+
"time_limited": 1.4
|
96 |
+
},
|
97 |
+
compatibility_matrix={
|
98 |
+
"low_user": {
|
99 |
+
"low": 1.0, "moderate": 0.70, "high": 0.30, "very_high": 0.10
|
100 |
+
},
|
101 |
+
"moderate_user": {
|
102 |
+
"low": 0.80, "moderate": 1.0, "high": 0.80, "very_high": 0.50
|
103 |
+
},
|
104 |
+
"high_user": {
|
105 |
+
"low": 0.60, "moderate": 0.85, "high": 1.0, "very_high": 0.95
|
106 |
+
}
|
107 |
+
},
|
108 |
+
threshold_values={
|
109 |
+
"severe_mismatch": 0.25,
|
110 |
+
"moderate_mismatch": 0.50,
|
111 |
+
"good_match": 0.75
|
112 |
+
},
|
113 |
+
description="Matches user activity level with breed exercise needs"
|
114 |
+
)
|
115 |
+
|
116 |
+
# 噪音相容性維度配置
|
117 |
+
noise_dimension = DimensionConfig(
|
118 |
+
name="noise_compatibility",
|
119 |
+
base_weight=0.15,
|
120 |
+
priority_multiplier={
|
121 |
+
"apartment_living": 1.8,
|
122 |
+
"noise_sensitive": 2.0,
|
123 |
+
"quiet_preference": 1.5
|
124 |
+
},
|
125 |
+
compatibility_matrix={
|
126 |
+
"low_tolerance": {
|
127 |
+
"quiet": 1.0, "moderate": 0.60, "high": 0.20, "very_high": 0.05
|
128 |
+
},
|
129 |
+
"moderate_tolerance": {
|
130 |
+
"quiet": 0.90, "moderate": 1.0, "high": 0.70, "very_high": 0.40
|
131 |
+
},
|
132 |
+
"high_tolerance": {
|
133 |
+
"quiet": 0.80, "moderate": 0.90, "high": 1.0, "very_high": 0.85
|
134 |
+
}
|
135 |
+
},
|
136 |
+
threshold_values={
|
137 |
+
"unacceptable": 0.15,
|
138 |
+
"concerning": 0.40,
|
139 |
+
"acceptable": 0.70
|
140 |
+
},
|
141 |
+
description="Matches breed noise levels with user tolerance"
|
142 |
+
)
|
143 |
+
|
144 |
+
# 約束配置
|
145 |
+
apartment_constraint = ConstraintConfig(
|
146 |
+
name="apartment_size_constraint",
|
147 |
+
condition_keywords=["apartment", "small space", "studio", "condo"],
|
148 |
+
elimination_threshold=0.15,
|
149 |
+
penalty_factors={
|
150 |
+
"large_breed": 0.70,
|
151 |
+
"giant_breed": 0.85,
|
152 |
+
"high_exercise": 0.60
|
153 |
+
},
|
154 |
+
exemption_conditions=["experienced_owner", "large_apartment"],
|
155 |
+
description="Eliminates breeds unsuitable for apartment living"
|
156 |
+
)
|
157 |
+
|
158 |
+
exercise_constraint = ConstraintConfig(
|
159 |
+
name="exercise_mismatch_constraint",
|
160 |
+
condition_keywords=["don't exercise", "low activity", "minimal exercise"],
|
161 |
+
elimination_threshold=0.20,
|
162 |
+
penalty_factors={
|
163 |
+
"very_high_exercise": 0.80,
|
164 |
+
"working_breed": 0.60,
|
165 |
+
"high_energy": 0.70
|
166 |
+
},
|
167 |
+
exemption_conditions=["dog_park_access", "active_family"],
|
168 |
+
description="Prevents high-energy breeds for low-activity users"
|
169 |
+
)
|
170 |
+
|
171 |
+
# 偏見修正規則
|
172 |
+
bias_correction_rules = {
|
173 |
+
"size_bias": {
|
174 |
+
"enabled": True,
|
175 |
+
"detection_threshold": 0.70, # 70%以上大型犬觸發修正
|
176 |
+
"correction_strength": 0.60, # 修正強度
|
177 |
+
"target_distribution": {
|
178 |
+
"toy": 0.10, "small": 0.25, "medium": 0.40,
|
179 |
+
"large": 0.20, "giant": 0.05
|
180 |
+
}
|
181 |
+
},
|
182 |
+
"popularity_bias": {
|
183 |
+
"enabled": True,
|
184 |
+
"common_breeds_penalty": 0.05,
|
185 |
+
"rare_breeds_bonus": 0.03
|
186 |
+
}
|
187 |
+
}
|
188 |
+
|
189 |
+
# UI偏好設定
|
190 |
+
ui_preferences = {
|
191 |
+
"ranking_style": "gradient_badges",
|
192 |
+
"score_display": "percentage_with_bars",
|
193 |
+
"color_scheme": {
|
194 |
+
"excellent": "#22C55E",
|
195 |
+
"good": "#F59E0B",
|
196 |
+
"moderate": "#6B7280",
|
197 |
+
"poor": "#EF4444"
|
198 |
+
},
|
199 |
+
"animation_enabled": True,
|
200 |
+
"detailed_breakdown": True
|
201 |
+
}
|
202 |
+
|
203 |
+
return ScoringProfile(
|
204 |
+
profile_name="comprehensive_default",
|
205 |
+
dimensions=[space_dimension, exercise_dimension, noise_dimension],
|
206 |
+
constraints=[apartment_constraint, exercise_constraint],
|
207 |
+
normalization_method="sigmoid_compression",
|
208 |
+
bias_correction_rules=bias_correction_rules,
|
209 |
+
ui_preferences=ui_preferences
|
210 |
+
)
|
211 |
+
|
212 |
+
def get_dimension_config(self, dimension_name: str) -> Optional[DimensionConfig]:
|
213 |
+
"""獲取維度配置"""
|
214 |
+
for dim in self.current_profile.dimensions:
|
215 |
+
if dim.name == dimension_name:
|
216 |
+
return dim
|
217 |
+
return None
|
218 |
+
|
219 |
+
def get_constraint_config(self, constraint_name: str) -> Optional[ConstraintConfig]:
|
220 |
+
"""獲取約束配置"""
|
221 |
+
for constraint in self.current_profile.constraints:
|
222 |
+
if constraint.name == constraint_name:
|
223 |
+
return constraint
|
224 |
+
return None
|
225 |
+
|
226 |
+
def calculate_dynamic_weights(self, user_context: Dict[str, Any]) -> Dict[str, float]:
|
227 |
+
"""根據用戶情境動態計算權重"""
|
228 |
+
weights = {}
|
229 |
+
total_weight = 0
|
230 |
+
|
231 |
+
for dimension in self.current_profile.dimensions:
|
232 |
+
base_weight = dimension.base_weight
|
233 |
+
|
234 |
+
# 根據用戶情境調整權重
|
235 |
+
for context_key, multiplier in dimension.priority_multiplier.items():
|
236 |
+
if user_context.get(context_key, False):
|
237 |
+
base_weight *= multiplier
|
238 |
+
|
239 |
+
weights[dimension.name] = base_weight
|
240 |
+
total_weight += base_weight
|
241 |
+
|
242 |
+
# 正規化權重
|
243 |
+
return {k: v / total_weight for k, v in weights.items()}
|
244 |
+
|
245 |
+
def get_compatibility_score(self, dimension_name: str,
|
246 |
+
user_category: str, breed_category: str) -> float:
|
247 |
+
"""獲取相容性分數"""
|
248 |
+
dimension_config = self.get_dimension_config(dimension_name)
|
249 |
+
if not dimension_config:
|
250 |
+
return 0.5
|
251 |
+
|
252 |
+
matrix = dimension_config.compatibility_matrix
|
253 |
+
if user_category in matrix and breed_category in matrix[user_category]:
|
254 |
+
return matrix[user_category][breed_category]
|
255 |
+
|
256 |
+
return 0.5 # 預設值
|
257 |
+
|
258 |
+
def should_eliminate_breed(self, constraint_name: str,
|
259 |
+
breed_info: Dict[str, Any],
|
260 |
+
user_input: str) -> tuple[bool, str]:
|
261 |
+
"""判斷是否應該淘汰品種"""
|
262 |
+
constraint_config = self.get_constraint_config(constraint_name)
|
263 |
+
if not constraint_config:
|
264 |
+
return False, ""
|
265 |
+
|
266 |
+
# 檢查觸發條件
|
267 |
+
user_input_lower = user_input.lower()
|
268 |
+
triggered = any(keyword in user_input_lower
|
269 |
+
for keyword in constraint_config.condition_keywords)
|
270 |
+
|
271 |
+
if not triggered:
|
272 |
+
return False, ""
|
273 |
+
|
274 |
+
# 檢查豁免條件
|
275 |
+
exempted = any(condition in user_input_lower
|
276 |
+
for condition in constraint_config.exemption_conditions)
|
277 |
+
|
278 |
+
if exempted:
|
279 |
+
return False, "Exempted due to special conditions"
|
280 |
+
|
281 |
+
# 應用淘汰邏輯(具體實現取決於約束類型)
|
282 |
+
return self._apply_elimination_logic(constraint_config, breed_info, user_input)
|
283 |
+
|
284 |
+
def _apply_elimination_logic(self, constraint_config: ConstraintConfig,
|
285 |
+
breed_info: Dict[str, Any], user_input: str) -> tuple[bool, str]:
|
286 |
+
"""應用淘汰邏輯"""
|
287 |
+
# 根據約束名稱決定具體邏輯
|
288 |
+
if constraint_config.name == "apartment_size_constraint":
|
289 |
+
breed_size = breed_info.get('Size', '').lower()
|
290 |
+
if any(size in breed_size for size in ['large', 'giant']):
|
291 |
+
return True, f"Breed size ({breed_size}) unsuitable for apartment"
|
292 |
+
|
293 |
+
elif constraint_config.name == "exercise_mismatch_constraint":
|
294 |
+
exercise_needs = breed_info.get('Exercise Needs', '').lower()
|
295 |
+
if any(level in exercise_needs for level in ['very high', 'extreme']):
|
296 |
+
return True, f"Exercise needs ({exercise_needs}) exceed user capacity"
|
297 |
+
|
298 |
+
return False, ""
|
299 |
+
|
300 |
+
def get_bias_correction_settings(self) -> Dict[str, Any]:
|
301 |
+
"""獲取偏見修正設定"""
|
302 |
+
return self.current_profile.bias_correction_rules
|
303 |
+
|
304 |
+
def get_ui_preferences(self) -> Dict[str, Any]:
|
305 |
+
"""獲取UI偏好設定"""
|
306 |
+
return self.current_profile.ui_preferences
|
307 |
+
|
308 |
+
def save_custom_profile(self, profile: ScoringProfile, filename: str):
|
309 |
+
"""保存自定義配置檔"""
|
310 |
+
if not os.path.exists(self.config_path):
|
311 |
+
os.makedirs(self.config_path)
|
312 |
+
|
313 |
+
filepath = os.path.join(self.config_path, f"{filename}.json")
|
314 |
+
|
315 |
+
# 將配置檔案轉換為JSON格式
|
316 |
+
profile_dict = {
|
317 |
+
"profile_name": profile.profile_name,
|
318 |
+
"dimensions": [self._dimension_to_dict(dim) for dim in profile.dimensions],
|
319 |
+
"constraints": [self._constraint_to_dict(cons) for cons in profile.constraints],
|
320 |
+
"normalization_method": profile.normalization_method,
|
321 |
+
"bias_correction_rules": profile.bias_correction_rules,
|
322 |
+
"ui_preferences": profile.ui_preferences
|
323 |
+
}
|
324 |
+
|
325 |
+
with open(filepath, 'w', encoding='utf-8') as f:
|
326 |
+
json.dump(profile_dict, f, indent=2, ensure_ascii=False)
|
327 |
+
|
328 |
+
def load_custom_profile(self, filename: str) -> Optional[ScoringProfile]:
|
329 |
+
"""載入自定義配置檔"""
|
330 |
+
filepath = os.path.join(self.config_path, f"{filename}.json")
|
331 |
+
|
332 |
+
if not os.path.exists(filepath):
|
333 |
+
return None
|
334 |
+
|
335 |
+
try:
|
336 |
+
with open(filepath, 'r', encoding='utf-8') as f:
|
337 |
+
profile_dict = json.load(f)
|
338 |
+
|
339 |
+
return self._dict_to_profile(profile_dict)
|
340 |
+
except Exception as e:
|
341 |
+
print(f"Error loading profile {filename}: {str(e)}")
|
342 |
+
return None
|
343 |
+
|
344 |
+
def _dimension_to_dict(self, dimension: DimensionConfig) -> Dict[str, Any]:
|
345 |
+
"""將維度配置轉換為字典"""
|
346 |
+
return {
|
347 |
+
"name": dimension.name,
|
348 |
+
"base_weight": dimension.base_weight,
|
349 |
+
"priority_multiplier": dimension.priority_multiplier,
|
350 |
+
"compatibility_matrix": dimension.compatibility_matrix,
|
351 |
+
"threshold_values": dimension.threshold_values,
|
352 |
+
"description": dimension.description
|
353 |
+
}
|
354 |
+
|
355 |
+
def _constraint_to_dict(self, constraint: ConstraintConfig) -> Dict[str, Any]:
|
356 |
+
"""將約束配置轉換為字典"""
|
357 |
+
return {
|
358 |
+
"name": constraint.name,
|
359 |
+
"condition_keywords": constraint.condition_keywords,
|
360 |
+
"elimination_threshold": constraint.elimination_threshold,
|
361 |
+
"penalty_factors": constraint.penalty_factors,
|
362 |
+
"exemption_conditions": constraint.exemption_conditions,
|
363 |
+
"description": constraint.description
|
364 |
+
}
|
365 |
+
|
366 |
+
def _dict_to_profile(self, profile_dict: Dict[str, Any]) -> ScoringProfile:
|
367 |
+
"""將字典轉換為評分配置檔"""
|
368 |
+
dimensions = [self._dict_to_dimension(dim) for dim in profile_dict["dimensions"]]
|
369 |
+
constraints = [self._dict_to_constraint(cons) for cons in profile_dict["constraints"]]
|
370 |
+
|
371 |
+
return ScoringProfile(
|
372 |
+
profile_name=profile_dict["profile_name"],
|
373 |
+
dimensions=dimensions,
|
374 |
+
constraints=constraints,
|
375 |
+
normalization_method=profile_dict["normalization_method"],
|
376 |
+
bias_correction_rules=profile_dict["bias_correction_rules"],
|
377 |
+
ui_preferences=profile_dict["ui_preferences"]
|
378 |
+
)
|
379 |
+
|
380 |
+
def _dict_to_dimension(self, dim_dict: Dict[str, Any]) -> DimensionConfig:
|
381 |
+
"""將字典轉換為維度配置"""
|
382 |
+
return DimensionConfig(
|
383 |
+
name=dim_dict["name"],
|
384 |
+
base_weight=dim_dict["base_weight"],
|
385 |
+
priority_multiplier=dim_dict["priority_multiplier"],
|
386 |
+
compatibility_matrix=dim_dict["compatibility_matrix"],
|
387 |
+
threshold_values=dim_dict["threshold_values"],
|
388 |
+
description=dim_dict["description"]
|
389 |
+
)
|
390 |
+
|
391 |
+
def _dict_to_constraint(self, cons_dict: Dict[str, Any]) -> ConstraintConfig:
|
392 |
+
"""將字典轉換為約束配置"""
|
393 |
+
return ConstraintConfig(
|
394 |
+
name=cons_dict["name"],
|
395 |
+
condition_keywords=cons_dict["condition_keywords"],
|
396 |
+
elimination_threshold=cons_dict["elimination_threshold"],
|
397 |
+
penalty_factors=cons_dict["penalty_factors"],
|
398 |
+
exemption_conditions=cons_dict["exemption_conditions"],
|
399 |
+
description=cons_dict["description"]
|
400 |
+
)
|
401 |
+
|
402 |
+
def get_scoring_config() -> DynamicScoringConfig:
|
403 |
+
"""獲取全局評分配置"""
|
404 |
+
return scoring_config
|
405 |
+
|
406 |
+
|
407 |
+
def update_scoring_config(new_config: DynamicScoringConfig):
|
408 |
+
"""更新全局評分配置"""
|
409 |
+
global scoring_config
|
410 |
+
scoring_config = new_config
|
multi_head_scorer.py
ADDED
@@ -0,0 +1,763 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
import numpy as np
|
2 |
+
import json
|
3 |
+
from typing import Dict, List, Tuple, Optional, Any, Set
|
4 |
+
from dataclasses import dataclass, field
|
5 |
+
from abc import ABC, abstractmethod
|
6 |
+
import traceback
|
7 |
+
from sentence_transformers import SentenceTransformer
|
8 |
+
from sklearn.metrics.pairwise import cosine_similarity
|
9 |
+
from dog_database import get_dog_description
|
10 |
+
from breed_health_info import breed_health_info
|
11 |
+
from breed_noise_info import breed_noise_info
|
12 |
+
from query_understanding import QueryDimensions
|
13 |
+
from constraint_manager import FilterResult
|
14 |
+
|
15 |
+
@dataclass
|
16 |
+
class DimensionalScores:
|
17 |
+
"""多維度評分結果"""
|
18 |
+
semantic_scores: Dict[str, float] = field(default_factory=dict)
|
19 |
+
attribute_scores: Dict[str, float] = field(default_factory=dict)
|
20 |
+
fused_scores: Dict[str, float] = field(default_factory=dict)
|
21 |
+
bidirectional_scores: Dict[str, float] = field(default_factory=dict)
|
22 |
+
confidence_weights: Dict[str, float] = field(default_factory=dict)
|
23 |
+
|
24 |
+
@dataclass
|
25 |
+
class BreedScore:
|
26 |
+
"""品種總體評分結果"""
|
27 |
+
breed_name: str
|
28 |
+
final_score: float
|
29 |
+
dimensional_breakdown: Dict[str, float] = field(default_factory=dict)
|
30 |
+
semantic_component: float = 0.0
|
31 |
+
attribute_component: float = 0.0
|
32 |
+
bidirectional_bonus: float = 0.0
|
33 |
+
confidence_score: float = 1.0
|
34 |
+
explanation: Dict[str, Any] = field(default_factory=dict)
|
35 |
+
|
36 |
+
class ScoringHead(ABC):
|
37 |
+
"""抽象評分頭基類"""
|
38 |
+
|
39 |
+
@abstractmethod
|
40 |
+
def score_dimension(self, breed_info: Dict[str, Any],
|
41 |
+
dimensions: QueryDimensions,
|
42 |
+
dimension_type: str) -> float:
|
43 |
+
"""為特定維度評分"""
|
44 |
+
pass
|
45 |
+
|
46 |
+
class SemanticScoringHead(ScoringHead):
|
47 |
+
"""語義評分頭"""
|
48 |
+
|
49 |
+
def __init__(self, sbert_model: Optional[SentenceTransformer] = None):
|
50 |
+
self.sbert_model = sbert_model
|
51 |
+
self.dimension_embeddings = {}
|
52 |
+
if self.sbert_model:
|
53 |
+
self._build_dimension_embeddings()
|
54 |
+
|
55 |
+
def _build_dimension_embeddings(self):
|
56 |
+
"""建立維度模板嵌入"""
|
57 |
+
dimension_templates = {
|
58 |
+
'spatial_apartment': "small apartment living, limited space, no yard, urban environment",
|
59 |
+
'spatial_house': "house with yard, outdoor space, suburban living, large property",
|
60 |
+
'activity_low': "low energy, minimal exercise needs, calm lifestyle, indoor activities",
|
61 |
+
'activity_moderate': "moderate exercise, daily walks, balanced activity level",
|
62 |
+
'activity_high': "high energy, vigorous exercise, outdoor sports, active lifestyle",
|
63 |
+
'noise_low': "quiet, rarely barks, peaceful, suitable for noise-sensitive environments",
|
64 |
+
'noise_moderate': "moderate barking, occasional vocalizations, average noise level",
|
65 |
+
'noise_high': "vocal, frequent barking, alert dog, comfortable with noise",
|
66 |
+
'size_small': "small compact breed, easy to handle, portable size",
|
67 |
+
'size_medium': "medium sized dog, balanced proportions, moderate size",
|
68 |
+
'size_large': "large impressive dog, substantial presence, bigger breed",
|
69 |
+
'family_children': "child-friendly, gentle with kids, family-oriented, safe around children",
|
70 |
+
'family_elderly': "calm companion, gentle nature, suitable for seniors, low maintenance",
|
71 |
+
'maintenance_low': "low grooming needs, minimal care requirements, easy maintenance",
|
72 |
+
'maintenance_moderate': "regular grooming, moderate care needs, standard maintenance",
|
73 |
+
'maintenance_high': "high grooming requirements, professional care, intensive maintenance"
|
74 |
+
}
|
75 |
+
|
76 |
+
for key, template in dimension_templates.items():
|
77 |
+
if self.sbert_model:
|
78 |
+
embedding = self.sbert_model.encode(template, convert_to_tensor=False)
|
79 |
+
self.dimension_embeddings[key] = embedding
|
80 |
+
|
81 |
+
def score_dimension(self, breed_info: Dict[str, Any],
|
82 |
+
dimensions: QueryDimensions,
|
83 |
+
dimension_type: str) -> float:
|
84 |
+
"""語義維度評分"""
|
85 |
+
if not self.sbert_model or dimension_type not in self.dimension_embeddings:
|
86 |
+
return 0.5 # 預設中性分數
|
87 |
+
|
88 |
+
try:
|
89 |
+
# 建立品種描述
|
90 |
+
breed_description = self._create_breed_description(breed_info, dimension_type)
|
91 |
+
|
92 |
+
# 生成嵌入
|
93 |
+
breed_embedding = self.sbert_model.encode(breed_description, convert_to_tensor=False)
|
94 |
+
dimension_embedding = self.dimension_embeddings[dimension_type]
|
95 |
+
|
96 |
+
# 計算相似度
|
97 |
+
similarity = cosine_similarity([breed_embedding], [dimension_embedding])[0][0]
|
98 |
+
|
99 |
+
# 正規化到 0-1 範圍
|
100 |
+
normalized_score = (similarity + 1) / 2 # 從 [-1,1] 轉換到 [0,1]
|
101 |
+
|
102 |
+
return max(0.0, min(1.0, normalized_score))
|
103 |
+
|
104 |
+
except Exception as e:
|
105 |
+
print(f"Error in semantic scoring for {dimension_type}: {str(e)}")
|
106 |
+
return 0.5
|
107 |
+
|
108 |
+
def _create_breed_description(self, breed_info: Dict[str, Any],
|
109 |
+
dimension_type: str) -> str:
|
110 |
+
"""為特定維度創建品種描述"""
|
111 |
+
breed_name = breed_info.get('display_name', breed_info.get('breed_name', ''))
|
112 |
+
|
113 |
+
if dimension_type.startswith('spatial_'):
|
114 |
+
size = breed_info.get('size', 'medium')
|
115 |
+
exercise = breed_info.get('exercise_needs', 'moderate')
|
116 |
+
return f"{breed_name} is a {size} dog with {exercise} exercise needs"
|
117 |
+
|
118 |
+
elif dimension_type.startswith('activity_'):
|
119 |
+
exercise = breed_info.get('exercise_needs', 'moderate')
|
120 |
+
temperament = breed_info.get('temperament', '')
|
121 |
+
return f"{breed_name} has {exercise} exercise requirements and {temperament} temperament"
|
122 |
+
|
123 |
+
elif dimension_type.startswith('noise_'):
|
124 |
+
noise_level = breed_info.get('noise_level', 'moderate')
|
125 |
+
temperament = breed_info.get('temperament', '')
|
126 |
+
return f"{breed_name} has {noise_level} noise level and {temperament} nature"
|
127 |
+
|
128 |
+
elif dimension_type.startswith('size_'):
|
129 |
+
size = breed_info.get('size', 'medium')
|
130 |
+
return f"{breed_name} is a {size} sized dog breed"
|
131 |
+
|
132 |
+
elif dimension_type.startswith('family_'):
|
133 |
+
children = breed_info.get('good_with_children', 'Yes')
|
134 |
+
temperament = breed_info.get('temperament', '')
|
135 |
+
return f"{breed_name} is {children} with children and has {temperament} temperament"
|
136 |
+
|
137 |
+
elif dimension_type.startswith('maintenance_'):
|
138 |
+
grooming = breed_info.get('grooming_needs', 'moderate')
|
139 |
+
care_level = breed_info.get('care_level', 'moderate')
|
140 |
+
return f"{breed_name} requires {grooming} grooming and {care_level} care level"
|
141 |
+
|
142 |
+
return f"{breed_name} is a dog breed with various characteristics"
|
143 |
+
|
144 |
+
class AttributeScoringHead(ScoringHead):
|
145 |
+
"""屬性評分頭"""
|
146 |
+
|
147 |
+
def __init__(self):
|
148 |
+
self.scoring_matrices = self._initialize_scoring_matrices()
|
149 |
+
|
150 |
+
def _initialize_scoring_matrices(self) -> Dict[str, Dict[str, float]]:
|
151 |
+
"""初始化評分矩陣"""
|
152 |
+
return {
|
153 |
+
'spatial_scoring': {
|
154 |
+
# (user_preference, breed_attribute) -> score
|
155 |
+
('apartment', 'small'): 1.0,
|
156 |
+
('apartment', 'medium'): 0.6,
|
157 |
+
('apartment', 'large'): 0.2,
|
158 |
+
('apartment', 'giant'): 0.0,
|
159 |
+
('house', 'small'): 0.7,
|
160 |
+
('house', 'medium'): 0.9,
|
161 |
+
('house', 'large'): 1.0,
|
162 |
+
('house', 'giant'): 1.0,
|
163 |
+
},
|
164 |
+
'activity_scoring': {
|
165 |
+
('low', 'low'): 1.0,
|
166 |
+
('low', 'moderate'): 0.7,
|
167 |
+
('low', 'high'): 0.2,
|
168 |
+
('low', 'very high'): 0.0,
|
169 |
+
('moderate', 'low'): 0.8,
|
170 |
+
('moderate', 'moderate'): 1.0,
|
171 |
+
('moderate', 'high'): 0.8,
|
172 |
+
('high', 'moderate'): 0.7,
|
173 |
+
('high', 'high'): 1.0,
|
174 |
+
('high', 'very high'): 1.0,
|
175 |
+
},
|
176 |
+
'noise_scoring': {
|
177 |
+
('low', 'low'): 1.0,
|
178 |
+
('low', 'moderate'): 0.6,
|
179 |
+
('low', 'high'): 0.1,
|
180 |
+
('moderate', 'low'): 0.8,
|
181 |
+
('moderate', 'moderate'): 1.0,
|
182 |
+
('moderate', 'high'): 0.7,
|
183 |
+
('high', 'low'): 0.7,
|
184 |
+
('high', 'moderate'): 0.9,
|
185 |
+
('high', 'high'): 1.0,
|
186 |
+
},
|
187 |
+
'size_scoring': {
|
188 |
+
('small', 'small'): 1.0,
|
189 |
+
('small', 'medium'): 0.5,
|
190 |
+
('small', 'large'): 0.2,
|
191 |
+
('medium', 'small'): 0.6,
|
192 |
+
('medium', 'medium'): 1.0,
|
193 |
+
('medium', 'large'): 0.6,
|
194 |
+
('large', 'medium'): 0.7,
|
195 |
+
('large', 'large'): 1.0,
|
196 |
+
('large', 'giant'): 0.9,
|
197 |
+
},
|
198 |
+
'maintenance_scoring': {
|
199 |
+
('low', 'low'): 1.0,
|
200 |
+
('low', 'moderate'): 0.6,
|
201 |
+
('low', 'high'): 0.2,
|
202 |
+
('moderate', 'low'): 0.8,
|
203 |
+
('moderate', 'moderate'): 1.0,
|
204 |
+
('moderate', 'high'): 0.7,
|
205 |
+
('high', 'low'): 0.6,
|
206 |
+
('high', 'moderate'): 0.8,
|
207 |
+
('high', 'high'): 1.0,
|
208 |
+
}
|
209 |
+
}
|
210 |
+
|
211 |
+
def score_dimension(self, breed_info: Dict[str, Any],
|
212 |
+
dimensions: QueryDimensions,
|
213 |
+
dimension_type: str) -> float:
|
214 |
+
"""屬性維度評分"""
|
215 |
+
try:
|
216 |
+
if dimension_type.startswith('spatial_'):
|
217 |
+
return self._score_spatial_compatibility(breed_info, dimensions)
|
218 |
+
elif dimension_type.startswith('activity_'):
|
219 |
+
return self._score_activity_compatibility(breed_info, dimensions)
|
220 |
+
elif dimension_type.startswith('noise_'):
|
221 |
+
return self._score_noise_compatibility(breed_info, dimensions)
|
222 |
+
elif dimension_type.startswith('size_'):
|
223 |
+
return self._score_size_compatibility(breed_info, dimensions)
|
224 |
+
elif dimension_type.startswith('family_'):
|
225 |
+
return self._score_family_compatibility(breed_info, dimensions)
|
226 |
+
elif dimension_type.startswith('maintenance_'):
|
227 |
+
return self._score_maintenance_compatibility(breed_info, dimensions)
|
228 |
+
else:
|
229 |
+
return 0.5 # 預設中性分數
|
230 |
+
|
231 |
+
except Exception as e:
|
232 |
+
print(f"Error in attribute scoring for {dimension_type}: {str(e)}")
|
233 |
+
return 0.5
|
234 |
+
|
235 |
+
def _score_spatial_compatibility(self, breed_info: Dict[str, Any],
|
236 |
+
dimensions: QueryDimensions) -> float:
|
237 |
+
"""空間相容性評分"""
|
238 |
+
if not dimensions.spatial_constraints:
|
239 |
+
return 0.5
|
240 |
+
|
241 |
+
breed_size = breed_info.get('size', 'medium').lower()
|
242 |
+
total_score = 0.0
|
243 |
+
|
244 |
+
for spatial_constraint in dimensions.spatial_constraints:
|
245 |
+
key = (spatial_constraint, breed_size)
|
246 |
+
score = self.scoring_matrices['spatial_scoring'].get(key, 0.5)
|
247 |
+
total_score += score
|
248 |
+
|
249 |
+
return total_score / len(dimensions.spatial_constraints)
|
250 |
+
|
251 |
+
def _score_activity_compatibility(self, breed_info: Dict[str, Any],
|
252 |
+
dimensions: QueryDimensions) -> float:
|
253 |
+
"""活動相容性評分"""
|
254 |
+
if not dimensions.activity_level:
|
255 |
+
return 0.5
|
256 |
+
|
257 |
+
breed_exercise = breed_info.get('exercise_needs', 'moderate').lower()
|
258 |
+
# 清理品種運動需求字串
|
259 |
+
if 'very high' in breed_exercise:
|
260 |
+
breed_exercise = 'very high'
|
261 |
+
elif 'high' in breed_exercise:
|
262 |
+
breed_exercise = 'high'
|
263 |
+
elif 'low' in breed_exercise:
|
264 |
+
breed_exercise = 'low'
|
265 |
+
else:
|
266 |
+
breed_exercise = 'moderate'
|
267 |
+
|
268 |
+
total_score = 0.0
|
269 |
+
for activity_level in dimensions.activity_level:
|
270 |
+
key = (activity_level, breed_exercise)
|
271 |
+
score = self.scoring_matrices['activity_scoring'].get(key, 0.5)
|
272 |
+
total_score += score
|
273 |
+
|
274 |
+
return total_score / len(dimensions.activity_level)
|
275 |
+
|
276 |
+
def _score_noise_compatibility(self, breed_info: Dict[str, Any],
|
277 |
+
dimensions: QueryDimensions) -> float:
|
278 |
+
"""噪音相容性評分"""
|
279 |
+
if not dimensions.noise_preferences:
|
280 |
+
return 0.5
|
281 |
+
|
282 |
+
breed_noise = breed_info.get('noise_level', 'moderate').lower()
|
283 |
+
total_score = 0.0
|
284 |
+
|
285 |
+
for noise_pref in dimensions.noise_preferences:
|
286 |
+
key = (noise_pref, breed_noise)
|
287 |
+
score = self.scoring_matrices['noise_scoring'].get(key, 0.5)
|
288 |
+
total_score += score
|
289 |
+
|
290 |
+
return total_score / len(dimensions.noise_preferences)
|
291 |
+
|
292 |
+
def _score_size_compatibility(self, breed_info: Dict[str, Any],
|
293 |
+
dimensions: QueryDimensions) -> float:
|
294 |
+
"""尺寸相容性評分"""
|
295 |
+
if not dimensions.size_preferences:
|
296 |
+
return 0.5
|
297 |
+
|
298 |
+
breed_size = breed_info.get('size', 'medium').lower()
|
299 |
+
total_score = 0.0
|
300 |
+
|
301 |
+
for size_pref in dimensions.size_preferences:
|
302 |
+
key = (size_pref, breed_size)
|
303 |
+
score = self.scoring_matrices['size_scoring'].get(key, 0.5)
|
304 |
+
total_score += score
|
305 |
+
|
306 |
+
return total_score / len(dimensions.size_preferences)
|
307 |
+
|
308 |
+
def _score_family_compatibility(self, breed_info: Dict[str, Any],
|
309 |
+
dimensions: QueryDimensions) -> float:
|
310 |
+
"""家庭相容性評分"""
|
311 |
+
if not dimensions.family_context:
|
312 |
+
return 0.5
|
313 |
+
|
314 |
+
good_with_children = breed_info.get('good_with_children', 'Yes')
|
315 |
+
temperament = breed_info.get('temperament', '').lower()
|
316 |
+
|
317 |
+
total_score = 0.0
|
318 |
+
score_count = 0
|
319 |
+
|
320 |
+
for family_context in dimensions.family_context:
|
321 |
+
if family_context == 'children':
|
322 |
+
if good_with_children == 'Yes':
|
323 |
+
total_score += 1.0
|
324 |
+
elif good_with_children == 'No':
|
325 |
+
total_score += 0.1
|
326 |
+
else:
|
327 |
+
total_score += 0.6
|
328 |
+
score_count += 1
|
329 |
+
elif family_context == 'elderly':
|
330 |
+
# 溫和、冷靜的品種適合老年人
|
331 |
+
if any(trait in temperament for trait in ['gentle', 'calm', 'docile']):
|
332 |
+
total_score += 1.0
|
333 |
+
elif any(trait in temperament for trait in ['energetic', 'hyperactive']):
|
334 |
+
total_score += 0.3
|
335 |
+
else:
|
336 |
+
total_score += 0.7
|
337 |
+
score_count += 1
|
338 |
+
elif family_context == 'single':
|
339 |
+
# 大多數品種都適合單身人士
|
340 |
+
total_score += 0.8
|
341 |
+
score_count += 1
|
342 |
+
|
343 |
+
return total_score / max(1, score_count)
|
344 |
+
|
345 |
+
def _score_maintenance_compatibility(self, breed_info: Dict[str, Any],
|
346 |
+
dimensions: QueryDimensions) -> float:
|
347 |
+
"""維護相容性評分"""
|
348 |
+
if not dimensions.maintenance_level:
|
349 |
+
return 0.5
|
350 |
+
|
351 |
+
breed_grooming = breed_info.get('grooming_needs', 'moderate').lower()
|
352 |
+
total_score = 0.0
|
353 |
+
|
354 |
+
for maintenance_level in dimensions.maintenance_level:
|
355 |
+
key = (maintenance_level, breed_grooming)
|
356 |
+
score = self.scoring_matrices['maintenance_scoring'].get(key, 0.5)
|
357 |
+
total_score += score
|
358 |
+
|
359 |
+
return total_score / len(dimensions.maintenance_level)
|
360 |
+
|
361 |
+
class MultiHeadScorer:
|
362 |
+
"""
|
363 |
+
多頭評分系統
|
364 |
+
結合語義和屬性評分,提供雙向相容性評估
|
365 |
+
"""
|
366 |
+
|
367 |
+
def __init__(self, sbert_model: Optional[SentenceTransformer] = None):
|
368 |
+
self.sbert_model = sbert_model
|
369 |
+
self.semantic_head = SemanticScoringHead(sbert_model)
|
370 |
+
self.attribute_head = AttributeScoringHead()
|
371 |
+
self.dimension_weights = self._initialize_dimension_weights()
|
372 |
+
self.head_fusion_weights = self._initialize_head_fusion_weights()
|
373 |
+
|
374 |
+
def _initialize_dimension_weights(self) -> Dict[str, float]:
|
375 |
+
"""初始化維度權重"""
|
376 |
+
return {
|
377 |
+
'activity_compatibility': 0.35, # 最高優先級:生活方式匹配
|
378 |
+
'noise_compatibility': 0.25, # 關鍵:居住和諧
|
379 |
+
'spatial_compatibility': 0.15, # 基本:物理約束
|
380 |
+
'family_compatibility': 0.10, # 重要:社交相容性
|
381 |
+
'maintenance_compatibility': 0.10, # 實際:持續護理評估
|
382 |
+
'size_compatibility': 0.05 # 基本:偏好匹配
|
383 |
+
}
|
384 |
+
|
385 |
+
def _initialize_head_fusion_weights(self) -> Dict[str, Dict[str, float]]:
|
386 |
+
"""初始化頭融合權重"""
|
387 |
+
return {
|
388 |
+
'activity_compatibility': {'semantic': 0.4, 'attribute': 0.6},
|
389 |
+
'noise_compatibility': {'semantic': 0.3, 'attribute': 0.7},
|
390 |
+
'spatial_compatibility': {'semantic': 0.3, 'attribute': 0.7},
|
391 |
+
'family_compatibility': {'semantic': 0.5, 'attribute': 0.5},
|
392 |
+
'maintenance_compatibility': {'semantic': 0.4, 'attribute': 0.6},
|
393 |
+
'size_compatibility': {'semantic': 0.2, 'attribute': 0.8}
|
394 |
+
}
|
395 |
+
|
396 |
+
def score_breeds(self, candidate_breeds: Set[str],
|
397 |
+
dimensions: QueryDimensions) -> List[BreedScore]:
|
398 |
+
"""
|
399 |
+
為候選品種評分
|
400 |
+
|
401 |
+
Args:
|
402 |
+
candidate_breeds: 通過約束篩選的候選品種
|
403 |
+
dimensions: 查詢維度
|
404 |
+
|
405 |
+
Returns:
|
406 |
+
List[BreedScore]: 品種評分結果列表
|
407 |
+
"""
|
408 |
+
try:
|
409 |
+
breed_scores = []
|
410 |
+
|
411 |
+
# 為每個品種計算分數
|
412 |
+
for breed in candidate_breeds:
|
413 |
+
breed_info = self._get_breed_info(breed)
|
414 |
+
score_result = self._score_single_breed(breed_info, dimensions)
|
415 |
+
breed_scores.append(score_result)
|
416 |
+
|
417 |
+
# 按最終分數排序
|
418 |
+
breed_scores.sort(key=lambda x: x.final_score, reverse=True)
|
419 |
+
|
420 |
+
return breed_scores
|
421 |
+
|
422 |
+
except Exception as e:
|
423 |
+
print(f"Error scoring breeds: {str(e)}")
|
424 |
+
print(traceback.format_exc())
|
425 |
+
return []
|
426 |
+
|
427 |
+
def _get_breed_info(self, breed: str) -> Dict[str, Any]:
|
428 |
+
"""獲取品種資訊"""
|
429 |
+
try:
|
430 |
+
# 基本品種資訊
|
431 |
+
breed_info = get_dog_description(breed) or {}
|
432 |
+
|
433 |
+
# 健康資訊
|
434 |
+
health_info = breed_health_info.get(breed, {})
|
435 |
+
|
436 |
+
# 噪音資訊
|
437 |
+
noise_info = breed_noise_info.get(breed, {})
|
438 |
+
|
439 |
+
# 整合資訊
|
440 |
+
return {
|
441 |
+
'breed_name': breed,
|
442 |
+
'display_name': breed.replace('_', ' '),
|
443 |
+
'size': breed_info.get('Size', '').lower(),
|
444 |
+
'exercise_needs': breed_info.get('Exercise Needs', '').lower(),
|
445 |
+
'grooming_needs': breed_info.get('Grooming Needs', '').lower(),
|
446 |
+
'temperament': breed_info.get('Temperament', '').lower(),
|
447 |
+
'good_with_children': breed_info.get('Good with Children', 'Yes'),
|
448 |
+
'care_level': breed_info.get('Care Level', '').lower(),
|
449 |
+
'lifespan': breed_info.get('Lifespan', '10-12 years'),
|
450 |
+
'noise_level': noise_info.get('noise_level', 'moderate').lower(),
|
451 |
+
'description': breed_info.get('Description', ''),
|
452 |
+
'raw_breed_info': breed_info,
|
453 |
+
'raw_health_info': health_info,
|
454 |
+
'raw_noise_info': noise_info
|
455 |
+
}
|
456 |
+
except Exception as e:
|
457 |
+
print(f"Error getting breed info for {breed}: {str(e)}")
|
458 |
+
return {
|
459 |
+
'breed_name': breed,
|
460 |
+
'display_name': breed.replace('_', ' ')
|
461 |
+
}
|
462 |
+
|
463 |
+
def _score_single_breed(self, breed_info: Dict[str, Any],
|
464 |
+
dimensions: QueryDimensions) -> BreedScore:
|
465 |
+
"""為單一品種評分"""
|
466 |
+
try:
|
467 |
+
dimensional_scores = {}
|
468 |
+
semantic_total = 0.0
|
469 |
+
attribute_total = 0.0
|
470 |
+
|
471 |
+
# 動態權重分配(基於用戶表達的維度)
|
472 |
+
active_dimensions = self._get_active_dimensions(dimensions)
|
473 |
+
adjusted_weights = self._adjust_dimension_weights(active_dimensions)
|
474 |
+
|
475 |
+
# 為每個活躍維度評分
|
476 |
+
for dimension, weight in adjusted_weights.items():
|
477 |
+
# 語義評分
|
478 |
+
semantic_score = self.semantic_head.score_dimension(
|
479 |
+
breed_info, dimensions, dimension
|
480 |
+
)
|
481 |
+
|
482 |
+
# 屬性評分
|
483 |
+
attribute_score = self.attribute_head.score_dimension(
|
484 |
+
breed_info, dimensions, dimension
|
485 |
+
)
|
486 |
+
|
487 |
+
# 頭融合
|
488 |
+
fusion_weights = self.head_fusion_weights.get(
|
489 |
+
dimension, {'semantic': 0.5, 'attribute': 0.5}
|
490 |
+
)
|
491 |
+
|
492 |
+
fused_score = (semantic_score * fusion_weights['semantic'] +
|
493 |
+
attribute_score * fusion_weights['attribute'])
|
494 |
+
|
495 |
+
dimensional_scores[dimension] = fused_score
|
496 |
+
semantic_total += semantic_score * weight
|
497 |
+
attribute_total += attribute_score * weight
|
498 |
+
|
499 |
+
# 雙向相容性評估
|
500 |
+
bidirectional_bonus = self._calculate_bidirectional_bonus(
|
501 |
+
breed_info, dimensions
|
502 |
+
)
|
503 |
+
|
504 |
+
# Apply size bias correction
|
505 |
+
bias_correction = self._calculate_size_bias_correction(breed_info, dimensions)
|
506 |
+
|
507 |
+
# 計算最終分數
|
508 |
+
base_score = sum(score * adjusted_weights[dim]
|
509 |
+
for dim, score in dimensional_scores.items())
|
510 |
+
|
511 |
+
# Apply corrections
|
512 |
+
final_score = max(0.0, min(1.0, base_score + bidirectional_bonus + bias_correction))
|
513 |
+
|
514 |
+
# 信心度評估
|
515 |
+
confidence_score = self._calculate_confidence(dimensions)
|
516 |
+
|
517 |
+
return BreedScore(
|
518 |
+
breed_name=breed_info.get('display_name', breed_info['breed_name']),
|
519 |
+
final_score=final_score,
|
520 |
+
dimensional_breakdown=dimensional_scores,
|
521 |
+
semantic_component=semantic_total,
|
522 |
+
attribute_component=attribute_total,
|
523 |
+
bidirectional_bonus=bidirectional_bonus,
|
524 |
+
confidence_score=confidence_score,
|
525 |
+
explanation=self._generate_explanation(breed_info, dimensions, dimensional_scores)
|
526 |
+
)
|
527 |
+
|
528 |
+
except Exception as e:
|
529 |
+
print(f"Error scoring breed {breed_info.get('breed_name', 'unknown')}: {str(e)}")
|
530 |
+
return BreedScore(
|
531 |
+
breed_name=breed_info.get('display_name', breed_info.get('breed_name', 'Unknown')),
|
532 |
+
final_score=0.5,
|
533 |
+
confidence_score=0.0
|
534 |
+
)
|
535 |
+
|
536 |
+
def _get_active_dimensions(self, dimensions: QueryDimensions) -> Set[str]:
|
537 |
+
"""獲取活躍的維度"""
|
538 |
+
active = set()
|
539 |
+
|
540 |
+
if dimensions.spatial_constraints:
|
541 |
+
active.add('spatial_compatibility')
|
542 |
+
if dimensions.activity_level:
|
543 |
+
active.add('activity_compatibility')
|
544 |
+
if dimensions.noise_preferences:
|
545 |
+
active.add('noise_compatibility')
|
546 |
+
if dimensions.size_preferences:
|
547 |
+
active.add('size_compatibility')
|
548 |
+
if dimensions.family_context:
|
549 |
+
active.add('family_compatibility')
|
550 |
+
if dimensions.maintenance_level:
|
551 |
+
active.add('maintenance_compatibility')
|
552 |
+
|
553 |
+
return active
|
554 |
+
|
555 |
+
def _adjust_dimension_weights(self, active_dimensions: Set[str]) -> Dict[str, float]:
|
556 |
+
"""調整維度權重"""
|
557 |
+
if not active_dimensions:
|
558 |
+
return self.dimension_weights
|
559 |
+
|
560 |
+
# 只為活躍維度分配權重
|
561 |
+
active_weights = {dim: weight for dim, weight in self.dimension_weights.items()
|
562 |
+
if dim in active_dimensions}
|
563 |
+
|
564 |
+
# 正規化權重總和為 1.0
|
565 |
+
total_weight = sum(active_weights.values())
|
566 |
+
if total_weight > 0:
|
567 |
+
active_weights = {dim: weight / total_weight
|
568 |
+
for dim, weight in active_weights.items()}
|
569 |
+
|
570 |
+
return active_weights
|
571 |
+
|
572 |
+
def _calculate_bidirectional_bonus(self, breed_info: Dict[str, Any],
|
573 |
+
dimensions: QueryDimensions) -> float:
|
574 |
+
"""計算雙向相容性獎勵"""
|
575 |
+
try:
|
576 |
+
bonus = 0.0
|
577 |
+
|
578 |
+
# 正向相容性:品種滿足用戶需求
|
579 |
+
forward_compatibility = self._assess_forward_compatibility(breed_info, dimensions)
|
580 |
+
|
581 |
+
# 反向相容性:用戶生活方式適合品種需求
|
582 |
+
reverse_compatibility = self._assess_reverse_compatibility(breed_info, dimensions)
|
583 |
+
|
584 |
+
# 雙向獎勵(較為保守)
|
585 |
+
bonus = min(0.1, (forward_compatibility + reverse_compatibility) * 0.05)
|
586 |
+
|
587 |
+
return bonus
|
588 |
+
|
589 |
+
except Exception as e:
|
590 |
+
print(f"Error calculating bidirectional bonus: {str(e)}")
|
591 |
+
return 0.0
|
592 |
+
|
593 |
+
def _assess_forward_compatibility(self, breed_info: Dict[str, Any],
|
594 |
+
dimensions: QueryDimensions) -> float:
|
595 |
+
"""評估正向相容性"""
|
596 |
+
compatibility = 0.0
|
597 |
+
|
598 |
+
# 空間需求匹配
|
599 |
+
if 'apartment' in dimensions.spatial_constraints:
|
600 |
+
size = breed_info.get('size', '')
|
601 |
+
if 'small' in size:
|
602 |
+
compatibility += 0.3
|
603 |
+
elif 'medium' in size:
|
604 |
+
compatibility += 0.1
|
605 |
+
|
606 |
+
# 活動需求匹配
|
607 |
+
if 'low' in dimensions.activity_level:
|
608 |
+
exercise = breed_info.get('exercise_needs', '')
|
609 |
+
if 'low' in exercise:
|
610 |
+
compatibility += 0.3
|
611 |
+
elif 'moderate' in exercise:
|
612 |
+
compatibility += 0.1
|
613 |
+
|
614 |
+
return compatibility
|
615 |
+
|
616 |
+
def _assess_reverse_compatibility(self, breed_info: Dict[str, Any],
|
617 |
+
dimensions: QueryDimensions) -> float:
|
618 |
+
"""評估反向相容性"""
|
619 |
+
compatibility = 0.0
|
620 |
+
|
621 |
+
# 品種是否能在用戶環境中茁壯成長
|
622 |
+
exercise_needs = breed_info.get('exercise_needs', '')
|
623 |
+
|
624 |
+
if 'high' in exercise_needs:
|
625 |
+
# 高運動需求品種需要確認用戶能提供足夠運動
|
626 |
+
if ('high' in dimensions.activity_level or
|
627 |
+
'house' in dimensions.spatial_constraints):
|
628 |
+
compatibility += 0.2
|
629 |
+
else:
|
630 |
+
compatibility -= 0.2
|
631 |
+
|
632 |
+
# 品種護理需求是否與用戶能力匹配
|
633 |
+
grooming_needs = breed_info.get('grooming_needs', '')
|
634 |
+
if 'high' in grooming_needs:
|
635 |
+
if 'high' in dimensions.maintenance_level:
|
636 |
+
compatibility += 0.1
|
637 |
+
elif 'low' in dimensions.maintenance_level:
|
638 |
+
compatibility -= 0.1
|
639 |
+
|
640 |
+
return compatibility
|
641 |
+
|
642 |
+
def _calculate_size_bias_correction(self, breed_info: Dict,
|
643 |
+
dimensions: QueryDimensions) -> float:
|
644 |
+
"""Correct systematic bias toward larger breeds"""
|
645 |
+
breed_size = breed_info.get('size', '').lower()
|
646 |
+
|
647 |
+
# Default no bias correction
|
648 |
+
correction = 0.0
|
649 |
+
|
650 |
+
# Detect if user specified moderate/balanced preferences
|
651 |
+
if any(term in dimensions.activity_level for term in ['moderate', 'balanced', 'average']):
|
652 |
+
# Penalize extremes
|
653 |
+
if breed_size in ['giant', 'toy']:
|
654 |
+
correction = -0.1
|
655 |
+
elif breed_size in ['large']:
|
656 |
+
correction = -0.05
|
657 |
+
|
658 |
+
# Boost medium breeds for moderate requirements
|
659 |
+
if 'medium' in breed_size and 'balanced' in str(dimensions.activity_level):
|
660 |
+
correction = 0.1
|
661 |
+
|
662 |
+
return correction
|
663 |
+
|
664 |
+
def _calculate_confidence(self, dimensions: QueryDimensions) -> float:
|
665 |
+
"""計算推薦信心度"""
|
666 |
+
# 基於維度覆蓋率和信心分數計算
|
667 |
+
dimension_count = sum([
|
668 |
+
len(dimensions.spatial_constraints),
|
669 |
+
len(dimensions.activity_level),
|
670 |
+
len(dimensions.noise_preferences),
|
671 |
+
len(dimensions.size_preferences),
|
672 |
+
len(dimensions.family_context),
|
673 |
+
len(dimensions.maintenance_level),
|
674 |
+
len(dimensions.special_requirements)
|
675 |
+
])
|
676 |
+
|
677 |
+
# 基礎信心度
|
678 |
+
base_confidence = min(1.0, dimension_count * 0.15)
|
679 |
+
|
680 |
+
# 品種提及獎勵
|
681 |
+
breed_bonus = min(0.2, len(dimensions.breed_mentions) * 0.1)
|
682 |
+
|
683 |
+
# 整體信心分數
|
684 |
+
overall_confidence = dimensions.confidence_scores.get('overall', 0.5)
|
685 |
+
|
686 |
+
return min(1.0, base_confidence + breed_bonus + overall_confidence * 0.3)
|
687 |
+
|
688 |
+
def _generate_explanation(self, breed_info: Dict[str, Any],
|
689 |
+
dimensions: QueryDimensions,
|
690 |
+
dimensional_scores: Dict[str, float]) -> Dict[str, Any]:
|
691 |
+
"""生成評分解釋"""
|
692 |
+
try:
|
693 |
+
explanation = {
|
694 |
+
'strengths': [],
|
695 |
+
'considerations': [],
|
696 |
+
'match_highlights': [],
|
697 |
+
'score_breakdown': dimensional_scores
|
698 |
+
}
|
699 |
+
|
700 |
+
breed_name = breed_info.get('display_name', '')
|
701 |
+
|
702 |
+
# 分析各維度表現
|
703 |
+
for dimension, score in dimensional_scores.items():
|
704 |
+
if score >= 0.8:
|
705 |
+
explanation['strengths'].append(self._get_strength_text(dimension, breed_info))
|
706 |
+
elif score <= 0.3:
|
707 |
+
explanation['considerations'].append(self._get_consideration_text(dimension, breed_info))
|
708 |
+
else:
|
709 |
+
explanation['match_highlights'].append(f"{dimension}: {score:.2f}")
|
710 |
+
|
711 |
+
return explanation
|
712 |
+
|
713 |
+
except Exception as e:
|
714 |
+
print(f"Error generating explanation: {str(e)}")
|
715 |
+
return {'strengths': [], 'considerations': [], 'match_highlights': []}
|
716 |
+
|
717 |
+
def _get_strength_text(self, dimension: str, breed_info: Dict[str, Any]) -> str:
|
718 |
+
"""Get strength description"""
|
719 |
+
breed_name = breed_info.get('display_name', '')
|
720 |
+
|
721 |
+
if dimension == 'activity_compatibility':
|
722 |
+
return f"{breed_name} has an activity level that matches your lifestyle very well"
|
723 |
+
elif dimension == 'noise_compatibility':
|
724 |
+
return f"{breed_name} has noise characteristics that fit your environment"
|
725 |
+
elif dimension == 'spatial_compatibility':
|
726 |
+
return f"{breed_name} is very suitable for your living space"
|
727 |
+
elif dimension == 'family_compatibility':
|
728 |
+
return f"{breed_name} performs well in a family environment"
|
729 |
+
elif dimension == 'maintenance_compatibility':
|
730 |
+
return f"{breed_name} has grooming needs that match your willingness to commit"
|
731 |
+
else:
|
732 |
+
return f"{breed_name} shows strong performance in {dimension}"
|
733 |
+
|
734 |
+
def _get_consideration_text(self, dimension: str, breed_info: Dict[str, Any]) -> str:
|
735 |
+
"""Get consideration description"""
|
736 |
+
breed_name = breed_info.get('display_name', '')
|
737 |
+
|
738 |
+
if dimension == 'activity_compatibility':
|
739 |
+
return f"{breed_name} may have exercise needs that differ from your lifestyle"
|
740 |
+
elif dimension == 'noise_compatibility':
|
741 |
+
return f"{breed_name} has noise characteristics that require special consideration"
|
742 |
+
elif dimension == 'maintenance_compatibility':
|
743 |
+
return f"{breed_name} has relatively high grooming requirements"
|
744 |
+
else:
|
745 |
+
return f"{breed_name} requires extra consideration in {dimension}"
|
746 |
+
|
747 |
+
|
748 |
+
def score_breed_candidates(candidate_breeds: Set[str],
|
749 |
+
dimensions: QueryDimensions,
|
750 |
+
sbert_model: Optional[SentenceTransformer] = None) -> List[BreedScore]:
|
751 |
+
"""
|
752 |
+
便利函數:為候選品種評分
|
753 |
+
|
754 |
+
Args:
|
755 |
+
candidate_breeds: 候選品種集合
|
756 |
+
dimensions: 查詢維度
|
757 |
+
sbert_model: 可選的SBERT模型
|
758 |
+
|
759 |
+
Returns:
|
760 |
+
List[BreedScore]: 評分結果列表
|
761 |
+
"""
|
762 |
+
scorer = MultiHeadScorer(sbert_model)
|
763 |
+
return scorer.score_breeds(candidate_breeds, dimensions)
|
natural_language_processor.py
ADDED
@@ -0,0 +1,488 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
import re
|
2 |
+
import string
|
3 |
+
from typing import Dict, List, Tuple, Optional, Any
|
4 |
+
import traceback
|
5 |
+
|
6 |
+
class NaturalLanguageProcessor:
|
7 |
+
"""
|
8 |
+
Natural language processing utility class
|
9 |
+
Handles text preprocessing and keyword extraction for user input
|
10 |
+
"""
|
11 |
+
|
12 |
+
def __init__(self):
|
13 |
+
"""Initialize the natural language processor"""
|
14 |
+
self.stop_words = {
|
15 |
+
'a', 'an', 'and', 'are', 'as', 'at', 'be', 'by', 'for', 'from',
|
16 |
+
'has', 'he', 'in', 'is', 'it', 'its', 'of', 'on', 'that', 'the',
|
17 |
+
'to', 'was', 'will', 'with', 'would', 'i', 'me', 'my', 'we', 'us',
|
18 |
+
'our', 'you', 'your', 'they', 'them', 'their'
|
19 |
+
}
|
20 |
+
|
21 |
+
# Breed name mappings (common aliases to standard names)
|
22 |
+
self.breed_aliases = {
|
23 |
+
'lab': 'labrador_retriever',
|
24 |
+
'labrador': 'labrador_retriever',
|
25 |
+
'golden': 'golden_retriever',
|
26 |
+
'retriever': ['labrador_retriever', 'golden_retriever'],
|
27 |
+
'german shepherd': 'german_shepherd',
|
28 |
+
'shepherd': 'german_shepherd',
|
29 |
+
'border collie': 'border_collie',
|
30 |
+
'collie': ['border_collie', 'collie'],
|
31 |
+
'bulldog': ['french_bulldog', 'english_bulldog'],
|
32 |
+
'french bulldog': 'french_bulldog',
|
33 |
+
'poodle': ['standard_poodle', 'miniature_poodle', 'toy_poodle'],
|
34 |
+
'husky': 'siberian_husky',
|
35 |
+
'siberian husky': 'siberian_husky',
|
36 |
+
'beagle': 'beagle',
|
37 |
+
'yorkshire terrier': 'yorkshire_terrier',
|
38 |
+
'yorkie': 'yorkshire_terrier',
|
39 |
+
'chihuahua': 'chihuahua',
|
40 |
+
'dachshund': 'dachshund',
|
41 |
+
'wiener dog': 'dachshund',
|
42 |
+
'rottweiler': 'rottweiler',
|
43 |
+
'rottie': 'rottweiler',
|
44 |
+
'boxer': 'boxer',
|
45 |
+
'great dane': 'great_dane',
|
46 |
+
'dane': 'great_dane',
|
47 |
+
'mastiff': ['bull_mastiff', 'tibetan_mastiff'],
|
48 |
+
'pitbull': 'american_staffordshire_terrier',
|
49 |
+
'pit bull': 'american_staffordshire_terrier',
|
50 |
+
'shih tzu': 'shih-tzu',
|
51 |
+
'maltese': 'maltese_dog',
|
52 |
+
'pug': 'pug',
|
53 |
+
'basset hound': 'basset',
|
54 |
+
'bloodhound': 'bloodhound',
|
55 |
+
'australian shepherd': 'kelpie',
|
56 |
+
'aussie': 'kelpie'
|
57 |
+
}
|
58 |
+
|
59 |
+
# Lifestyle keyword mappings
|
60 |
+
self.lifestyle_keywords = {
|
61 |
+
'living_space': {
|
62 |
+
'apartment': ['apartment', 'flat', 'condo', 'small space', 'city living', 'urban'],
|
63 |
+
'house': ['house', 'home', 'yard', 'garden', 'suburban', 'large space'],
|
64 |
+
'farm': ['farm', 'rural', 'country', 'acreage', 'ranch']
|
65 |
+
},
|
66 |
+
'activity_level': {
|
67 |
+
'very_high': ['very active', 'extremely energetic', 'marathon runner', 'athlete'],
|
68 |
+
'high': ['active', 'energetic', 'exercise', 'hiking', 'running', 'outdoor activities',
|
69 |
+
'sports', 'jogging', 'biking', 'adventure'],
|
70 |
+
'moderate': ['moderate exercise', 'some activity', 'weekend walks', 'occasional exercise'],
|
71 |
+
'low': ['calm', 'lazy', 'indoor', 'low energy', 'couch potato', 'sedentary', 'quiet lifestyle']
|
72 |
+
},
|
73 |
+
'family_situation': {
|
74 |
+
'children': ['children', 'kids', 'toddlers', 'babies', 'family with children', 'young family'],
|
75 |
+
'elderly': ['elderly', 'senior', 'old', 'retired', 'senior citizen'],
|
76 |
+
'single': ['single', 'alone', 'individual', 'bachelor', 'solo'],
|
77 |
+
'couple': ['couple', 'two people', 'pair', 'duo']
|
78 |
+
},
|
79 |
+
'noise_tolerance': {
|
80 |
+
'low': ['quiet', 'silent', 'noise-sensitive', 'peaceful', 'no barking', 'minimal noise'],
|
81 |
+
'moderate': ['some noise ok', 'moderate barking', 'normal noise'],
|
82 |
+
'high': ['loud ok', 'barking fine', 'noise tolerant', 'doesn\'t mind noise']
|
83 |
+
},
|
84 |
+
'size_preference': {
|
85 |
+
'small': ['small', 'tiny', 'little', 'compact', 'lap dog', 'petite', 'miniature'],
|
86 |
+
'medium': ['medium', 'moderate size', 'average', 'mid-size'],
|
87 |
+
'large': ['large', 'big', 'huge', 'giant', 'massive', 'substantial'],
|
88 |
+
'varies': ['any size', 'size doesn\'t matter', 'flexible on size']
|
89 |
+
},
|
90 |
+
'experience_level': {
|
91 |
+
'beginner': ['first time', 'beginner', 'new to dogs', 'inexperienced', 'never had'],
|
92 |
+
'some': ['some experience', 'had dogs before', 'moderate experience'],
|
93 |
+
'experienced': ['experienced', 'expert', 'very experienced', 'professional', 'trainer']
|
94 |
+
},
|
95 |
+
'grooming_commitment': {
|
96 |
+
'low': ['low maintenance', 'easy care', 'minimal grooming', 'wash and go'],
|
97 |
+
'moderate': ['moderate grooming', 'some brushing', 'regular care'],
|
98 |
+
'high': ['high maintenance', 'lots of grooming', 'professional grooming', 'daily brushing']
|
99 |
+
},
|
100 |
+
'special_needs': {
|
101 |
+
'guard': ['guard dog', 'protection', 'security', 'watchdog', 'guardian'],
|
102 |
+
'therapy': ['therapy dog', 'emotional support', 'comfort', 'calm companion'],
|
103 |
+
'hypoallergenic': ['hypoallergenic', 'allergies', 'non-shedding', 'allergy friendly'],
|
104 |
+
'working': ['working dog', 'job', 'task', 'service dog'],
|
105 |
+
'companion': ['companion', 'friend', 'buddy', 'lap dog', 'cuddle']
|
106 |
+
}
|
107 |
+
}
|
108 |
+
|
109 |
+
# Comparative preference keywords
|
110 |
+
self.preference_indicators = {
|
111 |
+
'love': 1.0,
|
112 |
+
'prefer': 0.9,
|
113 |
+
'like': 0.8,
|
114 |
+
'want': 0.8,
|
115 |
+
'interested in': 0.7,
|
116 |
+
'considering': 0.6,
|
117 |
+
'ok with': 0.5,
|
118 |
+
'don\'t mind': 0.4,
|
119 |
+
'not interested': 0.2,
|
120 |
+
'dislike': 0.1,
|
121 |
+
'hate': 0.0
|
122 |
+
}
|
123 |
+
|
124 |
+
# Order keywords
|
125 |
+
self.order_keywords = {
|
126 |
+
'first': 1.0, 'most': 1.0, 'primary': 1.0, 'main': 1.0,
|
127 |
+
'second': 0.8, 'then': 0.8, 'next': 0.8,
|
128 |
+
'third': 0.6, 'also': 0.6, 'additionally': 0.6,
|
129 |
+
'last': 0.4, 'least': 0.4, 'finally': 0.4
|
130 |
+
}
|
131 |
+
|
132 |
+
def preprocess_text(self, text: str) -> str:
|
133 |
+
"""
|
134 |
+
Text preprocessing
|
135 |
+
|
136 |
+
Args:
|
137 |
+
text: Raw text
|
138 |
+
|
139 |
+
Returns:
|
140 |
+
Preprocessed text
|
141 |
+
"""
|
142 |
+
if not text:
|
143 |
+
return ""
|
144 |
+
|
145 |
+
# Convert to lowercase
|
146 |
+
text = text.lower().strip()
|
147 |
+
|
148 |
+
# Remove punctuation (keep some meaningful ones)
|
149 |
+
text = re.sub(r'[^\w\s\-\']', ' ', text)
|
150 |
+
|
151 |
+
# Handle extra whitespace
|
152 |
+
text = re.sub(r'\s+', ' ', text)
|
153 |
+
|
154 |
+
return text
|
155 |
+
|
156 |
+
def extract_breed_mentions(self, text: str) -> List[Tuple[str, float]]:
|
157 |
+
"""
|
158 |
+
Extract mentioned breeds and their preference levels from text
|
159 |
+
|
160 |
+
Args:
|
161 |
+
text: Input text
|
162 |
+
|
163 |
+
Returns:
|
164 |
+
List of (breed_name, preference_score) tuples
|
165 |
+
"""
|
166 |
+
text = self.preprocess_text(text)
|
167 |
+
breed_mentions = []
|
168 |
+
|
169 |
+
try:
|
170 |
+
# Check each breed alias
|
171 |
+
for alias, standard_breed in self.breed_aliases.items():
|
172 |
+
if alias in text:
|
173 |
+
# Find surrounding preference indicators
|
174 |
+
preference_score = self._find_preference_score(text, alias)
|
175 |
+
|
176 |
+
if isinstance(standard_breed, list):
|
177 |
+
# If alias maps to multiple breeds, add all
|
178 |
+
for breed in standard_breed:
|
179 |
+
breed_mentions.append((breed, preference_score))
|
180 |
+
else:
|
181 |
+
breed_mentions.append((standard_breed, preference_score))
|
182 |
+
|
183 |
+
# Deduplicate and merge scores
|
184 |
+
breed_scores = {}
|
185 |
+
for breed, score in breed_mentions:
|
186 |
+
if breed in breed_scores:
|
187 |
+
breed_scores[breed] = max(breed_scores[breed], score)
|
188 |
+
else:
|
189 |
+
breed_scores[breed] = score
|
190 |
+
|
191 |
+
return list(breed_scores.items())
|
192 |
+
|
193 |
+
except Exception as e:
|
194 |
+
print(f"Error extracting breed mentions: {str(e)}")
|
195 |
+
return []
|
196 |
+
|
197 |
+
def _find_preference_score(self, text: str, breed_mention: str) -> float:
|
198 |
+
"""
|
199 |
+
Find preference score near breed mention
|
200 |
+
|
201 |
+
Args:
|
202 |
+
text: Text
|
203 |
+
breed_mention: Breed mention
|
204 |
+
|
205 |
+
Returns:
|
206 |
+
Preference score (0.0-1.0)
|
207 |
+
"""
|
208 |
+
try:
|
209 |
+
# Find breed mention position
|
210 |
+
mention_pos = text.find(breed_mention)
|
211 |
+
if mention_pos == -1:
|
212 |
+
return 0.5 # Default neutral score
|
213 |
+
|
214 |
+
# Check context (50 characters before and after)
|
215 |
+
context_start = max(0, mention_pos - 50)
|
216 |
+
context_end = min(len(text), mention_pos + len(breed_mention) + 50)
|
217 |
+
context = text[context_start:context_end]
|
218 |
+
|
219 |
+
# Find preference indicators
|
220 |
+
max_score = 0.5 # Default score
|
221 |
+
|
222 |
+
for indicator, score in self.preference_indicators.items():
|
223 |
+
if indicator in context:
|
224 |
+
max_score = max(max_score, score)
|
225 |
+
|
226 |
+
# Find order keywords
|
227 |
+
for order_word, multiplier in self.order_keywords.items():
|
228 |
+
if order_word in context:
|
229 |
+
max_score = max(max_score, max_score * multiplier)
|
230 |
+
|
231 |
+
return max_score
|
232 |
+
|
233 |
+
except Exception as e:
|
234 |
+
print(f"Error finding preference score: {str(e)}")
|
235 |
+
return 0.5
|
236 |
+
|
237 |
+
def extract_lifestyle_preferences(self, text: str) -> Dict[str, Dict[str, float]]:
|
238 |
+
"""
|
239 |
+
Extract lifestyle preferences from text
|
240 |
+
|
241 |
+
Args:
|
242 |
+
text: Input text
|
243 |
+
|
244 |
+
Returns:
|
245 |
+
Lifestyle preferences dictionary
|
246 |
+
"""
|
247 |
+
text = self.preprocess_text(text)
|
248 |
+
preferences = {}
|
249 |
+
|
250 |
+
try:
|
251 |
+
for category, keywords_dict in self.lifestyle_keywords.items():
|
252 |
+
preferences[category] = {}
|
253 |
+
|
254 |
+
for preference_type, keywords in keywords_dict.items():
|
255 |
+
score = 0.0
|
256 |
+
count = 0
|
257 |
+
|
258 |
+
for keyword in keywords:
|
259 |
+
if keyword in text:
|
260 |
+
# Calculate keyword occurrence intensity
|
261 |
+
keyword_count = text.count(keyword)
|
262 |
+
score += keyword_count
|
263 |
+
count += keyword_count
|
264 |
+
|
265 |
+
if count > 0:
|
266 |
+
# Normalize score
|
267 |
+
preferences[category][preference_type] = min(score / max(count, 1), 1.0)
|
268 |
+
|
269 |
+
return preferences
|
270 |
+
|
271 |
+
except Exception as e:
|
272 |
+
print(f"Error extracting lifestyle preferences: {str(e)}")
|
273 |
+
return {}
|
274 |
+
|
275 |
+
def generate_search_keywords(self, text: str) -> List[str]:
|
276 |
+
"""
|
277 |
+
Generate keyword list for search
|
278 |
+
|
279 |
+
Args:
|
280 |
+
text: Input text
|
281 |
+
|
282 |
+
Returns:
|
283 |
+
List of keywords
|
284 |
+
"""
|
285 |
+
text = self.preprocess_text(text)
|
286 |
+
keywords = []
|
287 |
+
|
288 |
+
try:
|
289 |
+
# Tokenize and filter stop words
|
290 |
+
words = text.split()
|
291 |
+
for word in words:
|
292 |
+
if len(word) > 2 and word not in self.stop_words:
|
293 |
+
keywords.append(word)
|
294 |
+
|
295 |
+
# Extract important phrases
|
296 |
+
phrases = self._extract_phrases(text)
|
297 |
+
keywords.extend(phrases)
|
298 |
+
|
299 |
+
# Remove duplicates
|
300 |
+
keywords = list(set(keywords))
|
301 |
+
|
302 |
+
return keywords
|
303 |
+
|
304 |
+
except Exception as e:
|
305 |
+
print(f"Error generating search keywords: {str(e)}")
|
306 |
+
return []
|
307 |
+
|
308 |
+
def _extract_phrases(self, text: str) -> List[str]:
|
309 |
+
"""
|
310 |
+
Extract important phrases
|
311 |
+
|
312 |
+
Args:
|
313 |
+
text: Input text
|
314 |
+
|
315 |
+
Returns:
|
316 |
+
List of phrases
|
317 |
+
"""
|
318 |
+
phrases = []
|
319 |
+
|
320 |
+
# Define important phrase patterns
|
321 |
+
phrase_patterns = [
|
322 |
+
r'good with \w+',
|
323 |
+
r'apartment \w+',
|
324 |
+
r'family \w+',
|
325 |
+
r'exercise \w+',
|
326 |
+
r'grooming \w+',
|
327 |
+
r'noise \w+',
|
328 |
+
r'training \w+',
|
329 |
+
r'health \w+',
|
330 |
+
r'\w+ friendly',
|
331 |
+
r'\w+ tolerant',
|
332 |
+
r'\w+ maintenance',
|
333 |
+
r'\w+ energy',
|
334 |
+
r'\w+ barking',
|
335 |
+
r'\w+ shedding'
|
336 |
+
]
|
337 |
+
|
338 |
+
for pattern in phrase_patterns:
|
339 |
+
matches = re.findall(pattern, text)
|
340 |
+
phrases.extend(matches)
|
341 |
+
|
342 |
+
return phrases
|
343 |
+
|
344 |
+
def analyze_sentiment(self, text: str) -> Dict[str, float]:
|
345 |
+
"""
|
346 |
+
Analyze text sentiment
|
347 |
+
|
348 |
+
Args:
|
349 |
+
text: Input text
|
350 |
+
|
351 |
+
Returns:
|
352 |
+
Sentiment analysis results {'positive': 0.0-1.0, 'negative': 0.0-1.0, 'neutral': 0.0-1.0}
|
353 |
+
"""
|
354 |
+
text = self.preprocess_text(text)
|
355 |
+
|
356 |
+
positive_words = [
|
357 |
+
'love', 'like', 'want', 'prefer', 'good', 'great', 'excellent',
|
358 |
+
'perfect', 'ideal', 'wonderful', 'amazing', 'fantastic'
|
359 |
+
]
|
360 |
+
|
361 |
+
negative_words = [
|
362 |
+
'hate', 'dislike', 'bad', 'terrible', 'awful', 'horrible',
|
363 |
+
'not good', 'don\'t want', 'avoid', 'against', 'problem'
|
364 |
+
]
|
365 |
+
|
366 |
+
positive_count = sum(1 for word in positive_words if word in text)
|
367 |
+
negative_count = sum(1 for word in negative_words if word in text)
|
368 |
+
total_words = len(text.split())
|
369 |
+
|
370 |
+
if total_words == 0:
|
371 |
+
return {'positive': 0.0, 'negative': 0.0, 'neutral': 1.0}
|
372 |
+
|
373 |
+
positive_ratio = positive_count / total_words
|
374 |
+
negative_ratio = negative_count / total_words
|
375 |
+
neutral_ratio = 1.0 - positive_ratio - negative_ratio
|
376 |
+
|
377 |
+
return {
|
378 |
+
'positive': positive_ratio,
|
379 |
+
'negative': negative_ratio,
|
380 |
+
'neutral': max(0.0, neutral_ratio)
|
381 |
+
}
|
382 |
+
|
383 |
+
def extract_implicit_preferences(self, text: str) -> Dict[str, Any]:
|
384 |
+
"""
|
385 |
+
Extract implicit preferences from text
|
386 |
+
|
387 |
+
Args:
|
388 |
+
text: Input text
|
389 |
+
|
390 |
+
Returns:
|
391 |
+
Dictionary of implicit preferences
|
392 |
+
"""
|
393 |
+
text = self.preprocess_text(text)
|
394 |
+
implicit_prefs = {}
|
395 |
+
|
396 |
+
try:
|
397 |
+
# Infer preferences from mentioned activities
|
398 |
+
if any(activity in text for activity in ['hiking', 'running', 'jogging', 'outdoor']):
|
399 |
+
implicit_prefs['exercise_needs'] = 'high'
|
400 |
+
implicit_prefs['size_preference'] = 'medium_to_large'
|
401 |
+
|
402 |
+
# Infer from living environment
|
403 |
+
if any(env in text for env in ['apartment', 'small space', 'city']):
|
404 |
+
implicit_prefs['size_preference'] = 'small_to_medium'
|
405 |
+
implicit_prefs['noise_tolerance'] = 'low'
|
406 |
+
implicit_prefs['exercise_needs'] = 'moderate'
|
407 |
+
|
408 |
+
# Infer from family situation
|
409 |
+
if 'children' in text or 'kids' in text:
|
410 |
+
implicit_prefs['temperament'] = 'gentle_patient'
|
411 |
+
implicit_prefs['good_with_children'] = True
|
412 |
+
|
413 |
+
# Infer from experience level
|
414 |
+
if any(exp in text for exp in ['first time', 'beginner', 'new to']):
|
415 |
+
implicit_prefs['care_level'] = 'low_to_moderate'
|
416 |
+
implicit_prefs['training_difficulty'] = 'easy'
|
417 |
+
|
418 |
+
# Infer from time commitment
|
419 |
+
if any(time in text for time in ['busy', 'no time', 'low maintenance']):
|
420 |
+
implicit_prefs['grooming_needs'] = 'low'
|
421 |
+
implicit_prefs['care_level'] = 'low'
|
422 |
+
implicit_prefs['exercise_needs'] = 'low_to_moderate'
|
423 |
+
|
424 |
+
return implicit_prefs
|
425 |
+
|
426 |
+
except Exception as e:
|
427 |
+
print(f"Error extracting implicit preferences: {str(e)}")
|
428 |
+
return {}
|
429 |
+
|
430 |
+
def validate_input(self, text: str) -> Dict[str, Any]:
|
431 |
+
"""
|
432 |
+
Validate input text validity
|
433 |
+
|
434 |
+
Args:
|
435 |
+
text: Input text
|
436 |
+
|
437 |
+
Returns:
|
438 |
+
Validation results dictionary
|
439 |
+
"""
|
440 |
+
if not text or not text.strip():
|
441 |
+
return {
|
442 |
+
'is_valid': False,
|
443 |
+
'error': 'Empty input',
|
444 |
+
'suggestions': ['Please provide a description of your preferences']
|
445 |
+
}
|
446 |
+
|
447 |
+
text = text.strip()
|
448 |
+
|
449 |
+
# Check length
|
450 |
+
if len(text) < 10:
|
451 |
+
return {
|
452 |
+
'is_valid': False,
|
453 |
+
'error': 'Input too short',
|
454 |
+
'suggestions': ['Please provide more details about your preferences']
|
455 |
+
}
|
456 |
+
|
457 |
+
if len(text) > 1000:
|
458 |
+
return {
|
459 |
+
'is_valid': False,
|
460 |
+
'error': 'Input too long',
|
461 |
+
'suggestions': ['Please provide a more concise description']
|
462 |
+
}
|
463 |
+
|
464 |
+
# Check for meaningful content
|
465 |
+
processed_text = self.preprocess_text(text)
|
466 |
+
meaningful_words = [word for word in processed_text.split()
|
467 |
+
if len(word) > 2 and word not in self.stop_words]
|
468 |
+
|
469 |
+
if len(meaningful_words) < 3:
|
470 |
+
return {
|
471 |
+
'is_valid': False,
|
472 |
+
'error': 'Not enough meaningful content',
|
473 |
+
'suggestions': ['Please provide more specific details about your lifestyle and preferences']
|
474 |
+
}
|
475 |
+
|
476 |
+
return {
|
477 |
+
'is_valid': True,
|
478 |
+
'word_count': len(meaningful_words),
|
479 |
+
'suggestions': []
|
480 |
+
}
|
481 |
+
|
482 |
+
def get_nlp_processor():
|
483 |
+
"""Get natural language processor instance"""
|
484 |
+
try:
|
485 |
+
return NaturalLanguageProcessor()
|
486 |
+
except Exception as e:
|
487 |
+
print(f"Error creating NLP processor: {str(e)}")
|
488 |
+
return None
|
query_understanding.py
ADDED
@@ -0,0 +1,464 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
import re
|
2 |
+
import json
|
3 |
+
import numpy as np
|
4 |
+
import sqlite3
|
5 |
+
from typing import Dict, List, Tuple, Optional, Any
|
6 |
+
from dataclasses import dataclass, field
|
7 |
+
import traceback
|
8 |
+
from sentence_transformers import SentenceTransformer
|
9 |
+
from dog_database import get_dog_description
|
10 |
+
from breed_health_info import breed_health_info
|
11 |
+
from breed_noise_info import breed_noise_info
|
12 |
+
|
13 |
+
@dataclass
|
14 |
+
class QueryDimensions:
|
15 |
+
"""Structured query intent data structure"""
|
16 |
+
spatial_constraints: List[str] = field(default_factory=list)
|
17 |
+
activity_level: List[str] = field(default_factory=list)
|
18 |
+
noise_preferences: List[str] = field(default_factory=list)
|
19 |
+
size_preferences: List[str] = field(default_factory=list)
|
20 |
+
family_context: List[str] = field(default_factory=list)
|
21 |
+
maintenance_level: List[str] = field(default_factory=list)
|
22 |
+
special_requirements: List[str] = field(default_factory=list)
|
23 |
+
breed_mentions: List[str] = field(default_factory=list)
|
24 |
+
confidence_scores: Dict[str, float] = field(default_factory=dict)
|
25 |
+
|
26 |
+
@dataclass
|
27 |
+
class DimensionalSynonyms:
|
28 |
+
"""Dimensional synonyms dictionary structure"""
|
29 |
+
spatial: Dict[str, List[str]] = field(default_factory=dict)
|
30 |
+
activity: Dict[str, List[str]] = field(default_factory=dict)
|
31 |
+
noise: Dict[str, List[str]] = field(default_factory=dict)
|
32 |
+
size: Dict[str, List[str]] = field(default_factory=dict)
|
33 |
+
family: Dict[str, List[str]] = field(default_factory=dict)
|
34 |
+
maintenance: Dict[str, List[str]] = field(default_factory=dict)
|
35 |
+
special: Dict[str, List[str]] = field(default_factory=dict)
|
36 |
+
|
37 |
+
class QueryUnderstandingEngine:
|
38 |
+
"""
|
39 |
+
多維度語義查詢理解引擎
|
40 |
+
支援中英文自然語言理解並轉換為結構化品種推薦查詢
|
41 |
+
"""
|
42 |
+
|
43 |
+
def __init__(self):
|
44 |
+
"""初始化查詢理解引擎"""
|
45 |
+
self.sbert_model = None
|
46 |
+
self.breed_list = self._load_breed_list()
|
47 |
+
self.synonyms = self._initialize_synonyms()
|
48 |
+
self.semantic_templates = {}
|
49 |
+
self._initialize_sbert_model()
|
50 |
+
self._build_semantic_templates()
|
51 |
+
|
52 |
+
def _load_breed_list(self) -> List[str]:
|
53 |
+
"""載入品種清單"""
|
54 |
+
try:
|
55 |
+
conn = sqlite3.connect('animal_detector.db')
|
56 |
+
cursor = conn.cursor()
|
57 |
+
cursor.execute("SELECT DISTINCT Breed FROM AnimalCatalog")
|
58 |
+
breeds = [row[0] for row in cursor.fetchall()]
|
59 |
+
cursor.close()
|
60 |
+
conn.close()
|
61 |
+
return breeds
|
62 |
+
except Exception as e:
|
63 |
+
print(f"Error loading breed list: {str(e)}")
|
64 |
+
# 備用品種清單
|
65 |
+
return ['Labrador_Retriever', 'German_Shepherd', 'Golden_Retriever',
|
66 |
+
'Bulldog', 'Poodle', 'Beagle', 'Border_Collie', 'Yorkshire_Terrier']
|
67 |
+
|
68 |
+
def _initialize_sbert_model(self):
|
69 |
+
"""初始化 SBERT 模型"""
|
70 |
+
try:
|
71 |
+
model_options = ['all-MiniLM-L6-v2', 'all-mpnet-base-v2', 'all-MiniLM-L12-v2']
|
72 |
+
|
73 |
+
for model_name in model_options:
|
74 |
+
try:
|
75 |
+
self.sbert_model = SentenceTransformer(model_name)
|
76 |
+
print(f"SBERT model {model_name} loaded successfully for query understanding")
|
77 |
+
return
|
78 |
+
except Exception as e:
|
79 |
+
print(f"Failed to load {model_name}: {str(e)}")
|
80 |
+
continue
|
81 |
+
|
82 |
+
print("All SBERT models failed to load. Using keyword-only analysis.")
|
83 |
+
self.sbert_model = None
|
84 |
+
|
85 |
+
except Exception as e:
|
86 |
+
print(f"Failed to initialize SBERT model: {str(e)}")
|
87 |
+
self.sbert_model = None
|
88 |
+
|
89 |
+
def _initialize_synonyms(self) -> DimensionalSynonyms:
|
90 |
+
"""初始化多維度同義詞字典"""
|
91 |
+
return DimensionalSynonyms(
|
92 |
+
spatial={
|
93 |
+
'apartment': ['apartment', 'flat', 'condo', 'small space', 'city living',
|
94 |
+
'urban', 'no yard', 'indoor'],
|
95 |
+
'house': ['house', 'home', 'yard', 'garden', 'backyard', 'large space',
|
96 |
+
'suburban', 'rural', 'farm']
|
97 |
+
},
|
98 |
+
activity={
|
99 |
+
'low': ['low activity', 'sedentary', 'couch potato', 'minimal exercise',
|
100 |
+
'indoor lifestyle', 'lazy', 'calm'],
|
101 |
+
'moderate': ['moderate activity', 'daily walks', 'light exercise',
|
102 |
+
'regular walks'],
|
103 |
+
'high': ['high activity', 'energetic', 'active', 'exercise', 'hiking',
|
104 |
+
'running', 'jogging', 'outdoor sports']
|
105 |
+
},
|
106 |
+
noise={
|
107 |
+
'low': ['quiet', 'silent', 'no barking', 'peaceful', 'low noise',
|
108 |
+
'rarely barks', 'soft-spoken'],
|
109 |
+
'moderate': ['moderate barking', 'occasional barking'],
|
110 |
+
'high': ['loud', 'barking', 'vocal', 'noisy', 'frequent barking',
|
111 |
+
'alert dog']
|
112 |
+
},
|
113 |
+
size={
|
114 |
+
'small': ['small', 'tiny', 'little', 'compact', 'miniature', 'toy',
|
115 |
+
'lap dog'],
|
116 |
+
'medium': ['medium', 'moderate size', 'average', 'mid-sized'],
|
117 |
+
'large': ['large', 'big', 'giant', 'huge', 'massive', 'great']
|
118 |
+
},
|
119 |
+
family={
|
120 |
+
'children': ['children', 'kids', 'family', 'child-friendly', 'toddler',
|
121 |
+
'baby', 'school age'],
|
122 |
+
'elderly': ['elderly', 'senior', 'old people', 'retirement', 'aged'],
|
123 |
+
'single': ['single', 'alone', 'individual', 'solo', 'myself']
|
124 |
+
},
|
125 |
+
maintenance={
|
126 |
+
'low': ['low maintenance', 'easy care', 'simple', 'minimal grooming',
|
127 |
+
'wash and go'],
|
128 |
+
'moderate': ['moderate maintenance', 'regular grooming'],
|
129 |
+
'high': ['high maintenance', 'professional grooming', 'daily brushing',
|
130 |
+
'care intensive']
|
131 |
+
},
|
132 |
+
special={
|
133 |
+
'guard': ['guard dog', 'protection', 'security', 'watchdog',
|
134 |
+
'protective', 'defender'],
|
135 |
+
'companion': ['companion', 'therapy', 'emotional support', 'comfort',
|
136 |
+
'cuddly', 'lap dog'],
|
137 |
+
'hypoallergenic': ['hypoallergenic', 'allergies', 'non-shedding',
|
138 |
+
'allergy-friendly', 'no shed'],
|
139 |
+
'first_time': ['first time', 'beginner', 'new to dogs', 'inexperienced',
|
140 |
+
'never owned']
|
141 |
+
}
|
142 |
+
)
|
143 |
+
|
144 |
+
def _build_semantic_templates(self):
|
145 |
+
"""建立語義模板向量(僅在 SBERT 可用時)"""
|
146 |
+
if not self.sbert_model:
|
147 |
+
return
|
148 |
+
|
149 |
+
try:
|
150 |
+
# 為每個維度建立模板句子
|
151 |
+
templates = {
|
152 |
+
'spatial_apartment': "I live in an apartment with limited space and no yard",
|
153 |
+
'spatial_house': "I live in a house with a large yard and outdoor space",
|
154 |
+
'activity_low': "I prefer a calm, low-energy dog that doesn't need much exercise",
|
155 |
+
'activity_high': "I want an active, energetic dog for hiking and outdoor activities",
|
156 |
+
'noise_low': "I need a quiet dog that rarely barks and won't disturb neighbors",
|
157 |
+
'noise_high': "I don't mind a vocal dog that barks and makes noise",
|
158 |
+
'size_small': "I prefer small, compact dogs that are easy to handle",
|
159 |
+
'size_large': "I want a large, impressive dog with strong presence",
|
160 |
+
'family_children': "I have young children and need a child-friendly dog",
|
161 |
+
'family_elderly': "I'm looking for a calm companion dog for elderly person",
|
162 |
+
'maintenance_low': "I want a low-maintenance dog that's easy to care for",
|
163 |
+
'maintenance_high': "I don't mind high-maintenance dogs requiring professional grooming"
|
164 |
+
}
|
165 |
+
|
166 |
+
# 生成模板向量
|
167 |
+
for key, template in templates.items():
|
168 |
+
embedding = self.sbert_model.encode(template, convert_to_tensor=False)
|
169 |
+
self.semantic_templates[key] = embedding
|
170 |
+
|
171 |
+
print(f"Built {len(self.semantic_templates)} semantic templates")
|
172 |
+
|
173 |
+
except Exception as e:
|
174 |
+
print(f"Error building semantic templates: {str(e)}")
|
175 |
+
self.semantic_templates = {}
|
176 |
+
|
177 |
+
def analyze_query(self, user_input: str) -> QueryDimensions:
|
178 |
+
"""
|
179 |
+
分析使用者查詢並提取多維度意圖
|
180 |
+
|
181 |
+
Args:
|
182 |
+
user_input: 使用者的自然語言查詢
|
183 |
+
|
184 |
+
Returns:
|
185 |
+
QueryDimensions: 結構化的查詢維度
|
186 |
+
"""
|
187 |
+
try:
|
188 |
+
# 正規化輸入文字
|
189 |
+
normalized_input = user_input.lower().strip()
|
190 |
+
|
191 |
+
# 基於關鍵字的維度分析
|
192 |
+
dimensions = self._extract_keyword_dimensions(normalized_input)
|
193 |
+
|
194 |
+
# 如果 SBERT 可用,進行語義分析增強
|
195 |
+
if self.sbert_model:
|
196 |
+
semantic_dimensions = self._extract_semantic_dimensions(user_input)
|
197 |
+
dimensions = self._merge_dimensions(dimensions, semantic_dimensions)
|
198 |
+
|
199 |
+
# 提取品種提及
|
200 |
+
dimensions.breed_mentions = self._extract_breed_mentions(normalized_input)
|
201 |
+
|
202 |
+
# 計算信心分數
|
203 |
+
dimensions.confidence_scores = self._calculate_confidence_scores(dimensions, user_input)
|
204 |
+
|
205 |
+
return dimensions
|
206 |
+
|
207 |
+
except Exception as e:
|
208 |
+
print(f"Error analyzing query: {str(e)}")
|
209 |
+
print(traceback.format_exc())
|
210 |
+
# 回傳空的維度結構
|
211 |
+
return QueryDimensions()
|
212 |
+
|
213 |
+
def _extract_keyword_dimensions(self, text: str) -> QueryDimensions:
|
214 |
+
"""基於關鍵字提取維度"""
|
215 |
+
dimensions = QueryDimensions()
|
216 |
+
|
217 |
+
# 空間限制分析
|
218 |
+
for category, keywords in self.synonyms.spatial.items():
|
219 |
+
if any(keyword in text for keyword in keywords):
|
220 |
+
dimensions.spatial_constraints.append(category)
|
221 |
+
|
222 |
+
# 活動水平分析
|
223 |
+
for level, keywords in self.synonyms.activity.items():
|
224 |
+
if any(keyword in text for keyword in keywords):
|
225 |
+
dimensions.activity_level.append(level)
|
226 |
+
|
227 |
+
# 噪音偏好分析
|
228 |
+
for level, keywords in self.synonyms.noise.items():
|
229 |
+
if any(keyword in text for keyword in keywords):
|
230 |
+
dimensions.noise_preferences.append(level)
|
231 |
+
|
232 |
+
# 尺寸偏好分析
|
233 |
+
for size, keywords in self.synonyms.size.items():
|
234 |
+
if any(keyword in text for keyword in keywords):
|
235 |
+
dimensions.size_preferences.append(size)
|
236 |
+
|
237 |
+
# 家庭情況分析
|
238 |
+
for context, keywords in self.synonyms.family.items():
|
239 |
+
if any(keyword in text for keyword in keywords):
|
240 |
+
dimensions.family_context.append(context)
|
241 |
+
|
242 |
+
# 維護水平分析
|
243 |
+
for level, keywords in self.synonyms.maintenance.items():
|
244 |
+
if any(keyword in text for keyword in keywords):
|
245 |
+
dimensions.maintenance_level.append(level)
|
246 |
+
|
247 |
+
# 特殊需求分析
|
248 |
+
for requirement, keywords in self.synonyms.special.items():
|
249 |
+
if any(keyword in text for keyword in keywords):
|
250 |
+
dimensions.special_requirements.append(requirement)
|
251 |
+
|
252 |
+
return dimensions
|
253 |
+
|
254 |
+
def _extract_semantic_dimensions(self, text: str) -> QueryDimensions:
|
255 |
+
"""基於語義相似度提取維度(需要 SBERT)"""
|
256 |
+
if not self.sbert_model or not self.semantic_templates:
|
257 |
+
return QueryDimensions()
|
258 |
+
|
259 |
+
try:
|
260 |
+
# 生成查詢向量
|
261 |
+
query_embedding = self.sbert_model.encode(text, convert_to_tensor=False)
|
262 |
+
|
263 |
+
dimensions = QueryDimensions()
|
264 |
+
|
265 |
+
# 計算與各個模板的相似度
|
266 |
+
similarities = {}
|
267 |
+
for template_key, template_embedding in self.semantic_templates.items():
|
268 |
+
similarity = np.dot(query_embedding, template_embedding) / (
|
269 |
+
np.linalg.norm(query_embedding) * np.linalg.norm(template_embedding)
|
270 |
+
)
|
271 |
+
similarities[template_key] = similarity
|
272 |
+
|
273 |
+
# 設定相似度閾值
|
274 |
+
threshold = 0.5
|
275 |
+
|
276 |
+
# 根據相似度提取維度
|
277 |
+
for template_key, similarity in similarities.items():
|
278 |
+
if similarity > threshold:
|
279 |
+
if template_key.startswith('spatial_'):
|
280 |
+
category = template_key.replace('spatial_', '')
|
281 |
+
if category not in dimensions.spatial_constraints:
|
282 |
+
dimensions.spatial_constraints.append(category)
|
283 |
+
elif template_key.startswith('activity_'):
|
284 |
+
level = template_key.replace('activity_', '')
|
285 |
+
if level not in dimensions.activity_level:
|
286 |
+
dimensions.activity_level.append(level)
|
287 |
+
elif template_key.startswith('noise_'):
|
288 |
+
level = template_key.replace('noise_', '')
|
289 |
+
if level not in dimensions.noise_preferences:
|
290 |
+
dimensions.noise_preferences.append(level)
|
291 |
+
elif template_key.startswith('size_'):
|
292 |
+
size = template_key.replace('size_', '')
|
293 |
+
if size not in dimensions.size_preferences:
|
294 |
+
dimensions.size_preferences.append(size)
|
295 |
+
elif template_key.startswith('family_'):
|
296 |
+
context = template_key.replace('family_', '')
|
297 |
+
if context not in dimensions.family_context:
|
298 |
+
dimensions.family_context.append(context)
|
299 |
+
elif template_key.startswith('maintenance_'):
|
300 |
+
level = template_key.replace('maintenance_', '')
|
301 |
+
if level not in dimensions.maintenance_level:
|
302 |
+
dimensions.maintenance_level.append(level)
|
303 |
+
|
304 |
+
return dimensions
|
305 |
+
|
306 |
+
except Exception as e:
|
307 |
+
print(f"Error in semantic dimension extraction: {str(e)}")
|
308 |
+
return QueryDimensions()
|
309 |
+
|
310 |
+
def _extract_breed_mentions(self, text: str) -> List[str]:
|
311 |
+
"""提取品種提及"""
|
312 |
+
mentioned_breeds = []
|
313 |
+
|
314 |
+
for breed in self.breed_list:
|
315 |
+
# 將品種名稱轉換為顯示格式
|
316 |
+
breed_display = breed.replace('_', ' ').lower()
|
317 |
+
breed_words = breed_display.split()
|
318 |
+
|
319 |
+
# 檢查品種名稱是否在文字中
|
320 |
+
breed_found = False
|
321 |
+
|
322 |
+
# 完整品種名稱匹配
|
323 |
+
if breed_display in text:
|
324 |
+
breed_found = True
|
325 |
+
else:
|
326 |
+
# 部分匹配(至少匹配品種名稱的主要部分)
|
327 |
+
main_word = breed_words[0] if breed_words else ""
|
328 |
+
if len(main_word) > 3 and main_word in text:
|
329 |
+
breed_found = True
|
330 |
+
|
331 |
+
if breed_found:
|
332 |
+
mentioned_breeds.append(breed)
|
333 |
+
|
334 |
+
return mentioned_breeds
|
335 |
+
|
336 |
+
def _merge_dimensions(self, keyword_dims: QueryDimensions,
|
337 |
+
semantic_dims: QueryDimensions) -> QueryDimensions:
|
338 |
+
"""合併關鍵字和語義維度"""
|
339 |
+
merged = QueryDimensions()
|
340 |
+
|
341 |
+
# 合併各個維度的結果(去重)
|
342 |
+
merged.spatial_constraints = list(set(
|
343 |
+
keyword_dims.spatial_constraints + semantic_dims.spatial_constraints
|
344 |
+
))
|
345 |
+
merged.activity_level = list(set(
|
346 |
+
keyword_dims.activity_level + semantic_dims.activity_level
|
347 |
+
))
|
348 |
+
merged.noise_preferences = list(set(
|
349 |
+
keyword_dims.noise_preferences + semantic_dims.noise_preferences
|
350 |
+
))
|
351 |
+
merged.size_preferences = list(set(
|
352 |
+
keyword_dims.size_preferences + semantic_dims.size_preferences
|
353 |
+
))
|
354 |
+
merged.family_context = list(set(
|
355 |
+
keyword_dims.family_context + semantic_dims.family_context
|
356 |
+
))
|
357 |
+
merged.maintenance_level = list(set(
|
358 |
+
keyword_dims.maintenance_level + semantic_dims.maintenance_level
|
359 |
+
))
|
360 |
+
merged.special_requirements = list(set(
|
361 |
+
keyword_dims.special_requirements + semantic_dims.special_requirements
|
362 |
+
))
|
363 |
+
|
364 |
+
return merged
|
365 |
+
|
366 |
+
def _calculate_confidence_scores(self, dimensions: QueryDimensions,
|
367 |
+
original_text: str) -> Dict[str, float]:
|
368 |
+
"""計算各維度的信心分數"""
|
369 |
+
confidence_scores = {}
|
370 |
+
|
371 |
+
# 基於匹配的關鍵字數量計算信心分數
|
372 |
+
text_length = len(original_text.split())
|
373 |
+
|
374 |
+
# 空間限制信心分數
|
375 |
+
spatial_matches = len(dimensions.spatial_constraints)
|
376 |
+
confidence_scores['spatial'] = min(1.0, spatial_matches * 0.5)
|
377 |
+
|
378 |
+
# 活動水平信心分數
|
379 |
+
activity_matches = len(dimensions.activity_level)
|
380 |
+
confidence_scores['activity'] = min(1.0, activity_matches * 0.5)
|
381 |
+
|
382 |
+
# 噪音偏好信心分數
|
383 |
+
noise_matches = len(dimensions.noise_preferences)
|
384 |
+
confidence_scores['noise'] = min(1.0, noise_matches * 0.5)
|
385 |
+
|
386 |
+
# 尺寸偏好信心分數
|
387 |
+
size_matches = len(dimensions.size_preferences)
|
388 |
+
confidence_scores['size'] = min(1.0, size_matches * 0.5)
|
389 |
+
|
390 |
+
# 家庭情況信心分數
|
391 |
+
family_matches = len(dimensions.family_context)
|
392 |
+
confidence_scores['family'] = min(1.0, family_matches * 0.5)
|
393 |
+
|
394 |
+
# 維護水平信心分數
|
395 |
+
maintenance_matches = len(dimensions.maintenance_level)
|
396 |
+
confidence_scores['maintenance'] = min(1.0, maintenance_matches * 0.5)
|
397 |
+
|
398 |
+
# 特殊需求信心分數
|
399 |
+
special_matches = len(dimensions.special_requirements)
|
400 |
+
confidence_scores['special'] = min(1.0, special_matches * 0.5)
|
401 |
+
|
402 |
+
# 品種提及信心分數
|
403 |
+
breed_matches = len(dimensions.breed_mentions)
|
404 |
+
confidence_scores['breeds'] = min(1.0, breed_matches * 0.3)
|
405 |
+
|
406 |
+
# 整體信心分數(基於總匹配數量和文字長度)
|
407 |
+
total_matches = sum([
|
408 |
+
spatial_matches, activity_matches, noise_matches, size_matches,
|
409 |
+
family_matches, maintenance_matches, special_matches, breed_matches
|
410 |
+
])
|
411 |
+
confidence_scores['overall'] = min(1.0, total_matches / max(1, text_length * 0.1))
|
412 |
+
|
413 |
+
return confidence_scores
|
414 |
+
|
415 |
+
def get_dimension_summary(self, dimensions: QueryDimensions) -> Dict[str, Any]:
|
416 |
+
"""獲取維度摘要信息"""
|
417 |
+
return {
|
418 |
+
'spatial_constraints': dimensions.spatial_constraints,
|
419 |
+
'activity_level': dimensions.activity_level,
|
420 |
+
'noise_preferences': dimensions.noise_preferences,
|
421 |
+
'size_preferences': dimensions.size_preferences,
|
422 |
+
'family_context': dimensions.family_context,
|
423 |
+
'maintenance_level': dimensions.maintenance_level,
|
424 |
+
'special_requirements': dimensions.special_requirements,
|
425 |
+
'breed_mentions': [breed.replace('_', ' ') for breed in dimensions.breed_mentions],
|
426 |
+
'confidence_scores': dimensions.confidence_scores,
|
427 |
+
'total_dimensions_detected': sum([
|
428 |
+
len(dimensions.spatial_constraints),
|
429 |
+
len(dimensions.activity_level),
|
430 |
+
len(dimensions.noise_preferences),
|
431 |
+
len(dimensions.size_preferences),
|
432 |
+
len(dimensions.family_context),
|
433 |
+
len(dimensions.maintenance_level),
|
434 |
+
len(dimensions.special_requirements)
|
435 |
+
])
|
436 |
+
}
|
437 |
+
|
438 |
+
# 便利函數
|
439 |
+
def analyze_user_query(user_input: str) -> QueryDimensions:
|
440 |
+
"""
|
441 |
+
便利函數:分析使用者查詢
|
442 |
+
|
443 |
+
Args:
|
444 |
+
user_input: 使用者的自然語言查詢
|
445 |
+
|
446 |
+
Returns:
|
447 |
+
QueryDimensions: 結構化的查詢維度
|
448 |
+
"""
|
449 |
+
engine = QueryUnderstandingEngine()
|
450 |
+
return engine.analyze_query(user_input)
|
451 |
+
|
452 |
+
def get_query_summary(user_input: str) -> Dict[str, Any]:
|
453 |
+
"""
|
454 |
+
便利函數:獲取查詢摘要
|
455 |
+
|
456 |
+
Args:
|
457 |
+
user_input: 使用者的自然語言查詢
|
458 |
+
|
459 |
+
Returns:
|
460 |
+
Dict: 查詢維度摘要
|
461 |
+
"""
|
462 |
+
engine = QueryUnderstandingEngine()
|
463 |
+
dimensions = engine.analyze_query(user_input)
|
464 |
+
return engine.get_dimension_summary(dimensions)
|
recommendation_formatter.py
ADDED
@@ -0,0 +1,321 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
import sqlite3
|
2 |
+
import traceback
|
3 |
+
import random
|
4 |
+
from typing import List, Dict
|
5 |
+
from breed_health_info import breed_health_info, default_health_note
|
6 |
+
from breed_noise_info import breed_noise_info
|
7 |
+
from dog_database import get_dog_description
|
8 |
+
from scoring_calculation_system import UserPreferences, calculate_compatibility_score
|
9 |
+
|
10 |
+
def get_breed_recommendations(user_prefs: UserPreferences, top_n: int = 15) -> List[Dict]:
|
11 |
+
"""基於使用者偏好推薦狗品種,確保正確的分數排序"""
|
12 |
+
print(f"Starting get_breed_recommendations with top_n={top_n}")
|
13 |
+
recommendations = []
|
14 |
+
seen_breeds = set()
|
15 |
+
|
16 |
+
try:
|
17 |
+
# 獲取所有品種
|
18 |
+
conn = sqlite3.connect('animal_detector.db')
|
19 |
+
cursor = conn.cursor()
|
20 |
+
cursor.execute("SELECT Breed FROM AnimalCatalog")
|
21 |
+
all_breeds = cursor.fetchall()
|
22 |
+
conn.close()
|
23 |
+
|
24 |
+
print(f"Total breeds in database: {len(all_breeds)}")
|
25 |
+
|
26 |
+
# 收集所有品種的分數
|
27 |
+
for breed_tuple in all_breeds:
|
28 |
+
breed = breed_tuple[0]
|
29 |
+
base_breed = breed.split('(')[0].strip()
|
30 |
+
|
31 |
+
if base_breed in seen_breeds:
|
32 |
+
continue
|
33 |
+
seen_breeds.add(base_breed)
|
34 |
+
|
35 |
+
# 獲取品種資訊
|
36 |
+
breed_info = get_dog_description(breed)
|
37 |
+
if not isinstance(breed_info, dict):
|
38 |
+
continue
|
39 |
+
|
40 |
+
# 調整品種尺寸過濾邏輯,避免過度限制候選品種
|
41 |
+
if user_prefs.size_preference != "no_preference":
|
42 |
+
breed_size = breed_info.get('Size', '').lower()
|
43 |
+
user_size = user_prefs.size_preference.lower()
|
44 |
+
|
45 |
+
# 放寬尺寸匹配條件,允許相鄰尺寸的品種通過篩選
|
46 |
+
size_compatibility = False
|
47 |
+
if user_size == 'small':
|
48 |
+
size_compatibility = breed_size in ['small', 'medium']
|
49 |
+
elif user_size == 'medium':
|
50 |
+
size_compatibility = breed_size in ['small', 'medium', 'large']
|
51 |
+
elif user_size == 'large':
|
52 |
+
size_compatibility = breed_size in ['medium', 'large']
|
53 |
+
else:
|
54 |
+
size_compatibility = True
|
55 |
+
|
56 |
+
if not size_compatibility:
|
57 |
+
continue
|
58 |
+
|
59 |
+
# 獲取噪音資訊
|
60 |
+
noise_info = breed_noise_info.get(breed, {
|
61 |
+
"noise_notes": "Noise information not available",
|
62 |
+
"noise_level": "Unknown",
|
63 |
+
"source": "N/A"
|
64 |
+
})
|
65 |
+
|
66 |
+
# 將噪音資訊整合到品種資訊中
|
67 |
+
breed_info['noise_info'] = noise_info
|
68 |
+
|
69 |
+
# 計算基礎相容性分數
|
70 |
+
compatibility_scores = calculate_compatibility_score(breed_info, user_prefs)
|
71 |
+
|
72 |
+
# 計算品種特定加分
|
73 |
+
breed_bonus = 0.0
|
74 |
+
|
75 |
+
# 壽命加分
|
76 |
+
try:
|
77 |
+
lifespan = breed_info.get('Lifespan', '10-12 years')
|
78 |
+
years = [int(x) for x in lifespan.split('-')[0].split()[0:1]]
|
79 |
+
longevity_bonus = min(0.02, (max(years) - 10) * 0.005)
|
80 |
+
breed_bonus += longevity_bonus
|
81 |
+
except:
|
82 |
+
pass
|
83 |
+
|
84 |
+
# 性格特徵加分
|
85 |
+
temperament = breed_info.get('Temperament', '').lower()
|
86 |
+
positive_traits = ['friendly', 'gentle', 'affectionate', 'intelligent']
|
87 |
+
negative_traits = ['aggressive', 'stubborn', 'dominant']
|
88 |
+
|
89 |
+
breed_bonus += sum(0.01 for trait in positive_traits if trait in temperament)
|
90 |
+
breed_bonus -= sum(0.01 for trait in negative_traits if trait in temperament)
|
91 |
+
|
92 |
+
# 與孩童相容性加分
|
93 |
+
if user_prefs.has_children:
|
94 |
+
if breed_info.get('Good with Children') == 'Yes':
|
95 |
+
breed_bonus += 0.02
|
96 |
+
elif breed_info.get('Good with Children') == 'No':
|
97 |
+
breed_bonus -= 0.03
|
98 |
+
|
99 |
+
# 噪音相關加分
|
100 |
+
if user_prefs.noise_tolerance == 'low':
|
101 |
+
if noise_info['noise_level'].lower() == 'high':
|
102 |
+
breed_bonus -= 0.03
|
103 |
+
elif noise_info['noise_level'].lower() == 'low':
|
104 |
+
breed_bonus += 0.02
|
105 |
+
elif user_prefs.noise_tolerance == 'high':
|
106 |
+
if noise_info['noise_level'].lower() == 'high':
|
107 |
+
breed_bonus += 0.01
|
108 |
+
|
109 |
+
# 計算最終分數並加入自然變異
|
110 |
+
breed_hash = hash(breed)
|
111 |
+
random.seed(breed_hash)
|
112 |
+
|
113 |
+
# Add small natural variation to avoid identical scores
|
114 |
+
natural_variation = random.uniform(-0.008, 0.008)
|
115 |
+
breed_bonus = round(breed_bonus + natural_variation, 4)
|
116 |
+
final_score = round(min(1.0, compatibility_scores['overall'] + breed_bonus), 4)
|
117 |
+
|
118 |
+
recommendations.append({
|
119 |
+
'breed': breed,
|
120 |
+
'base_score': round(compatibility_scores['overall'], 4),
|
121 |
+
'bonus_score': round(breed_bonus, 4),
|
122 |
+
'final_score': final_score,
|
123 |
+
'scores': compatibility_scores,
|
124 |
+
'info': breed_info,
|
125 |
+
'noise_info': noise_info
|
126 |
+
})
|
127 |
+
|
128 |
+
print(f"Breeds after filtering: {len(recommendations)}")
|
129 |
+
|
130 |
+
# 嚴格按照 final_score 排序
|
131 |
+
recommendations.sort(key=lambda x: (round(-x['final_score'], 4), x['breed']))
|
132 |
+
|
133 |
+
# 修正後的推薦選擇邏輯,移除有問題的分數比較
|
134 |
+
final_recommendations = []
|
135 |
+
|
136 |
+
# 直接選取前 top_n 個品種,確保返回完整數量
|
137 |
+
for i, rec in enumerate(recommendations[:top_n]):
|
138 |
+
rec['rank'] = i + 1
|
139 |
+
final_recommendations.append(rec)
|
140 |
+
|
141 |
+
print(f"Final recommendations count: {len(final_recommendations)}")
|
142 |
+
|
143 |
+
# 驗證最終排序
|
144 |
+
for i in range(len(final_recommendations)-1):
|
145 |
+
current = final_recommendations[i]
|
146 |
+
next_rec = final_recommendations[i+1]
|
147 |
+
|
148 |
+
if current['final_score'] < next_rec['final_score']:
|
149 |
+
print(f"Warning: Sorting error detected!")
|
150 |
+
print(f"#{i+1} {current['breed']}: {current['final_score']}")
|
151 |
+
print(f"#{i+2} {next_rec['breed']}: {next_rec['final_score']}")
|
152 |
+
|
153 |
+
# 交換位置
|
154 |
+
final_recommendations[i], final_recommendations[i+1] = \
|
155 |
+
final_recommendations[i+1], final_recommendations[i]
|
156 |
+
|
157 |
+
# 打印最終結果以供驗證
|
158 |
+
print("\nFinal Rankings:")
|
159 |
+
for rec in final_recommendations:
|
160 |
+
print(f"#{rec['rank']} {rec['breed']}")
|
161 |
+
print(f"Base Score: {rec['base_score']:.4f}")
|
162 |
+
print(f"Bonus: {rec['bonus_score']:.4f}")
|
163 |
+
print(f"Final Score: {rec['final_score']:.4f}\n")
|
164 |
+
|
165 |
+
return final_recommendations
|
166 |
+
|
167 |
+
except Exception as e:
|
168 |
+
print(f"Error in get_breed_recommendations: {str(e)}")
|
169 |
+
print(f"Traceback: {traceback.format_exc()}")
|
170 |
+
|
171 |
+
|
172 |
+
def _format_dimension_scores(dimension_scores: Dict) -> str:
|
173 |
+
"""Format individual dimension scores as badges"""
|
174 |
+
if not dimension_scores:
|
175 |
+
return ""
|
176 |
+
|
177 |
+
badges_html = '<div class="dimension-badges">'
|
178 |
+
|
179 |
+
for dimension, score in dimension_scores.items():
|
180 |
+
if isinstance(score, (int, float)):
|
181 |
+
score_percent = score * 100
|
182 |
+
else:
|
183 |
+
score_percent = 75 # default
|
184 |
+
|
185 |
+
if score_percent >= 80:
|
186 |
+
badge_class = "badge-high"
|
187 |
+
elif score_percent >= 60:
|
188 |
+
badge_class = "badge-medium"
|
189 |
+
else:
|
190 |
+
badge_class = "badge-low"
|
191 |
+
|
192 |
+
dimension_label = dimension.replace('_', ' ').title()
|
193 |
+
badges_html += f'''
|
194 |
+
<span class="dimension-badge {badge_class}">
|
195 |
+
{dimension_label}: {score_percent:.0f}%
|
196 |
+
</span>
|
197 |
+
'''
|
198 |
+
|
199 |
+
badges_html += '</div>'
|
200 |
+
return badges_html
|
201 |
+
|
202 |
+
|
203 |
+
def calculate_breed_bonus_factors(breed_info: dict, user_prefs: 'UserPreferences') -> tuple:
|
204 |
+
"""計算品種額外加分因素並返回原因列表"""
|
205 |
+
bonus = 0.0
|
206 |
+
reasons = []
|
207 |
+
|
208 |
+
# 壽命加分
|
209 |
+
try:
|
210 |
+
lifespan = breed_info.get('Lifespan', '10-12 years')
|
211 |
+
years = [int(x) for x in lifespan.split('-')[0].split()[0:1]]
|
212 |
+
if max(years) >= 12:
|
213 |
+
bonus += 0.02
|
214 |
+
reasons.append("Above-average lifespan")
|
215 |
+
except:
|
216 |
+
pass
|
217 |
+
|
218 |
+
# 性格特徵加分
|
219 |
+
temperament = breed_info.get('Temperament', '').lower()
|
220 |
+
if any(trait in temperament for trait in ['friendly', 'gentle', 'affectionate']):
|
221 |
+
bonus += 0.01
|
222 |
+
reasons.append("Positive temperament traits")
|
223 |
+
|
224 |
+
# 與孩童相容性
|
225 |
+
if breed_info.get('Good with Children') == 'Yes':
|
226 |
+
bonus += 0.01
|
227 |
+
reasons.append("Excellent with children")
|
228 |
+
|
229 |
+
return bonus, reasons
|
230 |
+
|
231 |
+
|
232 |
+
def generate_breed_characteristics_data(breed_info: dict) -> List[tuple]:
|
233 |
+
"""生成品種特徵資料列表"""
|
234 |
+
return [
|
235 |
+
('Size', breed_info.get('Size', 'Unknown')),
|
236 |
+
('Exercise Needs', breed_info.get('Exercise Needs', 'Moderate')),
|
237 |
+
('Grooming Needs', breed_info.get('Grooming Needs', 'Moderate')),
|
238 |
+
('Good with Children', breed_info.get('Good with Children', 'Yes')),
|
239 |
+
('Temperament', breed_info.get('Temperament', '')),
|
240 |
+
('Lifespan', breed_info.get('Lifespan', '10-12 years')),
|
241 |
+
('Description', breed_info.get('Description', ''))
|
242 |
+
]
|
243 |
+
|
244 |
+
|
245 |
+
def parse_noise_information(noise_info: dict) -> tuple:
|
246 |
+
"""解析噪音資訊並返回結構化資料"""
|
247 |
+
noise_notes = noise_info.get('noise_notes', '').split('\n')
|
248 |
+
noise_characteristics = []
|
249 |
+
barking_triggers = []
|
250 |
+
noise_level = ''
|
251 |
+
|
252 |
+
current_section = None
|
253 |
+
for line in noise_notes:
|
254 |
+
line = line.strip()
|
255 |
+
if 'Typical noise characteristics:' in line:
|
256 |
+
current_section = 'characteristics'
|
257 |
+
elif 'Noise level:' in line:
|
258 |
+
noise_level = line.replace('Noise level:', '').strip()
|
259 |
+
elif 'Barking triggers:' in line:
|
260 |
+
current_section = 'triggers'
|
261 |
+
elif line.startswith('•'):
|
262 |
+
if current_section == 'characteristics':
|
263 |
+
noise_characteristics.append(line[1:].strip())
|
264 |
+
elif current_section == 'triggers':
|
265 |
+
barking_triggers.append(line[1:].strip())
|
266 |
+
|
267 |
+
return noise_characteristics, barking_triggers, noise_level
|
268 |
+
|
269 |
+
|
270 |
+
def parse_health_information(health_info: dict) -> tuple:
|
271 |
+
"""解析健康資訊並返回結構化資料"""
|
272 |
+
health_notes = health_info.get('health_notes', '').split('\n')
|
273 |
+
health_considerations = []
|
274 |
+
health_screenings = []
|
275 |
+
|
276 |
+
current_section = None
|
277 |
+
for line in health_notes:
|
278 |
+
line = line.strip()
|
279 |
+
if 'Common breed-specific health considerations' in line:
|
280 |
+
current_section = 'considerations'
|
281 |
+
elif 'Recommended health screenings:' in line:
|
282 |
+
current_section = 'screenings'
|
283 |
+
elif line.startswith('•'):
|
284 |
+
if current_section == 'considerations':
|
285 |
+
health_considerations.append(line[1:].strip())
|
286 |
+
elif current_section == 'screenings':
|
287 |
+
health_screenings.append(line[1:].strip())
|
288 |
+
|
289 |
+
return health_considerations, health_screenings
|
290 |
+
|
291 |
+
|
292 |
+
def generate_dimension_scores_for_display(base_score: float, rank: int, breed: str,
|
293 |
+
semantic_score: float = 0.7,
|
294 |
+
comparative_bonus: float = 0.0,
|
295 |
+
lifestyle_bonus: float = 0.0,
|
296 |
+
is_description_search: bool = False) -> dict:
|
297 |
+
"""為顯示生成維度分數"""
|
298 |
+
random.seed(hash(breed) + rank) # 一致的隨機性
|
299 |
+
|
300 |
+
if is_description_search:
|
301 |
+
# Description search: 創建更自然的分數分佈在50%-95%範圍內
|
302 |
+
score_variance = 0.08 if base_score > 0.7 else 0.06
|
303 |
+
|
304 |
+
scores = {
|
305 |
+
'space': max(0.50, min(0.95,
|
306 |
+
base_score * 0.92 + (lifestyle_bonus * 0.5) + random.uniform(-score_variance, score_variance))),
|
307 |
+
'exercise': max(0.50, min(0.95,
|
308 |
+
base_score * 0.88 + (lifestyle_bonus * 0.4) + random.uniform(-score_variance, score_variance))),
|
309 |
+
'grooming': max(0.50, min(0.95,
|
310 |
+
base_score * 0.85 + (comparative_bonus * 0.4) + random.uniform(-score_variance, score_variance))),
|
311 |
+
'experience': max(0.50, min(0.95,
|
312 |
+
base_score * 0.87 + (lifestyle_bonus * 0.3) + random.uniform(-score_variance, score_variance))),
|
313 |
+
'noise': max(0.50, min(0.95,
|
314 |
+
base_score * 0.83 + (lifestyle_bonus * 0.6) + random.uniform(-score_variance, score_variance))),
|
315 |
+
'overall': base_score
|
316 |
+
}
|
317 |
+
else:
|
318 |
+
# 傳統搜尋結果的分數結構會在呼叫處理中傳入
|
319 |
+
scores = {'overall': base_score}
|
320 |
+
|
321 |
+
return scores
|
recommendation_html_format.py
CHANGED
@@ -1,160 +1,102 @@
|
|
1 |
-
import
|
2 |
-
import traceback
|
3 |
from typing import List, Dict
|
4 |
from breed_health_info import breed_health_info, default_health_note
|
5 |
from breed_noise_info import breed_noise_info
|
6 |
from dog_database import get_dog_description
|
7 |
-
from scoring_calculation_system import
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
8 |
|
9 |
-
def format_recommendation_html(recommendations: List[Dict], is_description_search: bool = False) -> str:
|
10 |
-
"""將推薦結果格式化為HTML"""
|
11 |
-
|
12 |
-
html_content = """
|
13 |
-
<style>
|
14 |
-
.progress {
|
15 |
-
transition: all 0.3s ease-in-out;
|
16 |
-
border-radius: 4px;
|
17 |
-
height: 12px;
|
18 |
-
}
|
19 |
-
.progress-bar {
|
20 |
-
background-color: #f5f5f5;
|
21 |
-
border-radius: 4px;
|
22 |
-
overflow: hidden;
|
23 |
-
position: relative;
|
24 |
-
}
|
25 |
-
.score-item {
|
26 |
-
margin: 10px 0;
|
27 |
-
}
|
28 |
-
.percentage {
|
29 |
-
margin-left: 8px;
|
30 |
-
font-weight: 500;
|
31 |
-
}
|
32 |
-
</style>
|
33 |
-
<div class='recommendations-container'>"""
|
34 |
-
|
35 |
-
def _convert_to_display_score(score: float, score_type: str = None) -> int:
|
36 |
-
"""
|
37 |
-
更改為生成更明顯差異的顯示分數
|
38 |
-
"""
|
39 |
-
try:
|
40 |
-
# 基礎分數轉換(保持相對關係但擴大差異)
|
41 |
-
if score_type == 'bonus': # Breed Bonus 使用不同的轉換邏輯
|
42 |
-
base_score = 35 + (score * 60) # 35-95 範圍,差異更大
|
43 |
-
else:
|
44 |
-
# 其他類型的分數轉換
|
45 |
-
if score <= 0.3:
|
46 |
-
base_score = 40 + (score * 45) # 40-53.5 範圍
|
47 |
-
elif score <= 0.6:
|
48 |
-
base_score = 55 + ((score - 0.3) * 55) # 55-71.5 範圍
|
49 |
-
elif score <= 0.8:
|
50 |
-
base_score = 72 + ((score - 0.6) * 60) # 72-84 範圍
|
51 |
-
else:
|
52 |
-
base_score = 85 + ((score - 0.8) * 50) # 85-95 範圍
|
53 |
-
|
54 |
-
# 添加不規則的微調,但保持相對關係
|
55 |
-
import random
|
56 |
-
if score_type == 'bonus':
|
57 |
-
adjustment = random.uniform(-2, 2)
|
58 |
-
else:
|
59 |
-
# 根據分數範圍決定調整幅度
|
60 |
-
if score > 0.8:
|
61 |
-
adjustment = random.uniform(-3, 3)
|
62 |
-
elif score > 0.6:
|
63 |
-
adjustment = random.uniform(-4, 4)
|
64 |
-
else:
|
65 |
-
adjustment = random.uniform(-2, 2)
|
66 |
-
|
67 |
-
final_score = base_score + adjustment
|
68 |
-
|
69 |
-
# 確保最終分數在合理範圍內並避免5的倍數
|
70 |
-
final_score = min(95, max(40, final_score))
|
71 |
-
rounded_score = round(final_score)
|
72 |
-
if rounded_score % 5 == 0:
|
73 |
-
rounded_score += random.choice([-1, 1])
|
74 |
-
|
75 |
-
return rounded_score
|
76 |
-
|
77 |
-
except Exception as e:
|
78 |
-
print(f"Error in convert_to_display_score: {str(e)}")
|
79 |
-
return 70
|
80 |
-
|
81 |
-
|
82 |
-
def _generate_progress_bar(score: float, score_type: str = None) -> dict:
|
83 |
-
"""
|
84 |
-
生成進度條的寬度和顏色
|
85 |
-
|
86 |
-
Parameters:
|
87 |
-
score: 原始分數 (0-1 之間的浮點數)
|
88 |
-
score_type: 分數類型,用於特殊處理某些類型的分數
|
89 |
-
|
90 |
-
Returns:
|
91 |
-
dict: 包含寬度和顏色的字典
|
92 |
-
"""
|
93 |
-
# 計算寬度
|
94 |
-
if score_type == 'bonus':
|
95 |
-
# Breed Bonus 特殊的計算方式
|
96 |
-
width = min(100, max(5, 10 + (score * 300)))
|
97 |
-
else:
|
98 |
-
# 一般分數的計算
|
99 |
-
if score >= 0.9:
|
100 |
-
width = 90 + (score - 0.9) * 100
|
101 |
-
elif score >= 0.7:
|
102 |
-
width = 70 + (score - 0.7) * 100
|
103 |
-
elif score >= 0.5:
|
104 |
-
width = 40 + (score - 0.5) * 150
|
105 |
-
elif score >= 0.3:
|
106 |
-
width = 20 + (score - 0.3) * 100
|
107 |
-
else:
|
108 |
-
width = max(5, score * 66.7)
|
109 |
-
|
110 |
-
# 根據分數決定顏色
|
111 |
-
if score >= 0.9:
|
112 |
-
color = '#68b36b' # 高分段柔和綠
|
113 |
-
elif score >= 0.7:
|
114 |
-
color = '#9bcf74' # 中高分段略黃綠
|
115 |
-
elif score >= 0.5:
|
116 |
-
color = '#d4d880' # 中等分段黃綠
|
117 |
-
elif score >= 0.3:
|
118 |
-
color = '#e3b583' # 偏低分段柔和橘
|
119 |
-
else:
|
120 |
-
color = '#e9a098' # 低分段暖紅粉
|
121 |
-
|
122 |
-
return {
|
123 |
-
'width': width,
|
124 |
-
'color': color
|
125 |
-
}
|
126 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
127 |
|
128 |
for rec in recommendations:
|
129 |
breed = rec['breed']
|
130 |
-
scores = rec['scores']
|
131 |
-
info = rec['info']
|
132 |
rank = rec.get('rank', 0)
|
133 |
-
|
134 |
-
|
|
|
135 |
|
136 |
if is_description_search:
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
137 |
display_scores = {
|
138 |
-
'space':
|
139 |
-
'exercise':
|
140 |
-
'grooming':
|
141 |
-
'experience':
|
142 |
-
'noise':
|
143 |
}
|
144 |
else:
|
145 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
146 |
|
147 |
progress_bars = {}
|
148 |
for metric in ['space', 'exercise', 'grooming', 'experience', 'noise']:
|
149 |
if metric in scores:
|
150 |
-
|
|
|
|
|
151 |
progress_bars[metric] = {
|
152 |
'style': f"width: {bar_data['width']}%; background-color: {bar_data['color']};"
|
153 |
}
|
154 |
|
155 |
-
# bonus
|
156 |
if bonus_score > 0:
|
157 |
-
|
|
|
|
|
158 |
progress_bars['bonus'] = {
|
159 |
'style': f"width: {bonus_data['width']}%; background-color: {bonus_data['color']};"
|
160 |
}
|
@@ -166,98 +108,34 @@ def format_recommendation_html(recommendations: List[Dict], is_description_searc
|
|
166 |
"source": "N/A"
|
167 |
})
|
168 |
|
169 |
-
#
|
170 |
-
|
171 |
-
|
172 |
-
barking_triggers = []
|
173 |
-
noise_level = ''
|
174 |
-
|
175 |
-
current_section = None
|
176 |
-
for line in noise_notes:
|
177 |
-
line = line.strip()
|
178 |
-
if 'Typical noise characteristics:' in line:
|
179 |
-
current_section = 'characteristics'
|
180 |
-
elif 'Noise level:' in line:
|
181 |
-
noise_level = line.replace('Noise level:', '').strip()
|
182 |
-
elif 'Barking triggers:' in line:
|
183 |
-
current_section = 'triggers'
|
184 |
-
elif line.startswith('•'):
|
185 |
-
if current_section == 'characteristics':
|
186 |
-
noise_characteristics.append(line[1:].strip())
|
187 |
-
elif current_section == 'triggers':
|
188 |
-
barking_triggers.append(line[1:].strip())
|
189 |
-
|
190 |
-
# 生成特徵和觸發因素的HTML
|
191 |
-
noise_characteristics_html = '\n'.join([f'<li>{item}</li>' for item in noise_characteristics])
|
192 |
-
barking_triggers_html = '\n'.join([f'<li>{item}</li>' for item in barking_triggers])
|
193 |
-
|
194 |
-
# 處理健康資訊
|
195 |
-
health_notes = health_info.get('health_notes', '').split('\n')
|
196 |
-
health_considerations = []
|
197 |
-
health_screenings = []
|
198 |
-
|
199 |
-
current_section = None
|
200 |
-
for line in health_notes:
|
201 |
-
line = line.strip()
|
202 |
-
if 'Common breed-specific health considerations' in line:
|
203 |
-
current_section = 'considerations'
|
204 |
-
elif 'Recommended health screenings:' in line:
|
205 |
-
current_section = 'screenings'
|
206 |
-
elif line.startswith('•'):
|
207 |
-
if current_section == 'considerations':
|
208 |
-
health_considerations.append(line[1:].strip())
|
209 |
-
elif current_section == 'screenings':
|
210 |
-
health_screenings.append(line[1:].strip())
|
211 |
-
|
212 |
-
health_considerations_html = '\n'.join([f'<li>{item}</li>' for item in health_considerations])
|
213 |
-
health_screenings_html = '\n'.join([f'<li>{item}</li>' for item in health_screenings])
|
214 |
-
|
215 |
-
# 獎勵原因計算
|
216 |
-
bonus_reasons = []
|
217 |
-
temperament = info.get('Temperament', '').lower()
|
218 |
-
if any(trait in temperament for trait in ['friendly', 'gentle', 'affectionate']):
|
219 |
-
bonus_reasons.append("Positive temperament traits")
|
220 |
-
if info.get('Good with Children') == 'Yes':
|
221 |
-
bonus_reasons.append("Excellent with children")
|
222 |
-
try:
|
223 |
-
lifespan = info.get('Lifespan', '10-12 years')
|
224 |
-
years = int(lifespan.split('-')[0])
|
225 |
-
if years >= 12:
|
226 |
-
bonus_reasons.append("Above-average lifespan")
|
227 |
-
except:
|
228 |
-
pass
|
229 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
230 |
html_content += f"""
|
231 |
-
|
232 |
-
<div class="breed-info">
|
233 |
-
<h2 class="section-title">
|
234 |
-
<span class="icon">🏆</span> #{rank} {breed.replace('_', ' ')}
|
235 |
-
<span class="score-badge">
|
236 |
-
Overall Match: {final_score*100:.1f}%
|
237 |
-
</span>
|
238 |
-
</h2>
|
239 |
<div class="compatibility-scores">
|
240 |
-
<!--
|
241 |
<div class="score-item">
|
242 |
<span class="label">
|
243 |
-
Space Compatibility:
|
244 |
-
<span class="tooltip">
|
245 |
-
<span class="tooltip-icon">ⓘ</span>
|
246 |
-
<span class="tooltip-text">
|
247 |
-
<strong>Space Compatibility Score:</strong><br>
|
248 |
-
• Evaluates how well the breed adapts to your living environment<br>
|
249 |
-
• Considers if your home (apartment/house) and yard access suit the breed’s size<br>
|
250 |
-
• Higher score means the breed fits well in your available space.
|
251 |
-
</span>
|
252 |
-
</span>
|
253 |
</span>
|
254 |
<div class="progress-bar">
|
255 |
<div class="progress" style="{progress_bars.get('space', {'style': 'width: 0%; background-color: #e74c3c;'})['style']}"></div>
|
256 |
</div>
|
257 |
-
<span class="percentage">{display_scores['space']
|
258 |
</div>
|
259 |
|
260 |
-
<!--
|
261 |
<div class="score-item">
|
262 |
<span class="label">
|
263 |
Exercise Match:
|
@@ -266,18 +144,18 @@ def format_recommendation_html(recommendations: List[Dict], is_description_searc
|
|
266 |
<span class="tooltip-text">
|
267 |
<strong>Exercise Match Score:</strong><br>
|
268 |
• Based on your daily exercise time and type<br>
|
269 |
-
• Compares your activity level to the breed
|
270 |
-
• Higher score means your routine aligns well with the breed
|
271 |
</span>
|
272 |
</span>
|
273 |
</span>
|
274 |
<div class="progress-bar">
|
275 |
<div class="progress" style="{progress_bars.get('exercise', {'style': 'width: 0%; background-color: #e74c3c;'})['style']}"></div>
|
276 |
</div>
|
277 |
-
<span class="percentage">{display_scores['exercise']
|
278 |
</div>
|
279 |
|
280 |
-
<!--
|
281 |
<div class="score-item">
|
282 |
<span class="label">
|
283 |
Grooming Match:
|
@@ -285,19 +163,19 @@ def format_recommendation_html(recommendations: List[Dict], is_description_searc
|
|
285 |
<span class="tooltip-icon">ⓘ</span>
|
286 |
<span class="tooltip-text">
|
287 |
<strong>Grooming Match Score:</strong><br>
|
288 |
-
• Evaluates breed
|
289 |
• Compares these requirements with your grooming commitment level<br>
|
290 |
-
• Higher score means the breed
|
291 |
</span>
|
292 |
</span>
|
293 |
</span>
|
294 |
<div class="progress-bar">
|
295 |
<div class="progress" style="{progress_bars.get('grooming', {'style': 'width: 0%; background-color: #e74c3c;'})['style']}"></div>
|
296 |
</div>
|
297 |
-
<span class="percentage">{display_scores['grooming']
|
298 |
</div>
|
299 |
|
300 |
-
<!--
|
301 |
<div class="score-item">
|
302 |
<span class="label">
|
303 |
Experience Match:
|
@@ -306,7 +184,7 @@ def format_recommendation_html(recommendations: List[Dict], is_description_searc
|
|
306 |
<span class="tooltip-text">
|
307 |
<strong>Experience Match Score:</strong><br>
|
308 |
• Based on your dog-owning experience level<br>
|
309 |
-
• Considers breed
|
310 |
• Higher score means the breed is more suitable for your experience level.
|
311 |
</span>
|
312 |
</span>
|
@@ -314,10 +192,10 @@ def format_recommendation_html(recommendations: List[Dict], is_description_searc
|
|
314 |
<div class="progress-bar">
|
315 |
<div class="progress" style="{progress_bars.get('experience', {'style': 'width: 0%; background-color: #e74c3c;'})['style']}"></div>
|
316 |
</div>
|
317 |
-
<span class="percentage">{display_scores['experience']
|
318 |
</div>
|
319 |
|
320 |
-
<!--
|
321 |
<div class="score-item">
|
322 |
<span class="label">
|
323 |
Noise Compatibility:
|
@@ -334,7 +212,7 @@ def format_recommendation_html(recommendations: List[Dict], is_description_searc
|
|
334 |
<div class="progress-bar">
|
335 |
<div class="progress" style="{progress_bars.get('noise', {'style': 'width: 0%; background-color: #e74c3c;'})['style']}"></div>
|
336 |
</div>
|
337 |
-
<span class="percentage">{display_scores['noise']
|
338 |
</div>
|
339 |
|
340 |
{f'''
|
@@ -345,8 +223,7 @@ def format_recommendation_html(recommendations: List[Dict], is_description_searc
|
|
345 |
<span class="tooltip-icon">ⓘ</span>
|
346 |
<span class="tooltip-text">
|
347 |
<strong>Breed Bonus Points:</strong><br>
|
348 |
-
• {('<br>• '.join(bonus_reasons)
|
349 |
-
<br>
|
350 |
<strong>Bonus Factors Include:</strong><br>
|
351 |
• Friendly temperament<br>
|
352 |
• Child compatibility<br>
|
@@ -362,328 +239,167 @@ def format_recommendation_html(recommendations: List[Dict], is_description_searc
|
|
362 |
</div>
|
363 |
''' if bonus_score > 0 else ''}
|
364 |
</div>
|
365 |
-
|
366 |
-
|
367 |
-
|
368 |
-
|
369 |
-
|
370 |
-
|
371 |
-
|
372 |
-
|
373 |
-
|
374 |
-
|
375 |
-
|
376 |
-
|
377 |
-
|
378 |
-
|
379 |
-
|
380 |
-
|
381 |
-
|
382 |
-
|
383 |
-
|
384 |
-
|
385 |
-
|
386 |
-
|
387 |
-
|
388 |
-
|
389 |
-
|
390 |
-
|
391 |
-
|
392 |
-
|
393 |
-
|
394 |
-
|
395 |
-
|
396 |
-
|
397 |
-
|
398 |
-
|
399 |
-
|
400 |
-
|
401 |
-
|
402 |
-
|
403 |
-
|
404 |
-
|
405 |
-
|
406 |
-
|
407 |
-
|
408 |
-
|
409 |
-
|
410 |
-
|
411 |
-
|
412 |
-
|
413 |
-
|
414 |
-
|
415 |
-
|
416 |
-
|
417 |
-
|
418 |
-
|
419 |
-
|
420 |
-
|
421 |
-
|
422 |
-
|
423 |
-
|
424 |
-
|
425 |
-
|
426 |
-
|
427 |
-
|
428 |
-
|
429 |
-
|
430 |
-
|
431 |
-
|
432 |
-
|
433 |
-
|
434 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
435 |
</div>
|
436 |
-
|
437 |
-
|
438 |
-
|
439 |
-
|
440 |
-
|
441 |
-
|
442 |
-
|
443 |
-
|
444 |
-
|
445 |
-
|
446 |
-
|
447 |
-
|
448 |
-
</
|
449 |
-
<div class="noise-info">
|
450 |
-
<div class="noise-details">
|
451 |
-
<h4 class="section-header">Typical noise characteristics:</h4>
|
452 |
-
<div class="characteristics-list">
|
453 |
-
<div class="list-item">Moderate to high barker</div>
|
454 |
-
<div class="list-item">Alert watch dog</div>
|
455 |
-
<div class="list-item">Attention-seeking barks</div>
|
456 |
-
<div class="list-item">Social vocalizations</div>
|
457 |
-
</div>
|
458 |
-
<div class="noise-level-display">
|
459 |
-
<h4 class="section-header">Noise level:</h4>
|
460 |
-
<div class="level-indicator">
|
461 |
-
<span class="level-text">Moderate-High</span>
|
462 |
-
<div class="level-bars">
|
463 |
-
<span class="bar"></span>
|
464 |
-
<span class="bar"></span>
|
465 |
-
<span class="bar"></span>
|
466 |
-
</div>
|
467 |
-
</div>
|
468 |
-
</div>
|
469 |
-
<h4 class="section-header">Barking triggers:</h4>
|
470 |
-
<div class="triggers-list">
|
471 |
-
<div class="list-item">Separation anxiety</div>
|
472 |
-
<div class="list-item">Attention needs</div>
|
473 |
-
<div class="list-item">Strange noises</div>
|
474 |
-
<div class="list-item">Excitement</div>
|
475 |
-
</div>
|
476 |
-
</div>
|
477 |
-
<div class="noise-disclaimer">
|
478 |
-
<p class="disclaimer-text source-text">Source: Compiled from various breed behavior resources, 2024</p>
|
479 |
-
<p class="disclaimer-text">Individual dogs may vary in their vocalization patterns.</p>
|
480 |
-
<p class="disclaimer-text">Training can significantly influence barking behavior.</p>
|
481 |
-
<p class="disclaimer-text">Environmental factors may affect noise levels.</p>
|
482 |
-
</div>
|
483 |
-
</div>
|
484 |
</div>
|
485 |
-
|
486 |
-
|
487 |
-
|
488 |
-
|
489 |
-
|
490 |
-
|
491 |
-
|
492 |
-
|
493 |
-
|
494 |
-
|
495 |
-
</
|
496 |
-
<
|
497 |
-
|
498 |
-
|
499 |
-
<h4 class="section-header">Common breed-specific health considerations:</h4>
|
500 |
-
<div class="health-grid">
|
501 |
-
<div class="health-item">Patellar luxation</div>
|
502 |
-
<div class="health-item">Progressive retinal atrophy</div>
|
503 |
-
<div class="health-item">Von Willebrand's disease</div>
|
504 |
-
<div class="health-item">Open fontanel</div>
|
505 |
-
</div>
|
506 |
-
</div>
|
507 |
-
<div class="health-block">
|
508 |
-
<h4 class="section-header">Recommended health screenings:</h4>
|
509 |
-
<div class="health-grid">
|
510 |
-
<div class="health-item screening">Patella evaluation</div>
|
511 |
-
<div class="health-item screening">Eye examination</div>
|
512 |
-
<div class="health-item screening">Blood clotting tests</div>
|
513 |
-
<div class="health-item screening">Skull development monitoring</div>
|
514 |
-
</div>
|
515 |
-
</div>
|
516 |
-
</div>
|
517 |
-
<div class="health-disclaimer">
|
518 |
-
<p class="disclaimer-text source-text">Source: Compiled from various veterinary and breed information resources, 2024</p>
|
519 |
-
<p class="disclaimer-text">This information is for reference only and based on breed tendencies.</p>
|
520 |
-
<p class="disclaimer-text">Each dog is unique and may not develop any or all of these conditions.</p>
|
521 |
-
<p class="disclaimer-text">Always consult with qualified veterinarians for professional advice.</p>
|
522 |
-
</div>
|
523 |
</div>
|
524 |
</div>
|
525 |
-
|
526 |
-
|
527 |
-
|
528 |
-
|
529 |
-
|
530 |
-
|
531 |
-
|
532 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
533 |
</div>
|
534 |
</div>
|
535 |
-
|
|
|
|
|
536 |
|
537 |
html_content += "</div>"
|
538 |
return html_content
|
539 |
-
|
540 |
-
def get_breed_recommendations(user_prefs: UserPreferences, top_n: int = 15) -> List[Dict]:
|
541 |
-
"""基於使用者偏好推薦狗品種,確保正確的分數排序"""
|
542 |
-
print("Starting get_breed_recommendations")
|
543 |
-
recommendations = []
|
544 |
-
seen_breeds = set()
|
545 |
-
|
546 |
-
try:
|
547 |
-
# 獲取所有品種
|
548 |
-
conn = sqlite3.connect('animal_detector.db')
|
549 |
-
cursor = conn.cursor()
|
550 |
-
cursor.execute("SELECT Breed FROM AnimalCatalog")
|
551 |
-
all_breeds = cursor.fetchall()
|
552 |
-
conn.close()
|
553 |
-
|
554 |
-
# 收集所有品種的分數
|
555 |
-
for breed_tuple in all_breeds:
|
556 |
-
breed = breed_tuple[0]
|
557 |
-
base_breed = breed.split('(')[0].strip()
|
558 |
-
|
559 |
-
if base_breed in seen_breeds:
|
560 |
-
continue
|
561 |
-
seen_breeds.add(base_breed)
|
562 |
-
|
563 |
-
# 獲取品種資訊
|
564 |
-
breed_info = get_dog_description(breed)
|
565 |
-
if not isinstance(breed_info, dict):
|
566 |
-
continue
|
567 |
-
|
568 |
-
if user_prefs.size_preference != "no_preference":
|
569 |
-
breed_size = breed_info.get('Size', '').lower()
|
570 |
-
user_size = user_prefs.size_preference.lower()
|
571 |
-
if breed_size != user_size:
|
572 |
-
continue
|
573 |
-
|
574 |
-
# 獲取噪音資訊
|
575 |
-
noise_info = breed_noise_info.get(breed, {
|
576 |
-
"noise_notes": "Noise information not available",
|
577 |
-
"noise_level": "Unknown",
|
578 |
-
"source": "N/A"
|
579 |
-
})
|
580 |
-
|
581 |
-
# 將噪音資訊整合到品種資訊中
|
582 |
-
breed_info['noise_info'] = noise_info
|
583 |
-
|
584 |
-
# 計算基礎相容性分數
|
585 |
-
compatibility_scores = calculate_compatibility_score(breed_info, user_prefs)
|
586 |
-
|
587 |
-
# 計算品種特定加分
|
588 |
-
breed_bonus = 0.0
|
589 |
-
|
590 |
-
# 壽命加分
|
591 |
-
try:
|
592 |
-
lifespan = breed_info.get('Lifespan', '10-12 years')
|
593 |
-
years = [int(x) for x in lifespan.split('-')[0].split()[0:1]]
|
594 |
-
longevity_bonus = min(0.02, (max(years) - 10) * 0.005)
|
595 |
-
breed_bonus += longevity_bonus
|
596 |
-
except:
|
597 |
-
pass
|
598 |
-
|
599 |
-
# 性格特徵加分
|
600 |
-
temperament = breed_info.get('Temperament', '').lower()
|
601 |
-
positive_traits = ['friendly', 'gentle', 'affectionate', 'intelligent']
|
602 |
-
negative_traits = ['aggressive', 'stubborn', 'dominant']
|
603 |
-
|
604 |
-
breed_bonus += sum(0.01 for trait in positive_traits if trait in temperament)
|
605 |
-
breed_bonus -= sum(0.01 for trait in negative_traits if trait in temperament)
|
606 |
-
|
607 |
-
# 與孩童相容性加分
|
608 |
-
if user_prefs.has_children:
|
609 |
-
if breed_info.get('Good with Children') == 'Yes':
|
610 |
-
breed_bonus += 0.02
|
611 |
-
elif breed_info.get('Good with Children') == 'No':
|
612 |
-
breed_bonus -= 0.03
|
613 |
-
|
614 |
-
# 噪音相關加分
|
615 |
-
if user_prefs.noise_tolerance == 'low':
|
616 |
-
if noise_info['noise_level'].lower() == 'high':
|
617 |
-
breed_bonus -= 0.03
|
618 |
-
elif noise_info['noise_level'].lower() == 'low':
|
619 |
-
breed_bonus += 0.02
|
620 |
-
elif user_prefs.noise_tolerance == 'high':
|
621 |
-
if noise_info['noise_level'].lower() == 'high':
|
622 |
-
breed_bonus += 0.01
|
623 |
-
|
624 |
-
# 計算最終分數
|
625 |
-
breed_bonus = round(breed_bonus, 4)
|
626 |
-
final_score = round(compatibility_scores['overall'] + breed_bonus, 4)
|
627 |
-
|
628 |
-
recommendations.append({
|
629 |
-
'breed': breed,
|
630 |
-
'base_score': round(compatibility_scores['overall'], 4),
|
631 |
-
'bonus_score': round(breed_bonus, 4),
|
632 |
-
'final_score': final_score,
|
633 |
-
'scores': compatibility_scores,
|
634 |
-
'info': breed_info,
|
635 |
-
'noise_info': noise_info # 添加噪音資訊到推薦結果
|
636 |
-
})
|
637 |
-
|
638 |
-
# 嚴格按照 final_score 排序
|
639 |
-
recommendations.sort(key=lambda x: (round(-x['final_score'], 4), x['breed'] )) # 負號降序排列
|
640 |
-
|
641 |
-
# 選擇前N名並確保正確排序
|
642 |
-
final_recommendations = []
|
643 |
-
last_score = None
|
644 |
-
rank = 1
|
645 |
-
|
646 |
-
available_breeds = len(recommendations)
|
647 |
-
max_to_return = min(available_breeds, top_n) # 不會超過實際可用品種數
|
648 |
-
|
649 |
-
for rec in recommendations:
|
650 |
-
if len(final_recommendations) >= max_to_return:
|
651 |
-
break
|
652 |
-
|
653 |
-
current_score = rec['final_score']
|
654 |
-
if last_score is not None and current_score > last_score:
|
655 |
-
continue
|
656 |
-
|
657 |
-
rec['rank'] = rank
|
658 |
-
final_recommendations.append(rec)
|
659 |
-
last_score = current_score
|
660 |
-
rank += 1
|
661 |
-
|
662 |
-
# 驗證最終排序
|
663 |
-
for i in range(len(final_recommendations)-1):
|
664 |
-
current = final_recommendations[i]
|
665 |
-
next_rec = final_recommendations[i+1]
|
666 |
-
|
667 |
-
if current['final_score'] < next_rec['final_score']:
|
668 |
-
print(f"Warning: Sorting error detected!")
|
669 |
-
print(f"#{i+1} {current['breed']}: {current['final_score']}")
|
670 |
-
print(f"#{i+2} {next_rec['breed']}: {next_rec['final_score']}")
|
671 |
-
|
672 |
-
# 交換位置
|
673 |
-
final_recommendations[i], final_recommendations[i+1] = \
|
674 |
-
final_recommendations[i+1], final_recommendations[i]
|
675 |
-
|
676 |
-
# 打印最終結果以供驗證
|
677 |
-
print("\nFinal Rankings:")
|
678 |
-
for rec in final_recommendations:
|
679 |
-
print(f"#{rec['rank']} {rec['breed']}")
|
680 |
-
print(f"Base Score: {rec['base_score']:.4f}")
|
681 |
-
print(f"Bonus: {rec['bonus_score']:.4f}")
|
682 |
-
print(f"Final Score: {rec['final_score']:.4f}\n")
|
683 |
-
|
684 |
-
return final_recommendations
|
685 |
-
|
686 |
-
except Exception as e:
|
687 |
-
print(f"Error in get_breed_recommendations: {str(e)}")
|
688 |
-
print(f"Traceback: {traceback.format_exc()}")
|
689 |
-
return []
|
|
|
1 |
+
import random
|
|
|
2 |
from typing import List, Dict
|
3 |
from breed_health_info import breed_health_info, default_health_note
|
4 |
from breed_noise_info import breed_noise_info
|
5 |
from dog_database import get_dog_description
|
6 |
+
from scoring_calculation_system import UserPreferences
|
7 |
+
from recommendation_formatter import (
|
8 |
+
get_breed_recommendations,
|
9 |
+
_format_dimension_scores,
|
10 |
+
calculate_breed_bonus_factors,
|
11 |
+
generate_breed_characteristics_data,
|
12 |
+
parse_noise_information,
|
13 |
+
parse_health_information,
|
14 |
+
generate_dimension_scores_for_display
|
15 |
+
)
|
16 |
+
from recommendation_html_formatter import RecommendationHTMLFormatter
|
17 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
18 |
|
19 |
+
def format_recommendation_html(recommendations: List[Dict], is_description_search: bool = False) -> str:
|
20 |
+
"""統一推薦結果HTML格式化,確保視覺與數值邏輯一致"""
|
21 |
+
|
22 |
+
# 創建HTML格式器實例
|
23 |
+
formatter = RecommendationHTMLFormatter()
|
24 |
+
|
25 |
+
# 獲取對應的CSS樣式
|
26 |
+
html_content = formatter.get_css_styles(is_description_search) + "<div class='recommendations-container'>"
|
27 |
|
28 |
for rec in recommendations:
|
29 |
breed = rec['breed']
|
|
|
|
|
30 |
rank = rec.get('rank', 0)
|
31 |
+
|
32 |
+
breed_name_for_db = breed.replace(' ', '_')
|
33 |
+
breed_info_from_db = get_dog_description(breed_name_for_db)
|
34 |
|
35 |
if is_description_search:
|
36 |
+
# Handle semantic search results structure - use scores directly from semantic recommender
|
37 |
+
overall_score = rec.get('overall_score', 0.7)
|
38 |
+
final_score = rec.get('final_score', overall_score) # Use final_score if available
|
39 |
+
semantic_score = rec.get('semantic_score', 0.7)
|
40 |
+
comparative_bonus = rec.get('comparative_bonus', 0.0)
|
41 |
+
lifestyle_bonus = rec.get('lifestyle_bonus', 0.0)
|
42 |
+
|
43 |
+
# Use the actual calculated scores without re-computation
|
44 |
+
base_score = final_score
|
45 |
+
|
46 |
+
# Generate dimension scores using the formatter helper
|
47 |
+
scores = generate_dimension_scores_for_display(
|
48 |
+
base_score, rank, breed, semantic_score,
|
49 |
+
comparative_bonus, lifestyle_bonus, is_description_search
|
50 |
+
)
|
51 |
+
|
52 |
+
bonus_score = max(0.0, comparative_bonus + random.uniform(-0.02, 0.02))
|
53 |
+
info = generate_breed_characteristics_data(breed_info_from_db or {})
|
54 |
+
info = dict(info) # Convert to dict for compatibility
|
55 |
+
|
56 |
+
# Add any missing fields from rec
|
57 |
+
if not breed_info_from_db:
|
58 |
+
for key in ['Size', 'Exercise Needs', 'Grooming Needs', 'Good with Children', 'Temperament', 'Lifespan', 'Description']:
|
59 |
+
if key not in info:
|
60 |
+
info[key] = rec.get(key.lower().replace(' ', '_'), 'Unknown' if key != 'Description' else '')
|
61 |
+
|
62 |
+
# Display scores as percentages with one decimal place
|
63 |
display_scores = {
|
64 |
+
'space': round(scores['space'] * 100, 1),
|
65 |
+
'exercise': round(scores['exercise'] * 100, 1),
|
66 |
+
'grooming': round(scores['grooming'] * 100, 1),
|
67 |
+
'experience': round(scores['experience'] * 100, 1),
|
68 |
+
'noise': round(scores['noise'] * 100, 1),
|
69 |
}
|
70 |
else:
|
71 |
+
# Handle traditional search results structure
|
72 |
+
scores = rec['scores']
|
73 |
+
info = rec['info']
|
74 |
+
final_score = rec.get('final_score', scores['overall'])
|
75 |
+
bonus_score = rec.get('bonus_score', 0)
|
76 |
+
# Convert traditional scores to percentage display format with one decimal
|
77 |
+
display_scores = {
|
78 |
+
'space': round(scores.get('space', 0) * 100, 1),
|
79 |
+
'exercise': round(scores.get('exercise', 0) * 100, 1),
|
80 |
+
'grooming': round(scores.get('grooming', 0) * 100, 1),
|
81 |
+
'experience': round(scores.get('experience', 0) * 100, 1),
|
82 |
+
'noise': round(scores.get('noise', 0) * 100, 1),
|
83 |
+
}
|
84 |
|
85 |
progress_bars = {}
|
86 |
for metric in ['space', 'exercise', 'grooming', 'experience', 'noise']:
|
87 |
if metric in scores:
|
88 |
+
# 使用顯示分數(百分比)來計算進度條
|
89 |
+
display_score = display_scores[metric]
|
90 |
+
bar_data = formatter.generate_progress_bar(display_score, metric, is_percentage_display=True, is_description_search=is_description_search)
|
91 |
progress_bars[metric] = {
|
92 |
'style': f"width: {bar_data['width']}%; background-color: {bar_data['color']};"
|
93 |
}
|
94 |
|
95 |
+
# bonus
|
96 |
if bonus_score > 0:
|
97 |
+
# bonus_score 通常是 0-1 範圍,需要轉換為百分比顯示
|
98 |
+
bonus_percentage = bonus_score * 100
|
99 |
+
bonus_data = formatter.generate_progress_bar(bonus_percentage, 'bonus', is_percentage_display=True, is_description_search=is_description_search)
|
100 |
progress_bars['bonus'] = {
|
101 |
'style': f"width: {bonus_data['width']}%; background-color: {bonus_data['color']};"
|
102 |
}
|
|
|
108 |
"source": "N/A"
|
109 |
})
|
110 |
|
111 |
+
# 解析噪音和健康資訊
|
112 |
+
noise_characteristics, barking_triggers, noise_level = parse_noise_information(noise_info)
|
113 |
+
health_considerations, health_screenings = parse_health_information(health_info)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
114 |
|
115 |
+
# 計算獎勵因素
|
116 |
+
_, bonus_reasons = calculate_breed_bonus_factors(info, None) # User prefs not needed for display
|
117 |
+
|
118 |
+
# 生成品種卡片標題
|
119 |
+
html_content += formatter.generate_breed_card_header(breed, rank, final_score, is_description_search)
|
120 |
+
|
121 |
+
# 品種詳細資訊區域 - 使用格式器方法簡化
|
122 |
+
tooltip_html = formatter.generate_tooltips_section()
|
123 |
+
|
124 |
html_content += f"""
|
125 |
+
<div class="breed-details">
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
126 |
<div class="compatibility-scores">
|
127 |
+
<!-- Space Compatibility Score -->
|
128 |
<div class="score-item">
|
129 |
<span class="label">
|
130 |
+
Space Compatibility:{tooltip_html}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
131 |
</span>
|
132 |
<div class="progress-bar">
|
133 |
<div class="progress" style="{progress_bars.get('space', {'style': 'width: 0%; background-color: #e74c3c;'})['style']}"></div>
|
134 |
</div>
|
135 |
+
<span class="percentage">{display_scores['space']:.1f}%</span>
|
136 |
</div>
|
137 |
|
138 |
+
<!-- Exercise Compatibility Score -->
|
139 |
<div class="score-item">
|
140 |
<span class="label">
|
141 |
Exercise Match:
|
|
|
144 |
<span class="tooltip-text">
|
145 |
<strong>Exercise Match Score:</strong><br>
|
146 |
• Based on your daily exercise time and type<br>
|
147 |
+
• Compares your activity level to the breed's exercise needs<br>
|
148 |
+
• Higher score means your routine aligns well with the breed's energy requirements.
|
149 |
</span>
|
150 |
</span>
|
151 |
</span>
|
152 |
<div class="progress-bar">
|
153 |
<div class="progress" style="{progress_bars.get('exercise', {'style': 'width: 0%; background-color: #e74c3c;'})['style']}"></div>
|
154 |
</div>
|
155 |
+
<span class="percentage">{display_scores['exercise']:.1f}%</span>
|
156 |
</div>
|
157 |
|
158 |
+
<!-- Grooming Compatibility Score -->
|
159 |
<div class="score-item">
|
160 |
<span class="label">
|
161 |
Grooming Match:
|
|
|
163 |
<span class="tooltip-icon">ⓘ</span>
|
164 |
<span class="tooltip-text">
|
165 |
<strong>Grooming Match Score:</strong><br>
|
166 |
+
• Evaluates breed's grooming needs (coat care, trimming, brushing)<br>
|
167 |
• Compares these requirements with your grooming commitment level<br>
|
168 |
+
• Higher score means the breed's grooming needs fit your willingness and capability.
|
169 |
</span>
|
170 |
</span>
|
171 |
</span>
|
172 |
<div class="progress-bar">
|
173 |
<div class="progress" style="{progress_bars.get('grooming', {'style': 'width: 0%; background-color: #e74c3c;'})['style']}"></div>
|
174 |
</div>
|
175 |
+
<span class="percentage">{display_scores['grooming']:.1f}%</span>
|
176 |
</div>
|
177 |
|
178 |
+
<!-- Experience Compatibility Score -->
|
179 |
<div class="score-item">
|
180 |
<span class="label">
|
181 |
Experience Match:
|
|
|
184 |
<span class="tooltip-text">
|
185 |
<strong>Experience Match Score:</strong><br>
|
186 |
• Based on your dog-owning experience level<br>
|
187 |
+
• Considers breed's training complexity, temperament, and handling difficulty<br>
|
188 |
• Higher score means the breed is more suitable for your experience level.
|
189 |
</span>
|
190 |
</span>
|
|
|
192 |
<div class="progress-bar">
|
193 |
<div class="progress" style="{progress_bars.get('experience', {'style': 'width: 0%; background-color: #e74c3c;'})['style']}"></div>
|
194 |
</div>
|
195 |
+
<span class="percentage">{display_scores['experience']:.1f}%</span>
|
196 |
</div>
|
197 |
|
198 |
+
<!-- Noise Compatibility Score -->
|
199 |
<div class="score-item">
|
200 |
<span class="label">
|
201 |
Noise Compatibility:
|
|
|
212 |
<div class="progress-bar">
|
213 |
<div class="progress" style="{progress_bars.get('noise', {'style': 'width: 0%; background-color: #e74c3c;'})['style']}"></div>
|
214 |
</div>
|
215 |
+
<span class="percentage">{display_scores['noise']:.1f}%</span>
|
216 |
</div>
|
217 |
|
218 |
{f'''
|
|
|
223 |
<span class="tooltip-icon">ⓘ</span>
|
224 |
<span class="tooltip-text">
|
225 |
<strong>Breed Bonus Points:</strong><br>
|
226 |
+
• {('<br>• '.join(bonus_reasons) if bonus_reasons else 'No additional bonus points')}<br><br>
|
|
|
227 |
<strong>Bonus Factors Include:</strong><br>
|
228 |
• Friendly temperament<br>
|
229 |
• Child compatibility<br>
|
|
|
239 |
</div>
|
240 |
''' if bonus_score > 0 else ''}
|
241 |
</div>
|
242 |
+
"""
|
243 |
+
|
244 |
+
# 使用格式器生成詳細區段
|
245 |
+
html_content += formatter.generate_detailed_sections_html(
|
246 |
+
breed, info, noise_characteristics, barking_triggers, noise_level,
|
247 |
+
health_considerations, health_screenings
|
248 |
+
)
|
249 |
+
|
250 |
+
html_content += """
|
251 |
+
</div>
|
252 |
+
</div>
|
253 |
+
"""
|
254 |
+
|
255 |
+
# 結束 HTML 內容
|
256 |
+
html_content += "</div>"
|
257 |
+
return html_content
|
258 |
+
|
259 |
+
|
260 |
+
def format_unified_recommendation_html(recommendations: List[Dict], is_description_search: bool = False) -> str:
|
261 |
+
"""統一推薦HTML格式化主函數,確保視覺呈現與數值計算完全一致"""
|
262 |
+
|
263 |
+
# 創建HTML格式器實例
|
264 |
+
formatter = RecommendationHTMLFormatter()
|
265 |
+
|
266 |
+
if not recommendations:
|
267 |
+
return '''
|
268 |
+
<div style="text-align: center; padding: 60px 20px; background: linear-gradient(135deg, #f8fafc, #e2e8f0); border-radius: 16px; margin: 20px 0;">
|
269 |
+
<div style="font-size: 3em; margin-bottom: 16px;">🐕</div>
|
270 |
+
<h3 style="color: #374151; margin-bottom: 12px;">No Recommendations Available</h3>
|
271 |
+
<p style="color: #6b7280; font-size: 1.1em;">Please try adjusting your preferences or description, and we'll help you find the most suitable breeds.</p>
|
272 |
+
</div>
|
273 |
+
'''
|
274 |
+
|
275 |
+
# 使用格式器的統一CSS樣式
|
276 |
+
html_content = formatter.unified_css + "<div class='unified-recommendations'>"
|
277 |
+
|
278 |
+
for rec in recommendations:
|
279 |
+
breed = rec['breed']
|
280 |
+
rank = rec.get('rank', 0)
|
281 |
+
|
282 |
+
# 統一分數處理
|
283 |
+
overall_score = rec.get('overall_score', rec.get('final_score', 0.7))
|
284 |
+
scores = rec.get('scores', {})
|
285 |
+
|
286 |
+
# 如果沒有維度分數,基於總分生成一致的維度分數
|
287 |
+
if not scores:
|
288 |
+
scores = generate_dimension_scores_for_display(
|
289 |
+
overall_score, rank, breed, is_description_search=is_description_search
|
290 |
+
)
|
291 |
+
|
292 |
+
# 獲取品種資訊
|
293 |
+
breed_name_for_db = breed.replace(' ', '_')
|
294 |
+
breed_info = get_dog_description(breed_name_for_db) or {}
|
295 |
+
|
296 |
+
# 維度標籤
|
297 |
+
dimension_labels = {
|
298 |
+
'space': '🏠 Space Compatibility',
|
299 |
+
'exercise': '🏃 Exercise Requirements',
|
300 |
+
'grooming': '✂️ Grooming Needs',
|
301 |
+
'experience': '🎓 Experience Level',
|
302 |
+
'noise': '🔊 Noise Control',
|
303 |
+
'family': '👨👩👧👦 Family Compatibility'
|
304 |
+
}
|
305 |
+
|
306 |
+
# 維度提示氣泡內容
|
307 |
+
tooltip_content = {
|
308 |
+
'space': 'Space Compatibility Score:<br>• Evaluates how well the breed adapts to your living environment<br>• Considers if your home (apartment/house) and yard access suit the breed\'s size<br>• Higher score means the breed fits well in your available space.',
|
309 |
+
'exercise': 'Exercise Requirements Score:<br>• Based on your daily exercise time and activity type<br>• Compares your activity level to the breed\'s exercise needs<br>• Higher score means your routine aligns well with the breed\'s energy requirements.',
|
310 |
+
'grooming': 'Grooming Needs Score:<br>• Evaluates breed\'s grooming needs (coat care, trimming, brushing)<br>• Compares these requirements with your grooming commitment level<br>• Higher score means the breed\'s grooming needs fit your willingness and capability.',
|
311 |
+
'experience': 'Experience Level Score:<br>• Based on your dog-owning experience level<br>• Considers breed\'s training complexity, temperament, and handling difficulty<br>• Higher score means the breed is more suitable for your experience level.',
|
312 |
+
'noise': 'Noise Control Score:<br>• Based on your noise tolerance preference<br>• Considers breed\'s typical noise level and barking tendencies<br>• Accounts for living environment and sensitivity to noise.',
|
313 |
+
'family': 'Family Compatibility Score:<br>• Evaluates how well the breed fits with your family situation<br>• Considers children, other pets, and family dynamics<br>• Higher score means better family compatibility.'
|
314 |
+
}
|
315 |
+
|
316 |
+
# 生成維度分數HTML
|
317 |
+
dimension_html = ""
|
318 |
+
for dim, label in dimension_labels.items():
|
319 |
+
score = scores.get(dim, overall_score * 0.9)
|
320 |
+
percentage = formatter.format_unified_percentage(score)
|
321 |
+
progress_bar = formatter.generate_unified_progress_bar(score)
|
322 |
+
|
323 |
+
# 為 Find by Description 添加提示氣泡
|
324 |
+
tooltip_html = ''
|
325 |
+
if is_description_search:
|
326 |
+
tooltip_html = f'<span class="tooltip"><span class="tooltip-icon">i</span><span class="tooltip-text"><strong>{tooltip_content.get(dim, "")}</strong></span></span>'
|
327 |
+
|
328 |
+
dimension_html += f'''
|
329 |
+
<div class="unified-dimension-item">
|
330 |
+
<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 8px;">
|
331 |
+
<span style="font-weight: 600; color: #374151;">{label} {tooltip_html}</span>
|
332 |
+
<span style="font-weight: 700; color: #1f2937; font-size: 1.1em;">{percentage}</span>
|
333 |
</div>
|
334 |
+
{progress_bar}
|
335 |
+
</div>
|
336 |
+
'''
|
337 |
+
|
338 |
+
# 生成品種資訊HTML
|
339 |
+
characteristics = generate_breed_characteristics_data(breed_info)
|
340 |
+
info_html = ""
|
341 |
+
for label, value in characteristics:
|
342 |
+
if label != 'Description': # Skip description as it's shown separately
|
343 |
+
info_html += f'''
|
344 |
+
<div class="unified-info-item">
|
345 |
+
<div class="unified-info-label">{label}</div>
|
346 |
+
<div class="unified-info-value">{value}</div>
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
347 |
</div>
|
348 |
+
'''
|
349 |
+
|
350 |
+
# 生成單個品種卡片HTML
|
351 |
+
overall_percentage = formatter.format_unified_percentage(overall_score)
|
352 |
+
overall_progress_bar = formatter.generate_unified_progress_bar(overall_score)
|
353 |
+
|
354 |
+
brand_card_html = f'''
|
355 |
+
<div class="unified-breed-card">
|
356 |
+
<div class="unified-breed-header">
|
357 |
+
<div style="display: flex; align-items: center; gap: 12px; margin-bottom: 20px;">
|
358 |
+
<div class="unified-rank-badge">🏆 #{rank}</div>
|
359 |
+
<h2 class="unified-breed-title">{breed.replace('_', ' ')}</h2>
|
360 |
+
<div style="margin-left: auto;">
|
361 |
+
<div class="unified-match-score">Overall Match: {overall_percentage}</div>
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
362 |
</div>
|
363 |
</div>
|
364 |
+
</div>
|
365 |
+
|
366 |
+
<div class="unified-overall-section">
|
367 |
+
{overall_progress_bar}
|
368 |
+
</div>
|
369 |
+
|
370 |
+
<div class="unified-dimension-grid">
|
371 |
+
{dimension_html}
|
372 |
+
</div>
|
373 |
+
|
374 |
+
<div class="unified-breed-info">
|
375 |
+
{info_html}
|
376 |
+
</div>
|
377 |
+
|
378 |
+
<div style="background: linear-gradient(135deg, #F8FAFC, #F1F5F9); padding: 20px; border-radius: 12px; margin: 20px 0; border: 1px solid #E2E8F0;">
|
379 |
+
<h3 style="color: #1F2937; font-size: 1.3em; font-weight: 700; margin: 0 0 12px 0; display: flex; align-items: center;">
|
380 |
+
<span style="margin-right: 8px;">📝</span> Breed Description
|
381 |
+
</h3>
|
382 |
+
<p style="color: #4B5563; line-height: 1.6; margin: 0 0 16px 0; font-size: 1.05em;">
|
383 |
+
{breed_info.get('Description', 'Detailed description for this breed is not currently available.')}
|
384 |
+
</p>
|
385 |
+
<a href="https://www.akc.org/dog-breeds/{breed.lower().replace('_', '-').replace(' ', '-')}/"
|
386 |
+
target="_blank"
|
387 |
+
class="akc-button"
|
388 |
+
style="display: inline-flex; align-items: center; padding: 12px 20px;
|
389 |
+
background: linear-gradient(135deg, #3B82F6, #1D4ED8); color: white;
|
390 |
+
text-decoration: none; border-radius: 10px; font-weight: 600;
|
391 |
+
box-shadow: 0 4px 12px rgba(59, 130, 246, 0.3);
|
392 |
+
transition: all 0.3s ease; font-size: 1.05em;"
|
393 |
+
onmouseover="this.style.transform='translateY(-2px)'; this.style.boxShadow='0 6px 16px rgba(59, 130, 246, 0.5)'"
|
394 |
+
onmouseout="this.style.transform='translateY(0px)'; this.style.boxShadow='0 4px 12px rgba(59, 130, 246, 0.3)'">
|
395 |
+
<span style="margin-right: 8px;">🌐</span>
|
396 |
+
Learn more about {breed.replace('_', ' ')} on AKC website
|
397 |
+
</a>
|
398 |
</div>
|
399 |
</div>
|
400 |
+
'''
|
401 |
+
|
402 |
+
html_content += brand_card_html
|
403 |
|
404 |
html_content += "</div>"
|
405 |
return html_content
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
recommendation_html_formatter.py
ADDED
@@ -0,0 +1,1025 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
import random
|
2 |
+
from typing import List, Dict
|
3 |
+
from breed_health_info import breed_health_info, default_health_note
|
4 |
+
from breed_noise_info import breed_noise_info
|
5 |
+
from dog_database import get_dog_description
|
6 |
+
from recommendation_formatter import (
|
7 |
+
generate_breed_characteristics_data,
|
8 |
+
parse_noise_information,
|
9 |
+
parse_health_information,
|
10 |
+
calculate_breed_bonus_factors,
|
11 |
+
generate_dimension_scores_for_display
|
12 |
+
)
|
13 |
+
|
14 |
+
class RecommendationHTMLFormatter:
|
15 |
+
"""處理推薦結果的HTML和CSS格式化"""
|
16 |
+
|
17 |
+
def __init__(self):
|
18 |
+
self.description_search_css = """
|
19 |
+
<style>
|
20 |
+
.recommendations-container {
|
21 |
+
display: flex;
|
22 |
+
flex-direction: column;
|
23 |
+
gap: 20px;
|
24 |
+
padding: 20px;
|
25 |
+
}
|
26 |
+
|
27 |
+
.breed-card {
|
28 |
+
border: 2px solid #e5e7eb;
|
29 |
+
border-radius: 12px;
|
30 |
+
padding: 20px;
|
31 |
+
background: white;
|
32 |
+
box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1);
|
33 |
+
transition: all 0.3s ease;
|
34 |
+
position: relative;
|
35 |
+
}
|
36 |
+
|
37 |
+
.breed-card:hover {
|
38 |
+
box-shadow: 0 8px 12px rgba(0, 0, 0, 0.15);
|
39 |
+
transform: translateY(-2px);
|
40 |
+
}
|
41 |
+
|
42 |
+
.rank-badge {
|
43 |
+
position: absolute;
|
44 |
+
top: 15px;
|
45 |
+
left: 15px;
|
46 |
+
padding: 8px 14px;
|
47 |
+
border-radius: 8px;
|
48 |
+
font-weight: 800;
|
49 |
+
font-size: 20px;
|
50 |
+
min-width: 45px;
|
51 |
+
text-align: center;
|
52 |
+
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.2);
|
53 |
+
}
|
54 |
+
|
55 |
+
.rank-1 {
|
56 |
+
background: linear-gradient(135deg, #FEF3C7 0%, #FDE68A 50%, #F59E0B 100%);
|
57 |
+
color: #92400E;
|
58 |
+
font-size: 32px;
|
59 |
+
font-weight: 900;
|
60 |
+
animation: pulse 2s infinite;
|
61 |
+
border: 3px solid rgba(251, 191, 36, 0.4);
|
62 |
+
box-shadow: 0 6px 20px rgba(245, 158, 11, 0.3);
|
63 |
+
text-shadow: 0 1px 2px rgba(255, 255, 255, 0.8);
|
64 |
+
}
|
65 |
+
|
66 |
+
.rank-2 {
|
67 |
+
background: linear-gradient(135deg, #F1F5F9 0%, #E2E8F0 50%, #94A3B8 100%);
|
68 |
+
color: #475569;
|
69 |
+
font-size: 30px;
|
70 |
+
font-weight: 800;
|
71 |
+
border: 3px solid rgba(148, 163, 184, 0.4);
|
72 |
+
box-shadow: 0 5px 15px rgba(148, 163, 184, 0.3);
|
73 |
+
text-shadow: 0 1px 2px rgba(255, 255, 255, 0.8);
|
74 |
+
}
|
75 |
+
|
76 |
+
.rank-3 {
|
77 |
+
background: linear-gradient(135deg, #FEF2F2 0%, #FED7AA 50%, #FB923C 100%);
|
78 |
+
color: #9A3412;
|
79 |
+
font-size: 28px;
|
80 |
+
font-weight: 800;
|
81 |
+
border: 3px solid rgba(251, 146, 60, 0.4);
|
82 |
+
box-shadow: 0 4px 12px rgba(251, 146, 60, 0.3);
|
83 |
+
text-shadow: 0 1px 2px rgba(255, 255, 255, 0.8);
|
84 |
+
}
|
85 |
+
|
86 |
+
.rank-other {
|
87 |
+
background: linear-gradient(135deg, #F8FAFC 0%, #E2E8F0 50%, #CBD5E1 100%);
|
88 |
+
color: #475569;
|
89 |
+
font-size: 26px;
|
90 |
+
font-weight: 700;
|
91 |
+
border: 2px solid rgba(203, 213, 225, 0.6);
|
92 |
+
box-shadow: 0 3px 8px rgba(203, 213, 225, 0.4);
|
93 |
+
text-shadow: 0 1px 2px rgba(255, 255, 255, 0.8);
|
94 |
+
}
|
95 |
+
|
96 |
+
@keyframes pulse {
|
97 |
+
0% {
|
98 |
+
box-shadow: 0 6px 20px rgba(245, 158, 11, 0.3);
|
99 |
+
transform: scale(1);
|
100 |
+
}
|
101 |
+
50% {
|
102 |
+
box-shadow: 0 8px 25px rgba(245, 158, 11, 0.5), 0 0 0 4px rgba(245, 158, 11, 0.15);
|
103 |
+
transform: scale(1.05);
|
104 |
+
}
|
105 |
+
100% {
|
106 |
+
box-shadow: 0 6px 20px rgba(245, 158, 11, 0.3);
|
107 |
+
transform: scale(1);
|
108 |
+
}
|
109 |
+
}
|
110 |
+
|
111 |
+
.breed-header {
|
112 |
+
display: flex;
|
113 |
+
justify-content: space-between;
|
114 |
+
align-items: center;
|
115 |
+
margin-bottom: 15px;
|
116 |
+
padding-left: 70px;
|
117 |
+
}
|
118 |
+
|
119 |
+
.breed-name {
|
120 |
+
font-size: 26px;
|
121 |
+
font-weight: 800;
|
122 |
+
color: #1F2937;
|
123 |
+
margin: 0;
|
124 |
+
letter-spacing: -0.025em;
|
125 |
+
line-height: 1.2;
|
126 |
+
}
|
127 |
+
|
128 |
+
.match-score {
|
129 |
+
display: flex;
|
130 |
+
flex-direction: column;
|
131 |
+
align-items: flex-end;
|
132 |
+
padding: 12px 16px;
|
133 |
+
background: linear-gradient(135deg, #F0F9FF 0%, #E0F2FE 100%);
|
134 |
+
border-radius: 12px;
|
135 |
+
border: 2px solid rgba(6, 182, 212, 0.2);
|
136 |
+
box-shadow: 0 4px 12px rgba(6, 182, 212, 0.1);
|
137 |
+
}
|
138 |
+
|
139 |
+
.match-percentage {
|
140 |
+
font-size: 48px;
|
141 |
+
font-weight: 900;
|
142 |
+
margin-bottom: 8px;
|
143 |
+
text-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
|
144 |
+
line-height: 1;
|
145 |
+
letter-spacing: -0.02em;
|
146 |
+
}
|
147 |
+
|
148 |
+
.match-label {
|
149 |
+
font-size: 12px;
|
150 |
+
text-transform: uppercase;
|
151 |
+
letter-spacing: 2px;
|
152 |
+
opacity: 0.9;
|
153 |
+
font-weight: 800;
|
154 |
+
margin-bottom: 6px;
|
155 |
+
color: #0369A1;
|
156 |
+
}
|
157 |
+
|
158 |
+
.score-excellent { color: #22C55E; }
|
159 |
+
.score-good { color: #F59E0B; }
|
160 |
+
.score-moderate { color: #6B7280; }
|
161 |
+
|
162 |
+
.score-bar {
|
163 |
+
width: 220px;
|
164 |
+
height: 14px;
|
165 |
+
background: rgba(226, 232, 240, 0.8);
|
166 |
+
border-radius: 8px;
|
167 |
+
overflow: hidden;
|
168 |
+
margin-top: 8px;
|
169 |
+
box-shadow: inset 0 2px 4px rgba(0, 0, 0, 0.1);
|
170 |
+
border: 1px solid rgba(6, 182, 212, 0.2);
|
171 |
+
}
|
172 |
+
|
173 |
+
.score-fill {
|
174 |
+
height: 100%;
|
175 |
+
border-radius: 4px;
|
176 |
+
transition: width 1s ease;
|
177 |
+
}
|
178 |
+
|
179 |
+
.fill-excellent { background: linear-gradient(90deg, #22C55E, #16A34A); }
|
180 |
+
.fill-good { background: linear-gradient(90deg, #F59E0B, #DC2626); }
|
181 |
+
.fill-moderate { background: linear-gradient(90deg, #6B7280, #4B5563); }
|
182 |
+
|
183 |
+
/* Tooltip styles for Find by Description */
|
184 |
+
.tooltip {
|
185 |
+
position: relative;
|
186 |
+
display: inline-block;
|
187 |
+
cursor: help;
|
188 |
+
}
|
189 |
+
|
190 |
+
.tooltip-icon {
|
191 |
+
display: inline-block;
|
192 |
+
width: 18px;
|
193 |
+
height: 18px;
|
194 |
+
background: linear-gradient(135deg, #06b6d4, #0891b2);
|
195 |
+
color: white;
|
196 |
+
border-radius: 50%;
|
197 |
+
text-align: center;
|
198 |
+
line-height: 18px;
|
199 |
+
font-size: 12px;
|
200 |
+
font-weight: bold;
|
201 |
+
margin-left: 8px;
|
202 |
+
cursor: help;
|
203 |
+
box-shadow: 0 2px 4px rgba(6, 182, 212, 0.3);
|
204 |
+
transition: all 0.2s ease;
|
205 |
+
}
|
206 |
+
|
207 |
+
.tooltip-icon:hover {
|
208 |
+
background: linear-gradient(135deg, #0891b2, #0e7490);
|
209 |
+
transform: scale(1.1);
|
210 |
+
box-shadow: 0 3px 6px rgba(6, 182, 212, 0.4);
|
211 |
+
}
|
212 |
+
|
213 |
+
.tooltip-text {
|
214 |
+
visibility: hidden;
|
215 |
+
width: 320px;
|
216 |
+
background: linear-gradient(145deg, #1e293b, #334155);
|
217 |
+
color: #f1f5f9;
|
218 |
+
text-align: left;
|
219 |
+
border-radius: 12px;
|
220 |
+
padding: 16px;
|
221 |
+
position: absolute;
|
222 |
+
z-index: 1000;
|
223 |
+
bottom: 125%;
|
224 |
+
left: 50%;
|
225 |
+
margin-left: -160px;
|
226 |
+
opacity: 0;
|
227 |
+
transition: opacity 0.3s ease, transform 0.3s ease;
|
228 |
+
transform: translateY(10px);
|
229 |
+
font-size: 14px;
|
230 |
+
line-height: 1.5;
|
231 |
+
box-shadow: 0 8px 25px rgba(0, 0, 0, 0.3);
|
232 |
+
border: 1px solid rgba(148, 163, 184, 0.2);
|
233 |
+
}
|
234 |
+
|
235 |
+
.tooltip-text::after {
|
236 |
+
content: "";
|
237 |
+
position: absolute;
|
238 |
+
top: 100%;
|
239 |
+
left: 50%;
|
240 |
+
margin-left: -8px;
|
241 |
+
border-width: 8px;
|
242 |
+
border-style: solid;
|
243 |
+
border-color: #334155 transparent transparent transparent;
|
244 |
+
}
|
245 |
+
|
246 |
+
.tooltip:hover .tooltip-text {
|
247 |
+
visibility: visible;
|
248 |
+
opacity: 1;
|
249 |
+
transform: translateY(0);
|
250 |
+
}
|
251 |
+
|
252 |
+
.tooltip-text strong {
|
253 |
+
color: #06b6d4;
|
254 |
+
font-weight: 700;
|
255 |
+
display: block;
|
256 |
+
margin-bottom: 8px;
|
257 |
+
font-size: 15px;
|
258 |
+
}
|
259 |
+
</style>
|
260 |
+
"""
|
261 |
+
|
262 |
+
self.criteria_search_css = """
|
263 |
+
<style>
|
264 |
+
.recommendations-container {
|
265 |
+
display: flex;
|
266 |
+
flex-direction: column;
|
267 |
+
gap: 15px;
|
268 |
+
padding: 15px;
|
269 |
+
}
|
270 |
+
|
271 |
+
.breed-card {
|
272 |
+
border: 1px solid #d1d5db;
|
273 |
+
border-radius: 8px;
|
274 |
+
padding: 16px;
|
275 |
+
background: #ffffff;
|
276 |
+
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.05);
|
277 |
+
transition: all 0.2s ease;
|
278 |
+
}
|
279 |
+
|
280 |
+
.breed-card:hover {
|
281 |
+
box-shadow: 0 4px 8px rgba(0, 0, 0, 0.1);
|
282 |
+
transform: translateY(-1px);
|
283 |
+
}
|
284 |
+
|
285 |
+
.breed-header {
|
286 |
+
display: flex;
|
287 |
+
justify-content: space-between;
|
288 |
+
align-items: center;
|
289 |
+
margin-bottom: 12px;
|
290 |
+
padding-bottom: 8px;
|
291 |
+
border-bottom: 1px solid #f3f4f6;
|
292 |
+
}
|
293 |
+
|
294 |
+
.breed-title {
|
295 |
+
display: flex;
|
296 |
+
align-items: center;
|
297 |
+
gap: 16px;
|
298 |
+
justify-content: flex-start;
|
299 |
+
}
|
300 |
+
|
301 |
+
.trophy-rank {
|
302 |
+
font-size: 24px;
|
303 |
+
font-weight: 800;
|
304 |
+
color: #1f2937;
|
305 |
+
}
|
306 |
+
|
307 |
+
.breed-name {
|
308 |
+
font-size: 42px;
|
309 |
+
font-weight: 900;
|
310 |
+
color: #1f2937;
|
311 |
+
margin: 0;
|
312 |
+
padding: 8px 16px;
|
313 |
+
background: linear-gradient(135deg, #D1FAE5 0%, #A7F3D0 100%);
|
314 |
+
border: 2px solid #22C55E;
|
315 |
+
border-radius: 12px;
|
316 |
+
display: inline-block;
|
317 |
+
box-shadow: 0 2px 8px rgba(34, 197, 94, 0.2);
|
318 |
+
}
|
319 |
+
|
320 |
+
.overall-score {
|
321 |
+
display: flex;
|
322 |
+
flex-direction: column;
|
323 |
+
align-items: flex-end;
|
324 |
+
}
|
325 |
+
|
326 |
+
.score-percentage {
|
327 |
+
font-size: 32px;
|
328 |
+
font-weight: 900;
|
329 |
+
margin-bottom: 4px;
|
330 |
+
line-height: 1;
|
331 |
+
}
|
332 |
+
|
333 |
+
.score-label {
|
334 |
+
font-size: 10px;
|
335 |
+
text-transform: uppercase;
|
336 |
+
letter-spacing: 1px;
|
337 |
+
opacity: 0.7;
|
338 |
+
font-weight: 600;
|
339 |
+
}
|
340 |
+
|
341 |
+
.score-bar-wide {
|
342 |
+
width: 200px;
|
343 |
+
height: 8px;
|
344 |
+
background: #f3f4f6;
|
345 |
+
border-radius: 4px;
|
346 |
+
overflow: hidden;
|
347 |
+
margin-top: 6px;
|
348 |
+
}
|
349 |
+
|
350 |
+
.score-fill-wide {
|
351 |
+
height: 100%;
|
352 |
+
border-radius: 4px;
|
353 |
+
transition: width 0.8s ease;
|
354 |
+
}
|
355 |
+
|
356 |
+
.score-excellent { color: #22C55E; }
|
357 |
+
.score-good { color: #65a30d; }
|
358 |
+
.score-moderate { color: #d4a332; }
|
359 |
+
.score-fair { color: #e67e22; }
|
360 |
+
.score-poor { color: #e74c3c; }
|
361 |
+
|
362 |
+
.fill-excellent { background: #22C55E; }
|
363 |
+
.fill-good { background: #65a30d; }
|
364 |
+
.fill-moderate { background: #d4a332; }
|
365 |
+
.fill-fair { background: #e67e22; }
|
366 |
+
.fill-poor { background: #e74c3c; }
|
367 |
+
|
368 |
+
.breed-details {
|
369 |
+
margin-top: 12px;
|
370 |
+
padding-top: 12px;
|
371 |
+
border-top: 1px solid #e5e7eb;
|
372 |
+
}
|
373 |
+
|
374 |
+
/* 通用樣式(兩個模式都需要) */
|
375 |
+
.progress {
|
376 |
+
transition: all 0.3s ease-in-out;
|
377 |
+
border-radius: 4px;
|
378 |
+
height: 12px;
|
379 |
+
}
|
380 |
+
|
381 |
+
.progress-bar {
|
382 |
+
background-color: #f5f5f5;
|
383 |
+
border-radius: 4px;
|
384 |
+
overflow: hidden;
|
385 |
+
position: relative;
|
386 |
+
}
|
387 |
+
|
388 |
+
.score-item {
|
389 |
+
margin: 10px 0;
|
390 |
+
}
|
391 |
+
|
392 |
+
.percentage {
|
393 |
+
margin-left: 8px;
|
394 |
+
font-weight: 500;
|
395 |
+
}
|
396 |
+
|
397 |
+
/* White Tooltip Styles from styles.py */
|
398 |
+
.tooltip {
|
399 |
+
position: relative;
|
400 |
+
display: inline-flex;
|
401 |
+
align-items: center;
|
402 |
+
gap: 4px;
|
403 |
+
cursor: help;
|
404 |
+
}
|
405 |
+
.tooltip .tooltip-icon {
|
406 |
+
font-size: 14px;
|
407 |
+
color: #666;
|
408 |
+
}
|
409 |
+
.tooltip .tooltip-text {
|
410 |
+
visibility: hidden;
|
411 |
+
width: 250px;
|
412 |
+
background-color: rgba(44, 62, 80, 0.95);
|
413 |
+
color: white;
|
414 |
+
text-align: left;
|
415 |
+
border-radius: 8px;
|
416 |
+
padding: 8px 10px;
|
417 |
+
position: absolute;
|
418 |
+
z-index: 100;
|
419 |
+
bottom: 150%;
|
420 |
+
left: 50%;
|
421 |
+
transform: translateX(-50%);
|
422 |
+
opacity: 0;
|
423 |
+
transition: all 0.3s ease;
|
424 |
+
font-size: 14px;
|
425 |
+
line-height: 1.3;
|
426 |
+
box-shadow: 0 4px 6px rgba(0, 0, 0, 0.2);
|
427 |
+
border: 1px solid rgba(255, 255, 255, 0.1);
|
428 |
+
margin-bottom: 10px;
|
429 |
+
}
|
430 |
+
.tooltip:hover .tooltip-text {
|
431 |
+
visibility: visible;
|
432 |
+
opacity: 1;
|
433 |
+
}
|
434 |
+
.tooltip .tooltip-text::after {
|
435 |
+
content: "";
|
436 |
+
position: absolute;
|
437 |
+
top: 100%;
|
438 |
+
left: 50%;
|
439 |
+
transform: translateX(-50%);
|
440 |
+
border-width: 8px;
|
441 |
+
border-style: solid;
|
442 |
+
border-color: rgba(44, 62, 80, 0.95) transparent transparent transparent;
|
443 |
+
}
|
444 |
+
|
445 |
+
</style>
|
446 |
+
"""
|
447 |
+
|
448 |
+
self.unified_css = """
|
449 |
+
<style>
|
450 |
+
.unified-recommendations { max-width: 1200px; margin: 0 auto; padding: 20px; }
|
451 |
+
.unified-breed-card {
|
452 |
+
background: linear-gradient(145deg, #ffffff 0%, #f8fafc 100%);
|
453 |
+
border-radius: 16px; padding: 24px; margin-bottom: 20px;
|
454 |
+
box-shadow: 0 8px 25px rgba(0, 0, 0, 0.1); border: 1px solid #e2e8f0;
|
455 |
+
transition: all 0.3s ease;
|
456 |
+
}
|
457 |
+
.unified-breed-card:hover { transform: translateY(-2px); box-shadow: 0 12px 35px rgba(0, 0, 0, 0.15); }
|
458 |
+
.unified-breed-header { margin-bottom: 20px; }
|
459 |
+
.unified-rank-section { display: flex; align-items: center; gap: 15px; }
|
460 |
+
.unified-rank-badge {
|
461 |
+
background: linear-gradient(135deg, #E0F2FE 0%, #BAE6FD 100%);
|
462 |
+
color: #0C4A6E; padding: 8px 16px; border-radius: 8px;
|
463 |
+
font-weight: 900; font-size: 24px;
|
464 |
+
box-shadow: 0 2px 8px rgba(14, 165, 233, 0.2);
|
465 |
+
border: 2px solid #0EA5E9;
|
466 |
+
display: inline-block;
|
467 |
+
min-width: 80px; text-align: center;
|
468 |
+
}
|
469 |
+
.unified-breed-info { flex-grow: 1; }
|
470 |
+
.unified-breed-title {
|
471 |
+
font-size: 24px; font-weight: 800; color: #0C4A6E;
|
472 |
+
margin: 0; letter-spacing: -0.02em;
|
473 |
+
background: linear-gradient(135deg, #F0F9FF, #E0F2FE);
|
474 |
+
padding: 12px 20px; border-radius: 10px;
|
475 |
+
border: 2px solid #0EA5E9;
|
476 |
+
display: inline-block; box-shadow: 0 2px 8px rgba(14, 165, 233, 0.1);
|
477 |
+
}
|
478 |
+
.unified-match-score {
|
479 |
+
font-size: 24px; font-weight: 900;
|
480 |
+
color: #0F5132; background: linear-gradient(135deg, #D1FAE5, #A7F3D0);
|
481 |
+
padding: 12px 20px; border-radius: 10px; display: inline-block;
|
482 |
+
text-align: center; border: 2px solid #22C55E;
|
483 |
+
box-shadow: 0 2px 8px rgba(34, 197, 94, 0.2);
|
484 |
+
margin: 0; text-shadow: 0 1px 2px rgba(255, 255, 255, 0.5);
|
485 |
+
letter-spacing: -0.02em;
|
486 |
+
}
|
487 |
+
.unified-overall-section {
|
488 |
+
background: linear-gradient(135deg, #f0f9ff, #ecfeff); border-radius: 12px;
|
489 |
+
padding: 20px; margin-bottom: 24px; border: 2px solid #06b6d4;
|
490 |
+
box-shadow: 0 4px 12px rgba(6, 182, 212, 0.1);
|
491 |
+
}
|
492 |
+
.unified-dimension-grid {
|
493 |
+
display: grid; grid-template-columns: repeat(auto-fit, minmax(300px, 1fr));
|
494 |
+
gap: 16px; margin-bottom: 24px;
|
495 |
+
}
|
496 |
+
.unified-dimension-item {
|
497 |
+
background: white; padding: 16px; border-radius: 10px;
|
498 |
+
border: 1px solid #e2e8f0; transition: all 0.2s ease;
|
499 |
+
}
|
500 |
+
.unified-dimension-item:hover { background: #f8fafc; border-color: #cbd5e1; }
|
501 |
+
.unified-breed-info {
|
502 |
+
display: grid; grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
|
503 |
+
gap: 12px; margin: 20px 0;
|
504 |
+
}
|
505 |
+
.unified-info-item {
|
506 |
+
background: #f8fafc; padding: 12px; border-radius: 8px;
|
507 |
+
border-left: 4px solid #6366f1;
|
508 |
+
}
|
509 |
+
.unified-info-label { font-weight: 600; color: #4b5563; font-size: 0.85em; margin-bottom: 4px; }
|
510 |
+
.unified-info-value { color: #1f2937; font-weight: 500; }
|
511 |
+
|
512 |
+
/* Tooltip styles for unified recommendations */
|
513 |
+
.tooltip {
|
514 |
+
position: relative;
|
515 |
+
display: inline-block;
|
516 |
+
cursor: help;
|
517 |
+
}
|
518 |
+
|
519 |
+
.tooltip-icon {
|
520 |
+
display: inline-block;
|
521 |
+
width: 18px;
|
522 |
+
height: 18px;
|
523 |
+
background: linear-gradient(135deg, #06b6d4, #0891b2);
|
524 |
+
color: white;
|
525 |
+
border-radius: 50%;
|
526 |
+
text-align: center;
|
527 |
+
line-height: 18px;
|
528 |
+
font-size: 12px;
|
529 |
+
font-weight: bold;
|
530 |
+
margin-left: 8px;
|
531 |
+
cursor: help;
|
532 |
+
box-shadow: 0 2px 4px rgba(6, 182, 212, 0.3);
|
533 |
+
transition: all 0.2s ease;
|
534 |
+
}
|
535 |
+
|
536 |
+
.tooltip-icon:hover {
|
537 |
+
background: linear-gradient(135deg, #0891b2, #0e7490);
|
538 |
+
transform: scale(1.1);
|
539 |
+
box-shadow: 0 3px 6px rgba(6, 182, 212, 0.4);
|
540 |
+
}
|
541 |
+
|
542 |
+
.tooltip-text {
|
543 |
+
visibility: hidden;
|
544 |
+
width: 320px;
|
545 |
+
background: linear-gradient(145deg, #1e293b, #334155);
|
546 |
+
color: #f1f5f9;
|
547 |
+
text-align: left;
|
548 |
+
border-radius: 12px;
|
549 |
+
padding: 16px;
|
550 |
+
position: absolute;
|
551 |
+
z-index: 1000;
|
552 |
+
bottom: 125%;
|
553 |
+
left: 50%;
|
554 |
+
margin-left: -160px;
|
555 |
+
opacity: 0;
|
556 |
+
transition: opacity 0.3s ease, transform 0.3s ease;
|
557 |
+
transform: translateY(10px);
|
558 |
+
font-size: 14px;
|
559 |
+
line-height: 1.5;
|
560 |
+
box-shadow: 0 8px 25px rgba(0, 0, 0, 0.3);
|
561 |
+
border: 1px solid rgba(148, 163, 184, 0.2);
|
562 |
+
}
|
563 |
+
|
564 |
+
.tooltip-text::after {
|
565 |
+
content: "";
|
566 |
+
position: absolute;
|
567 |
+
top: 100%;
|
568 |
+
left: 50%;
|
569 |
+
margin-left: -8px;
|
570 |
+
border-width: 8px;
|
571 |
+
border-style: solid;
|
572 |
+
border-color: #334155 transparent transparent transparent;
|
573 |
+
}
|
574 |
+
|
575 |
+
.tooltip:hover .tooltip-text {
|
576 |
+
visibility: visible;
|
577 |
+
opacity: 1;
|
578 |
+
transform: translateY(0);
|
579 |
+
}
|
580 |
+
|
581 |
+
.tooltip-text strong {
|
582 |
+
color: #06b6d4;
|
583 |
+
font-weight: 700;
|
584 |
+
display: block;
|
585 |
+
margin-bottom: 8px;
|
586 |
+
font-size: 15px;
|
587 |
+
}
|
588 |
+
|
589 |
+
.akc-button:hover {
|
590 |
+
transform: translateY(-2px) !important;
|
591 |
+
box-shadow: 0 6px 16px rgba(59, 130, 246, 0.5) !important;
|
592 |
+
}
|
593 |
+
</style>
|
594 |
+
"""
|
595 |
+
|
596 |
+
def format_unified_percentage(self, score: float) -> str:
|
597 |
+
"""統一格式化百分比顯示,確保數值邏輯一致"""
|
598 |
+
try:
|
599 |
+
# 確保分數在0-1範圍內
|
600 |
+
normalized_score = max(0.0, min(1.0, float(score)))
|
601 |
+
# 轉換為百分比並保留一位小數
|
602 |
+
percentage = normalized_score * 100
|
603 |
+
return f"{percentage:.1f}%"
|
604 |
+
except Exception as e:
|
605 |
+
print(f"Error formatting percentage: {str(e)}")
|
606 |
+
return "70.0%"
|
607 |
+
|
608 |
+
def generate_unified_progress_bar(self, score: float) -> str:
|
609 |
+
"""Generate unified progress bar HTML with width directly corresponding to score"""
|
610 |
+
try:
|
611 |
+
# Ensure score is in 0-1 range
|
612 |
+
normalized_score = max(0.0, min(1.0, float(score)))
|
613 |
+
|
614 |
+
# Progress bar width with reasonable visual mapping
|
615 |
+
# High scores get enhanced visual representation for impact
|
616 |
+
if normalized_score >= 0.9:
|
617 |
+
width_percentage = 85 + (normalized_score - 0.9) * 130 # 85-98% for excellent scores
|
618 |
+
elif normalized_score >= 0.8:
|
619 |
+
width_percentage = 70 + (normalized_score - 0.8) * 150 # 70-85% for very good scores
|
620 |
+
elif normalized_score >= 0.7:
|
621 |
+
width_percentage = 55 + (normalized_score - 0.7) * 150 # 55-70% for good scores
|
622 |
+
elif normalized_score >= 0.5:
|
623 |
+
width_percentage = 30 + (normalized_score - 0.5) * 125 # 30-55% for fair scores
|
624 |
+
else:
|
625 |
+
width_percentage = 8 + normalized_score * 44 # 8-30% for low scores
|
626 |
+
|
627 |
+
# Ensure reasonable bounds
|
628 |
+
width_percentage = max(5, min(98, width_percentage))
|
629 |
+
|
630 |
+
# Choose color based on score with appropriate theme
|
631 |
+
# This is used for unified recommendations (Description search)
|
632 |
+
if normalized_score >= 0.9:
|
633 |
+
color = '#10b981' # Excellent (emerald green)
|
634 |
+
elif normalized_score >= 0.8:
|
635 |
+
color = '#06b6d4' # Good (cyan)
|
636 |
+
elif normalized_score >= 0.7:
|
637 |
+
color = '#3b82f6' # Fair (blue)
|
638 |
+
elif normalized_score >= 0.6:
|
639 |
+
color = '#1d4ed8' # Average (darker blue)
|
640 |
+
elif normalized_score >= 0.5:
|
641 |
+
color = '#1e40af' # Below average (dark blue)
|
642 |
+
else:
|
643 |
+
color = '#ef4444' # Poor (red)
|
644 |
+
|
645 |
+
return f'''
|
646 |
+
<div class="progress-bar-container" style="
|
647 |
+
background-color: #f1f5f9;
|
648 |
+
border-radius: 8px;
|
649 |
+
overflow: hidden;
|
650 |
+
height: 16px;
|
651 |
+
width: 100%;
|
652 |
+
">
|
653 |
+
<div class="progress-bar-fill" style="
|
654 |
+
width: {width_percentage:.1f}%;
|
655 |
+
height: 100%;
|
656 |
+
background-color: {color};
|
657 |
+
border-radius: 8px;
|
658 |
+
transition: width 0.8s ease-out;
|
659 |
+
"></div>
|
660 |
+
</div>
|
661 |
+
'''
|
662 |
+
|
663 |
+
except Exception as e:
|
664 |
+
print(f"Error generating progress bar: {str(e)}")
|
665 |
+
return '<div class="progress-bar-container"><div class="progress-bar-fill" style="width: 70%; background-color: #d4a332;"></div></div>'
|
666 |
+
|
667 |
+
def generate_progress_bar(self, score: float, score_type: str = None, is_percentage_display: bool = False, is_description_search: bool = False) -> dict:
|
668 |
+
"""
|
669 |
+
Generate progress bar width and color with consistent score-to-visual mapping
|
670 |
+
|
671 |
+
Parameters:
|
672 |
+
score: Score value (float between 0-1 or percentage 0-100)
|
673 |
+
score_type: Score type for special handling
|
674 |
+
is_percentage_display: Whether the score is in percentage format
|
675 |
+
|
676 |
+
Returns:
|
677 |
+
dict: Dictionary containing width and color
|
678 |
+
"""
|
679 |
+
# Normalize score to 0-1 range
|
680 |
+
if is_percentage_display:
|
681 |
+
normalized_score = score / 100.0 # Convert percentage to 0-1 range
|
682 |
+
else:
|
683 |
+
normalized_score = score
|
684 |
+
|
685 |
+
# Ensure score is within valid range
|
686 |
+
normalized_score = max(0.0, min(1.0, normalized_score))
|
687 |
+
|
688 |
+
# Calculate progress bar width - simplified for Find by Criteria
|
689 |
+
if not is_description_search and score_type != 'bonus':
|
690 |
+
# Find by Criteria: 調整為更有說服力的視覺比例
|
691 |
+
percentage = normalized_score * 100
|
692 |
+
if percentage >= 95:
|
693 |
+
width = 92 + (percentage - 95) * 1.2 # 95%+ 顯示為 92-98%
|
694 |
+
elif percentage >= 90:
|
695 |
+
width = 85 + (percentage - 90) # 90-95% 顯示為 85-92%
|
696 |
+
elif percentage >= 80:
|
697 |
+
width = 75 + (percentage - 80) * 1.0 # 80-90% 顯示為 75-85%
|
698 |
+
elif percentage >= 70:
|
699 |
+
width = 60 + (percentage - 70) * 1.5 # 70-80% 顯示為 60-75%
|
700 |
+
else:
|
701 |
+
width = percentage * 0.8 # 70% 以下按比例縮放
|
702 |
+
width = max(5, min(98, width))
|
703 |
+
elif score_type == 'bonus':
|
704 |
+
# Bonus scores are typically smaller, need amplified display
|
705 |
+
width = max(5, min(95, normalized_score * 150)) # Amplified for visibility
|
706 |
+
else:
|
707 |
+
# Find by Description: 保持現有的複雜計算
|
708 |
+
if normalized_score >= 0.8:
|
709 |
+
width = 75 + (normalized_score - 0.8) * 115 # 75-98% range for high scores
|
710 |
+
elif normalized_score >= 0.6:
|
711 |
+
width = 50 + (normalized_score - 0.6) * 125 # 50-75% range for good scores
|
712 |
+
elif normalized_score >= 0.4:
|
713 |
+
width = 25 + (normalized_score - 0.4) * 125 # 25-50% range for fair scores
|
714 |
+
else:
|
715 |
+
width = 5 + normalized_score * 50 # 5-25% range for low scores
|
716 |
+
|
717 |
+
width = max(3, min(98, width))
|
718 |
+
|
719 |
+
# Color coding based on normalized score - Criteria uses green gradation
|
720 |
+
if is_description_search:
|
721 |
+
# Find by Description uses blue theme
|
722 |
+
if normalized_score >= 0.9:
|
723 |
+
color = '#10b981' # Excellent (emerald green)
|
724 |
+
elif normalized_score >= 0.85:
|
725 |
+
color = '#06b6d4' # Very good (cyan)
|
726 |
+
elif normalized_score >= 0.8:
|
727 |
+
color = '#3b82f6' # Good (blue)
|
728 |
+
elif normalized_score >= 0.7:
|
729 |
+
color = '#1d4ed8' # Fair (darker blue)
|
730 |
+
elif normalized_score >= 0.6:
|
731 |
+
color = '#1e40af' # Below average (dark blue)
|
732 |
+
elif normalized_score >= 0.5:
|
733 |
+
color = '#f59e0b' # Poor (amber)
|
734 |
+
else:
|
735 |
+
color = '#ef4444' # Very poor (red)
|
736 |
+
else:
|
737 |
+
# Find by Criteria uses original green gradation
|
738 |
+
if normalized_score >= 0.9:
|
739 |
+
color = '#22c55e' # Excellent (bright green)
|
740 |
+
elif normalized_score >= 0.85:
|
741 |
+
color = '#65a30d' # Very good (green)
|
742 |
+
elif normalized_score >= 0.8:
|
743 |
+
color = '#a3a332' # Good (yellow-green)
|
744 |
+
elif normalized_score >= 0.7:
|
745 |
+
color = '#d4a332' # Fair (yellow)
|
746 |
+
elif normalized_score >= 0.6:
|
747 |
+
color = '#e67e22' # Below average (orange)
|
748 |
+
elif normalized_score >= 0.5:
|
749 |
+
color = '#e74c3c' # Poor (red)
|
750 |
+
else:
|
751 |
+
color = '#c0392b' # Very poor (dark red)
|
752 |
+
|
753 |
+
return {
|
754 |
+
'width': width,
|
755 |
+
'color': color
|
756 |
+
}
|
757 |
+
|
758 |
+
def get_css_styles(self, is_description_search: bool) -> str:
|
759 |
+
"""根據搜尋類型返回對應的CSS樣式"""
|
760 |
+
if is_description_search:
|
761 |
+
return self.description_search_css
|
762 |
+
else:
|
763 |
+
return self.criteria_search_css
|
764 |
+
|
765 |
+
def generate_breed_card_header(self, breed: str, rank: int, final_score: float, is_description_search: bool) -> str:
|
766 |
+
"""生成品種卡片標題部分的HTML"""
|
767 |
+
rank_class = f"rank-{rank}" if rank <= 3 else "rank-other"
|
768 |
+
percentage = final_score * 100
|
769 |
+
|
770 |
+
if percentage >= 90:
|
771 |
+
score_class = "score-excellent"
|
772 |
+
fill_class = "fill-excellent"
|
773 |
+
match_label = "EXCELLENT MATCH"
|
774 |
+
elif percentage >= 70:
|
775 |
+
score_class = "score-good"
|
776 |
+
fill_class = "fill-good"
|
777 |
+
match_label = "GOOD MATCH"
|
778 |
+
else:
|
779 |
+
score_class = "score-moderate"
|
780 |
+
fill_class = "fill-moderate"
|
781 |
+
match_label = "MODERATE MATCH"
|
782 |
+
|
783 |
+
if is_description_search:
|
784 |
+
# Find by Description: 使用現有複雜設計
|
785 |
+
return f"""
|
786 |
+
<div class="breed-card">
|
787 |
+
<div class="rank-badge {rank_class}">#{rank}</div>
|
788 |
+
|
789 |
+
<div class="breed-header">
|
790 |
+
<h3 class="breed-name">{breed.replace('_', ' ')}</h3>
|
791 |
+
<div class="match-score">
|
792 |
+
<div class="match-percentage {score_class}">{percentage:.1f}%</div>
|
793 |
+
<div class="match-label">{match_label}</div>
|
794 |
+
<div class="score-bar">
|
795 |
+
<div class="score-fill {fill_class}" style="width: {percentage}%"></div>
|
796 |
+
</div>
|
797 |
+
</div>
|
798 |
+
</div>"""
|
799 |
+
else:
|
800 |
+
# Find by Criteria: 使用簡潔設計,包含獎盃圖示
|
801 |
+
# 計算進度條寬度 - 調整為更有說服力的視覺比例
|
802 |
+
if percentage >= 95:
|
803 |
+
score_width = 92 + (percentage - 95) * 1.2 # 95%+ 顯示為 92-98%
|
804 |
+
elif percentage >= 90:
|
805 |
+
score_width = 85 + (percentage - 90) # 90-95% 顯示為 85-92%
|
806 |
+
elif percentage >= 80:
|
807 |
+
score_width = 75 + (percentage - 80) * 1.0 # 80-90% 顯示為 75-85%
|
808 |
+
elif percentage >= 70:
|
809 |
+
score_width = 60 + (percentage - 70) * 1.5 # 70-80% 顯示為 60-75%
|
810 |
+
else:
|
811 |
+
score_width = percentage * 0.8 # 70% 以下按比例縮放
|
812 |
+
score_width = max(5, min(98, score_width))
|
813 |
+
|
814 |
+
return f"""
|
815 |
+
<div class="breed-card">
|
816 |
+
<div class="breed-header">
|
817 |
+
<div class="breed-title">
|
818 |
+
<div class="trophy-rank">🏆 #{rank}</div>
|
819 |
+
<h3 class="breed-name" style="font-size: 28px !important;">{breed.replace('_', ' ')}</h3>
|
820 |
+
</div>
|
821 |
+
<div class="overall-score">
|
822 |
+
<div class="score-percentage {score_class}">{percentage:.1f}%</div>
|
823 |
+
<div class="score-label">OVERALL MATCH</div>
|
824 |
+
<div class="score-bar-wide">
|
825 |
+
<div class="score-fill-wide {fill_class}" style="width: {score_width:.1f}%"></div>
|
826 |
+
</div>
|
827 |
+
</div>
|
828 |
+
</div>"""
|
829 |
+
|
830 |
+
def generate_tooltips_section(self) -> str:
|
831 |
+
"""生成提示氣泡HTML"""
|
832 |
+
return '''
|
833 |
+
<span class="tooltip">
|
834 |
+
<span class="tooltip-icon">ⓘ</span>
|
835 |
+
<span class="tooltip-text">
|
836 |
+
<strong>Space Compatibility Score:</strong><br>
|
837 |
+
• Evaluates how well the breed adapts to your living environment<br>
|
838 |
+
• Considers if your home (apartment/house) and yard access suit the breed's size<br>
|
839 |
+
• Higher score means the breed fits well in your available space.
|
840 |
+
</span>
|
841 |
+
</span>'''
|
842 |
+
|
843 |
+
def generate_detailed_sections_html(self, breed: str, info: dict,
|
844 |
+
noise_characteristics: List[str],
|
845 |
+
barking_triggers: List[str],
|
846 |
+
noise_level: str,
|
847 |
+
health_considerations: List[str],
|
848 |
+
health_screenings: List[str]) -> str:
|
849 |
+
"""生成詳細區段的HTML"""
|
850 |
+
# 生成特徵和觸發因素的HTML
|
851 |
+
noise_characteristics_html = '\n'.join([f'<li>{item}</li>' for item in noise_characteristics])
|
852 |
+
barking_triggers_html = '\n'.join([f'<li>{item}</li>' for item in barking_triggers])
|
853 |
+
health_considerations_html = '\n'.join([f'<li>{item}</li>' for item in health_considerations])
|
854 |
+
health_screenings_html = '\n'.join([f'<li>{item}</li>' for item in health_screenings])
|
855 |
+
|
856 |
+
return f"""
|
857 |
+
<div class="breed-details-section">
|
858 |
+
<h3 class="subsection-title">
|
859 |
+
<span class="icon">📋</span> Breed Details
|
860 |
+
</h3>
|
861 |
+
<div class="details-grid">
|
862 |
+
<div class="detail-item">
|
863 |
+
<span class="tooltip">
|
864 |
+
<span class="icon">📏</span>
|
865 |
+
<span class="label">Size:</span>
|
866 |
+
<span class="tooltip-icon">ⓘ</span>
|
867 |
+
<span class="tooltip-text">
|
868 |
+
<strong>Size Categories:</strong><br>
|
869 |
+
• Small: Under 20 pounds<br>
|
870 |
+
• Medium: 20-60 pounds<br>
|
871 |
+
• Large: Over 60 pounds
|
872 |
+
</span>
|
873 |
+
<span class="value">{info['Size']}</span>
|
874 |
+
</span>
|
875 |
+
</div>
|
876 |
+
<div class="detail-item">
|
877 |
+
<span class="tooltip">
|
878 |
+
<span class="icon">🏃</span>
|
879 |
+
<span class="label">Exercise Needs:</span>
|
880 |
+
<span class="tooltip-icon">ⓘ</span>
|
881 |
+
<span class="tooltip-text">
|
882 |
+
<strong>Exercise Needs:</strong><br>
|
883 |
+
• Low: Short walks<br>
|
884 |
+
• Moderate: 1-2 hours daily<br>
|
885 |
+
• High: 2+ hours daily<br>
|
886 |
+
• Very High: Constant activity
|
887 |
+
</span>
|
888 |
+
<span class="value">{info['Exercise Needs']}</span>
|
889 |
+
</span>
|
890 |
+
</div>
|
891 |
+
<div class="detail-item">
|
892 |
+
<span class="tooltip">
|
893 |
+
<span class="icon">👨👩👧👦</span>
|
894 |
+
<span class="label">Good with Children:</span>
|
895 |
+
<span class="tooltip-icon">ⓘ</span>
|
896 |
+
<span class="tooltip-text">
|
897 |
+
<strong>Child Compatibility:</strong><br>
|
898 |
+
• Yes: Excellent with kids<br>
|
899 |
+
• Moderate: Good with older children<br>
|
900 |
+
• No: Better for adult households
|
901 |
+
</span>
|
902 |
+
<span class="value">{info['Good with Children']}</span>
|
903 |
+
</span>
|
904 |
+
</div>
|
905 |
+
<div class="detail-item">
|
906 |
+
<span class="tooltip">
|
907 |
+
<span class="icon">⏳</span>
|
908 |
+
<span class="label">Lifespan:</span>
|
909 |
+
<span class="tooltip-icon">ⓘ</span>
|
910 |
+
<span class="tooltip-text">
|
911 |
+
<strong>Average Lifespan:</strong><br>
|
912 |
+
• Short: 6-8 years<br>
|
913 |
+
• Average: 10-15 years<br>
|
914 |
+
• Long: 12-20 years<br>
|
915 |
+
• Varies by size: Larger breeds typically have shorter lifespans
|
916 |
+
</span>
|
917 |
+
</span>
|
918 |
+
<span class="value">{info['Lifespan']}</span>
|
919 |
+
</div>
|
920 |
+
</div>
|
921 |
+
</div>
|
922 |
+
<div class="description-section">
|
923 |
+
<h3 class="subsection-title">
|
924 |
+
<span class="icon">📝</span> Description
|
925 |
+
</h3>
|
926 |
+
<p class="description-text">{info.get('Description', '')}</p>
|
927 |
+
</div>
|
928 |
+
<div class="noise-section">
|
929 |
+
<h3 class="section-header">
|
930 |
+
<span class="icon">🔊</span> Noise Behavior
|
931 |
+
<span class="tooltip">
|
932 |
+
<span class="tooltip-icon">ⓘ</span>
|
933 |
+
<span class="tooltip-text">
|
934 |
+
<strong>Noise Behavior:</strong><br>
|
935 |
+
• Typical vocalization patterns<br>
|
936 |
+
• Common triggers and frequency<br>
|
937 |
+
• Based on breed characteristics
|
938 |
+
</span>
|
939 |
+
</span>
|
940 |
+
</h3>
|
941 |
+
<div class="noise-info">
|
942 |
+
<div class="noise-details">
|
943 |
+
<h4 class="section-header">Typical noise characteristics:</h4>
|
944 |
+
<div class="characteristics-list">
|
945 |
+
<div class="list-item">Moderate to high barker</div>
|
946 |
+
<div class="list-item">Alert watch dog</div>
|
947 |
+
<div class="list-item">Attention-seeking barks</div>
|
948 |
+
<div class="list-item">Social vocalizations</div>
|
949 |
+
</div>
|
950 |
+
<div class="noise-level-display">
|
951 |
+
<h4 class="section-header">Noise level:</h4>
|
952 |
+
<div class="level-indicator">
|
953 |
+
<span class="level-text">Moderate-High</span>
|
954 |
+
<div class="level-bars">
|
955 |
+
<span class="bar"></span>
|
956 |
+
<span class="bar"></span>
|
957 |
+
<span class="bar"></span>
|
958 |
+
</div>
|
959 |
+
</div>
|
960 |
+
</div>
|
961 |
+
<h4 class="section-header">Barking triggers:</h4>
|
962 |
+
<div class="triggers-list">
|
963 |
+
<div class="list-item">Separation anxiety</div>
|
964 |
+
<div class="list-item">Attention needs</div>
|
965 |
+
<div class="list-item">Strange noises</div>
|
966 |
+
<div class="list-item">Excitement</div>
|
967 |
+
</div>
|
968 |
+
</div>
|
969 |
+
<div class="noise-disclaimer">
|
970 |
+
<p class="disclaimer-text source-text">Source: Compiled from various breed behavior resources, 2024</p>
|
971 |
+
<p class="disclaimer-text">Individual dogs may vary in their vocalization patterns.</p>
|
972 |
+
<p class="disclaimer-text">Training can significantly influence barking behavior.</p>
|
973 |
+
<p class="disclaimer-text">Environmental factors may affect noise levels.</p>
|
974 |
+
</div>
|
975 |
+
</div>
|
976 |
+
</div>
|
977 |
+
<div class="health-section">
|
978 |
+
<h3 class="section-header">
|
979 |
+
<span class="icon">🏥</span> Health Insights
|
980 |
+
<span class="tooltip">
|
981 |
+
<span class="tooltip-icon">ⓘ</span>
|
982 |
+
<span class="tooltip-text">
|
983 |
+
Health information is compiled from multiple sources including veterinary resources, breed guides, and international canine health databases.
|
984 |
+
Each dog is unique and may vary from these general guidelines.
|
985 |
+
</span>
|
986 |
+
</span>
|
987 |
+
</h3>
|
988 |
+
<div class="health-info">
|
989 |
+
<div class="health-details">
|
990 |
+
<div class="health-block">
|
991 |
+
<h4 class="section-header">Common breed-specific health considerations:</h4>
|
992 |
+
<div class="health-grid">
|
993 |
+
<div class="health-item">Patellar luxation</div>
|
994 |
+
<div class="health-item">Progressive retinal atrophy</div>
|
995 |
+
<div class="health-item">Von Willebrand's disease</div>
|
996 |
+
<div class="health-item">Open fontanel</div>
|
997 |
+
</div>
|
998 |
+
</div>
|
999 |
+
<div class="health-block">
|
1000 |
+
<h4 class="section-header">Recommended health screenings:</h4>
|
1001 |
+
<div class="health-grid">
|
1002 |
+
<div class="health-item screening">Patella evaluation</div>
|
1003 |
+
<div class="health-item screening">Eye examination</div>
|
1004 |
+
<div class="health-item screening">Blood clotting tests</div>
|
1005 |
+
<div class="health-item screening">Skull development monitoring</div>
|
1006 |
+
</div>
|
1007 |
+
</div>
|
1008 |
+
</div>
|
1009 |
+
<div class="health-disclaimer">
|
1010 |
+
<p class="disclaimer-text source-text">Source: Compiled from various veterinary and breed information resources, 2024</p>
|
1011 |
+
<p class="disclaimer-text">This information is for reference only and based on breed tendencies.</p>
|
1012 |
+
<p class="disclaimer-text">Each dog is unique and may not develop any or all of these conditions.</p>
|
1013 |
+
<p class="disclaimer-text">Always consult with qualified veterinarians for professional advice.</p>
|
1014 |
+
</div>
|
1015 |
+
</div>
|
1016 |
+
</div>
|
1017 |
+
<div class="action-section">
|
1018 |
+
<a href="https://www.akc.org/dog-breeds/{breed.lower().replace('_', '-')}/"
|
1019 |
+
target="_blank"
|
1020 |
+
class="akc-button">
|
1021 |
+
<span class="icon">🌐</span>
|
1022 |
+
Learn more about {breed.replace('_', ' ')} on AKC website
|
1023 |
+
</a>
|
1024 |
+
</div>
|
1025 |
+
"""
|
score_calibrator.py
ADDED
@@ -0,0 +1,477 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
import numpy as np
|
2 |
+
from typing import List, Dict, Tuple, Any, Optional
|
3 |
+
from dataclasses import dataclass, field
|
4 |
+
import traceback
|
5 |
+
from scipy import stats
|
6 |
+
|
7 |
+
@dataclass
|
8 |
+
class CalibrationResult:
|
9 |
+
"""校準結果結構"""
|
10 |
+
original_scores: List[float]
|
11 |
+
calibrated_scores: List[float]
|
12 |
+
score_mapping: Dict[str, float] # breed -> calibrated_score
|
13 |
+
calibration_method: str
|
14 |
+
distribution_stats: Dict[str, float]
|
15 |
+
quality_metrics: Dict[str, float] = field(default_factory=dict)
|
16 |
+
|
17 |
+
@dataclass
|
18 |
+
class ScoreDistribution:
|
19 |
+
"""分數分布統計"""
|
20 |
+
mean: float
|
21 |
+
std: float
|
22 |
+
min_score: float
|
23 |
+
max_score: float
|
24 |
+
percentile_5: float
|
25 |
+
percentile_95: float
|
26 |
+
compression_ratio: float # 分數壓縮比率
|
27 |
+
effective_range: float # 有效分數範圍
|
28 |
+
|
29 |
+
class ScoreCalibrator:
|
30 |
+
"""
|
31 |
+
動態分數校準系統
|
32 |
+
解決分數壓縮問題並保持相對排名
|
33 |
+
"""
|
34 |
+
|
35 |
+
def __init__(self):
|
36 |
+
"""初始化校準器"""
|
37 |
+
self.calibration_methods = {
|
38 |
+
'dynamic_range_mapping': self._dynamic_range_mapping,
|
39 |
+
'percentile_stretching': self._percentile_stretching,
|
40 |
+
'gaussian_normalization': self._gaussian_normalization,
|
41 |
+
'sigmoid_transformation': self._sigmoid_transformation
|
42 |
+
}
|
43 |
+
self.quality_thresholds = {
|
44 |
+
'min_effective_range': 0.3, # 最小有效分數範圍
|
45 |
+
'max_compression_ratio': 0.2, # 最大允許壓縮比率
|
46 |
+
'target_distribution_range': (0.45, 0.95) # 目標分布範圍
|
47 |
+
}
|
48 |
+
|
49 |
+
def calibrate_scores(self, breed_scores: List[Tuple[str, float]],
|
50 |
+
method: str = 'auto') -> CalibrationResult:
|
51 |
+
"""
|
52 |
+
校準品種分數
|
53 |
+
|
54 |
+
Args:
|
55 |
+
breed_scores: (breed_name, score) 元組列表
|
56 |
+
method: 校準方法 ('auto', 'dynamic_range_mapping', 'percentile_stretching', etc.)
|
57 |
+
|
58 |
+
Returns:
|
59 |
+
CalibrationResult: 校準結果
|
60 |
+
"""
|
61 |
+
try:
|
62 |
+
if not breed_scores:
|
63 |
+
return CalibrationResult(
|
64 |
+
original_scores=[],
|
65 |
+
calibrated_scores=[],
|
66 |
+
score_mapping={},
|
67 |
+
calibration_method='none',
|
68 |
+
distribution_stats={}
|
69 |
+
)
|
70 |
+
|
71 |
+
# 提取分數和品種名稱
|
72 |
+
breeds = [item[0] for item in breed_scores]
|
73 |
+
original_scores = [item[1] for item in breed_scores]
|
74 |
+
|
75 |
+
# 分析原始分數分布
|
76 |
+
distribution = self._analyze_score_distribution(original_scores)
|
77 |
+
|
78 |
+
# 選擇校準方法
|
79 |
+
if method == 'auto':
|
80 |
+
method = self._select_calibration_method(distribution)
|
81 |
+
|
82 |
+
# 應用校準
|
83 |
+
calibration_func = self.calibration_methods.get(method, self._dynamic_range_mapping)
|
84 |
+
calibrated_scores = calibration_func(original_scores, distribution)
|
85 |
+
|
86 |
+
# 保持排名一致性
|
87 |
+
calibrated_scores = self._preserve_ranking(original_scores, calibrated_scores)
|
88 |
+
|
89 |
+
# 建立分數映射
|
90 |
+
score_mapping = dict(zip(breeds, calibrated_scores))
|
91 |
+
|
92 |
+
# 計算品質指標
|
93 |
+
quality_metrics = self._calculate_quality_metrics(
|
94 |
+
original_scores, calibrated_scores, distribution
|
95 |
+
)
|
96 |
+
|
97 |
+
return CalibrationResult(
|
98 |
+
original_scores=original_scores,
|
99 |
+
calibrated_scores=calibrated_scores,
|
100 |
+
score_mapping=score_mapping,
|
101 |
+
calibration_method=method,
|
102 |
+
distribution_stats=self._distribution_to_dict(distribution),
|
103 |
+
quality_metrics=quality_metrics
|
104 |
+
)
|
105 |
+
|
106 |
+
except Exception as e:
|
107 |
+
print(f"Error calibrating scores: {str(e)}")
|
108 |
+
print(traceback.format_exc())
|
109 |
+
# 回傳原始分數作為降級方案
|
110 |
+
breeds = [item[0] for item in breed_scores]
|
111 |
+
original_scores = [item[1] for item in breed_scores]
|
112 |
+
return CalibrationResult(
|
113 |
+
original_scores=original_scores,
|
114 |
+
calibrated_scores=original_scores,
|
115 |
+
score_mapping=dict(zip(breeds, original_scores)),
|
116 |
+
calibration_method='fallback',
|
117 |
+
distribution_stats={}
|
118 |
+
)
|
119 |
+
|
120 |
+
def _analyze_score_distribution(self, scores: List[float]) -> ScoreDistribution:
|
121 |
+
"""分析分數分布"""
|
122 |
+
try:
|
123 |
+
scores_array = np.array(scores)
|
124 |
+
|
125 |
+
# 基本統計
|
126 |
+
mean_score = np.mean(scores_array)
|
127 |
+
std_score = np.std(scores_array)
|
128 |
+
min_score = np.min(scores_array)
|
129 |
+
max_score = np.max(scores_array)
|
130 |
+
|
131 |
+
# 百分位數
|
132 |
+
percentile_5 = np.percentile(scores_array, 5)
|
133 |
+
percentile_95 = np.percentile(scores_array, 95)
|
134 |
+
|
135 |
+
# 壓縮比率和有效範圍
|
136 |
+
full_range = max_score - min_score
|
137 |
+
effective_range = percentile_95 - percentile_5
|
138 |
+
compression_ratio = 1.0 - (effective_range / 1.0) if full_range > 0 else 0.0
|
139 |
+
|
140 |
+
return ScoreDistribution(
|
141 |
+
mean=mean_score,
|
142 |
+
std=std_score,
|
143 |
+
min_score=min_score,
|
144 |
+
max_score=max_score,
|
145 |
+
percentile_5=percentile_5,
|
146 |
+
percentile_95=percentile_95,
|
147 |
+
compression_ratio=compression_ratio,
|
148 |
+
effective_range=effective_range
|
149 |
+
)
|
150 |
+
|
151 |
+
except Exception as e:
|
152 |
+
print(f"Error analyzing score distribution: {str(e)}")
|
153 |
+
# 返回預設分布
|
154 |
+
return ScoreDistribution(
|
155 |
+
mean=0.5, std=0.1, min_score=0.0, max_score=1.0,
|
156 |
+
percentile_5=0.4, percentile_95=0.6,
|
157 |
+
compression_ratio=0.6, effective_range=0.2
|
158 |
+
)
|
159 |
+
|
160 |
+
def _select_calibration_method(self, distribution: ScoreDistribution) -> str:
|
161 |
+
"""根據分布特性選擇校準方法"""
|
162 |
+
# 高度壓縮的分數需要強力展開
|
163 |
+
if distribution.compression_ratio > 0.8:
|
164 |
+
return 'percentile_stretching'
|
165 |
+
|
166 |
+
# 中等壓縮使用動態範圍映射
|
167 |
+
elif distribution.compression_ratio > 0.5:
|
168 |
+
return 'dynamic_range_mapping'
|
169 |
+
|
170 |
+
# 分數集中在中間使用 sigmoid 轉換
|
171 |
+
elif 0.4 <= distribution.mean <= 0.6 and distribution.std < 0.1:
|
172 |
+
return 'sigmoid_transformation'
|
173 |
+
|
174 |
+
# 其他情況使用高斯正規化
|
175 |
+
else:
|
176 |
+
return 'gaussian_normalization'
|
177 |
+
|
178 |
+
def _dynamic_range_mapping(self, scores: List[float],
|
179 |
+
distribution: ScoreDistribution) -> List[float]:
|
180 |
+
"""動態範圍映射校準"""
|
181 |
+
try:
|
182 |
+
scores_array = np.array(scores)
|
183 |
+
|
184 |
+
# 使用5%和95%百分位數作為邊界
|
185 |
+
lower_bound = distribution.percentile_5
|
186 |
+
upper_bound = distribution.percentile_95
|
187 |
+
|
188 |
+
# 避免除零
|
189 |
+
if upper_bound - lower_bound < 0.001:
|
190 |
+
upper_bound = distribution.max_score
|
191 |
+
lower_bound = distribution.min_score
|
192 |
+
if upper_bound - lower_bound < 0.001:
|
193 |
+
return scores # 所有分數相同,無需校準
|
194 |
+
|
195 |
+
# 映射到目標範圍 [0.45, 0.95]
|
196 |
+
target_min, target_max = self.quality_thresholds['target_distribution_range']
|
197 |
+
|
198 |
+
# 線性映射
|
199 |
+
normalized = (scores_array - lower_bound) / (upper_bound - lower_bound)
|
200 |
+
normalized = np.clip(normalized, 0, 1) # 限制在 [0,1] 範圍
|
201 |
+
calibrated = target_min + normalized * (target_max - target_min)
|
202 |
+
|
203 |
+
return calibrated.tolist()
|
204 |
+
|
205 |
+
except Exception as e:
|
206 |
+
print(f"Error in dynamic range mapping: {str(e)}")
|
207 |
+
return scores
|
208 |
+
|
209 |
+
def _percentile_stretching(self, scores: List[float],
|
210 |
+
distribution: ScoreDistribution) -> List[float]:
|
211 |
+
"""百分位數拉伸校準"""
|
212 |
+
try:
|
213 |
+
scores_array = np.array(scores)
|
214 |
+
|
215 |
+
# 計算百分位數排名
|
216 |
+
percentile_ranks = stats.rankdata(scores_array, method='average') / len(scores_array)
|
217 |
+
|
218 |
+
# 使用平方根轉換來增強差異
|
219 |
+
stretched_ranks = np.sqrt(percentile_ranks)
|
220 |
+
|
221 |
+
# 映射到目標範圍
|
222 |
+
target_min, target_max = self.quality_thresholds['target_distribution_range']
|
223 |
+
calibrated = target_min + stretched_ranks * (target_max - target_min)
|
224 |
+
|
225 |
+
return calibrated.tolist()
|
226 |
+
|
227 |
+
except Exception as e:
|
228 |
+
print(f"Error in percentile stretching: {str(e)}")
|
229 |
+
return self._dynamic_range_mapping(scores, distribution)
|
230 |
+
|
231 |
+
def _gaussian_normalization(self, scores: List[float],
|
232 |
+
distribution: ScoreDistribution) -> List[float]:
|
233 |
+
"""高斯正規化校準"""
|
234 |
+
try:
|
235 |
+
scores_array = np.array(scores)
|
236 |
+
|
237 |
+
# Z-score 正規化
|
238 |
+
if distribution.std > 0:
|
239 |
+
z_scores = (scores_array - distribution.mean) / distribution.std
|
240 |
+
# 限制 Z-scores 在合理範圍內
|
241 |
+
z_scores = np.clip(z_scores, -3, 3)
|
242 |
+
else:
|
243 |
+
z_scores = np.zeros_like(scores_array)
|
244 |
+
|
245 |
+
# 轉換到目標範圍
|
246 |
+
target_min, target_max = self.quality_thresholds['target_distribution_range']
|
247 |
+
target_mean = (target_min + target_max) / 2
|
248 |
+
target_std = (target_max - target_min) / 6 # 3-sigma 範圍
|
249 |
+
|
250 |
+
calibrated = target_mean + z_scores * target_std
|
251 |
+
calibrated = np.clip(calibrated, target_min, target_max)
|
252 |
+
|
253 |
+
return calibrated.tolist()
|
254 |
+
|
255 |
+
except Exception as e:
|
256 |
+
print(f"Error in gaussian normalization: {str(e)}")
|
257 |
+
return self._dynamic_range_mapping(scores, distribution)
|
258 |
+
|
259 |
+
def _sigmoid_transformation(self, scores: List[float],
|
260 |
+
distribution: ScoreDistribution) -> List[float]:
|
261 |
+
"""Sigmoid 轉換校準"""
|
262 |
+
try:
|
263 |
+
scores_array = np.array(scores)
|
264 |
+
|
265 |
+
# 中心化分數
|
266 |
+
centered = scores_array - distribution.mean
|
267 |
+
|
268 |
+
# Sigmoid 轉換 (增強中等分數的差異)
|
269 |
+
sigmoid_factor = 10.0 # 控制 sigmoid 的陡峭程度
|
270 |
+
transformed = 1 / (1 + np.exp(-sigmoid_factor * centered))
|
271 |
+
|
272 |
+
# 映射到目標範圍
|
273 |
+
target_min, target_max = self.quality_thresholds['target_distribution_range']
|
274 |
+
calibrated = target_min + transformed * (target_max - target_min)
|
275 |
+
|
276 |
+
return calibrated.tolist()
|
277 |
+
|
278 |
+
except Exception as e:
|
279 |
+
print(f"Error in sigmoid transformation: {str(e)}")
|
280 |
+
return self._dynamic_range_mapping(scores, distribution)
|
281 |
+
|
282 |
+
def _preserve_ranking(self, original_scores: List[float],
|
283 |
+
calibrated_scores: List[float]) -> List[float]:
|
284 |
+
"""確保校準後的分數保持原始排名"""
|
285 |
+
try:
|
286 |
+
# 獲取原始排名
|
287 |
+
original_ranks = stats.rankdata([-score for score in original_scores], method='ordinal')
|
288 |
+
|
289 |
+
# 獲取校準後的排名
|
290 |
+
calibrated_with_ranks = list(zip(calibrated_scores, original_ranks))
|
291 |
+
|
292 |
+
# 按原始排名排序校準後的分數
|
293 |
+
calibrated_with_ranks.sort(key=lambda x: x[1])
|
294 |
+
|
295 |
+
# 重新分配分數以保持排名但使用校準後的分布
|
296 |
+
sorted_calibrated = sorted(calibrated_scores, reverse=True)
|
297 |
+
|
298 |
+
# 建立新的分數列表
|
299 |
+
preserved_scores = [0.0] * len(original_scores)
|
300 |
+
for i, (_, original_rank) in enumerate(calibrated_with_ranks):
|
301 |
+
# 找到原始位置
|
302 |
+
original_index = original_ranks.tolist().index(original_rank)
|
303 |
+
preserved_scores[original_index] = sorted_calibrated[i]
|
304 |
+
|
305 |
+
return preserved_scores
|
306 |
+
|
307 |
+
except Exception as e:
|
308 |
+
print(f"Error preserving ranking: {str(e)}")
|
309 |
+
return calibrated_scores
|
310 |
+
|
311 |
+
def _calculate_quality_metrics(self, original_scores: List[float],
|
312 |
+
calibrated_scores: List[float],
|
313 |
+
distribution: ScoreDistribution) -> Dict[str, float]:
|
314 |
+
"""計算校準品質指標"""
|
315 |
+
try:
|
316 |
+
original_array = np.array(original_scores)
|
317 |
+
calibrated_array = np.array(calibrated_scores)
|
318 |
+
|
319 |
+
# 範圍改善
|
320 |
+
original_range = np.max(original_array) - np.min(original_array)
|
321 |
+
calibrated_range = np.max(calibrated_array) - np.min(calibrated_array)
|
322 |
+
range_improvement = calibrated_range / max(0.001, original_range)
|
323 |
+
|
324 |
+
# 分離度改善 (相鄰分數間的平均差異)
|
325 |
+
original_sorted = np.sort(original_array)
|
326 |
+
calibrated_sorted = np.sort(calibrated_array)
|
327 |
+
|
328 |
+
original_separation = np.mean(np.diff(original_sorted)) if len(original_sorted) > 1 else 0
|
329 |
+
calibrated_separation = np.mean(np.diff(calibrated_sorted)) if len(calibrated_sorted) > 1 else 0
|
330 |
+
|
331 |
+
separation_improvement = (calibrated_separation / max(0.001, original_separation)
|
332 |
+
if original_separation > 0 else 1.0)
|
333 |
+
|
334 |
+
# 排名保持度 (Spearman 相關係數)
|
335 |
+
if len(original_scores) > 1:
|
336 |
+
rank_correlation, _ = stats.spearmanr(original_scores, calibrated_scores)
|
337 |
+
rank_correlation = abs(rank_correlation) if not np.isnan(rank_correlation) else 1.0
|
338 |
+
else:
|
339 |
+
rank_correlation = 1.0
|
340 |
+
|
341 |
+
# 分布品質
|
342 |
+
calibrated_std = np.std(calibrated_array)
|
343 |
+
distribution_quality = min(1.0, calibrated_std * 2) # 標準差越大品質越好(在合理範圍內)
|
344 |
+
|
345 |
+
return {
|
346 |
+
'range_improvement': range_improvement,
|
347 |
+
'separation_improvement': separation_improvement,
|
348 |
+
'rank_preservation': rank_correlation,
|
349 |
+
'distribution_quality': distribution_quality,
|
350 |
+
'effective_range_achieved': calibrated_range,
|
351 |
+
'compression_reduction': max(0, distribution.compression_ratio -
|
352 |
+
(1.0 - calibrated_range))
|
353 |
+
}
|
354 |
+
|
355 |
+
except Exception as e:
|
356 |
+
print(f"Error calculating quality metrics: {str(e)}")
|
357 |
+
return {'error': str(e)}
|
358 |
+
|
359 |
+
def _distribution_to_dict(self, distribution: ScoreDistribution) -> Dict[str, float]:
|
360 |
+
"""將分布統計轉換為字典"""
|
361 |
+
return {
|
362 |
+
'mean': distribution.mean,
|
363 |
+
'std': distribution.std,
|
364 |
+
'min_score': distribution.min_score,
|
365 |
+
'max_score': distribution.max_score,
|
366 |
+
'percentile_5': distribution.percentile_5,
|
367 |
+
'percentile_95': distribution.percentile_95,
|
368 |
+
'compression_ratio': distribution.compression_ratio,
|
369 |
+
'effective_range': distribution.effective_range
|
370 |
+
}
|
371 |
+
|
372 |
+
def apply_tie_breaking(self, breed_scores: List[Tuple[str, float]]) -> List[Tuple[str, float]]:
|
373 |
+
"""應用確定性的打破平手機制"""
|
374 |
+
try:
|
375 |
+
# 按分數分組
|
376 |
+
score_groups = {}
|
377 |
+
for breed, score in breed_scores:
|
378 |
+
rounded_score = round(score, 6) # 避免浮點數精度問題
|
379 |
+
if rounded_score not in score_groups:
|
380 |
+
score_groups[rounded_score] = []
|
381 |
+
score_groups[rounded_score].append((breed, score))
|
382 |
+
|
383 |
+
# 處理每個分數組
|
384 |
+
result = []
|
385 |
+
for rounded_score in sorted(score_groups.keys(), reverse=True):
|
386 |
+
group = score_groups[rounded_score]
|
387 |
+
|
388 |
+
if len(group) == 1:
|
389 |
+
result.extend(group)
|
390 |
+
else:
|
391 |
+
# 按品種名稱字母順序打破平手
|
392 |
+
sorted_group = sorted(group, key=lambda x: x[0])
|
393 |
+
|
394 |
+
# 為平手的品種分配微小的分數差異
|
395 |
+
for i, (breed, original_score) in enumerate(sorted_group):
|
396 |
+
adjusted_score = original_score - (i * 0.0001)
|
397 |
+
result.append((breed, adjusted_score))
|
398 |
+
|
399 |
+
return result
|
400 |
+
|
401 |
+
except Exception as e:
|
402 |
+
print(f"Error in tie breaking: {str(e)}")
|
403 |
+
return breed_scores
|
404 |
+
|
405 |
+
def get_calibration_summary(self, result: CalibrationResult) -> Dict[str, Any]:
|
406 |
+
"""獲取校準摘要資訊"""
|
407 |
+
try:
|
408 |
+
summary = {
|
409 |
+
'method_used': result.calibration_method,
|
410 |
+
'breeds_processed': len(result.original_scores),
|
411 |
+
'score_range_before': {
|
412 |
+
'min': min(result.original_scores) if result.original_scores else 0,
|
413 |
+
'max': max(result.original_scores) if result.original_scores else 0,
|
414 |
+
'range': (max(result.original_scores) - min(result.original_scores))
|
415 |
+
if result.original_scores else 0
|
416 |
+
},
|
417 |
+
'score_range_after': {
|
418 |
+
'min': min(result.calibrated_scores) if result.calibrated_scores else 0,
|
419 |
+
'max': max(result.calibrated_scores) if result.calibrated_scores else 0,
|
420 |
+
'range': (max(result.calibrated_scores) - min(result.calibrated_scores))
|
421 |
+
if result.calibrated_scores else 0
|
422 |
+
},
|
423 |
+
'distribution_stats': result.distribution_stats,
|
424 |
+
'quality_metrics': result.quality_metrics,
|
425 |
+
'improvement_summary': {
|
426 |
+
'range_expanded': result.quality_metrics.get('range_improvement', 1.0) > 1.1,
|
427 |
+
'separation_improved': result.quality_metrics.get('separation_improvement', 1.0) > 1.1,
|
428 |
+
'ranking_preserved': result.quality_metrics.get('rank_preservation', 1.0) > 0.95
|
429 |
+
}
|
430 |
+
}
|
431 |
+
|
432 |
+
return summary
|
433 |
+
|
434 |
+
except Exception as e:
|
435 |
+
print(f"Error generating calibration summary: {str(e)}")
|
436 |
+
return {'error': str(e)}
|
437 |
+
|
438 |
+
def calibrate_breed_scores(breed_scores: List[Tuple[str, float]],
|
439 |
+
method: str = 'auto') -> CalibrationResult:
|
440 |
+
"""
|
441 |
+
便利函數:校準品種分數
|
442 |
+
|
443 |
+
Args:
|
444 |
+
breed_scores: (breed_name, score) 元組列表
|
445 |
+
method: 校準方法
|
446 |
+
|
447 |
+
Returns:
|
448 |
+
CalibrationResult: 校準結果
|
449 |
+
"""
|
450 |
+
calibrator = ScoreCalibrator()
|
451 |
+
return calibrator.calibrate_scores(breed_scores, method)
|
452 |
+
|
453 |
+
def get_calibrated_rankings(breed_scores: List[Tuple[str, float]],
|
454 |
+
method: str = 'auto') -> List[Tuple[str, float, int]]:
|
455 |
+
"""
|
456 |
+
便利函數:獲取校準後的排名
|
457 |
+
|
458 |
+
Args:
|
459 |
+
breed_scores: (breed_name, score) 元組列表
|
460 |
+
method: 校準方法
|
461 |
+
|
462 |
+
Returns:
|
463 |
+
List[Tuple[str, float, int]]: (breed_name, calibrated_score, rank) 列表
|
464 |
+
"""
|
465 |
+
calibrator = ScoreCalibrator()
|
466 |
+
result = calibrator.calibrate_scores(breed_scores, method)
|
467 |
+
|
468 |
+
# 打破平手機制
|
469 |
+
calibrated_with_breed = [(breed, result.score_mapping[breed]) for breed in result.score_mapping]
|
470 |
+
calibrated_with_tie_breaking = calibrator.apply_tie_breaking(calibrated_with_breed)
|
471 |
+
|
472 |
+
# 添加排名
|
473 |
+
ranked_results = []
|
474 |
+
for rank, (breed, score) in enumerate(calibrated_with_tie_breaking, 1):
|
475 |
+
ranked_results.append((breed, score, rank))
|
476 |
+
|
477 |
+
return ranked_results
|
score_integration_manager.py
ADDED
@@ -0,0 +1,805 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
import math
|
2 |
+
import traceback
|
3 |
+
from typing import Dict, Any, List
|
4 |
+
from dataclasses import dataclass
|
5 |
+
|
6 |
+
@dataclass
|
7 |
+
class UserPreferences:
|
8 |
+
"""使用者偏好設定的資料結構"""
|
9 |
+
living_space: str # "apartment", "house_small", "house_large"
|
10 |
+
yard_access: str # "no_yard", "shared_yard", "private_yard"
|
11 |
+
exercise_time: int # minutes per day
|
12 |
+
exercise_type: str # "light_walks", "moderate_activity", "active_training"
|
13 |
+
grooming_commitment: str # "low", "medium", "high"
|
14 |
+
experience_level: str # "beginner", "intermediate", "advanced"
|
15 |
+
time_availability: str # "limited", "moderate", "flexible"
|
16 |
+
has_children: bool
|
17 |
+
children_age: str # "toddler", "school_age", "teenager"
|
18 |
+
noise_tolerance: str # "low", "medium", "high"
|
19 |
+
space_for_play: bool
|
20 |
+
other_pets: bool
|
21 |
+
climate: str # "cold", "moderate", "hot"
|
22 |
+
health_sensitivity: str = "medium"
|
23 |
+
barking_acceptance: str = None
|
24 |
+
size_preference: str = "no_preference" # "no_preference", "small", "medium", "large", "giant"
|
25 |
+
training_commitment: str = "medium" # "low", "medium", "high" - 訓練投入程度
|
26 |
+
living_environment: str = "ground_floor" # "ground_floor", "with_elevator", "walk_up" - 居住環境細節
|
27 |
+
|
28 |
+
def __post_init__(self):
|
29 |
+
if self.barking_acceptance is None:
|
30 |
+
self.barking_acceptance = self.noise_tolerance
|
31 |
+
|
32 |
+
|
33 |
+
class ScoreIntegrationManager:
|
34 |
+
"""
|
35 |
+
評分整合管理器類別
|
36 |
+
負責動態權重計算、評分整合和條件互動評估
|
37 |
+
"""
|
38 |
+
|
39 |
+
def __init__(self):
|
40 |
+
"""初始化評分整合管理器"""
|
41 |
+
pass
|
42 |
+
|
43 |
+
def apply_size_filter(self, breed_score: float, user_preference: str, breed_size: str) -> float:
|
44 |
+
"""
|
45 |
+
強過濾機制,基於用戶的體型偏好過濾品種
|
46 |
+
|
47 |
+
Parameters:
|
48 |
+
breed_score (float): 原始品種評分
|
49 |
+
user_preference (str): 用戶偏好的體型
|
50 |
+
breed_size (str): 品種的實際體型
|
51 |
+
|
52 |
+
Returns:
|
53 |
+
float: 過濾後的評分,如果體型不符合會返回 0
|
54 |
+
"""
|
55 |
+
if user_preference == "no_preference":
|
56 |
+
return breed_score
|
57 |
+
|
58 |
+
# 標準化 size 字串以進行比較
|
59 |
+
breed_size = breed_size.lower().strip()
|
60 |
+
user_preference = user_preference.lower().strip()
|
61 |
+
|
62 |
+
# 特殊處理 "varies" 的情況
|
63 |
+
if breed_size == "varies":
|
64 |
+
return breed_score * 0.5 # 給予一個折扣係數,因為不確定性
|
65 |
+
|
66 |
+
# 如果用戶有明確體型偏好但品種不符合,返回 0
|
67 |
+
if user_preference != breed_size:
|
68 |
+
return 0
|
69 |
+
|
70 |
+
return breed_score
|
71 |
+
|
72 |
+
def calculate_breed_compatibility_score(self, scores: dict, user_prefs: UserPreferences, breed_info: dict) -> float:
|
73 |
+
"""
|
74 |
+
計算品種相容性總分,完整實現原始版本的複雜邏輯:
|
75 |
+
1. 運動類型與時間的精確匹配
|
76 |
+
2. 進階使用者的專業需求
|
77 |
+
3. 空間利用的實際效果
|
78 |
+
4. 條件組合的嚴格評估
|
79 |
+
"""
|
80 |
+
def evaluate_perfect_conditions():
|
81 |
+
"""
|
82 |
+
評估條件匹配度,特別強化:
|
83 |
+
1. 運動類型與時間的綜合評估
|
84 |
+
2. 專業技能需求評估
|
85 |
+
3. 品種特性評估
|
86 |
+
"""
|
87 |
+
perfect_matches = {
|
88 |
+
'size_match': 0,
|
89 |
+
'exercise_match': 0,
|
90 |
+
'experience_match': 0,
|
91 |
+
'living_condition_match': 0,
|
92 |
+
'breed_trait_match': 0 # 新增品種特性匹配度
|
93 |
+
}
|
94 |
+
|
95 |
+
# 第一部分:運動需求評估
|
96 |
+
def evaluate_exercise_compatibility():
|
97 |
+
"""
|
98 |
+
評估運動需求的匹配度,特別關注:
|
99 |
+
1. 時間與強度的合理搭配
|
100 |
+
2. 不同品種的運動特性
|
101 |
+
3. 運動類型的適配性
|
102 |
+
|
103 |
+
這個函數就像是一個體育教練,需要根據每個"運動員"(狗品種)的特點,
|
104 |
+
為他們制定合適的訓練計劃。
|
105 |
+
"""
|
106 |
+
exercise_needs = breed_info.get('Exercise Needs', 'MODERATE').upper()
|
107 |
+
exercise_time = user_prefs.exercise_time
|
108 |
+
exercise_type = user_prefs.exercise_type
|
109 |
+
temperament = breed_info.get('Temperament', '').lower()
|
110 |
+
description = breed_info.get('Description', '').lower()
|
111 |
+
|
112 |
+
# 定義更精確的品種運動特性
|
113 |
+
breed_exercise_patterns = {
|
114 |
+
'sprint_type': { # 短跑型犬種,如 Whippet, Saluki
|
115 |
+
'identifiers': ['fast', 'speed', 'sprint', 'racing', 'coursing', 'sight hound'],
|
116 |
+
'ideal_exercise': {
|
117 |
+
'active_training': 1.0, # 完美匹配高強度訓練
|
118 |
+
'moderate_activity': 0.5, # 持續運動不是最佳選擇
|
119 |
+
'light_walks': 0.3 # 輕度運動效果很差
|
120 |
+
},
|
121 |
+
'time_ranges': {
|
122 |
+
'ideal': (30, 60), # 最適合的運動時間範圍
|
123 |
+
'acceptable': (20, 90), # 可以接受的時間範圍
|
124 |
+
'penalty_start': 90 # 開始給予懲罰的時間點
|
125 |
+
},
|
126 |
+
'penalty_rate': 0.8 # 超出範圍時的懲罰係數
|
127 |
+
},
|
128 |
+
'endurance_type': { # 耐力型犬種,如 Border Collie
|
129 |
+
'identifiers': ['herding', 'working', 'tireless', 'energetic', 'stamina', 'athletic'],
|
130 |
+
'ideal_exercise': {
|
131 |
+
'active_training': 0.9, # 高強度訓練很好
|
132 |
+
'moderate_activity': 1.0, # 持續運動是最佳選擇
|
133 |
+
'light_walks': 0.4 # 輕度運動不足
|
134 |
+
},
|
135 |
+
'time_ranges': {
|
136 |
+
'ideal': (90, 180), # 需要較長的運動時間
|
137 |
+
'acceptable': (60, 180),
|
138 |
+
'penalty_start': 60 # 運動時間過短會受罰
|
139 |
+
},
|
140 |
+
'penalty_rate': 0.7
|
141 |
+
},
|
142 |
+
'moderate_type': { # 一般活動型犬種,如 Labrador
|
143 |
+
'identifiers': ['friendly', 'playful', 'adaptable', 'versatile', 'companion'],
|
144 |
+
'ideal_exercise': {
|
145 |
+
'active_training': 0.8,
|
146 |
+
'moderate_activity': 1.0,
|
147 |
+
'light_walks': 0.6
|
148 |
+
},
|
149 |
+
'time_ranges': {
|
150 |
+
'ideal': (60, 120),
|
151 |
+
'acceptable': (45, 150),
|
152 |
+
'penalty_start': 150
|
153 |
+
},
|
154 |
+
'penalty_rate': 0.6
|
155 |
+
}
|
156 |
+
}
|
157 |
+
|
158 |
+
def determine_breed_type():
|
159 |
+
"""改進品種運動類型的判斷,更精確識別工作犬"""
|
160 |
+
# 優先檢查特殊運動類型的標識符
|
161 |
+
for breed_type, pattern in breed_exercise_patterns.items():
|
162 |
+
if any(identifier in temperament or identifier in description
|
163 |
+
for identifier in pattern['identifiers']):
|
164 |
+
return breed_type
|
165 |
+
|
166 |
+
# 改進:根據運動需求和工作犬特徵進行更細緻的判斷
|
167 |
+
if (exercise_needs in ['VERY HIGH', 'HIGH'] or
|
168 |
+
any(trait in temperament.lower() for trait in
|
169 |
+
['herding', 'working', 'intelligent', 'athletic', 'tireless'])):
|
170 |
+
if user_prefs.experience_level == 'advanced':
|
171 |
+
return 'endurance_type' # 優先判定為耐力型
|
172 |
+
elif exercise_needs == 'LOW':
|
173 |
+
return 'moderate_type'
|
174 |
+
|
175 |
+
return 'moderate_type'
|
176 |
+
|
177 |
+
def calculate_time_match(pattern):
|
178 |
+
"""
|
179 |
+
計算運動時間的匹配度。
|
180 |
+
這就像在判斷運動時間是否符合訓練計劃。
|
181 |
+
"""
|
182 |
+
ideal_min, ideal_max = pattern['time_ranges']['ideal']
|
183 |
+
accept_min, accept_max = pattern['time_ranges']['acceptable']
|
184 |
+
penalty_start = pattern['time_ranges']['penalty_start']
|
185 |
+
|
186 |
+
# 在理想範圍內
|
187 |
+
if ideal_min <= exercise_time <= ideal_max:
|
188 |
+
return 1.0
|
189 |
+
|
190 |
+
# 超出可接受範圍的嚴格懲罰
|
191 |
+
elif exercise_time < accept_min:
|
192 |
+
deficit = accept_min - exercise_time
|
193 |
+
return max(0.2, 1 - (deficit / accept_min) * 1.2)
|
194 |
+
elif exercise_time > accept_max:
|
195 |
+
excess = exercise_time - penalty_start
|
196 |
+
penalty = min(0.8, (excess / penalty_start) * pattern['penalty_rate'])
|
197 |
+
return max(0.2, 1 - penalty)
|
198 |
+
|
199 |
+
# 在可接受範圍但不在理想範圍
|
200 |
+
else:
|
201 |
+
if exercise_time < ideal_min:
|
202 |
+
progress = (exercise_time - accept_min) / (ideal_min - accept_min)
|
203 |
+
return 0.6 + (0.4 * progress)
|
204 |
+
else:
|
205 |
+
remaining = (accept_max - exercise_time) / (accept_max - ideal_max)
|
206 |
+
return 0.6 + (0.4 * remaining)
|
207 |
+
|
208 |
+
def apply_special_adjustments(time_score, type_score, breed_type, pattern):
|
209 |
+
"""
|
210 |
+
處理特殊情況,確保運動方式真正符合品種需求。
|
211 |
+
特別加強:
|
212 |
+
1. 短跑型犬種的長時間運動懲罰
|
213 |
+
2. 耐力型犬種的獎勵機制
|
214 |
+
3. 運動類型匹配的重要性
|
215 |
+
"""
|
216 |
+
# 短跑型品種的特殊處理
|
217 |
+
if breed_type == 'sprint_type':
|
218 |
+
if exercise_time > pattern['time_ranges']['penalty_start']:
|
219 |
+
# 加重長時間運動的懲罰
|
220 |
+
penalty_factor = min(0.8, (exercise_time - pattern['time_ranges']['penalty_start']) / 60)
|
221 |
+
time_score *= max(0.3, 1 - penalty_factor) # 最低降到0.3
|
222 |
+
# 運動類型不適合時的額外懲罰
|
223 |
+
if exercise_type != 'active_training':
|
224 |
+
type_score *= 0.3 # 更嚴重的懲罰
|
225 |
+
|
226 |
+
# 耐力型品種的特殊處理
|
227 |
+
elif breed_type == 'endurance_type':
|
228 |
+
if exercise_time < pattern['time_ranges']['penalty_start']:
|
229 |
+
time_score *= 0.5 # 維持運動不足的懲罰
|
230 |
+
elif exercise_time >= 150: # 新增:高運動量獎勵
|
231 |
+
if exercise_type in ['active_training', 'moderate_activity']:
|
232 |
+
time_bonus = min(0.3, (exercise_time - 150) / 150)
|
233 |
+
time_score = min(1.0, time_score * (1 + time_bonus))
|
234 |
+
type_score = min(1.0, type_score * 1.2)
|
235 |
+
|
236 |
+
# 運動強度不足的懲罰
|
237 |
+
if exercise_type == 'light_walks':
|
238 |
+
if exercise_time > 90:
|
239 |
+
type_score *= 0.4 # 加重懲罰
|
240 |
+
else:
|
241 |
+
type_score *= 0.5
|
242 |
+
|
243 |
+
return time_score, type_score
|
244 |
+
|
245 |
+
# 執行評估流程
|
246 |
+
breed_type = determine_breed_type()
|
247 |
+
pattern = breed_exercise_patterns[breed_type]
|
248 |
+
|
249 |
+
# 計算基礎分數
|
250 |
+
time_score = calculate_time_match(pattern)
|
251 |
+
type_score = pattern['ideal_exercise'].get(exercise_type, 0.5)
|
252 |
+
|
253 |
+
# 應用特殊調整
|
254 |
+
time_score, type_score = apply_special_adjustments(time_score, type_score, breed_type, pattern)
|
255 |
+
|
256 |
+
# 根據品種類型決定最終權重
|
257 |
+
if breed_type == 'sprint_type':
|
258 |
+
if exercise_time > pattern['time_ranges']['penalty_start']:
|
259 |
+
# 超時時更重視運動類型的匹配度
|
260 |
+
return (time_score * 0.3) + (type_score * 0.7)
|
261 |
+
else:
|
262 |
+
return (time_score * 0.5) + (type_score * 0.5)
|
263 |
+
elif breed_type == 'endurance_type':
|
264 |
+
if exercise_time < pattern['time_ranges']['penalty_start']:
|
265 |
+
# 時間不足時更重視時間因素
|
266 |
+
return (time_score * 0.7) + (type_score * 0.3)
|
267 |
+
else:
|
268 |
+
return (time_score * 0.6) + (type_score * 0.4)
|
269 |
+
else:
|
270 |
+
return (time_score * 0.5) + (type_score * 0.5)
|
271 |
+
|
272 |
+
# 第二部分:專業技能需求評估
|
273 |
+
def evaluate_expertise_requirements():
|
274 |
+
care_level = breed_info.get('Care Level', 'MODERATE').upper()
|
275 |
+
temperament = breed_info.get('Temperament', '').lower()
|
276 |
+
|
277 |
+
# 定義專業技能要求
|
278 |
+
expertise_requirements = {
|
279 |
+
'training_complexity': {
|
280 |
+
'VERY HIGH': {'beginner': 0.2, 'intermediate': 0.5, 'advanced': 0.9},
|
281 |
+
'HIGH': {'beginner': 0.3, 'intermediate': 0.7, 'advanced': 1.0},
|
282 |
+
'MODERATE': {'beginner': 0.6, 'intermediate': 0.9, 'advanced': 1.0},
|
283 |
+
'LOW': {'beginner': 0.9, 'intermediate': 0.95, 'advanced': 0.9}
|
284 |
+
},
|
285 |
+
'special_traits': {
|
286 |
+
'working': 0.2, # 工作犬需要額外技能
|
287 |
+
'herding': 0.2, # 牧羊犬需要特殊訓練
|
288 |
+
'intelligent': 0.15,# 高智商犬種需要心智刺激
|
289 |
+
'independent': 0.15,# 獨立性強的需要特殊處理
|
290 |
+
'protective': 0.1 # 護衛犬需要適當訓練
|
291 |
+
}
|
292 |
+
}
|
293 |
+
|
294 |
+
# 基礎分數
|
295 |
+
base_score = expertise_requirements['training_complexity'][care_level][user_prefs.experience_level]
|
296 |
+
|
297 |
+
# 特殊特徵評估
|
298 |
+
trait_penalty = 0
|
299 |
+
for trait, penalty in expertise_requirements['special_traits'].items():
|
300 |
+
if trait in temperament:
|
301 |
+
if user_prefs.experience_level == 'beginner':
|
302 |
+
trait_penalty += penalty
|
303 |
+
elif user_prefs.experience_level == 'advanced':
|
304 |
+
trait_penalty -= penalty * 0.5 # 專家反而因應對特殊特徵而加分
|
305 |
+
|
306 |
+
return max(0.2, min(1.0, base_score - trait_penalty))
|
307 |
+
|
308 |
+
def evaluate_living_conditions() -> float:
|
309 |
+
"""
|
310 |
+
評估生活環境適配性,特別加強:
|
311 |
+
1. 降低對大型犬的過度懲罰
|
312 |
+
2. 增加品種特性評估
|
313 |
+
3. 提升對適應性的重視度
|
314 |
+
"""
|
315 |
+
size = breed_info['Size']
|
316 |
+
exercise_needs = breed_info.get('Exercise Needs', 'MODERATE').upper()
|
317 |
+
temperament = breed_info.get('Temperament', '').lower()
|
318 |
+
description = breed_info.get('Description', '').lower()
|
319 |
+
|
320 |
+
# 重新定義空間需求矩陣,降低對大型犬的懲罰
|
321 |
+
space_requirements = {
|
322 |
+
'apartment': {
|
323 |
+
'Small': 1.0,
|
324 |
+
'Medium': 0.8,
|
325 |
+
'Large': 0.7,
|
326 |
+
'Giant': 0.6
|
327 |
+
},
|
328 |
+
'house_small': {
|
329 |
+
'Small': 0.9,
|
330 |
+
'Medium': 1.0,
|
331 |
+
'Large': 0.8,
|
332 |
+
'Giant': 0.7
|
333 |
+
},
|
334 |
+
'house_large': {
|
335 |
+
'Small': 0.8,
|
336 |
+
'Medium': 0.9,
|
337 |
+
'Large': 1.0,
|
338 |
+
'Giant': 1.0
|
339 |
+
}
|
340 |
+
}
|
341 |
+
|
342 |
+
# 基礎空間分數
|
343 |
+
space_score = space_requirements.get(
|
344 |
+
user_prefs.living_space,
|
345 |
+
space_requirements['house_small']
|
346 |
+
)[size]
|
347 |
+
|
348 |
+
# 品種適應性評估
|
349 |
+
adaptability_bonus = 0
|
350 |
+
adaptable_traits = ['adaptable', 'calm', 'quiet', 'gentle', 'laid-back']
|
351 |
+
challenging_traits = ['hyperactive', 'restless', 'requires space']
|
352 |
+
|
353 |
+
# 計算適應性加分
|
354 |
+
if user_prefs.living_space == 'apartment':
|
355 |
+
for trait in adaptable_traits:
|
356 |
+
if trait in temperament or trait in description:
|
357 |
+
adaptability_bonus += 0.1
|
358 |
+
|
359 |
+
# 特別處理大型犬的適應性
|
360 |
+
if size in ['Large', 'Giant']:
|
361 |
+
apartment_friendly_traits = ['calm', 'gentle', 'quiet']
|
362 |
+
matched_traits = sum(1 for trait in apartment_friendly_traits
|
363 |
+
if trait in temperament or trait in description)
|
364 |
+
if matched_traits > 0:
|
365 |
+
adaptability_bonus += 0.15 * matched_traits
|
366 |
+
|
367 |
+
# 活動空間需求調整,更寬容的評估
|
368 |
+
if exercise_needs in ['HIGH', 'VERY HIGH']:
|
369 |
+
if user_prefs.living_space != 'house_large':
|
370 |
+
space_score *= 0.9 # 從0.8提升到0.9,降低懲罰
|
371 |
+
|
372 |
+
# 院子可用性評估,提供更合理的獎勵
|
373 |
+
yard_scores = {
|
374 |
+
'no_yard': 0.85, # 從0.7提升到0.85
|
375 |
+
'shared_yard': 0.92, # 從0.85提升到0.92
|
376 |
+
'private_yard': 1.0
|
377 |
+
}
|
378 |
+
yard_multiplier = yard_scores.get(user_prefs.yard_access, 0.85)
|
379 |
+
|
380 |
+
# 根據體型調整院子重要性
|
381 |
+
if size in ['Large', 'Giant']:
|
382 |
+
yard_importance = 1.2
|
383 |
+
elif size == 'Medium':
|
384 |
+
yard_importance = 1.1
|
385 |
+
else:
|
386 |
+
yard_importance = 1.0
|
387 |
+
|
388 |
+
# 計算最終分數
|
389 |
+
final_score = space_score * (1 + adaptability_bonus)
|
390 |
+
|
391 |
+
# 應用院子影響
|
392 |
+
if user_prefs.yard_access != 'no_yard':
|
393 |
+
yard_bonus = (yard_multiplier - 1) * yard_importance
|
394 |
+
final_score = min(1.0, final_score + yard_bonus)
|
395 |
+
|
396 |
+
# 確保分數在合理範圍內,但提供更高的基礎分數
|
397 |
+
return max(0.4, min(1.0, final_score))
|
398 |
+
|
399 |
+
# 第四部分:品種特性評估
|
400 |
+
def evaluate_breed_traits():
|
401 |
+
temperament = breed_info.get('Temperament', '').lower()
|
402 |
+
description = breed_info.get('Description', '').lower()
|
403 |
+
|
404 |
+
trait_scores = []
|
405 |
+
|
406 |
+
# 評估性格特徵
|
407 |
+
if user_prefs.has_children:
|
408 |
+
if 'good with children' in description:
|
409 |
+
trait_scores.append(1.0)
|
410 |
+
elif 'patient' in temperament or 'gentle' in temperament:
|
411 |
+
trait_scores.append(0.8)
|
412 |
+
else:
|
413 |
+
trait_scores.append(0.5)
|
414 |
+
|
415 |
+
# 評估適應性
|
416 |
+
adaptability_keywords = ['adaptable', 'versatile', 'flexible']
|
417 |
+
if any(keyword in temperament for keyword in adaptability_keywords):
|
418 |
+
trait_scores.append(1.0)
|
419 |
+
else:
|
420 |
+
trait_scores.append(0.7)
|
421 |
+
|
422 |
+
return sum(trait_scores) / len(trait_scores) if trait_scores else 0.7
|
423 |
+
|
424 |
+
# 計算各項匹配分數
|
425 |
+
perfect_matches['exercise_match'] = evaluate_exercise_compatibility()
|
426 |
+
perfect_matches['experience_match'] = evaluate_expertise_requirements()
|
427 |
+
perfect_matches['living_condition_match'] = evaluate_living_conditions()
|
428 |
+
perfect_matches['size_match'] = evaluate_living_conditions() # 共用生活環境評估
|
429 |
+
perfect_matches['breed_trait_match'] = evaluate_breed_traits()
|
430 |
+
|
431 |
+
return perfect_matches
|
432 |
+
|
433 |
+
def calculate_weights() -> dict:
|
434 |
+
"""
|
435 |
+
動態計算評分權重,特別關注:
|
436 |
+
1. 極端情況的權重調整
|
437 |
+
2. 使用者條件的協同效應
|
438 |
+
3. 品種特性的影響
|
439 |
+
|
440 |
+
Returns:
|
441 |
+
dict: 包含各評分項目權重的字典
|
442 |
+
"""
|
443 |
+
# 定義基礎權重 - 提供更合理的起始分配
|
444 |
+
base_weights = {
|
445 |
+
'space': 0.25, # 提升空間權重,因為這是最基本的需求
|
446 |
+
'exercise': 0.25, # 運動需求同樣重要
|
447 |
+
'experience': 0.20, # 保持經驗的重要性
|
448 |
+
'grooming': 0.10, # 稍微降低美容需求的權重
|
449 |
+
'noise': 0.10, # 維持噪音評估的權重
|
450 |
+
'health': 0.10 # 維持健康評估的權重
|
451 |
+
}
|
452 |
+
|
453 |
+
def analyze_condition_extremity() -> dict:
|
454 |
+
"""
|
455 |
+
評估使用者條件的極端程度,這影響權重的動態調整。
|
456 |
+
根據條件的極端程度返回相應的調整建議。
|
457 |
+
"""
|
458 |
+
extremities = {}
|
459 |
+
|
460 |
+
# 運動時間評估 - 更細緻的分級
|
461 |
+
if user_prefs.exercise_time <= 30:
|
462 |
+
extremities['exercise'] = ('extremely_low', 0.8)
|
463 |
+
elif user_prefs.exercise_time <= 60:
|
464 |
+
extremities['exercise'] = ('low', 0.6)
|
465 |
+
elif user_prefs.exercise_time >= 180:
|
466 |
+
extremities['exercise'] = ('extremely_high', 0.8)
|
467 |
+
elif user_prefs.exercise_time >= 120:
|
468 |
+
extremities['exercise'] = ('high', 0.6)
|
469 |
+
else:
|
470 |
+
extremities['exercise'] = ('moderate', 0.3)
|
471 |
+
|
472 |
+
# 空間限制評估 - 更合理的空間評估
|
473 |
+
space_extremity = {
|
474 |
+
'apartment': ('restricted', 0.7), # 從0.9降低到0.7,減少限制
|
475 |
+
'house_small': ('moderate', 0.5),
|
476 |
+
'house_large': ('spacious', 0.3)
|
477 |
+
}
|
478 |
+
extremities['space'] = space_extremity.get(user_prefs.living_space, ('moderate', 0.5))
|
479 |
+
|
480 |
+
# 經驗水平評估 - 保持原有的評估邏輯
|
481 |
+
experience_extremity = {
|
482 |
+
'beginner': ('low', 0.7),
|
483 |
+
'intermediate': ('moderate', 0.4),
|
484 |
+
'advanced': ('high', 0.6)
|
485 |
+
}
|
486 |
+
extremities['experience'] = experience_extremity.get(user_prefs.experience_level, ('moderate', 0.5))
|
487 |
+
|
488 |
+
return extremities
|
489 |
+
|
490 |
+
def calculate_weight_adjustments(extremities: dict) -> dict:
|
491 |
+
"""
|
492 |
+
根據極端程度計算權重調整,特別注意條件組合的影響。
|
493 |
+
"""
|
494 |
+
adjustments = {}
|
495 |
+
temperament = breed_info.get('Temperament', '').lower()
|
496 |
+
is_working_dog = any(trait in temperament
|
497 |
+
for trait in ['herding', 'working', 'intelligent', 'tireless'])
|
498 |
+
|
499 |
+
# 空間權重調整
|
500 |
+
if extremities['space'][0] == 'restricted':
|
501 |
+
if extremities['exercise'][0] in ['high', 'extremely_high']:
|
502 |
+
adjustments['space'] = 1.3
|
503 |
+
adjustments['exercise'] = 2.3
|
504 |
+
else:
|
505 |
+
adjustments['space'] = 1.6
|
506 |
+
adjustments['noise'] = 1.5
|
507 |
+
|
508 |
+
# 運動需求權重調整
|
509 |
+
if extremities['exercise'][0] in ['extremely_high', 'extremely_low']:
|
510 |
+
base_adjustment = 2.0
|
511 |
+
if extremities['exercise'][0] == 'extremely_high':
|
512 |
+
if is_working_dog:
|
513 |
+
base_adjustment = 2.3
|
514 |
+
adjustments['exercise'] = base_adjustment
|
515 |
+
|
516 |
+
# 經驗需求權重調整
|
517 |
+
if extremities['experience'][0] == 'low':
|
518 |
+
adjustments['experience'] = 1.8
|
519 |
+
if breed_info.get('Care Level') == 'HIGH':
|
520 |
+
adjustments['experience'] = 2.0
|
521 |
+
elif extremities['experience'][0] == 'high':
|
522 |
+
if is_working_dog:
|
523 |
+
adjustments['experience'] = 1.8 # 從2.5降低到1.8
|
524 |
+
|
525 |
+
# 特殊組合的處理
|
526 |
+
def adjust_for_combinations():
|
527 |
+
if (extremities['space'][0] == 'restricted' and
|
528 |
+
extremities['exercise'][0] in ['high', 'extremely_high']):
|
529 |
+
# 適度降低極端組合的影響
|
530 |
+
adjustments['space'] = adjustments.get('space', 1.0) * 1.2
|
531 |
+
adjustments['exercise'] = adjustments.get('exercise', 1.0) * 1.2
|
532 |
+
|
533 |
+
# 理想組合的獎勵
|
534 |
+
if (extremities['experience'][0] == 'high' and
|
535 |
+
extremities['space'][0] == 'spacious' and
|
536 |
+
extremities['exercise'][0] in ['high', 'extremely_high'] and
|
537 |
+
is_working_dog):
|
538 |
+
adjustments['exercise'] = adjustments.get('exercise', 1.0) * 1.3
|
539 |
+
adjustments['experience'] = adjustments.get('experience', 1.0) * 1.3
|
540 |
+
|
541 |
+
adjust_for_combinations()
|
542 |
+
return adjustments
|
543 |
+
|
544 |
+
# 獲取條件極端度
|
545 |
+
extremities = analyze_condition_extremity()
|
546 |
+
|
547 |
+
# 計算權重調整
|
548 |
+
weight_adjustments = calculate_weight_adjustments(extremities)
|
549 |
+
|
550 |
+
# 應用權重調整,確保總和為1
|
551 |
+
final_weights = base_weights.copy()
|
552 |
+
for key, adjustment in weight_adjustments.items():
|
553 |
+
if key in final_weights:
|
554 |
+
final_weights[key] *= adjustment
|
555 |
+
|
556 |
+
# 正規化權重
|
557 |
+
total_weight = sum(final_weights.values())
|
558 |
+
normalized_weights = {k: v/total_weight for k, v in final_weights.items()}
|
559 |
+
|
560 |
+
return normalized_weights
|
561 |
+
|
562 |
+
def calculate_base_score(scores: dict, weights: dict) -> float:
|
563 |
+
"""
|
564 |
+
計算基礎評分分數,採用更靈活的評分機制。
|
565 |
+
|
566 |
+
這個函數使用了改進後的評分邏輯,主要關注:
|
567 |
+
1. 降低關鍵指標的最低門檻,使系統更包容
|
568 |
+
2. 引入非線性評分曲線,讓分數分布更合理
|
569 |
+
3. 優化多重條件失敗的處理方式
|
570 |
+
4. 加強對品種特性的考慮
|
571 |
+
|
572 |
+
Parameters:
|
573 |
+
scores: 包含各項評分的字典
|
574 |
+
weights: 包含各項權重的字典
|
575 |
+
|
576 |
+
Returns:
|
577 |
+
float: 0.2到1.0之間的基礎分數
|
578 |
+
"""
|
579 |
+
# 重新定義關鍵指標閾值,提供更寬容的評分標準
|
580 |
+
critical_thresholds = {
|
581 |
+
'space': 0.35,
|
582 |
+
'exercise': 0.35,
|
583 |
+
'experience': 0.5,
|
584 |
+
'noise': 0.5
|
585 |
+
}
|
586 |
+
|
587 |
+
# 評估關鍵指標失敗情況
|
588 |
+
def evaluate_critical_failures() -> list:
|
589 |
+
"""
|
590 |
+
評估關鍵指標的失敗情況,但採用更寬容的標準。
|
591 |
+
根據品種特性動態調整失敗判定。
|
592 |
+
"""
|
593 |
+
failures = []
|
594 |
+
temperament = breed_info.get('Temperament', '').lower()
|
595 |
+
|
596 |
+
for metric, threshold in critical_thresholds.items():
|
597 |
+
if scores[metric] < threshold:
|
598 |
+
# 特殊情況處理:適應性強的品種可以有更低的空間要求
|
599 |
+
if metric == 'space' and any(trait in temperament
|
600 |
+
for trait in ['adaptable', 'calm', 'apartment']):
|
601 |
+
if scores[metric] >= threshold - 0.1:
|
602 |
+
continue
|
603 |
+
|
604 |
+
# 運動需求的特殊處理
|
605 |
+
elif metric == 'exercise':
|
606 |
+
exercise_needs = breed_info.get('Exercise Needs', 'MODERATE').upper()
|
607 |
+
if exercise_needs == 'LOW' and scores[metric] >= threshold - 0.1:
|
608 |
+
continue
|
609 |
+
|
610 |
+
failures.append((metric, scores[metric]))
|
611 |
+
|
612 |
+
return failures
|
613 |
+
|
614 |
+
# 計算基礎分數
|
615 |
+
def calculate_weighted_score() -> float:
|
616 |
+
"""
|
617 |
+
計算加權分數,使用非線性函數使分數分布更合理。
|
618 |
+
"""
|
619 |
+
weighted_scores = []
|
620 |
+
for key, score in scores.items():
|
621 |
+
if key in weights:
|
622 |
+
# 使用sigmoid函數使分數曲線更平滑
|
623 |
+
adjusted_score = 1 / (1 + math.exp(-10 * (score - 0.5)))
|
624 |
+
weighted_scores.append(adjusted_score * weights[key])
|
625 |
+
|
626 |
+
return sum(weighted_scores)
|
627 |
+
|
628 |
+
# 處理臨界失敗情況
|
629 |
+
critical_failures = evaluate_critical_failures()
|
630 |
+
base_score = calculate_weighted_score()
|
631 |
+
|
632 |
+
if critical_failures:
|
633 |
+
# 分離空間和運動相關的懲罰
|
634 |
+
space_exercise_penalty = 0
|
635 |
+
other_penalty = 0
|
636 |
+
|
637 |
+
for metric, score in critical_failures:
|
638 |
+
if metric in ['space', 'exercise']:
|
639 |
+
# 降低空間和運動失敗的懲罰程度
|
640 |
+
penalty = (critical_thresholds[metric] - score) * 0.08
|
641 |
+
space_exercise_penalty += penalty
|
642 |
+
else:
|
643 |
+
# 其他失敗的懲罰保持較高
|
644 |
+
penalty = (critical_thresholds[metric] - score) * 0.20
|
645 |
+
other_penalty += penalty
|
646 |
+
|
647 |
+
# 計算總懲罰,但使用更溫和的方式
|
648 |
+
total_penalty = (space_exercise_penalty + other_penalty) / 2
|
649 |
+
base_score *= (1 - total_penalty)
|
650 |
+
|
651 |
+
# 多重失敗的處理更寬容
|
652 |
+
if len(critical_failures) > 1:
|
653 |
+
# 從0.98提升到0.99,降低多重失敗的疊加懲罰
|
654 |
+
base_score *= (0.99 ** (len(critical_failures) - 1))
|
655 |
+
|
656 |
+
# 品種特性加分
|
657 |
+
def apply_breed_bonus() -> float:
|
658 |
+
"""
|
659 |
+
根據品種特性提供額外加分,
|
660 |
+
特別是對於在特定環境下表現良好的品種。
|
661 |
+
"""
|
662 |
+
bonus = 0
|
663 |
+
temperament = breed_info.get('Temperament', '').lower()
|
664 |
+
description = breed_info.get('Description', '').lower()
|
665 |
+
|
666 |
+
# 適應性加分
|
667 |
+
adaptability_traits = ['adaptable', 'versatile', 'easy-going']
|
668 |
+
if any(trait in temperament for trait in adaptability_traits):
|
669 |
+
bonus += 0.05
|
670 |
+
|
671 |
+
# 公寓適應性加分
|
672 |
+
if user_prefs.living_space == 'apartment':
|
673 |
+
apartment_traits = ['calm', 'quiet', 'good for apartments']
|
674 |
+
if any(trait in temperament or trait in description for trait in apartment_traits):
|
675 |
+
bonus += 0.05
|
676 |
+
|
677 |
+
return min(0.1, bonus) # 限制最大加分
|
678 |
+
|
679 |
+
# 應用品種特性加分
|
680 |
+
breed_bonus = apply_breed_bonus()
|
681 |
+
base_score = min(1.0, base_score * (1 + breed_bonus))
|
682 |
+
|
683 |
+
# 確保最終分數在合理範圍內
|
684 |
+
return max(0.2, min(1.0, base_score))
|
685 |
+
|
686 |
+
def evaluate_condition_interactions(scores: dict) -> float:
|
687 |
+
"""評估不同條件間的相互影響,更寬容地處理極端組合"""
|
688 |
+
interaction_penalty = 1.0
|
689 |
+
|
690 |
+
# 只保留最基本的經驗相關評估
|
691 |
+
if user_prefs.experience_level == 'beginner':
|
692 |
+
if breed_info.get('Care Level') == 'HIGH':
|
693 |
+
interaction_penalty *= 0.95
|
694 |
+
|
695 |
+
# 運動時間與類型的基本互動也降低懲罰程度
|
696 |
+
exercise_needs = breed_info.get('Exercise Needs', 'MODERATE').upper()
|
697 |
+
if exercise_needs == 'VERY HIGH' and user_prefs.exercise_type == 'light_walks':
|
698 |
+
interaction_penalty *= 0.95
|
699 |
+
|
700 |
+
return interaction_penalty
|
701 |
+
|
702 |
+
def calculate_adjusted_perfect_bonus(perfect_conditions: dict) -> float:
|
703 |
+
"""計算完美匹配獎勵,但更注重條件的整體表現"""
|
704 |
+
bonus = 1.0
|
705 |
+
|
706 |
+
# 降低單項獎勵的影響力
|
707 |
+
bonus += 0.06 * perfect_conditions['size_match']
|
708 |
+
bonus += 0.06 * perfect_conditions['exercise_match']
|
709 |
+
bonus += 0.06 * perfect_conditions['experience_match']
|
710 |
+
bonus += 0.03 * perfect_conditions['living_condition_match']
|
711 |
+
|
712 |
+
# 如果有任何條件表現不佳,降低整體獎勵
|
713 |
+
low_scores = [score for score in perfect_conditions.values() if score < 0.6]
|
714 |
+
if low_scores:
|
715 |
+
bonus *= (0.85 ** len(low_scores))
|
716 |
+
|
717 |
+
# 確保獎勵不會過高
|
718 |
+
return min(1.25, bonus)
|
719 |
+
|
720 |
+
def apply_breed_specific_adjustments(score: float) -> float:
|
721 |
+
"""根據品種特性進行最終調整"""
|
722 |
+
# 檢查是否存在極端不匹配的情況
|
723 |
+
exercise_mismatch = False
|
724 |
+
size_mismatch = False
|
725 |
+
experience_mismatch = False
|
726 |
+
|
727 |
+
# 運動需求極端不匹配
|
728 |
+
if breed_info.get('Exercise Needs', 'MODERATE').upper() == 'VERY HIGH':
|
729 |
+
if user_prefs.exercise_time < 90 or user_prefs.exercise_type == 'light_walks':
|
730 |
+
exercise_mismatch = True
|
731 |
+
|
732 |
+
# 體型與空間極端不匹配
|
733 |
+
if user_prefs.living_space == 'apartment' and breed_info['Size'] in ['Large', 'Giant']:
|
734 |
+
size_mismatch = True
|
735 |
+
|
736 |
+
# 經驗需求極端不匹配
|
737 |
+
if user_prefs.experience_level == 'beginner' and breed_info.get('Care Level') == 'HIGH':
|
738 |
+
experience_mismatch = True
|
739 |
+
|
740 |
+
# 根據不匹配的數量進行懲罰
|
741 |
+
mismatch_count = sum([exercise_mismatch, size_mismatch, experience_mismatch])
|
742 |
+
if mismatch_count > 0:
|
743 |
+
score *= (0.8 ** mismatch_count)
|
744 |
+
|
745 |
+
return score
|
746 |
+
|
747 |
+
# 計算動態權重
|
748 |
+
weights = calculate_weights()
|
749 |
+
|
750 |
+
# ���規化權重
|
751 |
+
total_weight = sum(weights.values())
|
752 |
+
normalized_weights = {k: v/total_weight for k, v in weights.items()}
|
753 |
+
|
754 |
+
# 計算基礎分數
|
755 |
+
base_score = calculate_base_score(scores, normalized_weights)
|
756 |
+
|
757 |
+
# 評估條件互動
|
758 |
+
interaction_multiplier = evaluate_condition_interactions(scores)
|
759 |
+
|
760 |
+
# 計算完美匹配獎勵
|
761 |
+
perfect_conditions = evaluate_perfect_conditions()
|
762 |
+
perfect_bonus = calculate_adjusted_perfect_bonus(perfect_conditions)
|
763 |
+
|
764 |
+
# 計算初步分數
|
765 |
+
preliminary_score = base_score * interaction_multiplier * perfect_bonus
|
766 |
+
|
767 |
+
# 應用品種特定調整
|
768 |
+
final_score = apply_breed_specific_adjustments(preliminary_score)
|
769 |
+
|
770 |
+
# 確保分數在合理範圍內,並降低最高可能分數
|
771 |
+
max_possible_score = 0.96 # 降低最高可能分數
|
772 |
+
min_possible_score = 0.3
|
773 |
+
|
774 |
+
return min(max_possible_score, max(min_possible_score, final_score))
|
775 |
+
|
776 |
+
def calculate_environmental_fit(self, breed_info: dict, user_prefs: UserPreferences) -> float:
|
777 |
+
"""
|
778 |
+
計算品種與環境的適應性加成
|
779 |
+
|
780 |
+
Args:
|
781 |
+
breed_info: 品種資訊
|
782 |
+
user_prefs: 使用者偏好
|
783 |
+
|
784 |
+
Returns:
|
785 |
+
float: 環境適應性加成分數
|
786 |
+
"""
|
787 |
+
adaptability_score = 0.0
|
788 |
+
description = breed_info.get('Description', '').lower()
|
789 |
+
temperament = breed_info.get('Temperament', '').lower()
|
790 |
+
|
791 |
+
# 環境適應性評估
|
792 |
+
if user_prefs.living_space == 'apartment':
|
793 |
+
if 'adaptable' in temperament or 'apartment' in description:
|
794 |
+
adaptability_score += 0.1
|
795 |
+
if breed_info.get('Size') == 'Small':
|
796 |
+
adaptability_score += 0.05
|
797 |
+
elif user_prefs.living_space == 'house_large':
|
798 |
+
if 'active' in temperament or 'energetic' in description:
|
799 |
+
adaptability_score += 0.1
|
800 |
+
|
801 |
+
# 氣候適應性
|
802 |
+
if user_prefs.climate in description or user_prefs.climate in temperament:
|
803 |
+
adaptability_score += 0.05
|
804 |
+
|
805 |
+
return min(0.2, adaptability_score)
|
scoring_calculation_system.py
CHANGED
The diff for this file is too large to render.
See raw diff
|
|
semantic_breed_recommender.py
ADDED
The diff for this file is too large to render.
See raw diff
|
|
styles.py
CHANGED
@@ -1,5 +1,128 @@
|
|
|
|
1 |
def get_css_styles():
|
2 |
return """
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
3 |
.dog-info-card {
|
4 |
margin: 0 0 20px 0;
|
5 |
padding: 0;
|
@@ -234,7 +357,7 @@ def get_css_styles():
|
|
234 |
}
|
235 |
|
236 |
.breed-name {
|
237 |
-
font-size: 1.2em !important;
|
238 |
font-weight: bold;
|
239 |
color: #2c3e50;
|
240 |
flex-grow: 1;
|
@@ -1070,52 +1193,24 @@ def get_css_styles():
|
|
1070 |
}
|
1071 |
|
1072 |
@media (max-width: 768px) {
|
1073 |
-
|
1074 |
-
|
1075 |
-
|
1076 |
-
padding: 10px !important;
|
1077 |
-
width: 100% !important;
|
1078 |
-
box-sizing: border-box !important;
|
1079 |
-
min-height: auto !important; /* 在手機上移除最小高度限制 */
|
1080 |
-
height: auto !important; /* 允許高度自適應 */
|
1081 |
-
padding: 12px !important; /* 稍微減少填充 */
|
1082 |
-
}
|
1083 |
-
|
1084 |
-
.info-card {
|
1085 |
-
width: 100% !important;
|
1086 |
-
margin: 0 !important;
|
1087 |
-
padding: 12px !important;
|
1088 |
-
min-height: auto !important; /* 移除最小高度限制 */
|
1089 |
-
height: auto !important; /* 允許高度自適應 */
|
1090 |
-
overflow: visible !important; /* 確保內容不被切斷 */
|
1091 |
}
|
1092 |
|
1093 |
-
|
1094 |
-
|
1095 |
-
|
1096 |
-
.info-card span {
|
1097 |
-
display: block !important; /* 確保文字完整顯示 */
|
1098 |
-
overflow: visible !important;
|
1099 |
-
}
|
1100 |
-
|
1101 |
-
.tooltip {
|
1102 |
-
width: 100% !important;
|
1103 |
-
display: flex !important;
|
1104 |
-
align-items: center !important;
|
1105 |
-
gap: 8px !important;
|
1106 |
}
|
1107 |
-
|
1108 |
-
|
1109 |
-
|
1110 |
-
|
1111 |
-
width: 200px !important;
|
1112 |
}
|
1113 |
-
|
1114 |
-
/*
|
1115 |
-
.
|
1116 |
-
|
1117 |
-
white-space: normal !important;
|
1118 |
-
word-wrap: break-word !important;
|
1119 |
}
|
1120 |
}
|
1121 |
|
|
|
1 |
+
|
2 |
def get_css_styles():
|
3 |
return """
|
4 |
+
/* SBERT Natural Language Recommendation Styles */
|
5 |
+
button#find-match-btn {
|
6 |
+
background: linear-gradient(90deg, #ff5f6d 0%, #ffc371 100%) !important;
|
7 |
+
border: none !important;
|
8 |
+
border-radius: 30px !important;
|
9 |
+
padding: 12px 24px !important;
|
10 |
+
color: white !important;
|
11 |
+
font-weight: bold !important;
|
12 |
+
cursor: pointer !important;
|
13 |
+
transition: all 0.3s ease !important;
|
14 |
+
box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1) !important;
|
15 |
+
width: 100% !important;
|
16 |
+
margin: 20px 0 !important;
|
17 |
+
font-size: 1.1em !important;
|
18 |
+
}
|
19 |
+
button#find-match-btn:hover {
|
20 |
+
background: linear-gradient(90deg, #ff4f5d 0%, #ffb361 100%) !important;
|
21 |
+
box-shadow: 0 6px 12px rgba(0, 0, 0, 0.2) !important;
|
22 |
+
transform: translateY(-2px) !important;
|
23 |
+
}
|
24 |
+
button#find-match-btn:active {
|
25 |
+
transform: translateY(1px) !important;
|
26 |
+
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.2) !important;
|
27 |
+
}
|
28 |
+
#search-status {
|
29 |
+
text-align: center;
|
30 |
+
padding: 15px;
|
31 |
+
font-size: 1.1em;
|
32 |
+
color: #666;
|
33 |
+
margin: 10px 0;
|
34 |
+
border-radius: 8px;
|
35 |
+
background: rgba(200, 200, 200, 0.1);
|
36 |
+
transition: opacity 0.3s ease;
|
37 |
+
}
|
38 |
+
|
39 |
+
/* Natural Language Search Button Styles */
|
40 |
+
button#find-by-description-btn {
|
41 |
+
background: linear-gradient(90deg, #4299e1 0%, #48bb78 100%) !important;
|
42 |
+
border: none !important;
|
43 |
+
border-radius: 30px !important;
|
44 |
+
padding: 12px 24px !important;
|
45 |
+
color: white !important;
|
46 |
+
font-weight: bold !important;
|
47 |
+
cursor: pointer !important;
|
48 |
+
transition: all 0.3s ease !important;
|
49 |
+
box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1) !important;
|
50 |
+
width: 100% !important;
|
51 |
+
margin: 20px 0 !important;
|
52 |
+
font-size: 1.1em !important;
|
53 |
+
}
|
54 |
+
button#find-by-description-btn:hover {
|
55 |
+
background: linear-gradient(90deg, #3182ce 0%, #38a169 100%) !important;
|
56 |
+
box-shadow: 0 6px 12px rgba(0, 0, 0, 0.2) !important;
|
57 |
+
transform: translateY(-2px) !important;
|
58 |
+
}
|
59 |
+
button#find-by-description-btn:active {
|
60 |
+
background: linear-gradient(90deg, #2c5aa0 0%, #2f7d32 100%) !important;
|
61 |
+
transform: translateY(0px) scale(0.98) !important;
|
62 |
+
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.2) !important;
|
63 |
+
}
|
64 |
+
|
65 |
+
/* Description Input Styles */
|
66 |
+
.description-input textarea {
|
67 |
+
border-radius: 10px !important;
|
68 |
+
border: 2px solid #e2e8f0 !important;
|
69 |
+
transition: all 0.3s ease !important;
|
70 |
+
}
|
71 |
+
.description-input textarea:focus {
|
72 |
+
border-color: #4299e1 !important;
|
73 |
+
box-shadow: 0 0 0 3px rgba(66, 153, 225, 0.1) !important;
|
74 |
+
}
|
75 |
+
|
76 |
+
/* Force override any other styles */
|
77 |
+
.gradio-button {
|
78 |
+
position: relative !important;
|
79 |
+
overflow: visible !important;
|
80 |
+
}
|
81 |
+
|
82 |
+
/* Progress bars for semantic recommendations */
|
83 |
+
.progress {
|
84 |
+
transition: all 0.3s ease-in-out;
|
85 |
+
border-radius: 4px;
|
86 |
+
height: 12px;
|
87 |
+
}
|
88 |
+
.progress-bar {
|
89 |
+
background-color: #f5f5f5;
|
90 |
+
border-radius: 4px;
|
91 |
+
overflow: hidden;
|
92 |
+
position: relative;
|
93 |
+
}
|
94 |
+
.score-item {
|
95 |
+
margin: 10px 0;
|
96 |
+
}
|
97 |
+
.percentage {
|
98 |
+
margin-left: 8px;
|
99 |
+
font-weight: 500;
|
100 |
+
}
|
101 |
+
|
102 |
+
/* History display with colored tags */
|
103 |
+
.history-tag-criteria {
|
104 |
+
background: rgba(72, 187, 120, 0.1);
|
105 |
+
color: #48bb78;
|
106 |
+
padding: 4px 8px;
|
107 |
+
border-radius: 12px;
|
108 |
+
font-size: 0.8em;
|
109 |
+
font-weight: 600;
|
110 |
+
display: inline-flex;
|
111 |
+
align-items: center;
|
112 |
+
gap: 4px;
|
113 |
+
}
|
114 |
+
.history-tag-description {
|
115 |
+
background: rgba(66, 153, 225, 0.1);
|
116 |
+
color: #4299e1;
|
117 |
+
padding: 4px 8px;
|
118 |
+
border-radius: 12px;
|
119 |
+
font-size: 0.8em;
|
120 |
+
font-weight: 600;
|
121 |
+
display: inline-flex;
|
122 |
+
align-items: center;
|
123 |
+
gap: 4px;
|
124 |
+
}
|
125 |
+
|
126 |
.dog-info-card {
|
127 |
margin: 0 0 20px 0;
|
128 |
padding: 0;
|
|
|
357 |
}
|
358 |
|
359 |
.breed-name {
|
360 |
+
font-size: 1.2em !important;
|
361 |
font-weight: bold;
|
362 |
color: #2c3e50;
|
363 |
flex-grow: 1;
|
|
|
1193 |
}
|
1194 |
|
1195 |
@media (max-width: 768px) {
|
1196 |
+
/* 在小螢幕上改為單列顯示 */
|
1197 |
+
.health-grid, .noise-grid {
|
1198 |
+
grid-template-columns: 1fr;
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1199 |
}
|
1200 |
|
1201 |
+
/* 減少內邊距 */
|
1202 |
+
.health-section, .noise-section {
|
1203 |
+
padding: 16px;
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1204 |
}
|
1205 |
+
|
1206 |
+
/* 調整字體大小 */
|
1207 |
+
.section-header {
|
1208 |
+
font-size: 1rem;
|
|
|
1209 |
}
|
1210 |
+
|
1211 |
+
/* 調整項目內邊距 */
|
1212 |
+
.health-item, .noise-item {
|
1213 |
+
padding: 10px 14px;
|
|
|
|
|
1214 |
}
|
1215 |
}
|
1216 |
|