This commit is contained in:
@@ -323,7 +323,7 @@ YOUTUBE_API_KEY = os.getenv('YOUTUBE_API_KEY', '')
|
||||
# ==========================================
|
||||
# 系統版本與路徑
|
||||
# ==========================================
|
||||
SYSTEM_VERSION = "V10.362"
|
||||
SYSTEM_VERSION = "V10.363"
|
||||
LOG_FILE_PATH = os.path.join(BASE_DIR, 'logs/system.log')
|
||||
public_url = PUBLIC_URL # 用於模板顯示
|
||||
|
||||
|
||||
@@ -13,6 +13,7 @@
|
||||
## 📅 詳細更新日誌 (考古存檔)
|
||||
|
||||
### 2026-05-21:瀏覽器測試守門與 PChome 熱路徑優化
|
||||
- **V10.363 Dashing Diva variant-safe search**: PChome/MOMO matcher 針對 Dashing Diva 美甲片補「商品頁目錄有 30片/盒、MOMO 標題省略片數」的安全豁免,只限同品牌、同美甲片線、同具名款式錨點;搜尋詞也優先帶出 `月影柔霧`、`銀絲柔彩` 等款式名,降低同系列不同款式互撞。
|
||||
- **V10.362 111 fallback shrink-to-3B**: 111 Mac 實測 `hermes3` / `qwen2.5-coder` 雖是 7B/8B,但 large context runner 仍會佔用 6-10GB RSS 並推高 swap;111 fallback 改為所有 7B+、vision 與 long-context 文字生成都降級到 `llama3.2:latest`,`ai_calls.model` 也會記錄實際降級模型並把原請求模型放入 `meta.requested_model`。
|
||||
- **V10.361 111 fallback resource guard**: 實測 111 Mac 高 load 主要來自 Codex app / WindowServer 前台負載,且 Ollama 曾因 fallback 載入 `qwen3:14b` 造成 16GB RAM / swap 壓力;已手動 unload 111 上的重模型,並讓 `OllamaService.generate()` 落到 111 時自動把 14B+ 模型降到 `OLLAMA_111_MODEL_FALLBACK`、`keep_alive` 縮至 `OLLAMA_111_KEEP_ALIVE=5m`、timeout 封頂 `OLLAMA_111_MAX_TIMEOUT=45`。GCP-A/GCP-B 仍可跑 `qwen3:14b`,111 只做短時最後備援。
|
||||
- **V10.360 browser smoke guard**: `tests/test_image_fetch.py` 改為預設 skip,只有 `RUN_MOMO_BROWSER_TESTS=1` 才會打開外部 MOMO 網站;手動執行時預設 headless,並關閉 Chrome password manager/autofill,避免一般 pytest 觸發瀏覽器與密碼允許提示。
|
||||
|
||||
@@ -956,6 +956,36 @@ def _has_hard_count_unit_conflict(left: ProductIdentity, right: ProductIdentity)
|
||||
return False
|
||||
|
||||
|
||||
def _allow_catalog_count_omission(left: ProductIdentity, right: ProductIdentity) -> bool:
|
||||
"""Allow catalog-side piece counts for Dashing Diva nail lines when MOMO omits pack count."""
|
||||
left_has_counts = bool(left.counts)
|
||||
right_has_counts = bool(right.counts)
|
||||
if left_has_counts == right_has_counts:
|
||||
return False
|
||||
|
||||
shared_brand_tokens = {token.lower() for token in left.brand_tokens} & {
|
||||
token.lower() for token in right.brand_tokens
|
||||
}
|
||||
if not ({"dashing", "diva"} <= shared_brand_tokens):
|
||||
return False
|
||||
|
||||
searchable_pair = f"{left.searchable_name} {right.searchable_name}"
|
||||
if "美甲片" not in searchable_pair:
|
||||
return False
|
||||
|
||||
counted = left if left_has_counts else right
|
||||
omitted = right if left_has_counts else left
|
||||
if omitted.counts:
|
||||
return False
|
||||
if (counted.total_piece_count or 0) < 20:
|
||||
return False
|
||||
|
||||
return any(
|
||||
anchor in searchable_pair
|
||||
for anchor in ("時尚潮流美甲片", "頂級璀燦美甲片", "薄型經典美甲片")
|
||||
)
|
||||
|
||||
|
||||
def _count_score(left: ProductIdentity, right: ProductIdentity) -> tuple[float, bool]:
|
||||
left_counts = [count for count, _unit in left.counts]
|
||||
right_counts = [count for count, _unit in right.counts]
|
||||
@@ -985,6 +1015,8 @@ def _count_score(left: ProductIdentity, right: ProductIdentity) -> tuple[float,
|
||||
if ratio >= 1.5:
|
||||
return 0.0, True
|
||||
return 0.35, False
|
||||
if _allow_catalog_count_omission(left, right):
|
||||
return 0.55, False
|
||||
if (left_counts and max(left_counts) > 1) or (right_counts and max(right_counts) > 1):
|
||||
return 0.0, True
|
||||
return 0.5, False
|
||||
@@ -1819,9 +1851,25 @@ def _ranked_search_core_phrases(identity: ProductIdentity, limit: int = 4) -> li
|
||||
return phrases
|
||||
|
||||
|
||||
def _variant_primary_phrase(identity: ProductIdentity) -> str:
|
||||
text = identity.searchable_name
|
||||
for anchor in ("時尚潮流美甲片", "頂級璀燦美甲片", "薄型經典美甲片", "足部時尚潮流美甲片"):
|
||||
pattern = rf"{re.escape(anchor)}[-_ ]*([\u4e00-\u9fff]{{2,8}})"
|
||||
match = re.search(pattern, text)
|
||||
if not match:
|
||||
continue
|
||||
phrase = _clean_search_phrase(match.group(1))
|
||||
compact = phrase.replace(" ", "")
|
||||
if compact and compact not in SEARCH_NOISE_TOKENS:
|
||||
return phrase
|
||||
variant_descriptors = sorted(_variant_descriptors(identity), key=lambda token: (len(token), token))
|
||||
return variant_descriptors[0] if variant_descriptors else ""
|
||||
|
||||
|
||||
def build_search_terms(name: str, max_terms: int = 3) -> list[str]:
|
||||
identity = parse_product_identity(name)
|
||||
terms: list[str] = []
|
||||
is_dashing_diva_nail_line = {"dashing", "diva"} <= identity.brand_tokens and "美甲片" in identity.searchable_name
|
||||
|
||||
def primary_brand_phrase() -> str:
|
||||
if {"dashing", "diva"} <= identity.brand_tokens:
|
||||
@@ -1863,8 +1911,7 @@ def build_search_terms(name: str, max_terms: int = 3) -> list[str]:
|
||||
modifier_with_primary = " ".join(
|
||||
part for part in (chinese_detail_phrases[0] if chinese_detail_phrases else "", core_primary) if part
|
||||
)
|
||||
variant_descriptors = sorted(_variant_descriptors(identity), key=lambda token: (len(token), token))
|
||||
variant_primary = variant_descriptors[0] if variant_descriptors else ""
|
||||
variant_primary = _variant_primary_phrase(identity)
|
||||
variant_options = sorted(_explicit_variant_option_tokens(identity), key=lambda token: (len(token), token))
|
||||
variant_option_part = " ".join(variant_options[:2])
|
||||
model_phrases = [
|
||||
@@ -1878,6 +1925,9 @@ def build_search_terms(name: str, max_terms: int = 3) -> list[str]:
|
||||
)
|
||||
variant_sensitive = any(keyword in identity.searchable_name for keyword in VARIANT_SENSITIVE_KEYWORDS)
|
||||
for value in (
|
||||
" ".join(part for part in (brand_part, core_primary, variant_primary, spec_part) if part)
|
||||
if is_dashing_diva_nail_line and variant_sensitive and variant_primary
|
||||
else "",
|
||||
" ".join(part for part in (brand_part, core_primary, variant_option_part, spec_part) if part)
|
||||
if variant_sensitive and variant_option_part
|
||||
else "",
|
||||
|
||||
@@ -591,6 +591,21 @@ def test_marketplace_matcher_promotes_variant_safe_exact_option():
|
||||
assert "shared_identity_anchor_variant_safe" in diagnostics.reasons
|
||||
|
||||
|
||||
def test_marketplace_matcher_allows_catalog_piece_count_for_exact_dashing_diva_variant():
|
||||
from services.marketplace_product_matcher import score_marketplace_match
|
||||
|
||||
diagnostics = score_marketplace_match(
|
||||
"【DASHING DIVA】MAGICPRESS 時尚潮流美甲片-銀河綠波(貓眼 漸層)",
|
||||
"Dashing Diva/F 時尚潮流美甲片-銀河綠波 30片/盒 MDF5F012AG",
|
||||
momo_price=399,
|
||||
competitor_price=420,
|
||||
)
|
||||
|
||||
assert diagnostics.score >= 0.76
|
||||
assert diagnostics.hard_veto is False
|
||||
assert "count_conflict" not in diagnostics.reasons
|
||||
|
||||
|
||||
def test_marketplace_matcher_rejects_explicit_shade_option_mismatch():
|
||||
from services.marketplace_product_matcher import score_marketplace_match
|
||||
|
||||
@@ -860,6 +875,22 @@ def test_marketplace_search_terms_keep_variant_descriptor_for_sensitive_lines():
|
||||
assert romand_terms[0] == "romand 勝過眼皮十色眼影盤"
|
||||
|
||||
|
||||
def test_marketplace_search_terms_prioritize_exact_dashing_diva_variant_name():
|
||||
from services.marketplace_product_matcher import build_search_terms
|
||||
|
||||
moon_terms = build_search_terms(
|
||||
"【DASHING DIVA】MAGICPRESS 時尚潮流美甲片-月影柔霧(貓眼 金色 漸層)",
|
||||
max_terms=5,
|
||||
)
|
||||
silk_terms = build_search_terms(
|
||||
"【DASHING DIVA】MAGICPRESS 時尚潮流美甲片-銀絲柔彩(紫色 漸層 貓眼)",
|
||||
max_terms=5,
|
||||
)
|
||||
|
||||
assert moon_terms[0] == "dashing diva 時尚潮流美甲片 月影柔霧"
|
||||
assert silk_terms[0] == "dashing diva 時尚潮流美甲片 銀絲柔彩"
|
||||
|
||||
|
||||
def test_marketplace_search_terms_prioritize_private_wash_identity_phrase():
|
||||
from services.marketplace_product_matcher import build_search_terms
|
||||
|
||||
|
||||
Reference in New Issue
Block a user