diff --git a/config.py b/config.py index 09a7e08..0949ea2 100644 --- a/config.py +++ b/config.py @@ -414,7 +414,7 @@ YOUTUBE_API_KEY = os.getenv('YOUTUBE_API_KEY', '') # ========================================== # 系統版本與路徑 # ========================================== -SYSTEM_VERSION = "V10.795" +SYSTEM_VERSION = "V10.796" 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 fc338f1..f86360b 100644 --- a/docs/AI_INTELLIGENCE_MODULE_SOT.md +++ b/docs/AI_INTELLIGENCE_MODULE_SOT.md @@ -1,8 +1,17 @@ # PChome 業績成長自動化作戰系統 — AI 競價情報模組 Single Source of Truth > **最後更新**: 2026-07-15 (台北時間) -> **狀態**: 🟠 Partial。V10.795 已把 PChome TOP50 比價主指標改為業績加權覆蓋、建立 MOMO 同商品 controlled apply 與 durable receipt/post-verifier,並以報表到檔 SLA 取代午夜切日造成的誤判;production 桌機/手機可見驗證已通過。目前業績覆蓋為 40%,但仍有 45/50 商品未形成正式同商品證據、外部平台 runtime 僅 1/15。SEC-P0-001 已完成 production closure,SEC-P0-002 維持 production hybrid canary-ready;full asset reconciliation、software supply chain、統一 controlled-apply envelope、restore drill、internal RAG canary 與 MCP/RAG runtime closure 尚未全部完成。任何局部 receipt 不得代表整體閉環完成。 -> **適用版本**: V10.795 +> **狀態**: 🟠 Partial。V10.796 source 新增 EwoooC / MOMO 兩個 canonical product 的公開唯讀 MCP/RAG aggregate receipt,但 production 部署與 AWOOOI durable federation readback 尚待同一 release 驗證;目前 production 仍以 V10.795 runtime truth 為準。V10.795 已把 PChome TOP50 比價主指標改為業績加權覆蓋、建立 MOMO 同商品 controlled apply 與 durable receipt/post-verifier,並以報表到檔 SLA 取代午夜切日造成的誤判;production 桌機/手機可見驗證已通過。目前業績覆蓋為 40%,但仍有 45/50 商品未形成正式同商品證據、外部平台 runtime 僅 1/15。SEC-P0-001 已完成 production closure,SEC-P0-002 維持 production hybrid canary-ready;full asset reconciliation、software supply chain、統一 controlled-apply envelope、restore drill、internal RAG canary 與 MCP/RAG runtime closure 尚未全部完成。任何局部 receipt 不得代表整體閉環完成。 +> **適用版本**: V10.796 source;production runtime 需由 `/health` 與 federation post-verifier 另行確認 + +--- + +## 零之負三、AWOOOI MCP federation 公開唯讀契約(V10.796 source) + +- 新增 `/api/public/mcp-federation-readback`,只輸出 `ewoooc` 與 `momo-pro-system` 兩個 canonical identity 的 aggregate metadata;既有需要登入的 AI automation API 不放寬權限。 +- 每個 product receipt 僅包含 runtime version/health、MCP server IDs 與 counts、pgvector embedding metadata、PixelRAG platform/stage、blockers、Asia/Taipei timestamp,以及可由接收端重算的 SHA-256 fingerprint;不輸出 endpoint URL、tool registry、credential、request/response body、tool output、RAG 內容或正式商品價格。 +- operation boundary 固定為唯讀:不得 network call、DB write、secret read、external tool call、RAG write 或 production price write。PixelRAG 仍只允許 visual evidence receipt,不得直接寫正式價差或 `ai_insights`。 +- receipt 會誠實揭露 `:latest` embedding model 等 immutable supply-chain 缺口;本次 source/test 綠燈不代表 production 已部署,也不代表 `MCP-P0-001`、`RAG-P0-001` 或 12 產品 federation 已完成。 --- diff --git a/docs/guides/ai_automation_mainline_work_items.md b/docs/guides/ai_automation_mainline_work_items.md index 04d7c22..e35f013 100644 --- a/docs/guides/ai_automation_mainline_work_items.md +++ b/docs/guides/ai_automation_mainline_work_items.md @@ -24,7 +24,7 @@ | 6 | `GOV-P0-001` | In progress | Canonical full asset graph + runtime reconciliation | `governance/ewoooc_asset_inventory.json` seeds hosts, services, data, AI, routes, supply chain, observability and recovery. Exit: same-run probe receipt for every asset; drift auto-creates work items. | | 7 | `GOV-P0-002` | Not started | Unified controlled-apply envelope | Introduce one `trace_id/run_id/work_item_id` across sensor, identity, SOT diff, decision, risk, dry-run, execution, verifier, rollback/retry and learning acknowledgement. Start with EventRouter + AutoHeal. | | 8 | `RAG-P0-001` | Not started | Internal RAG candidate canary | V10.770 stops at candidate preview. Exit: bounded pgvector candidate canary, signature/readback/feedback receipt, no price write and no premature `ai_insights` promotion. Next: `run_internal_rag_candidate_canary`. | -| 9 | `MCP-P0-001` | In progress | MCP/RAG production runtime closure | Registry and smoke wiring exist, but runtime flags/ports must be live. Exit: MCP servers healthy, router enabled, RAG enabled, approved caller/tool boundary and production query canary pass. | +| 9 | `MCP-P0-001` | In progress (`federation_source_ready`) | MCP/RAG production runtime closure | V10.796 source adds a strict public aggregate receipt for canonical `ewoooc` and `momo-pro-system` identities without opening authenticated internal APIs or exposing endpoint/tool payload data. Exit still requires V10.796 production `/health`, two fresh AWOOOI durable receipts with fingerprint recompute, live MCP servers/router/RAG, approved caller/tool boundary and production query canary. Current source readiness must not be reported as runtime closure. | | 10 | `SEC-P0-004` | Not started | Security operations lifecycle and metrics | Add durable security incident state and publish MTTA, MTTR, recurrence, false positive, human intervention, verifier pass, rollback and freshness. Exit: detect-to-learn production receipt. | | 11 | `REL-P0-001` | In progress | Formal deploy and visible proof discipline | Gitea runtime source is main `11d3865dfc15f8b52909ac853c7177a19fa7d5ee`; dev carries the same runtime changes through `fa94c31`. Action #1127 is waiting because no matching `ewoooc-host` runner is online, so it provides no execution evidence. A bounded exact-Git-object fallback deployed V10.795 after 10/10 staging and target SHA-256 checks; archive SHA-256 is `eb3f8618fb4a151e1e96671b0c16aefc35d561529cfc9e8a1fca4f14cca05217` and rollback backup is `/home/ollama/momo-deploy-backups/ewoooc-20260714T174327Z-11d3865`. Production `/health` is healthy at V10.795; all three application containers are healthy and retain their expected identities; `momo-db` remained running and unchanged at `cd092451cb5fd555d0ffff70642e109f3b742882c418beeab631793d1e9dc55d`. Desktop/mobile visible QA confirms the SLA label, 40% revenue KPI and no horizontal overflow. Next: restore the dedicated runner and require commit-bound automated CD/post-verifier proof for subsequent runtime changes. | diff --git a/routes/system_public_routes.py b/routes/system_public_routes.py index 402b2b9..d593088 100644 --- a/routes/system_public_routes.py +++ b/routes/system_public_routes.py @@ -1177,6 +1177,32 @@ def ai_automation_external_mcp_rag_integration_api(): )) +@system_public_bp.route('/api/public/mcp-federation-readback') +def public_mcp_federation_readback_api(): + """公開 aggregate-only MCP/RAG receipt;不放寬既有登入後 API。""" + from services.external_mcp_rag_integration_service import ( + PUBLIC_FEDERATION_SCHEMA_VERSION, + build_public_mcp_federation_receipt, + ) + + try: + return jsonify(build_public_mcp_federation_receipt( + runtime_version=SYSTEM_VERSION, + )) + except Exception: + sys_log.error( + "[MCPFederation] [PublicReadback] failed to build aggregate receipt", + exc_info=True, + ) + return jsonify({ + 'success': False, + 'schema_version': PUBLIC_FEDERATION_SCHEMA_VERSION, + 'status': 'degraded_with_safe_next_action', + 'error_class': 'federation_receipt_build_failed', + 'next_machine_action': 'inspect_internal_service_logs_no_public_detail', + }), 503 + + @system_public_bp.route('/api/ai-automation/security-governance-review') @login_required def ai_automation_security_governance_review_api(): diff --git a/services/external_mcp_rag_integration_service.py b/services/external_mcp_rag_integration_service.py index 282bb05..feafe2e 100644 --- a/services/external_mcp_rag_integration_service.py +++ b/services/external_mcp_rag_integration_service.py @@ -3,12 +3,31 @@ from __future__ import annotations import os +import hashlib +import json from copy import deepcopy from datetime import datetime, timezone from typing import Any +from zoneinfo import ZoneInfo POLICY = "read_only_external_mcp_rag_integration_readback_v1" +PUBLIC_FEDERATION_SCHEMA_VERSION = "ewoooc_mcp_federation_receipt_v1" +PUBLIC_FEDERATION_POLICY = "public_aggregate_read_only_mcp_federation_v1" +TAIPEI_TZ = ZoneInfo("Asia/Taipei") + +_FEDERATED_PRODUCTS = ( + { + "product_id": "ewoooc", + "canonical_repo": "wooo/ewoooc", + "runtime_role": "mcp_rag_automation_source_plane", + }, + { + "product_id": "momo-pro-system", + "canonical_repo": "wooo/momo-pro-system", + "runtime_role": "commerce_runtime_consumer_plane", + }, +) def _enabled(value: str | None) -> bool: @@ -25,6 +44,173 @@ def _controlled_apply_boundary() -> dict[str, bool]: } +def _public_operation_boundaries() -> dict[str, bool]: + """V-New: 公開 receipt 只允許無副作用的聚合狀態讀回。""" + return { + "read_only_observation_allowed": True, + "network_call_allowed": False, + "db_write_allowed": False, + "secret_read_allowed": False, + "external_tool_call_allowed": False, + "rag_write_allowed": False, + "production_price_write_allowed": False, + "request_or_response_body_storage_allowed": False, + } + + +def _safe_mcp_summary(snapshot: dict[str, Any]) -> dict[str, Any]: + servers = snapshot.get("servers") + servers = servers if isinstance(servers, dict) else {} + registry = snapshot.get("tool_registry") + registry = registry if isinstance(registry, dict) else {} + tool_count = 0 + for server_map in registry.values(): + if not isinstance(server_map, dict): + continue + for tools in server_map.values(): + if isinstance(tools, (list, tuple, set)): + tool_count += len(tools) + return { + "enabled": snapshot.get("enabled") is True, + "server_count": len(servers), + "server_ids": sorted(str(server_id) for server_id in servers), + "caller_count": int(snapshot.get("caller_count") or 0), + "tool_count": tool_count, + } + + +def _safe_rag_summary(snapshot: dict[str, Any]) -> dict[str, Any]: + model = str(snapshot.get("embedding_model") or "") + return { + "enabled": snapshot.get("enabled") is True, + "vector_store": str(snapshot.get("vector_store") or "pgvector"), + "embedding_model": model, + "embedding_dim": int(snapshot.get("embedding_dim") or 0), + "embedding_signature": str(snapshot.get("embedding_signature") or ""), + "embedding_version_state": ( + "floating_tag_detected" if model.endswith(":latest") else "explicit_or_unknown" + ), + } + + +def _safe_pixelrag_summary(snapshot: dict[str, Any]) -> dict[str, Any]: + return { + "enabled": snapshot.get("enabled") is True, + "platform_count": int(snapshot.get("platform_count") or 0), + "platforms": sorted( + str(platform) for platform in snapshot.get("platforms") or [] + ), + "visual_rag_stage": str( + snapshot.get("visual_rag_stage") or "unavailable" + ), + "formal_product_write_allowed": False, + } + + +def compute_public_federation_receipt_fingerprint( + receipt: dict[str, Any], +) -> str: + """Return the verifier-recomputable hash for normalized public fields.""" + covered = { + key: value + for key, value in receipt.items() + if key not in {"receipt_id", "observed_at", "integrity"} + } + canonical = json.dumps( + covered, + ensure_ascii=False, + sort_keys=True, + separators=(",", ":"), + ) + return hashlib.sha256(canonical.encode("utf-8")).hexdigest() + + +def build_public_mcp_federation_receipt( + *, + runtime_version: str, + observed_at: str | None = None, +) -> dict[str, Any]: + """Build two product-scoped, aggregate-only MCP/RAG runtime receipts. + + This function performs no network call, database read/write, tool call, or + RAG write. Internal endpoint URLs and raw tool registries are deliberately + reduced to stable IDs and counts before reaching the public response. + """ + timestamp = observed_at or datetime.now(TAIPEI_TZ).isoformat(timespec="seconds") + mcp = _safe_mcp_summary(_mcp_runtime_snapshot()) + rag = _safe_rag_summary(_rag_runtime_snapshot()) + pixelrag = _safe_pixelrag_summary(_pixelrag_snapshot()) + + blockers: list[str] = [] + if not mcp["enabled"]: + blockers.append("mcp_router_runtime_not_enabled") + if mcp["server_count"] == 0 or mcp["tool_count"] == 0: + blockers.append("mcp_runtime_inventory_empty") + if not rag["enabled"]: + blockers.append("rag_runtime_not_enabled") + if not rag["embedding_signature"]: + blockers.append("rag_embedding_signature_missing") + if rag["embedding_version_state"] == "floating_tag_detected": + blockers.append("rag_embedding_model_not_immutable") + if not pixelrag["enabled"]: + blockers.append("pixelrag_runtime_not_enabled") + blockers = sorted(blockers) + + normalized_runtime = { + "mcp": mcp, + "rag": rag, + "pixelrag": pixelrag, + } + receipts: list[dict[str, Any]] = [] + for product in _FEDERATED_PRODUCTS: + receipt: dict[str, Any] = { + "receipt_schema_version": PUBLIC_FEDERATION_SCHEMA_VERSION, + "product_id": product["product_id"], + "canonical_repo": product["canonical_repo"], + "runtime_role": product["runtime_role"], + "runtime_version": str(runtime_version or "unknown"), + "health_status": "ready" if not blockers else "partial_degraded", + "runtime": deepcopy(normalized_runtime), + "data_boundaries": { + "scope": "aggregate_runtime_metadata_only", + "pixelrag": "public_visual_receipts_no_formal_product_write", + "rag": "pgvector_internal_only_no_public_content", + "mcp": "server_ids_and_counts_only_no_endpoint_or_payload", + }, + "operation_boundaries": _public_operation_boundaries(), + "blockers": list(blockers), + } + fingerprint = compute_public_federation_receipt_fingerprint(receipt) + receipt["observed_at"] = timestamp + receipt["receipt_id"] = ( + f"mcp-federation:{product['product_id']}:{fingerprint[:16]}" + ) + receipt["integrity"] = { + "algorithm": "sha256", + "canonicalization": "json_sort_keys_compact_v1", + "excluded_fields": ["receipt_id", "observed_at", "integrity"], + "normalized_fingerprint_sha256": fingerprint, + } + receipts.append(receipt) + + return { + "success": True, + "schema_version": PUBLIC_FEDERATION_SCHEMA_VERSION, + "policy": PUBLIC_FEDERATION_POLICY, + "generated_at": timestamp, + "status": "ready" if not blockers else "partial_degraded", + "receipt_count": len(receipts), + "receipts": receipts, + "blockers": blockers, + "operation_boundaries": _public_operation_boundaries(), + "next_machine_action": ( + "pin_embedding_model_and_verify_runtime_receipts" + if blockers + else "continue_scheduled_read_only_federation" + ), + } + + def _status_counts(items: list[dict[str, Any]]) -> dict[str, int]: counts: dict[str, int] = {} for item in items: @@ -304,5 +490,9 @@ def build_external_mcp_rag_integration_readback( __all__ = [ "POLICY", + "PUBLIC_FEDERATION_POLICY", + "PUBLIC_FEDERATION_SCHEMA_VERSION", "build_external_mcp_rag_integration_readback", + "build_public_mcp_federation_receipt", + "compute_public_federation_receipt_fingerprint", ] diff --git a/tests/test_external_mcp_rag_integration_service.py b/tests/test_external_mcp_rag_integration_service.py index 3f54d55..c7bfb18 100644 --- a/tests/test_external_mcp_rag_integration_service.py +++ b/tests/test_external_mcp_rag_integration_service.py @@ -71,3 +71,102 @@ def test_external_mcp_rag_ai_automation_route_returns_readback(): assert payload["success"] is True assert {item["family"] for item in payload["inventory"]} == {"external_mcp"} assert payload["runtime"]["mcp"]["caller_count"] >= 1 + + +def test_public_federation_receipt_preserves_two_product_identities(monkeypatch): + from services import external_mcp_rag_integration_service as service + + monkeypatch.setattr(service, "_mcp_runtime_snapshot", lambda: { + "enabled": True, + "servers": {"firecrawl": "internal", "postgres": "internal"}, + "caller_count": 2, + "tool_registry": { + "collector": {"firecrawl": ["scrape_url"]}, + "diagnostics": {"postgres": ["query"]}, + }, + }) + monkeypatch.setattr(service, "_rag_runtime_snapshot", lambda: { + "enabled": True, + "vector_store": "pgvector", + "embedding_model": "bge-m3:latest", + "embedding_dim": 1024, + "embedding_signature": "signature-v1", + }) + monkeypatch.setattr(service, "_pixelrag_snapshot", lambda: { + "enabled": True, + "platform_count": 2, + "platforms": ["shopee", "coupang"], + "visual_rag_stage": "phase1_visual_evidence_receipts", + }) + + payload = service.build_public_mcp_federation_receipt( + runtime_version="V10.796", + observed_at="2026-07-15T10:00:00+08:00", + ) + receipts = {item["product_id"]: item for item in payload["receipts"]} + + assert payload["receipt_count"] == 2 + assert set(receipts) == {"ewoooc", "momo-pro-system"} + assert receipts["ewoooc"]["canonical_repo"] == "wooo/ewoooc" + assert receipts["momo-pro-system"]["canonical_repo"] == "wooo/momo-pro-system" + assert payload["status"] == "partial_degraded" + assert "rag_embedding_model_not_immutable" in payload["blockers"] + assert receipts["ewoooc"]["runtime"]["mcp"]["tool_count"] == 2 + assert receipts["ewoooc"]["operation_boundaries"]["db_write_allowed"] is False + assert receipts["ewoooc"]["operation_boundaries"]["secret_read_allowed"] is False + for receipt in receipts.values(): + assert ( + service.compute_public_federation_receipt_fingerprint(receipt) + == receipt["integrity"]["normalized_fingerprint_sha256"] + ) + + +def test_public_federation_receipt_does_not_expose_internal_endpoints(monkeypatch): + from services import external_mcp_rag_integration_service as service + + monkeypatch.setattr(service, "_mcp_runtime_snapshot", lambda: { + "enabled": True, + "servers": {"firecrawl": "http://192.168.0.188:3002"}, + "caller_count": 1, + "tool_registry": {"collector": {"firecrawl": ["scrape_url"]}}, + }) + payload = service.build_public_mcp_federation_receipt( + runtime_version="V10.796", + observed_at="2026-07-15T10:00:00+08:00", + ) + serialized = json.dumps(payload, ensure_ascii=False) + + assert "192.168." not in serialized + assert "http://" not in serialized + assert "https://" not in serialized + assert "scrape_url" not in serialized + + +def test_public_federation_route_is_anonymous_aggregate_only(monkeypatch): + from flask import Flask + from routes import system_public_routes as routes + from services import external_mcp_rag_integration_service as service + + monkeypatch.setattr(service, "build_public_mcp_federation_receipt", lambda **_: { + "success": True, + "schema_version": service.PUBLIC_FEDERATION_SCHEMA_VERSION, + "policy": service.PUBLIC_FEDERATION_POLICY, + "status": "ready", + "receipt_count": 2, + "receipts": [ + {"product_id": "ewoooc"}, + {"product_id": "momo-pro-system"}, + ], + }) + app = Flask(__name__) + app.register_blueprint(routes.system_public_bp) + + response = app.test_client().get("/api/public/mcp-federation-readback") + payload = response.get_json() + + assert response.status_code == 200 + assert payload["receipt_count"] == 2 + assert {item["product_id"] for item in payload["receipts"]} == { + "ewoooc", + "momo-pro-system", + }