325 lines
13 KiB
Python
325 lines
13 KiB
Python
"""Catalog and deterministic selector for bundled report-page templates.
|
||
|
||
HTML skeletons are packaged with the backend so page generation never depends
|
||
on the frontend checkout or a browser upload. Deployments can optionally
|
||
override the built-in library with ``DEERFLOW_PAGE_TEMPLATE_DIR``.
|
||
"""
|
||
|
||
from __future__ import annotations
|
||
|
||
import os
|
||
import re
|
||
from dataclasses import asdict, dataclass
|
||
from hashlib import sha256
|
||
from pathlib import Path
|
||
from typing import Literal
|
||
|
||
Theme = Literal["dark", "light"]
|
||
|
||
|
||
@dataclass(frozen=True)
|
||
class PageTemplateProfile:
|
||
"""A concise, agent-facing description of one HTML report skeleton."""
|
||
|
||
id: str
|
||
filename: str
|
||
theme: Theme
|
||
title: str
|
||
style: str
|
||
page_count: int
|
||
features: tuple[str, ...]
|
||
data_entry: str
|
||
best_for: tuple[str, ...]
|
||
|
||
def to_dict(self) -> dict[str, object]:
|
||
"""Serialize tuples as JSON-friendly lists."""
|
||
value = asdict(self)
|
||
value["features"] = list(self.features)
|
||
value["best_for"] = list(self.best_for)
|
||
return value
|
||
|
||
|
||
# Keep this declarative. Later templates only require one profile here; their
|
||
# original HTML never needs to be copied into the backend or rewritten.
|
||
_TEMPLATES: tuple[PageTemplateProfile, ...] = (
|
||
PageTemplateProfile(
|
||
id="dark-charts-portal",
|
||
filename="report-screen-dark-charts-portal-template.html",
|
||
theme="dark",
|
||
title="深蓝图表运营门户",
|
||
style="深蓝科技感、密度较高、带动效的大屏门户",
|
||
page_count=5,
|
||
features=("kpi", "line", "bar", "pie", "radar", "scatter", "gantt", "table", "text-panels", "animation"),
|
||
data_entry="reportData(图表和滚动列表统一入口)",
|
||
best_for=("经营汇报", "数据较多", "多页面", "图表优先", "督办"),
|
||
),
|
||
PageTemplateProfile(
|
||
id="dark-neon-portal",
|
||
filename="report-screen-dark-neon-portal-template.html",
|
||
theme="dark",
|
||
title="深蓝霓虹专题门户",
|
||
style="深蓝霓虹、沉浸式科技感、内容分区明显",
|
||
page_count=5,
|
||
features=("kpi", "timeline", "table", "text-panels", "animation"),
|
||
data_entry="页面结构直接编辑(先复制再删减)",
|
||
best_for=("专题汇报", "文字较多", "多页面", "研判"),
|
||
),
|
||
PageTemplateProfile(
|
||
id="dark-neon",
|
||
filename="report-screen-dark-neon-template.html",
|
||
theme="dark",
|
||
title="深蓝霓虹单页大屏",
|
||
style="深蓝发光边框、重点指标突出",
|
||
page_count=1,
|
||
features=("kpi", "line", "bar", "text-panels", "animation"),
|
||
data_entry="页面结构直接编辑(先复制再删减)",
|
||
best_for=("单页汇报", "简洁数据", "值班态势"),
|
||
),
|
||
PageTemplateProfile(
|
||
id="dark-classic",
|
||
filename="report-screen-dark-template.html",
|
||
theme="dark",
|
||
title="深蓝综合经营大屏",
|
||
style="稳重深蓝、经营驾驶舱、信息密度适中",
|
||
page_count=3,
|
||
features=("kpi", "bar", "table", "timeline", "text-panels"),
|
||
data_entry="页面内置数据变量和结构化区块",
|
||
best_for=("经营汇报", "文字与数据均衡", "三页汇报"),
|
||
),
|
||
PageTemplateProfile(
|
||
id="light-charts-portal",
|
||
filename="report-screen-light-charts-portal-template.html",
|
||
theme="light",
|
||
title="浅色图表运营门户",
|
||
style="明亮专业、留白充足、带动效的多页面门户",
|
||
page_count=5,
|
||
features=("kpi", "line", "bar", "pie", "radar", "scatter", "gantt", "table", "text-panels", "animation"),
|
||
data_entry="reportData(图表和滚动列表统一入口)",
|
||
best_for=("经营汇报", "数据较多", "多页面", "图表优先", "督办"),
|
||
),
|
||
PageTemplateProfile(
|
||
id="light-airy-portal",
|
||
filename="report-screen-light-airy-portal-template.html",
|
||
theme="light",
|
||
title="浅色轻盈专题门户",
|
||
style="浅色留白、内容阅读友好、清爽分栏",
|
||
page_count=5,
|
||
features=("kpi", "timeline", "table", "text-panels", "animation"),
|
||
data_entry="页面结构直接编辑(先复制再删减)",
|
||
best_for=("专题汇报", "文字较多", "多页面", "研判"),
|
||
),
|
||
PageTemplateProfile(
|
||
id="light-airy",
|
||
filename="report-screen-light-airy-template.html",
|
||
theme="light",
|
||
title="浅色轻盈单页报告",
|
||
style="浅色卡片、阅读节奏舒展",
|
||
page_count=1,
|
||
features=("kpi", "bar", "table", "text-panels"),
|
||
data_entry="页面内置数据变量和结构化区块",
|
||
best_for=("单页汇报", "文字为主", "简洁数据"),
|
||
),
|
||
PageTemplateProfile(
|
||
id="multipage-suite",
|
||
filename="report-screen-multipage-suite-template.html",
|
||
theme="dark",
|
||
title="多页面汇报套件",
|
||
style="深蓝 / 浅色可切换、文字信息组织强、标准化汇报套件",
|
||
page_count=3,
|
||
features=("kpi", "bar", "radar", "gantt", "table", "timeline", "text-panels", "theme-switch"),
|
||
data_entry="reportData(日期、可见页面)+ 页面结构",
|
||
best_for=("多页面", "文字为主", "汇报研判", "行动督办"),
|
||
),
|
||
)
|
||
|
||
|
||
def _template_root_candidates() -> list[Path]:
|
||
explicit = os.getenv("DEERFLOW_PAGE_TEMPLATE_DIR", "").strip()
|
||
candidates: list[Path] = [Path(explicit).expanduser()] if explicit else []
|
||
candidates.append(Path(__file__).resolve().parent / "assets")
|
||
return candidates
|
||
|
||
|
||
def resolve_template_root() -> Path:
|
||
"""Locate the template directory or raise a useful deployment error."""
|
||
for candidate in _template_root_candidates():
|
||
if candidate.is_dir():
|
||
return candidate.resolve()
|
||
raise FileNotFoundError(
|
||
"Page template directory is unavailable. Set DEERFLOW_PAGE_TEMPLATE_DIR "
|
||
"to the directory containing the report-screen-*-template.html files."
|
||
)
|
||
|
||
|
||
def _auto_profile(source: Path) -> PageTemplateProfile:
|
||
"""Create a conservative profile for a newly dropped-in HTML template.
|
||
|
||
Auto-discovered files are intentionally tagged as ``待人工确认`` rather
|
||
than claiming capabilities we cannot prove. The designer can use them for
|
||
broad page requests immediately; a later catalog entry can add richer,
|
||
hand-verified selection metadata without changing the source HTML.
|
||
"""
|
||
html = source.read_text(encoding="utf-8", errors="ignore")[:1_000_000]
|
||
lowered = html.lower()
|
||
stem = source.stem
|
||
is_dark = "dark" in stem.lower() or 'data-theme="dark"' in lowered or "#061" in lowered
|
||
features: list[str] = ["text-panels"]
|
||
for feature, hints in {
|
||
"kpi": ("kpi", "metric", "stat-"),
|
||
"line": ("linechart", "line-chart", "line-svg"),
|
||
"bar": ("bar-chart", "bars", "bar >"),
|
||
"pie": ("pie", "donut"),
|
||
"radar": ("radar",),
|
||
"scatter": ("scatter",),
|
||
"gantt": ("gantt",),
|
||
"table": ("<table",),
|
||
"timeline": ("timeline",),
|
||
"animation": ("@keyframes", "requestanimationframe", "transition:"),
|
||
}.items():
|
||
if any(hint in lowered for hint in hints):
|
||
features.append(feature)
|
||
page_count = max(
|
||
1,
|
||
len(re.findall(r"\bdata-page\s*=", lowered)),
|
||
len(re.findall(r"\breport-page\b", lowered)),
|
||
)
|
||
identifier = "auto-" + sha256(source.name.encode("utf-8")).hexdigest()[:10]
|
||
return PageTemplateProfile(
|
||
id=identifier,
|
||
filename=source.name,
|
||
theme="dark" if is_dark else "light",
|
||
title=f"自动发现:{stem}",
|
||
style="自动发现的 HTML 页面骨架(能力待人工确认)",
|
||
page_count=page_count,
|
||
features=tuple(dict.fromkeys(features)),
|
||
data_entry="reportData" if "const reportdata" in lowered else "页面结构直接编辑(先复制再删减)",
|
||
best_for=("自动发现", "待人工确认"),
|
||
)
|
||
|
||
|
||
def list_template_profiles() -> tuple[PageTemplateProfile, ...]:
|
||
"""Return registered templates plus safely auto-discovered future files."""
|
||
profiles = list(_TEMPLATES)
|
||
known_filenames = {profile.filename for profile in profiles}
|
||
try:
|
||
root = resolve_template_root()
|
||
for source in sorted(root.glob("report-screen-*-template.html"), key=lambda item: item.name.lower()):
|
||
if source.name not in known_filenames:
|
||
profiles.append(_auto_profile(source))
|
||
except (FileNotFoundError, OSError):
|
||
# Catalog information remains useful in deployments where the frontend
|
||
# package is not mounted yet; prepare() will surface the real error.
|
||
pass
|
||
return tuple(profiles)
|
||
|
||
|
||
def get_template_profile(template_id: str) -> PageTemplateProfile:
|
||
normalized = (template_id or "").strip().lower()
|
||
for profile in list_template_profiles():
|
||
if profile.id == normalized:
|
||
return profile
|
||
available = ", ".join(profile.id for profile in list_template_profiles())
|
||
raise ValueError(f"Unknown page template '{template_id}'. Available: {available}")
|
||
|
||
|
||
def resolve_template_file(template_id: str) -> Path:
|
||
"""Return a verified source-template path for a catalog id."""
|
||
profile = get_template_profile(template_id)
|
||
source = (resolve_template_root() / profile.filename).resolve()
|
||
root = resolve_template_root()
|
||
try:
|
||
source.relative_to(root)
|
||
except ValueError as exc: # defensive: profile filenames are repository data
|
||
raise ValueError("Template path escapes the template root") from exc
|
||
if not source.is_file():
|
||
raise FileNotFoundError(
|
||
f"Template '{template_id}' is listed in the catalog but is missing: {source}"
|
||
)
|
||
return source
|
||
|
||
|
||
def _normalize_theme(theme: str | None) -> Theme | None:
|
||
value = (theme or "").strip().lower()
|
||
if value in {"dark", "deep-blue", "dark-blue"}:
|
||
return "dark"
|
||
if value in {"light", "white", "bright"}:
|
||
return "light"
|
||
return None
|
||
|
||
|
||
def _score_template(
|
||
profile: PageTemplateProfile,
|
||
*,
|
||
content_density: str,
|
||
required_features: set[str],
|
||
preferred_page_count: int | None,
|
||
) -> int:
|
||
score = 0
|
||
features = set(profile.features)
|
||
# A required feature weighs substantially more than aesthetics.
|
||
score += 12 * len(features & required_features)
|
||
score -= 30 * len(required_features - features)
|
||
density = (content_density or "balanced").strip().lower()
|
||
if density in {"text-heavy", "rich-text", "文字较多"} and "text-panels" in features:
|
||
score += 8
|
||
if density in {"data-heavy", "chart-heavy", "图表较多"} and {"line", "bar", "pie"}.issubset(features):
|
||
score += 8
|
||
if preferred_page_count:
|
||
score -= min(abs(profile.page_count - preferred_page_count), 3) * 3
|
||
return score
|
||
|
||
|
||
def choose_template(
|
||
*,
|
||
theme: str | None,
|
||
content_density: str = "balanced",
|
||
required_features: list[str] | None = None,
|
||
preferred_page_count: int | None = None,
|
||
selection_key: str = "",
|
||
exclude_template_ids: list[str] | None = None,
|
||
) -> PageTemplateProfile:
|
||
"""Choose a suitable template, varying ties deterministically by request.
|
||
|
||
The caller can pass recent choices through ``exclude_template_ids``. We
|
||
only fall back to an excluded item when it is the sole match for the chosen
|
||
theme, which prevents an agent from losing the requested colour mode.
|
||
"""
|
||
target_theme = _normalize_theme(theme)
|
||
all_profiles = list_template_profiles()
|
||
candidates = [profile for profile in all_profiles if target_theme is None or profile.theme == target_theme]
|
||
if not candidates:
|
||
candidates = list(all_profiles)
|
||
excluded = {value.strip().lower() for value in (exclude_template_ids or []) if value.strip()}
|
||
non_excluded = [profile for profile in candidates if profile.id not in excluded]
|
||
if non_excluded:
|
||
candidates = non_excluded
|
||
feature_set = {value.strip().lower() for value in (required_features or []) if value.strip()}
|
||
scored = sorted(
|
||
((
|
||
_score_template(
|
||
profile,
|
||
content_density=content_density,
|
||
required_features=feature_set,
|
||
preferred_page_count=preferred_page_count,
|
||
),
|
||
profile,
|
||
) for profile in candidates),
|
||
key=lambda item: (-item[0], item[1].id),
|
||
)
|
||
best_score = scored[0][0]
|
||
ties = [profile for score, profile in scored if score == best_score]
|
||
if len(ties) == 1:
|
||
return ties[0]
|
||
digest = sha256((selection_key or "page-report").encode("utf-8")).digest()
|
||
return ties[int.from_bytes(digest[:4], "big") % len(ties)]
|
||
|
||
|
||
__all__ = [
|
||
"PageTemplateProfile",
|
||
"choose_template",
|
||
"get_template_profile",
|
||
"list_template_profiles",
|
||
"resolve_template_file",
|
||
"resolve_template_root",
|
||
]
|