deerflow-code/offline-backend-20260512/backend/packages/harness/deerflow/page_reports/templates.py
2026-09-07 18:24:55 +08:00

325 lines
13 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""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",
]