diff --git a/config.py b/config.py index fcb6216..db0b753 100644 --- a/config.py +++ b/config.py @@ -325,7 +325,7 @@ YOUTUBE_API_KEY = os.getenv('YOUTUBE_API_KEY', '') # ========================================== # 系統版本與路徑 # ========================================== -SYSTEM_VERSION = "V10.419" +SYSTEM_VERSION = "V10.420" LOG_FILE_PATH = os.path.join(BASE_DIR, 'logs/system.log') public_url = PUBLIC_URL # 用於模板顯示 diff --git a/docs/AI_INTELLIGENCE_MODULE_SOT.md b/docs/AI_INTELLIGENCE_MODULE_SOT.md index 7d7aeb7..57e4ff1 100644 --- a/docs/AI_INTELLIGENCE_MODULE_SOT.md +++ b/docs/AI_INTELLIGENCE_MODULE_SOT.md @@ -2,7 +2,7 @@ > **最後更新**: 2026-05-24 (台北時間) > **狀態**: 🟢 四 AI Agent 自動化閉環已落地;LLM 路由紅線升級為 Ollama-first 三主機級聯,Gemini 備援預設關閉 -> **適用版本**: V10.419 +> **適用版本**: V10.420 --- @@ -31,6 +31,7 @@ - Gemini 不可被任何狀態面板或 router 推薦為主提供者:`AIProviderService._get_recommended_provider()` 不得回傳 `gemini`,只能顯示為 fallback 狀態;`llm_model_router` 的 `ea_engine` 若收到 `gemini-*` default 必須改回 `hermes3:latest`,需要深推理時才升本地 `deepseek-r1:14b`。 - ElephantAlpha prompt / agent registry 不得再把 OpenClaw 描述為 Gemini 主模型;OpenClaw 是 `qwen2.5-coder:7b` / `qwen3:14b` Ollama-first 策略師,Gemini 僅能在 guard 顯式解鎖後作 emergency fallback。 - 111 `192.168.0.111` 只是最後一道 Mac fallback,不承接 7B+、vision、long-context 模型長駐;`OllamaService.generate()` 落到 111 時會將 `qwen3`、`deepseek-r1`、`hermes3`、`qwen2.5*`、`gemma3`、`llava`、`minicpm-v` 與 7B+ 模型依 `OLLAMA_111_MODEL_DOWNGRADE_PATTERNS` 降級到 `OLLAMA_111_MODEL_FALLBACK=llama3.2:latest`,並以 `OLLAMA_111_KEEP_ALIVE=5m`、`OLLAMA_111_MAX_TIMEOUT=20`、`OLLAMA_111_NUM_CTX=4096`、`OLLAMA_111_NUM_PREDICT=512` 封頂。OpenClaw 報告型路徑的業務 keep-alive 預設 `5m`;Code Review 以 `CODE_REVIEW_ALLOW_111_FALLBACK=false`、Hermes 以 `HERMES_ALLOW_111_FALLBACK=false` 預設跳過 111,避免 16GB RAM 主機與 GCP-B 被長駐 runner、長輸出與 24h keep-alive 壓到高 load。 +- 111 的 LAN 入口必須經 `scripts/ops/ollama111_allow_proxy.py` allowlist proxy:真實 Ollama 綁 `127.0.0.1:11434`,proxy 綁 `192.168.0.111:11434`,預設只允許 111 本機與 188 生產宿主;110 / 121 / 其他 LAN client 不能直接打 111,避免跨專案 CI 或 VM 繞過 momo-pro router 載入 7B+ runner。 - ElephantAlpha 的 `price_drop_alert` / `market_opportunity` Telegram HITL 告警必須把同款證據獨立呈現,至少包含 `match_type`、`price_basis`、`alert_tier` 與 `match_score`;沒有高信心同款與總價可比證據時,不得把 PChome/MOMO 價差寫成可直接跟價建議。 ## 一、四 AI Agent 路由架構 diff --git a/docs/memory/history_logs.md b/docs/memory/history_logs.md index abc5278..a04f832 100644 --- a/docs/memory/history_logs.md +++ b/docs/memory/history_logs.md @@ -13,6 +13,7 @@ ## 📅 詳細更新日誌 (考古存檔) ### 2026-05-24:PChome 近門檻身份回收第二輪 +- **V10.420 111 Ollama LAN allowlist proxy**: 追查 111 高負載時確認來源不是 momo-pro,而是 110 上 `awoooi-cd` 臨時測試與 121 VMware VM 直接打 `192.168.0.111:11434`,繞過 `ai_calls` 與 momo-pro router 載入 7B runner。新增 `scripts/ops/ollama111_allow_proxy.py`,將真實 Ollama 收斂到 `127.0.0.1:11434`,由 user-space proxy 綁 `192.168.0.111:11434` 並預設只允許 111 本機與 188 生產宿主;110 / 121 會被 reset,111 fallback 保留給 momo production。 - **V10.419 Dr.Hsieh LabSmart 精華品線防錯配**: marketplace matcher 追加 `dr_hsieh_labsmart_line_conflict`,只針對 Dr.Hsieh/達特醫的 `LabSmart Hi-Tech` / `LabSmart Classic` 精華被拿去對 `神經醯胺多重修復保濕精華液` 的近門檻錯配做 hard veto;同品牌同容量但不同產品線不再因規格相同停在 `true_low_confidence` 或被誤推進比價。 - **V10.419 production pilot**: 正式回刷 SKU `10413050` / `10413051`,兩筆 Dr.Hsieh LabSmart 精華 vs 神經醯胺多重修復精華皆由 `true_low_confidence` 轉為 `identity_veto`,`diagnostic_codes=["dr_hsieh_labsmart_line_conflict"]`;`matched` 維持 1619、`true_low_confidence` 753→751、`identity_veto` 4011→4013,無正式 `competitor_prices` 覆寫。 - **V10.418 bge-m3 一致性檢查不打 111**: `verify_embedding_consistency()` 預設只比對 GCP-A / GCP-B,不再每週把 111 Mac 納入 bge-m3 背景驗證;新增 `EMBED_CONSISTENCY_INCLUDE_111=false` 預設,只有救急需要驗證 fallback 模型時才 opt-in。這補上 V10.417 後仍會由監測任務載入 111/GCP-B embedding runner 的缺口。 diff --git a/scripts/ops/ollama111_allow_proxy.py b/scripts/ops/ollama111_allow_proxy.py new file mode 100644 index 0000000..62d6b1c --- /dev/null +++ b/scripts/ops/ollama111_allow_proxy.py @@ -0,0 +1,119 @@ +#!/usr/bin/env python3 +""" +User-space allowlist TCP proxy for the 111 Ollama fallback. + +Purpose: + - Keep the real Ollama server bound to 127.0.0.1:11434. + - Expose 192.168.0.111:11434 only to approved momo-pro clients. + - Reject noisy LAN / VM clients without requiring sudo/pfctl. +""" + +from __future__ import annotations + +import asyncio +import ipaddress +import logging +import os +import signal +from typing import Iterable + + +LISTEN_HOST = os.getenv("OLLAMA111_PROXY_LISTEN_HOST", "192.168.0.111") +LISTEN_PORT = int(os.getenv("OLLAMA111_PROXY_LISTEN_PORT", "11434")) +TARGET_HOST = os.getenv("OLLAMA111_PROXY_TARGET_HOST", "127.0.0.1") +TARGET_PORT = int(os.getenv("OLLAMA111_PROXY_TARGET_PORT", "11434")) +ALLOWED_CIDRS = tuple( + item.strip() + for item in os.getenv( + "OLLAMA111_PROXY_ALLOWED_CIDRS", + "127.0.0.1/32,192.168.0.80/32,192.168.0.111/32,192.168.0.188/32", + ).split(",") + if item.strip() +) + + +def _allowed_networks() -> tuple[ipaddress._BaseNetwork, ...]: + return tuple(ipaddress.ip_network(item, strict=False) for item in ALLOWED_CIDRS) + + +ALLOWED_NETWORKS = _allowed_networks() + + +def _is_allowed(peer_ip: str) -> bool: + try: + ip = ipaddress.ip_address(peer_ip) + except ValueError: + return False + return any(ip in network for network in ALLOWED_NETWORKS) + + +async def _pipe(reader: asyncio.StreamReader, writer: asyncio.StreamWriter) -> None: + try: + while True: + data = await reader.read(65536) + if not data: + break + writer.write(data) + await writer.drain() + except (ConnectionError, asyncio.CancelledError): + pass + finally: + try: + writer.close() + await writer.wait_closed() + except Exception: + pass + + +async def _handle_client(reader: asyncio.StreamReader, writer: asyncio.StreamWriter) -> None: + peer = writer.get_extra_info("peername") + peer_ip = peer[0] if peer else "unknown" + if not _is_allowed(peer_ip): + logging.warning("reject peer=%s allowed=%s", peer_ip, ",".join(ALLOWED_CIDRS)) + writer.close() + await writer.wait_closed() + return + + try: + target_reader, target_writer = await asyncio.open_connection(TARGET_HOST, TARGET_PORT) + except Exception as exc: + logging.error("target connect failed peer=%s target=%s:%s err=%r", peer_ip, TARGET_HOST, TARGET_PORT, exc) + writer.close() + await writer.wait_closed() + return + + logging.info("proxy peer=%s -> %s:%s", peer_ip, TARGET_HOST, TARGET_PORT) + await asyncio.gather( + _pipe(reader, target_writer), + _pipe(target_reader, writer), + ) + + +async def _main() -> None: + logging.basicConfig( + level=os.getenv("OLLAMA111_PROXY_LOG_LEVEL", "INFO"), + format="%(asctime)s %(levelname)s %(message)s", + ) + server = await asyncio.start_server(_handle_client, LISTEN_HOST, LISTEN_PORT) + sockets = ", ".join(str(sock.getsockname()) for sock in (server.sockets or [])) + logging.info( + "ollama111 allow proxy listening=%s target=%s:%s allowed=%s", + sockets, + TARGET_HOST, + TARGET_PORT, + ",".join(ALLOWED_CIDRS), + ) + + stop = asyncio.Event() + loop = asyncio.get_running_loop() + for sig in (signal.SIGINT, signal.SIGTERM): + loop.add_signal_handler(sig, stop.set) + + async with server: + await stop.wait() + server.close() + await server.wait_closed() + + +if __name__ == "__main__": + asyncio.run(_main())