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