fix(ai): bind PixelRAG VLM worker to selected route

This commit is contained in:
ogt
2026-07-10 00:23:05 +08:00
parent bed3a8a8ee
commit 9cf8b55b72
5 changed files with 150 additions and 47 deletions

View File

@@ -9,7 +9,7 @@
python scripts/ops/check_production_version_truth.py
目前最新版本仍以 production `https://mo.wooo.work/health` readback 為準。
本輪 source target 為 `V10.753`;部署完成前不得宣稱正式環境已是 `V10.753`。
本輪 source target 為 `V10.754`;部署完成前不得宣稱正式環境已是 `V10.754`。
舊 iCloud checkout 不是 Gitea dev worktree不得拿來當最新版本真相。
================================================================================

View File

@@ -402,7 +402,7 @@ YOUTUBE_API_KEY = os.getenv('YOUTUBE_API_KEY', '')
# ==========================================
# 系統版本與路徑
# ==========================================
SYSTEM_VERSION = "V10.753"
SYSTEM_VERSION = "V10.754"
LOG_FILE_PATH = os.path.join(BASE_DIR, 'logs/system.log')
public_url = PUBLIC_URL # 用於模板顯示

View File

@@ -883,6 +883,7 @@ POSTGRES_HOST=momo-db
| 2026-07-09 | PixelRAG Ollama-first VLM replay worker 必須有 runtime monitoring | V10.751 起 `/api/ai-automation/pixelrag-vlm-replay-worker``scripts/ops/run_pixelrag_vlm_replay_worker.py``/api/ai-automation/smoke``/api/ai-automation/scheduled-health-summary` 必須輸出 controlled VLM replay worker / `pixelrag_vlm_replay_worker` familyreadback 預設 dry-run不呼叫模型、不寫 artifactexecute 模式只讀 saved tiles、呼叫 approved Ollama VLM route、驗證 JSON field confidence/evidence refs並只寫 artifact receiptmodel_error 也必須寫 failure artifact receiptreceipt 檔內需自證 `artifact_write_performed=true``receipt_path`。此 worker 明確標記 `writes_database=false``writes_ai_insights=false``writes_price_tables=false``secret_read=false``primary_human_gate_count=0`blocked page 不得輸出商品欄位ready VLM 結果仍需 identity matcher replay 與 PromotionGate。 |
| 2026-07-09 | PixelRAG VLM route readiness 必須有 runtime monitoring 與 execute 前自動選模 | V10.752 起 `/api/ai-automation/pixelrag-vlm-route-readiness``scripts/ops/report_pixelrag_vlm_route_readiness.py``/api/ai-automation/smoke``/api/ai-automation/scheduled-health-summary` 必須輸出 read-only approved Ollama route/model readiness / `pixelrag_vlm_route_readiness` familyreadback 只打 `/api/tags`,不呼叫 `/api/generate`,輸出 reachable host、configured model available count、candidate model、candidate host、model_route_ready 與 next machine action。`run_pixelrag_vlm_replay_worker.py --execute` 預設必須使用此 readback 自動選擇已安裝候選模型;完全沒有候選時寫 `model_route_not_ready` artifact receipt不得盲打 missing model。 |
| 2026-07-10 | PixelRAG VLM execute preflight 必須驗證 generate route | V10.753 起 `/api/ai-automation/pixelrag-vlm-route-readiness?probe_generate=true``scripts/ops/report_pixelrag_vlm_route_readiness.py --probe-generate` 可對已安裝候選模型執行極小 `/api/generate` preflightsmoke 預設仍不呼叫模型。`run_pixelrag_vlm_replay_worker.py --execute` 預設先執行 generate preflight若 GCP-A direct / 110 proxy timeout、GCP-B candidate 雖安裝但 generate 不健康worker 必須在送入 screenshot tiles 前寫 `model_route_not_ready` artifact receipt輸出 `tag_model_route_ready``generate_route_ready``route_model_call_performed``tile_model_call_performed=false`,下一步固定為 `repair_ollama_vlm_generate_runtime_or_proxy_timeout`;不得把 tags 可見誤當 VLM 可執行。 |
| 2026-07-10 | PixelRAG VLM tile generate 必須綁定 preflight 選出的 host / exact model | V10.754 起 `run_pixelrag_vlm_replay_worker.py --execute` 若 route readiness 已輸出 `candidate_host`tile VLM generate 必須使用該 approved host 與 exact `candidate_model` 直呼 `/api/generate`,不得重新進入全域 Ollama resolver、不得因 111 fallback 規則把 VLM 模型降級成文字模型、不得再出現 preflight 選 111 但 tile generate tried GCP-B/GCP-A/111 的 route drift。receipt 必須輸出 `route_candidate_host`,模型錯誤時下一步為 `repair_ollama_vlm_generate_runtime_or_proxy_timeout`,仍然不寫 DB、不寫正式價格表。 |
| 2026-06-29 | PChome DB apply 授權 lane 必須先通過 no-write guard / decision preflight / decision closeout / issuer gate / signing-decision preflight / signing-decision closeout / signing-issuer guard | V10.725 的 PChome mapping backlog auto-policy 已新增 `/api/ai/pchome-growth/mapping-backlog/auto-policy-db-apply-authorization-lane-guard``/api/ai/pchome-growth/mapping-backlog/auto-policy-db-apply-authorization-decision-preflight``/api/ai/pchome-growth/mapping-backlog/auto-policy-db-apply-authorization-decision-closeout``/api/ai/pchome-growth/mapping-backlog/auto-policy-db-apply-authorization-issuer-gate``/api/ai/pchome-growth/mapping-backlog/auto-policy-db-apply-authorization-signing-decision-preflight``/api/ai/pchome-growth/mapping-backlog/auto-policy-db-apply-authorization-signing-decision-closeout``/api/ai/pchome-growth/mapping-backlog/auto-policy-db-apply-authorization-signing-issuer-guard`;這些 endpoint 只驗證 final exact request package、same-run production truth requirement、secret rejection、rollback boundary、lane entry requirements、decision input requirements、rejection policy、post-apply verifier、future authorization decision package、final nonsecret authorization envelope、signing decision preflight inputs、unsigned signing decision package 與 signable request boundary不讀 secret、不執行 shell/SQL、不寫 DB也不簽發 database apply authorization。 |
| 2026-06-29 | PChome DB apply 授權簽署發行者 lane 必須先產出 final signable request package | V10.725 的 PChome mapping backlog auto-policy 新增 `/api/ai/pchome-growth/mapping-backlog/auto-policy-db-apply-authorization-signing-issuer-closeout`;此 endpoint 只把 signing-issuer guard 的 signable request boundary 收斂成 final signable request package 與 closeout contract確認 fresh production truth、post-apply verifier、migration hash、secret boundary 與 no-side-effect checks不讀 secret、不簽發 authorization、不執行 shell/SQL、不寫 DB也不代表正式 DB apply 已授權。 |
| 2026-06-29 | PChome DB apply 授權簽署執行 lane 必須先通過 operator-held secret boundary preflight | V10.725 的 PChome mapping backlog auto-policy 新增 `/api/ai/pchome-growth/mapping-backlog/auto-policy-db-apply-authorization-signing-execution-preflight`;此 endpoint 只把 final signable request package 轉成 future signing execution preflight package、operator-held secret boundary contract、nonsecret signing inputs、command-shape preview、rollback boundary 與 abort conditions不讀 secret、不接受 plaintext secret、不簽發 authorization、不執行 shell/SQL、不寫 DB也不代表正式 DB apply 已授權。 |

