功能说明:\n- 新增页面报告设计器的路由级 seed 接口与初始化模板资源加载能力\n- 新增内置页面报告模板工具,实现报告生成流程中的模板路径与参数接入\n- 补充页面报告模板与相关资产目录,供运行时/离线环境直接读取\n- 将页面报告配置与工具链纳入后端启动路径,支持离线部署下可复用的默认模板
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",
|
|
]
|