feat(backend): 新增页面报告设计器 Seed 与模板工具
功能说明:\n- 新增页面报告设计器的路由级 seed 接口与初始化模板资源加载能力\n- 新增内置页面报告模板工具,实现报告生成流程中的模板路径与参数接入\n- 补充页面报告模板与相关资产目录,供运行时/离线环境直接读取\n- 将页面报告配置与工具链纳入后端启动路径,支持离线部署下可复用的默认模板
This commit is contained in:
1 parent
6d326a21cd
commit
1c891df8fb
14 files changed
+9256
No files matched your search
@@ -0,0 +1,324 @@
|
||||
"""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",
|
||||
]
|
||||
Reference in new issue
Block a user