View File

@@ -16,7 +16,15 @@ from datetime import datetime, timezone
from pathlib import Path
from typing import Any, Mapping
from services.ollama_service import OllamaService, get_host_label, get_provider_tag
import requests
from services.ollama_service import (
OllamaResponse,
OllamaService,
get_host_label,
get_provider_tag,
is_approved_ollama_host,
)
from services.pixelrag_crawler_integration_service import (
DEFAULT_ARTIFACT_MAX_AGE_HOURS,
DEFAULT_ARTIFACT_ROOT,
@@ -223,6 +231,78 @@ def _validate_model_payload(
}
def _generate_exact_host(
prompt: str,
*,
host: str,
model: str,
temperature: float,
timeout: int,
options: Mapping[str, Any] | None,
images: list[str],
) -> OllamaResponse:
"""Call the route-readiness selected host without fallback or model downgrade."""
clean_host = str(host or "").rstrip("/")
if not is_approved_ollama_host(clean_host):
return OllamaResponse(
success=False,
content="",
model=model,
error=f"unapproved_pixelrag_vlm_candidate_host: {clean_host}",
host=clean_host or "unknown",
)
payload: dict[str, Any] = {
"model": model,
"prompt": prompt,
"stream": False,
"options": {"temperature": temperature},
}
if options:
payload["options"].update(dict(options))
if images:
payload["images"] = images
try:
response = requests.post(
f"{clean_host}/api/generate",
json=payload,
timeout=max(1, int(timeout or DEFAULT_TIMEOUT_SECONDS)),
)
if response.status_code != 200:
return OllamaResponse(
success=False,
content="",
model=model,
error=f"HTTP {response.status_code}: {response.text[:RAW_EXCERPT_LIMIT]}",
host=clean_host,
)
data = response.json()
return OllamaResponse(
success=True,
content=data.get("response", ""),
model=model,
total_duration=(data.get("total_duration", 0) or 0) / 1e9,
host=clean_host,
input_tokens=int(data.get("prompt_eval_count", 0) or 0),
output_tokens=int(data.get("eval_count", 0) or 0),
)
except requests.Timeout:
return OllamaResponse(
success=False,
content="",
model=model,
error=f"timeout ({max(1, int(timeout or DEFAULT_TIMEOUT_SECONDS))}s)",
host=clean_host,
)
except Exception as exc:
return OllamaResponse(
success=False,
content="",
model=model,
error=f"{type(exc).__name__}: {str(exc)[:RAW_EXCERPT_LIMIT]}",
host=clean_host,
)
def _write_replay_receipt(
*,
output_root: Path,
@@ -324,6 +404,7 @@ def _execute_item(
root: Path,
output_root: Path,
model: str,
route_host: str | None,
timeout: int,
tile_limit: int,
write_receipt: bool,
@@ -335,6 +416,7 @@ def _execute_item(
"source_receipt_path": item.get("source_receipt_path"),
"worker_status": "executing",
"model": model,
"route_candidate_host": str(route_host or ""),
"tile_evidence": tile_evidence,
"tile_image_count": len(images),
"model_call_performed": bool(images),
@@ -348,14 +430,27 @@ def _execute_item(
})
return base
response = OllamaService(model=model).generate(
_prompt_for_item(item),
model=model,
temperature=0.1,
timeout=max(10, int(timeout or DEFAULT_TIMEOUT_SECONDS)),
options={"num_predict": 700, "num_ctx": 4096},
images=images,
)
prompt = _prompt_for_item(item)
options = {"num_predict": 700, "num_ctx": 4096}
if route_host:
response = _generate_exact_host(
prompt,
host=route_host,
model=model,
temperature=0.1,
timeout=max(10, int(timeout or DEFAULT_TIMEOUT_SECONDS)),
options=options,
images=images,
)
else:
response = OllamaService(model=model).generate(
prompt,
model=model,
temperature=0.1,
timeout=max(10, int(timeout or DEFAULT_TIMEOUT_SECONDS)),
options=options,
images=images,
)
base.update({
"host": response.host,
"host_label": get_host_label(response.host or ""),
@@ -369,7 +464,11 @@ def _execute_item(
base.update({
"worker_status": "model_error",
"model_error": str(response.error or "")[:RAW_EXCERPT_LIMIT],
"next_machine_action": "repair_ollama_vlm_runtime_or_model_route",
"next_machine_action": (
"repair_ollama_vlm_generate_runtime_or_proxy_timeout"
if route_host
else "repair_ollama_vlm_runtime_or_model_route"
),
})
if write_receipt:
base["receipt_path"] = _write_replay_receipt(
@@ -454,6 +553,7 @@ def run_pixelrag_ollama_vlm_replay_worker(
item_limit = max(1, min(int(limit or DEFAULT_LIMIT), 250))
tiles = max(1, min(int(tile_limit or DEFAULT_TILE_LIMIT), 12))
selected_model = str(model or DEFAULT_MODEL)
selected_route_host = ""
selected_timeout = max(10, int(timeout or DEFAULT_TIMEOUT_SECONDS))
readiness_timeout = max(1, min(int(route_readiness_timeout or 3), 20))
generate_probe_timeout = max(
@@ -489,6 +589,7 @@ def run_pixelrag_ollama_vlm_replay_worker(
model_route_ready = bool(route_summary.get("model_route_ready"))
if candidate_model:
selected_model = candidate_model
selected_route_host = str(route_summary.get("candidate_host") or "").strip()
worker_items: list[dict[str, Any]] = []
for item in replay_items:
@@ -511,6 +612,7 @@ def run_pixelrag_ollama_vlm_replay_worker(
root=root,
output_root=output,
model=selected_model,
route_host=selected_route_host,
timeout=selected_timeout,
tile_limit=tiles,
write_receipt=write_receipt,
@@ -607,6 +709,7 @@ def run_pixelrag_ollama_vlm_replay_worker(
"tile_limit": tiles,
"model": selected_model,
"configured_model": str(model or DEFAULT_MODEL),
"route_candidate_host": selected_route_host,
"timeout_seconds": selected_timeout,
"execute": bool(execute),
"write_receipt": bool(write_receipt),

View File

@@ -204,44 +204,41 @@ def test_pixelrag_vlm_replay_worker_auto_selects_installed_candidate(tmp_path, m
},
)
class FakeOllama:
def __init__(self, model):
self.model = model
def generate(self, *args, **kwargs):
assert kwargs["model"] == "gemma3:4b"
return SimpleNamespace(
success=True,
content=json.dumps({
"blocked_page_detected": False,
"fields": {
"title": {
"value": "防曬乳 SPF50",
"confidence": 0.92,
"evidence_refs": ["tile:1"],
},
"price": {
"value": "399",
"confidence": 0.88,
"evidence_refs": ["tile:1"],
},
def fake_generate_exact_host(prompt, **kwargs):
assert kwargs["host"] == "http://34.21.145.224:11434"
assert kwargs["model"] == "gemma3:4b"
return SimpleNamespace(
success=True,
content=json.dumps({
"blocked_page_detected": False,
"fields": {
"title": {
"value": "防曬乳 SPF50",
"confidence": 0.92,
"evidence_refs": ["tile:1"],
},
"quality": {
"overall_confidence": 0.90,
"missing_required_fields": [],
"requires_identity_matcher_replay": True,
"requires_promotion_gate": True,
"price": {
"value": "399",
"confidence": 0.88,
"evidence_refs": ["tile:1"],
},
}),
model="gemma3:4b",
error=None,
total_duration=1.0,
host="http://34.21.145.224:11434",
input_tokens=10,
output_tokens=50,
)
},
"quality": {
"overall_confidence": 0.90,
"missing_required_fields": [],
"requires_identity_matcher_replay": True,
"requires_promotion_gate": True,
},
}),
model="gemma3:4b",
error=None,
total_duration=1.0,
host="http://34.21.145.224:11434",
input_tokens=10,
output_tokens=50,
)
monkeypatch.setattr(service, "OllamaService", FakeOllama)
monkeypatch.setattr(service, "_generate_exact_host", fake_generate_exact_host)
payload = service.run_pixelrag_ollama_vlm_replay_worker(
artifact_root=tmp_path,
platform="shopee_tw",
@@ -253,7 +250,9 @@ def test_pixelrag_vlm_replay_worker_auto_selects_installed_candidate(tmp_path, m
assert payload["model"] == "gemma3:4b"
assert payload["configured_model"] == "minicpm-v:latest"
assert payload["route_candidate_host"] == "http://34.21.145.224:11434"
assert payload["route_readiness"]["summary"]["candidate_model"] == "gemma3:4b"
assert payload["worker_items"][0]["route_candidate_host"] == "http://34.21.145.224:11434"
assert payload["summary"]["executed_ok_count"] == 1