From feeef99474bc06f384390986eda92b8161066d0b Mon Sep 17 00:00:00 2001 From: ogt Date: Fri, 3 Jul 2026 00:41:49 +0800 Subject: [PATCH] feat(ux): add sitewide ui ux agent --- docs/AI_INTELLIGENCE_MODULE_SOT.md | 1 + .../guides/external_professional_benchmark.md | 1 + .../pchome_ai_automation_priority_backlog.md | 10 +- routes/README.md | 2 +- routes/system_public_routes.py | 28 ++ services/ai_surface_html_readback_service.py | 416 ++++++++++++++++++ templates/ai_history.html | 27 +- templates/ai_recommend.html | 2 +- tests/test_ai_automation_smoke_service.py | 101 +++++ 9 files changed, 584 insertions(+), 4 deletions(-) diff --git a/docs/AI_INTELLIGENCE_MODULE_SOT.md b/docs/AI_INTELLIGENCE_MODULE_SOT.md index 4f425ae..18a7a62 100644 --- a/docs/AI_INTELLIGENCE_MODULE_SOT.md +++ b/docs/AI_INTELLIGENCE_MODULE_SOT.md @@ -109,6 +109,7 @@ - 2026-07-03 起 `/observability/ppt_audit_history` 必須採密集 AI 視覺 QA 工作台:首屏使用 `data-density-guardrail="compact-ppt-workbench"`,以「預覽就緒、視覺審核、修復閉環」短標籤承接 PPT 產線狀態;hero 說明、KPI 小註、runtime/env var、DB/table wording 與頁尾重複標題不得佔用第一層視覺,畫面先保留最新簡報、產線健康度、最近可預覽簡報、工作隊列、視覺問題追蹤、產出紀錄與修復流程。 - 2026-07-03 起高可見 AI observability surfaces 必須有 route-level rendered HTML readback guard;`/observability/overview`、`/observability/ai_calls`、`/observability/quality_trend`、`/observability/budget`、`/observability/business_intel`、`/observability/host_health`、`/observability/rag_queries`、`/observability/agent_orchestration`、`/observability/promotion_review`、`/observability/ppt_audit_history` 必須在 Flask client render 後仍保留 compact density marker,且不得漏出 `PPT_VISION_ENABLED=`、`PPT_AUTO_GENERATION_ENABLED=`、`ppt_generation_runs`、DB write raw wording、manual/human gate primary wording、Artifact retention / Compact 回讀等產品面不該可見的 engineering copy。 - 2026-07-03 起高可見 AI surface HTML readback 必須納入 runtime AI smoke:`/api/ai-automation/surface-html-readback` 會 read-only 檢查 10 個 AI observability template contract,`/api/ai-automation/smoke` 需包含 `AI surface HTML readback` 檢查,`/api/ai-automation/scheduled-health-summary` 需輸出 `ai_surface_html_readback` family;此檢查不得寫 DB、不得送通知、不得要求 browser,失敗時下一個機器動作是 `repair_ai_surface_html_contract`。 +- 2026-07-03 起整站 UI/UX 專業化必須由 Sitewide UI/UX Agent 管理:`/api/ai-automation/sitewide-ui-ux-agent` 會掃描真正頁面模板、排除 partial/components/base layouts,輸出高優先頁、compact guardrail、raw engineering issue 與 next machine action;`/api/ai-automation/sitewide-ui-ux-repair-package` 會產生 no-write controlled repair package。當前基線由 Agent 判定為 55 個頁面模板、31 個高優先模板、14 個已具 compact guardrail、15 個待專業化、0 個 raw engineering leak;`ai_history` 與 `ai_recommend` 已完成第一批 controlled UI/UX repair,下一個機器動作為 `apply_sitewide_ui_ux_controlled_repairs`。 - 2026-07-02 起 `/ai_intelligence` 商品明細與單品作戰詳情的四格價格證據必須可測:PChome 價格、MOMO 參考價、差距、可信度需以 `data-evidence` 固定,並以 `aria-label="價格證據"` 對應可掃描區塊;候選待確認或缺資料只能顯示「候選待確認 / 待補」,不得捏造價格或讓使用者打開 raw payload 才知道判斷依據。 - 2026-07-02 起 `/ai_intelligence` 必須是密集 AI 工作台,不得退回大段文字說明頁:首屏與明細可見內容只保留短狀態、數字、四格證據與下一步按鈕;KPI note、benchmark detail、alert 副句、策略說明、decision copy、來源長句與單品 reason list 不得佔用第一層視覺。`tests/test_ai_intelligence_text_density_guardrails.py` 必須鎖住 `data-density-guardrail="compact-ai-workbench"`、短任務文案、detail meta 與 hidden explanatory copy。 - 2026-07-02 起 `/observability/overview` 也必須採密集 AI 觀測工作台:首屏以 `data-density-guardrail="compact-observability-workbench"`、`AI 觀測 / 風險優先 / 下一步` 與 golden signals 先呈現狀態、數字與操作入口;hero lede、signal note、route desc、host meta 與資料來源長句不得佔用第一層視覺。`tests/test_observability_text_density_guardrails.py` 必須鎖住 compact marker 與 hidden explanatory copy。 diff --git a/docs/guides/external_professional_benchmark.md b/docs/guides/external_professional_benchmark.md index 181ff0d..2fcb8ca 100644 --- a/docs/guides/external_professional_benchmark.md +++ b/docs/guides/external_professional_benchmark.md @@ -83,6 +83,7 @@ Baymard 的商品頁與比較 UX 研究強調:使用者需要清楚的 product - AI visual QA surface rollout: `/observability/ppt_audit_history` 已套用 compact AI visual QA workbench guardrails;`tests/test_ppt_audit_text_density_guardrails.py` 鎖住首屏短標籤、預覽/審核/修復核心訊號與 non-raw env/table wording。 - Route HTML readback rollout: high-visibility AI observability surfaces 已新增 Flask route-level rendered HTML guard;`tests/test_admin_observability_routes.py::test_high_visibility_ai_surfaces_route_html_readback_keeps_compact_contract` 鎖住 10 個 route 的 compact density marker 與 non-raw engineering wording。 - Runtime HTML readback rollout: `/api/ai-automation/surface-html-readback` 與 AI smoke 已接入 high-visibility AI surface contract readback;`tests/test_ai_automation_smoke_service.py` 鎖住 10 個 surface、退化偵測、smoke check 與 scheduled health family。 +- Sitewide UI/UX Agent rollout: `/api/ai-automation/sitewide-ui-ux-agent` 掃描真正頁面模板並產生整站專業化 inventory;`/api/ai-automation/sitewide-ui-ux-repair-package` 會輸出 no-write controlled repair package,把主流產品頁原則轉成可排序、可驗證的修復項。 ## 下一步 TODO 候選 diff --git a/docs/guides/pchome_ai_automation_priority_backlog.md b/docs/guides/pchome_ai_automation_priority_backlog.md index 353838e..98d577f 100644 --- a/docs/guides/pchome_ai_automation_priority_backlog.md +++ b/docs/guides/pchome_ai_automation_priority_backlog.md @@ -101,7 +101,7 @@ ## P1 - Product Visibility And Professional Website Experience -狀態: 進行中,本輪已完成 `/ai_intelligence`、`/observability/overview`、`/ai_automation_smoke`、`/observability/agent_orchestration`、`/observability/ai_calls`、`/observability/quality_trend`、`/observability/budget`、`/observability/business_intel`、`/observability/host_health`、`/observability/rag_queries`、`/observability/promotion_review` 與 `/observability/ppt_audit_history` 密集 AI 工作台文字密度守門,並新增高可見 AI surfaces route-level HTML readback 防退化 guard 與 runtime AI smoke readback。 +狀態: 進行中,本輪已完成 `/ai_intelligence`、`/observability/overview`、`/ai_automation_smoke`、`/observability/agent_orchestration`、`/observability/ai_calls`、`/observability/quality_trend`、`/observability/budget`、`/observability/business_intel`、`/observability/host_health`、`/observability/rag_queries`、`/observability/promotion_review` 與 `/observability/ppt_audit_history` 密集 AI 工作台文字密度守門,並新增高可見 AI surfaces route-level HTML readback 防退化 guard、runtime AI smoke readback 與整站 UI/UX Agent inventory。 目的: 讓 AI 自動化在產品裡可見,成為專業營運工作流,而不是只藏在後端。 @@ -177,6 +177,11 @@ - `/api/ai-automation/smoke` 已加入 `AI surface HTML readback`,從 7 項 smoke 擴充為 8 項。 - `/api/ai-automation/scheduled-health-summary` 已輸出 `ai_surface_html_readback` family,讓排程健康摘要也能看見產品頁退化。 - 此檢查不寫 DB、不發通知、不要求 browser;失敗時 next machine action 是 `repair_ai_surface_html_contract`。 +- 整站 UI/UX Agent inventory 已完成: + - `/api/ai-automation/sitewide-ui-ux-agent` 會掃描真正頁面模板,排除 partial/components/base layouts。 + - 當前基線: 55 個頁面模板、31 個高優先模板、14 個 compact guardrail、15 個待專業化、0 個 raw engineering leak。 + - `/api/ai-automation/sitewide-ui-ux-repair-package` 會產生 no-write controlled repair package,優先修復高曝光頁,且不寫 DB、不執行 shell、不讀 secrets。 + - 第一批 controlled UI/UX repair 已套用到 `ai_history` 與 `ai_recommend`,整站 Agent 已回讀 issue count 從 17 降為 15。 已完成 / 下一步,必須照順序: @@ -189,6 +194,8 @@ 3. 增加 route / HTML readback,避免產品頁退回 raw engineering wording。 - 已完成: 高可見 AI observability surfaces 已有 route-level rendered HTML guard。 - 已完成: 高可見 AI surface readback 已接入 runtime smoke 與 scheduled health summary。 +4. 啟動整站 UI/UX Agent,將所有頁面依主流專業 guardrails 排序並產生 controlled repair package。 + - 已完成: Sitewide UI/UX Agent inventory 與 repair package API。 完成標準: @@ -308,6 +315,7 @@ | P1.11 | PPT audit compact AI visual QA workbench guardrails | 已完成 | `/observability/ppt_audit_history` first viewport hides explanatory copy and avoids env/table wording; focused density guard test | P1.12 route-level HTML readback guard | | P1.12 | High-visibility AI surface route HTML readback guard | 已完成 | 10 observability routes render compact markers and block raw engineering wording in Flask client readback | Continue P1 surface sweep and P3 automation expansion | | P1.13 | Runtime AI surface HTML readback automation | 已完成 | `/api/ai-automation/surface-html-readback` + AI smoke check + scheduled health family + focused tests | P3 safe lane expansion | +| P1.14 | Sitewide UI/UX Agent inventory and repair package | 已完成 | 55 page templates inventoried, 31 high-priority, 15 controlled repair candidates, 0 raw engineering leaks; first repairs applied to `ai_history` and `ai_recommend` | Apply next controlled UI/UX repairs | | P2.1 | External benchmark encoded into requirements | 已完成 | benchmark guide + focused guard test + first-viewport status | P3.1 safe lane expansion | | P3.1 | Extend receipt / replay / drift pattern to more lanes | 已完成 | direct mapping candidate decision lane closeout route + focused tests | P3.2 scheduled automation health summaries | | P3.2 | Scheduled automation health summaries | 已完成 | `/api/ai-automation/scheduled-health-summary` + smoke service focused tests | P3.3 rollback evidence packages | diff --git a/routes/README.md b/routes/README.md index b451166..d3e4494 100644 --- a/routes/README.md +++ b/routes/README.md @@ -14,7 +14,7 @@ |------|------|----------| | `dashboard_routes.py` | 商品看板首頁 | `/` | | `sales_routes.py` | 業績分析與 ABC 明細 | `/sales_analysis`, `/growth_analysis`, `/abc_analysis/detail`, `/api/sales_analysis/*` | -| `system_public_routes.py` | 無 prefix 公開系統頁與監控 | `/health`, `/metrics`, `/ai_automation_smoke`, `/api/ai-automation/smoke*`, `/api/ai-automation/scheduled-health-summary`, `/api/ai-automation/surface-html-readback`, `/settings`, `/system_settings`, `/logs`, `/api/logs`, `/api/backup` | +| `system_public_routes.py` | 無 prefix 公開系統頁與監控 | `/health`, `/metrics`, `/ai_automation_smoke`, `/api/ai-automation/smoke*`, `/api/ai-automation/scheduled-health-summary`, `/api/ai-automation/surface-html-readback`, `/api/ai-automation/surface-html-repair-package`, `/api/ai-automation/sitewide-ui-ux-agent`, `/api/ai-automation/sitewide-ui-ux-repair-package`, `/settings`, `/system_settings`, `/logs`, `/api/logs`, `/api/backup` | | `system_routes.py` | 內部系統維護 API | `/api/system/*` | | `edm_routes.py` | EDM 與節慶儀表板 | `/edm`, `/festival` | | `monthly_routes.py` | 月結分析 | `/monthly_summary_analysis`, `/api/monthly_summary_data` | diff --git a/routes/system_public_routes.py b/routes/system_public_routes.py index 1549a74..b07a79f 100644 --- a/routes/system_public_routes.py +++ b/routes/system_public_routes.py @@ -596,6 +596,34 @@ def ai_automation_surface_html_readback_api(): return jsonify(build_ai_surface_html_readback()) +@system_public_bp.route('/api/ai-automation/surface-html-repair-package') +@login_required +def ai_automation_surface_html_repair_package_api(): + """Read-only controlled repair package for AI product surface HTML drift.""" + from services.ai_surface_html_readback_service import build_ai_surface_html_repair_package + + return jsonify(build_ai_surface_html_repair_package()) + + +@system_public_bp.route('/api/ai-automation/sitewide-ui-ux-agent') +@login_required +def ai_automation_sitewide_ui_ux_agent_api(): + """Read-only sitewide UI/UX agent inventory for professionalization work.""" + from services.ai_surface_html_readback_service import build_sitewide_ui_ux_agent_inventory + + return jsonify(build_sitewide_ui_ux_agent_inventory()) + + +@system_public_bp.route('/api/ai-automation/sitewide-ui-ux-repair-package') +@login_required +def ai_automation_sitewide_ui_ux_repair_package_api(): + """Read-only sitewide UI/UX controlled repair package.""" + from services.ai_surface_html_readback_service import build_sitewide_ui_ux_repair_package + + limit = request.args.get('limit', 12, type=int) + return jsonify(build_sitewide_ui_ux_repair_package(limit=max(1, min(limit or 12, 50)))) + + @system_public_bp.route('/api/ai-automation/smoke/history/export') @login_required def ai_automation_smoke_history_export(): diff --git a/services/ai_surface_html_readback_service.py b/services/ai_surface_html_readback_service.py index 6754673..db46154 100644 --- a/services/ai_surface_html_readback_service.py +++ b/services/ai_surface_html_readback_service.py @@ -11,6 +11,9 @@ from config import SYSTEM_VERSION POLICY = "read_only_ai_surface_html_readback_v1" +REPAIR_POLICY = "read_only_ai_surface_html_repair_package_v1" +SITEWIDE_POLICY = "read_only_sitewide_ui_ux_agent_inventory_v1" +SITEWIDE_REPAIR_POLICY = "read_only_sitewide_ui_ux_repair_package_v1" ROOT = Path(__file__).resolve().parents[1] @@ -101,6 +104,36 @@ FORBIDDEN_PRODUCT_FRAGMENTS: tuple[str, ...] = ( "Compact 回讀", ) +HIGH_PRIORITY_TEMPLATE_HINTS: tuple[str, ...] = ( + "dashboard", + "ai_", + "ai-", + "sales", + "growth", + "price", + "pchome", + "vendor_stockout", + "observability", + "agent_orchestration", + "ai_calls", + "quality_trend", + "budget", + "business_intel", + "host_health", + "rag_queries", + "promotion_review", + "ppt_audit", +) + +PROFESSIONAL_GUARDRAIL_MARKERS: tuple[str, ...] = ( + "data-density-guardrail=", + "data-benchmark-guardrail=", + "growth-command", + "ewoooc-shell", + "momo-page-shell", + "dashboard-v2", +) + def _read_surface_html(root: Path, contract: SurfaceContract) -> str: return (root / contract.template).read_text(encoding="utf-8") @@ -206,3 +239,386 @@ def build_ai_surface_html_readback( "requires_browser": False, }, } + + +def _build_repair_item(surface: dict[str, Any]) -> dict[str, Any]: + missing_markers = list(surface.get("missing_markers") or []) + forbidden_leaks = list(surface.get("forbidden_leaks") or []) + actions: list[dict[str, Any]] = [] + + for marker in missing_markers: + actions.append({ + "action": "restore_density_guardrail_marker", + "target_template": surface.get("template"), + "expected_marker": marker, + "safe_apply_hint": ( + f"Restore data-density-guardrail=\"{marker}\" on the first-viewport workbench shell." + ), + }) + + for leak in forbidden_leaks: + actions.append({ + "action": "remove_raw_engineering_copy_from_product_surface", + "target_template": surface.get("template"), + "forbidden_fragment": leak, + "safe_apply_hint": ( + "Replace the raw engineering fragment with Traditional Chinese product language " + "or move it behind evidence-on-demand / hidden contract." + ), + }) + + return { + "route": surface.get("route"), + "template": surface.get("template"), + "label": surface.get("label"), + "status": "ready_for_controlled_repair" if actions else "no_repair_required", + "missing_markers": missing_markers, + "forbidden_leaks": forbidden_leaks, + "controlled_actions": actions, + "post_apply_verifier": { + "service_function": "build_ai_surface_html_readback", + "expected_status": "ok", + "expected_failed_count": 0, + "expected_forbidden_leak_count": 0, + }, + } + + +def build_ai_surface_html_repair_package( + *, + root: Path | str | None = None, + source_readback: dict[str, Any] | None = None, + rendered_html_by_route: dict[str, str] | None = None, +) -> dict[str, Any]: + """Build a no-write controlled repair package for failed surface readbacks.""" + readback = source_readback or build_ai_surface_html_readback( + root=root, + rendered_html_by_route=rendered_html_by_route, + ) + failed_surfaces = list(readback.get("failed_surfaces") or []) + repair_items = [_build_repair_item(surface) for surface in failed_surfaces] + action_count = sum(len(item.get("controlled_actions") or []) for item in repair_items) + ready = bool(action_count) + status = "repair_ready" if ready else "no_op" + + return { + "policy": REPAIR_POLICY, + "status": status, + "version": SYSTEM_VERSION, + "generated_at": datetime.now().isoformat(timespec="seconds"), + "source_readback_policy": readback.get("policy"), + "source_readback_status": readback.get("status"), + "summary": { + "failed_surface_count": len(failed_surfaces), + "controlled_action_count": action_count, + "forbidden_leak_count": int((readback.get("summary") or {}).get("forbidden_leak_count") or 0), + "primary_human_gate_count": 0, + "writes_database_count": 0, + "executes_shell_count": 0, + }, + "repair_items": repair_items, + "controlled_apply_contract": { + "mode": "ai_controlled_low_blast_radius", + "allowed_targets": sorted({ + str(item.get("template")) + for item in repair_items + if item.get("template") + }), + "forbidden_targets": [ + ".env", + "runtime volumes", + "database", + "secrets", + "raw sessions", + "sqlite", + ], + "requires_post_apply_verifier": True, + "post_apply_verifier": "build_ai_surface_html_readback", + "rollback_strategy": "revert_template_patch_and_rerun_surface_html_readback", + }, + "next_machine_action": ( + "apply_ai_surface_html_contract_repair" + if ready + else "keep_surface_readback_in_ai_smoke" + ), + "automation_policy": { + "primary_flow": "ai_controlled", + "manual_review_mode": "exception_only", + "machine_verifiable_evidence": True, + "primary_human_gate_count": 0, + }, + "safety": { + "read_only_package": True, + "writes_database": False, + "writes_database_count": 0, + "executes_shell": False, + "sends_notifications": False, + "requires_browser": False, + "requires_secret": False, + }, + } + + +def _iter_site_templates(root: Path) -> list[Path]: + template_root = root / "templates" + if not template_root.exists(): + return [] + return sorted( + path + for path in template_root.rglob("*") + if path.suffix in {".html", ".j2"} + and path.is_file() + and not path.name.startswith("_") + and "components" not in path.relative_to(template_root).parts + and path.name not in {"base.html", "ewoooc_base.html", "test_base.html"} + ) + + +def _relative_template_path(root: Path, path: Path) -> str: + return str(path.relative_to(root)).replace("\\", "/") + + +def _is_high_priority_template(relpath: str) -> bool: + lowered = relpath.lower() + return any(hint in lowered for hint in HIGH_PRIORITY_TEMPLATE_HINTS) + + +def _template_family(relpath: str) -> str: + lowered = relpath.lower() + if "/admin/" in lowered or "observability" in lowered: + return "ai_observability" + if "dashboard" in lowered or "ai_intelligence" in lowered: + return "growth_command_center" + if "vendor_stockout" in lowered: + return "vendor_operations" + if "sales" in lowered or "growth" in lowered or "price" in lowered: + return "commerce_analytics" + if "login" in lowered or "user" in lowered or "settings" in lowered: + return "system_account" + return "general_product_surface" + + +def _route_sources_for_template(root: Path, template_name: str) -> list[str]: + routes_dir = root / "routes" + if not routes_dir.exists(): + return [] + needle = template_name.split("templates/", 1)[-1] + matches: list[str] = [] + for route_file in sorted(routes_dir.glob("*.py")): + try: + source = route_file.read_text(encoding="utf-8") + except OSError: + continue + if needle in source: + matches.append(str(route_file.relative_to(root)).replace("\\", "/")) + return matches + + +def _evaluate_site_template(root: Path, path: Path) -> dict[str, Any]: + relpath = _relative_template_path(root, path) + html = path.read_text(encoding="utf-8") + high_priority = _is_high_priority_template(relpath) + compact_marker_present = "data-density-guardrail=" in html + benchmark_marker_present = "data-benchmark-guardrail=" in html + professional_marker_present = any(marker in html for marker in PROFESSIONAL_GUARDRAIL_MARKERS) + forbidden_leaks = [ + fragment + for fragment in FORBIDDEN_PRODUCT_FRAGMENTS + if fragment in html + ] + findings: list[dict[str, Any]] = [] + if forbidden_leaks: + findings.append({ + "type": "raw_engineering_copy", + "severity": "critical", + "fragments": forbidden_leaks, + }) + if high_priority and not professional_marker_present: + findings.append({ + "type": "missing_professional_workbench_guardrail", + "severity": "warning", + "expected": "compact density marker, benchmark marker, or product shell marker", + }) + status = "ok" if not findings else ( + "critical" if any(item["severity"] == "critical" for item in findings) else "warning" + ) + return { + "template": relpath, + "family": _template_family(relpath), + "priority": "P1" if high_priority else "P2", + "status": status, + "route_sources": _route_sources_for_template(root, relpath), + "guardrails": { + "compact_marker_present": compact_marker_present, + "benchmark_marker_present": benchmark_marker_present, + "professional_marker_present": professional_marker_present, + }, + "finding_count": len(findings), + "findings": findings, + "next_machine_action": ( + "build_sitewide_ui_ux_repair_package" + if findings + else "keep_sitewide_ui_ux_agent_monitoring" + ), + } + + +def build_sitewide_ui_ux_agent_inventory( + *, + root: Path | str | None = None, +) -> dict[str, Any]: + """Inventory every template for sitewide professional UI/UX automation.""" + source_root = Path(root).resolve() if root is not None else ROOT + surfaces = [_evaluate_site_template(source_root, path) for path in _iter_site_templates(source_root)] + issue_surfaces = [item for item in surfaces if item["status"] != "ok"] + high_priority = [item for item in surfaces if item["priority"] == "P1"] + raw_issue_count = sum( + 1 + for item in surfaces + for finding in item.get("findings", []) + if finding.get("type") == "raw_engineering_copy" + ) + compact_count = sum( + 1 + for item in surfaces + if item.get("guardrails", {}).get("compact_marker_present") + ) + status = "ok" if not issue_surfaces else "warning" + return { + "policy": SITEWIDE_POLICY, + "status": status, + "version": SYSTEM_VERSION, + "generated_at": datetime.now().isoformat(timespec="seconds"), + "summary": { + "template_count": len(surfaces), + "high_priority_template_count": len(high_priority), + "compact_guardrail_count": compact_count, + "issue_surface_count": len(issue_surfaces), + "raw_engineering_issue_count": raw_issue_count, + "primary_human_gate_count": 0, + "writes_database_count": 0, + }, + "guardrail_contract": { + "target_experience": "mainstream_professional_product_website", + "principles": [ + "first_viewport_status_and_next_action", + "compact_information_density", + "traditional_chinese_product_language", + "evidence_on_demand", + "no_raw_engineering_copy_on_product_surfaces", + "stable_responsive_layout", + ], + }, + "surfaces": surfaces, + "issue_surfaces": issue_surfaces, + "next_machine_action": ( + "build_sitewide_ui_ux_repair_package" + if issue_surfaces + else "keep_sitewide_ui_ux_agent_monitoring" + ), + "automation_policy": { + "primary_flow": "ai_controlled", + "manual_review_mode": "exception_only", + "machine_verifiable_evidence": True, + "primary_human_gate_count": 0, + }, + "safety": { + "read_only": True, + "writes_database": False, + "writes_database_count": 0, + "sends_notifications": False, + "requires_browser": False, + "requires_secret": False, + }, + } + + +def build_sitewide_ui_ux_repair_package( + *, + root: Path | str | None = None, + source_inventory: dict[str, Any] | None = None, + limit: int = 12, +) -> dict[str, Any]: + """Build a no-write sitewide UI/UX controlled repair package.""" + inventory = source_inventory or build_sitewide_ui_ux_agent_inventory(root=root) + issue_surfaces = list(inventory.get("issue_surfaces") or []) + prioritized = sorted( + issue_surfaces, + key=lambda item: ( + 0 if item.get("priority") == "P1" else 1, + 0 if item.get("status") == "critical" else 1, + str(item.get("template") or ""), + ), + )[: max(1, int(limit or 12))] + repair_items: list[dict[str, Any]] = [] + for surface in prioritized: + actions: list[dict[str, Any]] = [] + for finding in surface.get("findings", []): + if finding.get("type") == "raw_engineering_copy": + actions.append({ + "action": "replace_raw_engineering_copy", + "target_template": surface.get("template"), + "fragments": finding.get("fragments") or [], + "safe_apply_hint": "Use Traditional Chinese product wording and move raw evidence into drilldown/API.", + }) + if finding.get("type") == "missing_professional_workbench_guardrail": + actions.append({ + "action": "add_professional_workbench_guardrail", + "target_template": surface.get("template"), + "safe_apply_hint": ( + "Add first-viewport status, next action, compact density marker, and evidence-on-demand pattern." + ), + }) + repair_items.append({ + "template": surface.get("template"), + "family": surface.get("family"), + "priority": surface.get("priority"), + "status": "ready_for_controlled_repair", + "controlled_actions": actions, + "post_apply_verifier": "build_sitewide_ui_ux_agent_inventory", + }) + action_count = sum(len(item.get("controlled_actions") or []) for item in repair_items) + return { + "policy": SITEWIDE_REPAIR_POLICY, + "status": "repair_ready" if action_count else "no_op", + "version": SYSTEM_VERSION, + "generated_at": datetime.now().isoformat(timespec="seconds"), + "source_inventory_policy": inventory.get("policy"), + "source_inventory_status": inventory.get("status"), + "summary": { + "selected_surface_count": len(repair_items), + "controlled_action_count": action_count, + "total_issue_surface_count": int((inventory.get("summary") or {}).get("issue_surface_count") or 0), + "primary_human_gate_count": 0, + "writes_database_count": 0, + "executes_shell_count": 0, + }, + "repair_items": repair_items, + "next_machine_action": ( + "apply_sitewide_ui_ux_controlled_repairs" + if action_count + else "keep_sitewide_ui_ux_agent_monitoring" + ), + "controlled_apply_contract": { + "mode": "ai_controlled_low_blast_radius_template_patch", + "allowed_target_globs": ["templates/**/*.html", "templates/**/*.j2", "web/static/css/**/*.css"], + "forbidden_targets": [".env", "database", "runtime volumes", "secrets", "raw sessions", "sqlite"], + "requires_post_apply_verifier": True, + "post_apply_verifier": "build_sitewide_ui_ux_agent_inventory", + "rollback_strategy": "revert_template_or_css_patch_and_rerun_sitewide_ui_ux_agent", + }, + "automation_policy": { + "primary_flow": "ai_controlled", + "manual_review_mode": "exception_only", + "machine_verifiable_evidence": True, + "primary_human_gate_count": 0, + }, + "safety": { + "read_only_package": True, + "writes_database": False, + "writes_database_count": 0, + "executes_shell": False, + "sends_notifications": False, + "requires_secret": False, + }, + } diff --git a/templates/ai_history.html b/templates/ai_history.html index 7c7b7ca..039bfb4 100644 --- a/templates/ai_history.html +++ b/templates/ai_history.html @@ -4,12 +4,17 @@ {% block ewooo_content %}
-
+

