222 lines
7.0 KiB
Python
222 lines
7.0 KiB
Python
#!/usr/bin/env python3
|
||
"""Guardrail checks for AI observability UI templates.
|
||
|
||
This script is intentionally lightweight: it scans the observability templates
|
||
and shared CSS for regression signals that previously hurt the war-room UI,
|
||
including unreadable typography, raw SQL errors, oversized titles, and inline
|
||
chart sizing.
|
||
"""
|
||
|
||
from __future__ import annotations
|
||
|
||
import re
|
||
import sys
|
||
from dataclasses import dataclass
|
||
from pathlib import Path
|
||
|
||
|
||
ROOT = Path(__file__).resolve().parents[1]
|
||
|
||
TEMPLATE_PATHS = [
|
||
"templates/admin/observability_overview.html",
|
||
"templates/admin/agent_orchestration.html",
|
||
"templates/admin/business_intel.html",
|
||
"templates/admin/host_health.html",
|
||
"templates/admin/ai_calls_dashboard.html",
|
||
"templates/admin/budget.html",
|
||
"templates/admin/promotion_review.html",
|
||
"templates/admin/rag_queries.html",
|
||
"templates/admin/quality_trend.html",
|
||
"templates/admin/ppt_audit_history.html",
|
||
]
|
||
|
||
CSS_PATH = Path("static/css/observability-system.css")
|
||
SHELL_PATH = Path("templates/components/_ewoooc_shell.html")
|
||
BASE_PATH = Path("templates/ewoooc_base.html")
|
||
|
||
|
||
@dataclass(frozen=True)
|
||
class Rule:
|
||
code: str
|
||
pattern: re.Pattern[str]
|
||
message: str
|
||
|
||
|
||
TEMPLATE_RULES = [
|
||
Rule(
|
||
"legacy_serif_font",
|
||
re.compile(r"\b(Georgia|Times New Roman)\b", re.IGNORECASE),
|
||
"觀測台標題不得回退到 Times/Georgia;使用 Noto Sans TC / Inter 與 CSS token。",
|
||
),
|
||
Rule(
|
||
"raw_error_copy",
|
||
re.compile(
|
||
r"(查詢失敗:|ProgrammingError|UndefinedError|Traceback|Internal Server Error|relation\s+"|relation\s+\")"
|
||
),
|
||
"不得把 SQL/Jinja exception 或 raw failure 文案直接顯示給使用者。",
|
||
),
|
||
Rule(
|
||
"inline_height",
|
||
re.compile(r"style=\"[^\"]*height\s*:", re.IGNORECASE),
|
||
"圖表/區塊高度請使用 .obs-chart-frame 系列或 CSS class,不要 inline height。",
|
||
),
|
||
Rule(
|
||
"static_inline_width",
|
||
re.compile(r"style=\"(?=[^\"]*width\s*:)(?![^\"]*\{\{)[^\"]*\"", re.IGNORECASE),
|
||
"靜態寬度請移到 CSS class;僅允許 progress 類動態 width={{ ... }}。",
|
||
),
|
||
Rule(
|
||
"oversized_title",
|
||
re.compile(r"font-size:\s*clamp\([^;]*(3\.8rem|4\.45rem)", re.IGNORECASE),
|
||
"頁面大標不得使用過大 clamp;使用 --obs-title-size token。",
|
||
),
|
||
Rule(
|
||
"pure_white_surface",
|
||
re.compile(r"background\s*:\s*#fff\s*;", re.IGNORECASE),
|
||
"觀測台 hover/card surface 不用純白;使用暖色 paper/surface token。",
|
||
),
|
||
Rule(
|
||
"wrong_business_active_page",
|
||
re.compile(r"active_page\s*=\s*['\"]obs_business['\"]"),
|
||
"商業戰情頁 active_page 必須是 obs_business_intel,否則觀測台 CSS 不會載入。",
|
||
),
|
||
]
|
||
|
||
REQUIRED_CSS_SNIPPETS = [
|
||
"--obs-title-size",
|
||
"--obs-value-size",
|
||
".momo-observability-mode",
|
||
".obs-chart-frame",
|
||
".obs-chart-frame-tall",
|
||
".obs-chart-frame-slim",
|
||
".obs-modal-preview",
|
||
".obs-progress-xs",
|
||
".obs-table-shell",
|
||
]
|
||
|
||
REQUIRED_SHELL_SNIPPETS = [
|
||
"AI 中樞",
|
||
"AI 觀測台",
|
||
"戰情室",
|
||
"系統與成本",
|
||
"RAG 與品質",
|
||
"momo-nav-tree",
|
||
"momo-nav-subtree",
|
||
"momo-nav-subtitle",
|
||
"momo-nav-sublink",
|
||
"rgba(255, 248, 238, 0.72)",
|
||
"linear-gradient(180deg",
|
||
]
|
||
|
||
FORBIDDEN_SHELL_PATTERNS = [
|
||
Rule(
|
||
"pure_black_sidebar",
|
||
re.compile(r"\.momo-sidebar\s*\{[^}]*background\s*:\s*(#000|black)\b", re.IGNORECASE | re.DOTALL),
|
||
"側欄不得回退成純黑背景;必須維持暖深咖啡漸層。",
|
||
),
|
||
Rule(
|
||
"low_contrast_sublink",
|
||
re.compile(r"\.momo-nav-sublink\s*\{[^}]*color\s*:\s*rgba\([^)]*,\s*0\.(?:[0-5][0-9]|60)\)", re.IGNORECASE | re.DOTALL),
|
||
"第二/三層導覽文字透明度過低,會看不清楚;需維持足夠對比。",
|
||
),
|
||
]
|
||
|
||
REQUIRED_BASE_SNIPPETS = [
|
||
"observability-system.css",
|
||
"momo-observability-mode",
|
||
"/observability/overview",
|
||
"/observability/api/health_indicator",
|
||
]
|
||
|
||
|
||
def line_number(text: str, index: int) -> int:
|
||
return text.count("\n", 0, index) + 1
|
||
|
||
|
||
def scan_file(relative_path: Path) -> list[str]:
|
||
path = ROOT / relative_path
|
||
if not path.exists():
|
||
return [f"{relative_path}: missing required observability file"]
|
||
|
||
text = path.read_text(encoding="utf-8")
|
||
findings: list[str] = []
|
||
for rule in TEMPLATE_RULES:
|
||
for match in rule.pattern.finditer(text):
|
||
line = line_number(text, match.start())
|
||
snippet = match.group(0).replace("\n", " ")[:90]
|
||
findings.append(
|
||
f"{relative_path}:{line}: [{rule.code}] {rule.message} ({snippet})"
|
||
)
|
||
|
||
return findings
|
||
|
||
|
||
def scan_css() -> list[str]:
|
||
path = ROOT / CSS_PATH
|
||
if not path.exists():
|
||
return [f"{CSS_PATH}: missing required observability CSS"]
|
||
|
||
text = path.read_text(encoding="utf-8")
|
||
findings: list[str] = []
|
||
for snippet in REQUIRED_CSS_SNIPPETS:
|
||
if snippet not in text:
|
||
findings.append(f"{CSS_PATH}: missing required token/class `{snippet}`")
|
||
|
||
return findings
|
||
|
||
|
||
def scan_required_snippets(relative_path: Path, snippets: list[str], label: str) -> list[str]:
|
||
path = ROOT / relative_path
|
||
if not path.exists():
|
||
return [f"{relative_path}: missing required {label} file"]
|
||
|
||
text = path.read_text(encoding="utf-8")
|
||
findings: list[str] = []
|
||
for snippet in snippets:
|
||
if snippet not in text:
|
||
findings.append(f"{relative_path}: missing required {label} snippet `{snippet}`")
|
||
return findings
|
||
|
||
|
||
def scan_shell() -> list[str]:
|
||
path = ROOT / SHELL_PATH
|
||
if not path.exists():
|
||
return [f"{SHELL_PATH}: missing required shell file"]
|
||
|
||
text = path.read_text(encoding="utf-8")
|
||
findings = scan_required_snippets(SHELL_PATH, REQUIRED_SHELL_SNIPPETS, "sidebar/nav")
|
||
for rule in FORBIDDEN_SHELL_PATTERNS:
|
||
for match in rule.pattern.finditer(text):
|
||
line = line_number(text, match.start())
|
||
snippet = match.group(0).replace("\n", " ")[:90]
|
||
findings.append(
|
||
f"{SHELL_PATH}:{line}: [{rule.code}] {rule.message} ({snippet})"
|
||
)
|
||
return findings
|
||
|
||
|
||
def main() -> int:
|
||
findings: list[str] = []
|
||
for template_path in TEMPLATE_PATHS:
|
||
findings.extend(scan_file(Path(template_path)))
|
||
findings.extend(scan_css())
|
||
findings.extend(scan_shell())
|
||
findings.extend(scan_required_snippets(BASE_PATH, REQUIRED_BASE_SNIPPETS, "base/topbar"))
|
||
|
||
if findings:
|
||
print("Observability UI guard: FAIL")
|
||
for finding in findings:
|
||
print(f"- {finding}")
|
||
return 1
|
||
|
||
print("Observability UI guard: PASS")
|
||
print(f"- templates checked: {len(TEMPLATE_PATHS)}")
|
||
print(f"- css guardrails checked: {len(REQUIRED_CSS_SNIPPETS)}")
|
||
print(f"- sidebar/nav guardrails checked: {len(REQUIRED_SHELL_SNIPPETS)}")
|
||
print(f"- base/topbar guardrails checked: {len(REQUIRED_BASE_SNIPPETS)}")
|
||
return 0
|
||
|
||
|
||
if __name__ == "__main__":
|
||
sys.exit(main())
|