"""HTML 模板「JSON 填充」纯引擎 + 模板注册表。
定时任务的「模板数据转换器」内置 agent 把用户传入的任意 JSON 智能映射成某个
HTML 模板需要的 ``DATA`` 结构后,本模块负责把数据**填充**进模板并产出一份完整、
可预览的 HTML 文档。
设计要点
--------
* **模板自身即 schema 来源**:每个模板的 HTML 里都内嵌一行 ``const DATA={...};``
(台湾滨海防卫展示系统 no.1 还内嵌 ``const MANIFEST={...};``)。我们直接解析这行
拿到「参考数据」,既当**目标格式的范例**(喂给大模型),又当**结构校验**的基准
(顶层键 + 类型)。注册表因此不需要手写一份庞大的 schema 字面量,改模板即改契约。
* **行级安全替换**:模板把每个常量压在**单独一行**(``const DATA=...;`` 以分号结尾、
行内无换行),所以用 ``^const VAR=.*;$`` (MULTILINE) 精确命中该行整体替换,绝不
误伤模板里别处出现的同名子串。替换值用函数回调写入,避开 ``re.sub`` 把 JSON 里的
反斜杠当反向引用的坑。
* **harness 层、零 app.* 依赖**:可被调度器(harness)与 Gateway 路由(app)共同 import。
公开入口:``list_templates`` / ``get_template`` / ``reference_data`` /
``schema_skeleton_json`` / ``validate_data`` / ``field_coverage_gaps`` /
``shape_mismatches`` / ``fill_template``。
"""
from __future__ import annotations
import json
import re
from dataclasses import dataclass
from datetime import date
from functools import lru_cache
from pathlib import Path
from typing import Any
_FENCE_RE = re.compile(r"```(?:json)?\s*(.*?)```", re.DOTALL | re.IGNORECASE)
def _scan_balanced(text: str, open_ch: str, close_ch: str) -> Any | None:
"""从 text 里扫第一个**平衡**的 ``open_ch…close_ch`` 块并 ``json.loads``。
字符串感知(跳过引号内的括号/转义),失败返回 None。供从「含 JSON 的文本」里
把 JSON 段抠出来用。
"""
start = text.find(open_ch)
if start == -1:
return None
depth = 0
in_string = False
escape = False
for i in range(start, len(text)):
ch = text[i]
if in_string:
if escape:
escape = False
elif ch == "\\":
escape = True
elif ch == '"':
in_string = False
continue
if ch == '"':
in_string = True
elif ch == open_ch:
depth += 1
elif ch == close_ch:
depth -= 1
if depth == 0:
blob = text[start : i + 1]
try:
return json.loads(blob)
except (ValueError, TypeError):
return None
return None
def extract_embedded_json(text: str | None) -> Any | None:
"""尽力从一段文本里抽出内嵌的 JSON(对象或数组)。
覆盖三种用户输入:纯 JSON(直接解析)、含 JSON 的文本(抠出第一个平衡的
``{…}`` 或 ``[…]``)、纯文本(返回 ``None``)。也识别 ```json fence。
"""
if not text or not isinstance(text, str):
return None
s = text.strip()
fence = _FENCE_RE.search(s)
if fence:
inner = fence.group(1).strip()
try:
return json.loads(inner)
except (ValueError, TypeError):
s = inner # fence 内不是完整 JSON,继续往下扫
try:
return json.loads(s)
except (ValueError, TypeError):
pass
# 取最靠前出现的那种括号块(对象优先于数组,谁先出现谁优先)。
brace = _scan_balanced(s, "{", "}")
bracket = _scan_balanced(s, "[", "]")
if brace is not None and bracket is not None:
return brace if s.find("{") <= s.find("[") else bracket
return brace if brace is not None else bracket
# 模板资源根目录(随 git 发布,与本模块同级 ``templates/`` 子目录)。
_TEMPLATES_ROOT = Path(__file__).parent / "templates"
@dataclass(frozen=True)
class TemplateSpec:
"""一个 HTML 模板的注册信息。
``file`` 相对 :data:`_TEMPLATES_ROOT`;``data_var``/``manifest_var`` 是模板里
待替换的 JS 常量名(``manifest_var`` 为空表示该模板没有 MANIFEST 行)。
``convertible=False`` 表示该模板暂只「直接展示已有数据」——执行时原样输出模板
HTML,不做 JSON 转换/校验/填充(无 ``const DATA=`` 注入行的模板用它,后续接入
转换后改回 True)。
"""
id: str
name: str
description: str
file: str
data_var: str = "DATA"
manifest_var: str = ""
convertible: bool = True
# 已注册模板。新增 no.2/no.3 时在此追加一条即可——前端下拉、调度器填充全自动生效。
TEMPLATES: tuple[TemplateSpec, ...] = (
TemplateSpec(
id="no1",
name="no.1",
description="",
file="no1/taiwan_littoral_ui_redwhite_v2.html",
data_var="DATA",
manifest_var="MANIFEST",
),
TemplateSpec(
id="no2",
name="no.2",
description="",
file="no2/taiwan_air_force_v3_2.html",
# 暂只展示已有数据,不做 JSON 转换/填充(后续接入转换后再开)。
convertible=False,
),
TemplateSpec(
id="no3",
name="no.3",
description="",
file="no3/no3.html",
# 无 const DATA= 注入点,暂只展示已有数据(后续接入转换后再开)。
convertible=False,
),
TemplateSpec(
id="no4",
name="no.4",
description="",
file="no4/no4.html",
# 数据存于