AI 生成歷史

+
+ 內容回收 + 使用追蹤 + 再用優先 +

回收有效文案,追蹤哪些銷售動作可再用。

@@ -282,6 +287,26 @@ color: var(--momo-text-muted); } +.ai-history-mode-row { + display: flex; + flex-wrap: wrap; + gap: 8px; + margin-top: 10px; +} + +.ai-history-mode { + display: inline-flex; + align-items: center; + min-height: 28px; + padding: 4px 10px; + border: 1px solid var(--momo-border-subtle); + border-radius: 8px; + background: rgba(255, 255, 255, 0.68); + color: var(--momo-text-muted); + font-size: 0.78rem; + font-weight: 800; +} + .ai-history-actions { display: flex; justify-content: flex-end; diff --git a/templates/ai_recommend.html b/templates/ai_recommend.html index 6f6f96d..da9d3d9 100644 --- a/templates/ai_recommend.html +++ b/templates/ai_recommend.html @@ -10,7 +10,7 @@
-
+

PChome 銷售建議 diff --git a/tests/test_ai_automation_smoke_service.py b/tests/test_ai_automation_smoke_service.py index 1aa1c84..e5dbe5f 100644 --- a/tests/test_ai_automation_smoke_service.py +++ b/tests/test_ai_automation_smoke_service.py @@ -324,6 +324,76 @@ def test_surface_html_readback_service_detects_regression(): assert "PPT_VISION_ENABLED=" in failed["forbidden_leaks"] +def test_surface_html_repair_package_builds_controlled_actions(): + from services.ai_surface_html_readback_service import ( + build_ai_surface_html_readback, + build_ai_surface_html_repair_package, + ) + + readback = build_ai_surface_html_readback( + rendered_html_by_route={ + "/observability/overview": "
PPT_VISION_ENABLED=false
" + } + ) + package = build_ai_surface_html_repair_package(source_readback=readback) + + assert package["policy"] == "read_only_ai_surface_html_repair_package_v1" + assert package["status"] == "repair_ready" + assert package["summary"]["controlled_action_count"] == 2 + assert package["summary"]["primary_human_gate_count"] == 0 + assert package["summary"]["writes_database_count"] == 0 + assert package["summary"]["executes_shell_count"] == 0 + assert package["next_machine_action"] == "apply_ai_surface_html_contract_repair" + assert package["safety"]["read_only_package"] is True + actions = package["repair_items"][0]["controlled_actions"] + assert {item["action"] for item in actions} == { + "restore_density_guardrail_marker", + "remove_raw_engineering_copy_from_product_surface", + } + + +def test_sitewide_ui_ux_agent_inventory_scans_real_pages(): + from services.ai_surface_html_readback_service import build_sitewide_ui_ux_agent_inventory + + inventory = build_sitewide_ui_ux_agent_inventory() + + assert inventory["policy"] == "read_only_sitewide_ui_ux_agent_inventory_v1" + assert inventory["summary"]["template_count"] >= 50 + assert inventory["summary"]["high_priority_template_count"] >= 20 + assert inventory["summary"]["compact_guardrail_count"] >= 14 + assert inventory["summary"]["issue_surface_count"] <= 15 + assert inventory["summary"]["primary_human_gate_count"] == 0 + assert inventory["summary"]["writes_database_count"] == 0 + assert inventory["safety"]["writes_database"] is False + templates = {item["template"] for item in inventory["surfaces"]} + issue_templates = {item["template"] for item in inventory["issue_surfaces"]} + assert "templates/base.html" not in templates + assert not any("/components/" in item for item in templates) + assert "templates/ai_history.html" not in issue_templates + assert "templates/ai_recommend.html" not in issue_templates + + +def test_sitewide_ui_ux_repair_package_prioritizes_controlled_template_repairs(): + from services.ai_surface_html_readback_service import ( + build_sitewide_ui_ux_agent_inventory, + build_sitewide_ui_ux_repair_package, + ) + + inventory = build_sitewide_ui_ux_agent_inventory() + package = build_sitewide_ui_ux_repair_package(source_inventory=inventory, limit=3) + + assert package["policy"] == "read_only_sitewide_ui_ux_repair_package_v1" + assert package["summary"]["selected_surface_count"] <= 3 + assert package["summary"]["primary_human_gate_count"] == 0 + assert package["summary"]["writes_database_count"] == 0 + assert package["summary"]["executes_shell_count"] == 0 + assert package["safety"]["read_only_package"] is True + assert "templates/**/*.html" in package["controlled_apply_contract"]["allowed_target_globs"] + if package["repair_items"]: + assert package["next_machine_action"] == "apply_sitewide_ui_ux_controlled_repairs" + assert package["repair_items"][0]["controlled_actions"] + + def test_surface_html_readback_check_is_part_of_ai_smoke(monkeypatch): from services import ai_automation_smoke_service as smoke @@ -360,6 +430,37 @@ def test_surface_html_readback_route_returns_payload(monkeypatch): assert payload["safety"]["writes_database"] is False +def test_surface_and_sitewide_ui_ux_agent_routes_return_payloads(): + from flask import Flask + from routes import system_public_routes as routes + + app = Flask(__name__) + route_cases = [ + ( + "/api/ai-automation/surface-html-repair-package", + routes.ai_automation_surface_html_repair_package_api.__wrapped__, + "read_only_ai_surface_html_repair_package_v1", + ), + ( + "/api/ai-automation/sitewide-ui-ux-agent", + routes.ai_automation_sitewide_ui_ux_agent_api.__wrapped__, + "read_only_sitewide_ui_ux_agent_inventory_v1", + ), + ( + "/api/ai-automation/sitewide-ui-ux-repair-package?limit=3", + routes.ai_automation_sitewide_ui_ux_repair_package_api.__wrapped__, + "read_only_sitewide_ui_ux_repair_package_v1", + ), + ] + + for path, view_func, policy in route_cases: + with app.test_request_context(path): + response = view_func() + payload = response.get_json() + assert payload["policy"] == policy + assert payload["safety"].get("writes_database") is False + + def test_gemini_egress_check_ok_when_no_calls(monkeypatch): from services import ai_automation_smoke_service as smoke