renderer.py 20 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546
  1. """
  2. Jinja2 模板渲染服务 - 自动变量识别与条件分支预览版
  3. 从 daping_rec 模板逻辑中提取所有变量,支持条件分支控制预览
  4. """
  5. from __future__ import annotations
  6. import copy
  7. import random as random_module
  8. import re
  9. from typing import Dict, Iterable, List, Optional
  10. from jinja2 import BaseLoader, Environment, StrictUndefined, meta
  11. from jinja2.exceptions import TemplateError
  12. from fastapi import HTTPException
  13. from ..schemas import (
  14. TemplatePreviewRequest,
  15. TemplateRecord,
  16. TemplateRenderRequest,
  17. VariableDefinition,
  18. )
  19. # === 从 daping_rec 模板中识别的完整变量配置 ===
  20. VARIABLE_DEFAULTS = {
  21. # 基础变量
  22. 'city': '青岛市',
  23. 'period': '早上',
  24. 'season': '夏天',
  25. # 程序相关
  26. 'program_id': '1',
  27. # 推荐类型 - 条件分支控制变量
  28. 'recommend_type': 'POPULAR', # POPULAR / CUSTOM
  29. 'rerank_reason': '', # PIN_CONSECUTIVE / PIN_WEEKLY / ""(空=走推荐类型分支)
  30. 'feature_name': '最喜欢用',
  31. # 人气统计
  32. 'count': 150,
  33. 'user_count': 50,
  34. 'user_proportion': 60,
  35. # 天气
  36. 'weather': '小雪',
  37. 'wd_link': '1',
  38. 'washdry': '_wd',
  39. # 季节
  40. 'time_term': '立春',
  41. 'filter_rule_code': 'time_season_in',
  42. # 关怀(同时包含洗衣机和干衣机的关怀功能,避免模板找不到键)
  43. 'care': {
  44. 'night_wash': {'active': 1, 'display_name': '夜间洗'}, # 洗衣机
  45. 'night_dry': {'active': 1, 'display_name': '夜间烘'}, # 干衣机
  46. 'wind_cruise': {'active': 1, 'display_name': '风巡航'} # 洗衣机/干衣机共用
  47. },
  48. # 干衣机参数
  49. 'changed_params_dry': {
  50. 'DryLevel': '3',
  51. 'DryTemp': '2',
  52. 'FanLevel': '2'
  53. },
  54. # 洗衣机参数
  55. 'changed_params_wash': {
  56. 'setTemp': 40,
  57. 'speed': 12,
  58. 'spinTime': 3,
  59. 'washTime': 45
  60. },
  61. # 简写变量映射(模板中可能直接使用 changed_params)
  62. # 合并洗衣机和干衣机参数,避免干衣机模板访问 DryLevel 等键时报错
  63. 'changed_params': {
  64. 'setTemp': 40,
  65. 'speed': 12,
  66. 'spinTime': 3,
  67. 'washTime': 45,
  68. 'DryLevel': '3',
  69. 'DryTemp': '2',
  70. 'FanLevel': '2'
  71. },
  72. # 多卡模板变量
  73. 'washNight': '1',
  74. 'WFreeCn': '1',
  75. }
  76. # === 条件分支变量定义 ===
  77. CONDITION_VARIABLES = {
  78. 'rerank_reason': {
  79. 'label': '推荐原因',
  80. 'type': 'select',
  81. 'options': [
  82. {'value': 'PIN_CONSECUTIVE', 'label': '连续使用习惯'},
  83. {'value': 'PIN_WEEKLY', 'label': '每周习惯'},
  84. {'value': '', 'label': '无(走推荐类型分支)'},
  85. ],
  86. 'description': '控制推荐原因分支',
  87. },
  88. 'recommend_type': {
  89. 'label': '推荐类型',
  90. 'type': 'select',
  91. 'options': [
  92. {'value': 'POPULAR', 'label': '人气推荐'},
  93. {'value': 'CUSTOM', 'label': '定制推荐'},
  94. ],
  95. 'description': '控制推荐类型分支',
  96. },
  97. 'filter_rule_code': {
  98. 'label': '季节规则',
  99. 'type': 'select',
  100. 'options': [
  101. {'value': 'time_season_in', 'label': '入季/上新'},
  102. {'value': 'time_season_storage', 'label': '收纳'},
  103. ],
  104. 'description': '控制季节模板分支',
  105. },
  106. 'feature_name': {
  107. 'label': '特征名称',
  108. 'type': 'select',
  109. 'options': [
  110. {'value': '最喜欢用', 'label': '最喜欢用'},
  111. {'value': '最近很喜欢用', 'label': '最近很喜欢用'},
  112. {'value': '最近一周常用', 'label': '最近一周常用'},
  113. {'value': '上周这个时间喜欢用', 'label': '上周这个时间喜欢用'},
  114. {'value': '', 'label': '空(走兜底播报)'},
  115. ],
  116. 'description': '控制特征分支(空字符串走else分支)',
  117. },
  118. 'program_id': {
  119. 'label': '程序ID',
  120. 'type': 'text',
  121. 'placeholder': '1-131',
  122. 'description': '程序ID(1=棉麻, 15=速洗, etc)',
  123. },
  124. }
  125. # === 数据变量定义 ===
  126. DATA_VARIABLES = {
  127. 'city': {'label': '城市', 'placeholder': '青岛市'},
  128. 'period': {'label': '时段', 'placeholder': '早上/中午/晚上'},
  129. 'season': {'label': '季节', 'placeholder': '夏天'},
  130. 'weather': {'label': '天气', 'placeholder': '小雪'},
  131. 'count': {'label': '使用次数', 'placeholder': '150'},
  132. 'user_count': {'label': '用户数', 'placeholder': '50'},
  133. 'user_proportion': {'label': '用户比例', 'placeholder': '60'},
  134. 'time_term': {'label': '节气', 'placeholder': '立春'},
  135. }
  136. class TemplateRenderer:
  137. """智能模板渲染器 - 支持条件分支预览"""
  138. def __init__(self) -> None:
  139. self.env = Environment(
  140. loader=BaseLoader(),
  141. undefined=StrictUndefined,
  142. trim_blocks=True,
  143. lstrip_blocks=True,
  144. autoescape=False,
  145. )
  146. # 注册 random filter - 解决 'str' object is not callable
  147. self.env.filters['random'] = self._jinja_random
  148. # 注册全局变量
  149. self.env.globals['program_name_map'] = self._default_program_name_map()
  150. def _jinja_random(self, seq):
  151. """Jinja2 random filter - 从序列中随机选一个元素"""
  152. if not seq:
  153. return ""
  154. try:
  155. return random_module.choice(list(seq))
  156. except:
  157. return ""
  158. def _default_program_name_map(self) -> dict:
  159. """程序名称映射"""
  160. programs = {
  161. "1": ("棉麻", ""), "2": ("化纤", ""), "3": ("羽绒", ""), "6": ("衬衣", ""), "7": ("童装", ""),
  162. "9": ("烫烫净", ""), "10": ("羊毛", ""), "13": ("筒自洁", "清洁洗衣机专用"), "14": ("内衣", ""),
  163. "15": ("速洗", "快速洗涤"), "17": ("大件", ""), "18": ("运动服", ""), "19": ("超柔", ""),
  164. "22": ("丝绸", ""), "27": ("漂+脱", ""), "28": ("单脱水", ""), "30": ("除毛烘", ""),
  165. "33": ("定时烘", ""), "34": ("静筒烘", ""), "39": ("云程序", ""),
  166. "40": ("强力洗", ""), "43": ("定时烘", ""), "45": ("云程序", ""),
  167. "52": ("冲锋衣", ""), "56": ("智慧烘", ""), "58": ("浴巾", ""),
  168. "61": ("智慧洗", "智能识别衣物"), "79": ("快洗15", ""), "107": ("除菌螨", ""),
  169. "118": ("除菌螨", ""), "131": ("夏凉被", ""),
  170. }
  171. class Program:
  172. def __init__(self, name, desc=""):
  173. self.name = name
  174. self.desc = desc
  175. def __str__(self):
  176. return self.name
  177. def __format__(self, fmt):
  178. return self.name.__format__(fmt)
  179. def get(self, key, default=None):
  180. if key in programs:
  181. return programs[key][0]
  182. return default
  183. return {
  184. pid: Program(name, desc) for pid, (name, desc) in programs.items()
  185. }
  186. def _extract_common_support(self, common_body: str) -> str:
  187. """
  188. 从common模板中提取宏定义和列表变量定义
  189. 返回需要合并到子模板前面的代码片段
  190. """
  191. import re
  192. if not common_body:
  193. return ""
  194. parts = []
  195. # 1. 提取 {% macro name(...) %}...{% endmacro %}
  196. # 支持 {%- macro ... -%} 和 {%- endmacro -%} 等 trim 语法变体
  197. macro_pattern = r'{%-?\s*macro\s+\w+\s*\([^)]*\)\s*-%?%}[\s\S]*?{%-?\s*endmacro\s*-%?%}'
  198. for match in re.finditer(macro_pattern, common_body):
  199. parts.append(match.group(0))
  200. # 2. 提取 {% set var_name = ["..."] %} 形式的列表变量
  201. # 匹配 greeting/assistant1/assistant2/user1/user2 等列表变量
  202. list_set_pattern = r'{%\s*set\s+(greeting|assistant1|assistant2|user1|user2|period_list|city_list)\s*=\s*\[[^\]]*\]\s*%}'
  203. for match in re.finditer(list_set_pattern, common_body, re.IGNORECASE):
  204. parts.append(match.group(0))
  205. # 3. 也提取 {% set var_name = ("...") %} 或 {% set var_name = "..." %}
  206. # 简单的字符串/数字变量
  207. simple_set_pattern = r'{%\s*set\s+(feature_week_name|feature_name_default|default_program)\s*=\s*[^%]+\s*%}'
  208. for match in re.finditer(simple_set_pattern, common_body, re.IGNORECASE):
  209. parts.append(match.group(0))
  210. return '\n\n'.join(parts) if parts else ""
  211. def _find_macro_calls(self, template_body: str) -> set:
  212. """从模板AST中提取被当作函数调用的变量名(即宏调用)"""
  213. try:
  214. ast = self.env.parse(template_body)
  215. macro_calls = set()
  216. # 遍历AST查找Call节点
  217. import jinja2.nodes as nodes
  218. for node in ast.find_all(nodes.Call):
  219. if isinstance(node.node, nodes.Name):
  220. macro_calls.add(node.node.name)
  221. return macro_calls
  222. except:
  223. return set()
  224. def preview(self, payload: TemplatePreviewRequest) -> dict:
  225. """
  226. 预览模板 - 基于真实模板正文渲染
  227. 自动检测 {% set xxx %} 定义的变量,如果没有对应的 {{ xxx }} 输出则自动添加
  228. 如果有common_body,自动合并宏定义和列表变量
  229. 返回:{success, result, error, condition_vars, data_vars, template_body}
  230. """
  231. try:
  232. # 先对原始模板处理 auto_add_output
  233. body = self._auto_add_output(payload.template_body)
  234. # 合并common模板的宏和变量定义(如果提供)
  235. if payload.common_body:
  236. body = payload.common_body + '\n\n' + body
  237. # 分析变量
  238. condition_vars, data_vars = self._analyze_variables(body)
  239. # 构建上下文 - 使用默认值 + 用户自定义值
  240. context = self._build_context(body, payload.parameters)
  241. # 渲染
  242. template = self.env.from_string(body)
  243. rendered = template.render(**context)
  244. # 清理输出
  245. paragraphs = [p.strip() for p in rendered.split("\n\n") if p.strip()]
  246. result_text = "\n\n".join(paragraphs)
  247. return {
  248. "success": True,
  249. "result": result_text,
  250. "error": "",
  251. "condition_vars": condition_vars,
  252. "data_vars": data_vars,
  253. "template_body": payload.template_body,
  254. "context_used": {k: str(v)[:100] for k, v in context.items() if k != 'program_name_map'}
  255. }
  256. except TemplateError as exc:
  257. return {
  258. "success": False,
  259. "result": "",
  260. "error": f"模板语法错误: {exc}",
  261. "condition_vars": [],
  262. "data_vars": [],
  263. "template_body": payload.template_body,
  264. "context_used": {}
  265. }
  266. except Exception as exc:
  267. import traceback
  268. return {
  269. "success": False,
  270. "result": "",
  271. "error": f"渲染失败: {exc}\n{traceback.format_exc()}",
  272. "condition_vars": [],
  273. "data_vars": [],
  274. "template_body": payload.template_body,
  275. "context_used": {}
  276. }
  277. def _auto_add_output(self, template_body: str) -> str:
  278. """
  279. 自动检测 {% set xxx %}...{% endset %} 块级定义的变量,
  280. 如果没有对应的 {{ xxx }} 输出则自动添加。
  281. 只处理块级set(内容块),不处理赋值型set(如 {% set count = 1 %})
  282. """
  283. import re
  284. body = template_body
  285. # 只匹配块级set: {% set name %}...{% endset %}
  286. set_block_pattern = r'{%\s*set\s+([a-zA-Z_][a-zA-Z0-9_]*)\s*%}'
  287. set_vars = set()
  288. for match in re.finditer(set_block_pattern, body):
  289. var_name = match.group(1)
  290. # 排除宏定义
  291. if var_name not in ('macro', 'endmacro'):
  292. set_vars.add(var_name)
  293. # 过滤掉宏名
  294. set_vars = {v for v in set_vars if v not in ('macro', 'endmacro')}
  295. # 检查每个块级set变量是否有输出
  296. added_outputs = []
  297. for var in set_vars:
  298. # 检查是否有 {{ var }} 输出
  299. output_pattern = r'{{\s*' + var + r'\s*}}'
  300. if not re.search(output_pattern, body):
  301. added_outputs.append(f'{{{{ {var} }}}}')
  302. if added_outputs:
  303. body = body.rstrip() + '\n\n' + '\n'.join(added_outputs)
  304. return body
  305. def _analyze_variables(self, template_body: str) -> tuple:
  306. """
  307. 分析模板中的变量,分离条件变量和数据变量
  308. 返回: (condition_vars, data_vars)
  309. """
  310. try:
  311. ast = self.env.parse(template_body)
  312. undeclared = meta.find_undeclared_variables(ast)
  313. except:
  314. return [], []
  315. condition_vars = []
  316. data_vars = []
  317. for var in sorted(undeclared):
  318. if var == 'program_name_map':
  319. continue
  320. # 检查是否是条件变量
  321. if var in CONDITION_VARIABLES:
  322. cfg = CONDITION_VARIABLES[var]
  323. var_def = {
  324. 'name': var,
  325. 'label': cfg['label'],
  326. 'type': cfg.get('type', 'text'),
  327. 'value': str(VARIABLE_DEFAULTS.get(var, '')),
  328. 'default': str(VARIABLE_DEFAULTS.get(var, '')),
  329. 'options': cfg.get('options', []),
  330. 'placeholder': cfg.get('placeholder', ''),
  331. 'description': cfg.get('description', ''),
  332. }
  333. condition_vars.append(var_def)
  334. elif var in DATA_VARIABLES:
  335. cfg = DATA_VARIABLES[var]
  336. var_def = {
  337. 'name': var,
  338. 'label': cfg['label'],
  339. 'type': 'text',
  340. 'value': str(VARIABLE_DEFAULTS.get(var, '')),
  341. 'default': str(VARIABLE_DEFAULTS.get(var, '')),
  342. 'placeholder': cfg.get('placeholder', ''),
  343. 'description': cfg.get('description', ''),
  344. }
  345. data_vars.append(var_def)
  346. else:
  347. # 未知变量,作为数据变量处理
  348. var_def = {
  349. 'name': var,
  350. 'label': var,
  351. 'type': 'text',
  352. 'value': str(VARIABLE_DEFAULTS.get(var, '')),
  353. 'default': str(VARIABLE_DEFAULTS.get(var, '')),
  354. 'placeholder': '',
  355. 'description': '',
  356. }
  357. data_vars.append(var_def)
  358. return condition_vars, data_vars
  359. def _build_context(self, template_body: str, custom_params: Optional[dict] = None) -> dict:
  360. """
  361. 智能构建渲染上下文
  362. """
  363. context = {}
  364. # 提取模板变量
  365. try:
  366. ast = self.env.parse(template_body)
  367. undeclared = meta.find_undeclared_variables(ast)
  368. except:
  369. undeclared = set()
  370. # 检测模板中的宏调用,不为宏名填充空字符串
  371. macro_calls = self._find_macro_calls(template_body)
  372. # 为每个变量填充默认值
  373. for var in undeclared:
  374. if var == 'program_name_map':
  375. continue
  376. # 跳过宏调用(这些应该由模板中的 {% macro %} 定义提供)
  377. if var in macro_calls:
  378. continue
  379. if var in VARIABLE_DEFAULTS:
  380. context[var] = copy.deepcopy(VARIABLE_DEFAULTS[var])
  381. else:
  382. context[var] = ""
  383. # 合并自定义参数(覆盖默认值)
  384. if custom_params:
  385. for key, value in custom_params.items():
  386. # 尝试类型转换
  387. if key in ('count', 'user_count', 'user_proportion'):
  388. try:
  389. context[key] = int(value)
  390. except:
  391. context[key] = value
  392. elif key in ('changed_params', 'changed_params_wash', 'changed_params_dry', 'care'):
  393. # 如果是JSON字符串,先解析为对象
  394. if isinstance(value, str):
  395. import json
  396. try:
  397. context[key] = json.loads(value)
  398. except (json.JSONDecodeError, ValueError):
  399. # 解析失败,使用默认值
  400. context[key] = copy.deepcopy(VARIABLE_DEFAULTS.get(key, {}))
  401. else:
  402. context[key] = value
  403. else:
  404. context[key] = value
  405. return context
  406. def render_from_record(self, record: TemplateRecord, parameters: dict) -> str:
  407. """渲染模板记录(保留旧接口兼容)"""
  408. normalized = self._normalize_parameters(parameters, record.input_variables)
  409. normalized = self._fill_missing_variables(record.template_body, normalized)
  410. return self._render(record.template_body, normalized)
  411. def _fill_missing_variables(self, template_body: str, parameters: dict) -> dict:
  412. """扫描模板,为未提供的变量填充默认值"""
  413. try:
  414. ast = self.env.parse(template_body)
  415. undeclared = meta.find_undeclared_variables(ast)
  416. for var in undeclared:
  417. if var not in parameters and var != 'program_name_map':
  418. parameters[var] = copy.deepcopy(VARIABLE_DEFAULTS.get(var, ""))
  419. except Exception:
  420. pass
  421. return parameters
  422. def _render(self, template_body: str, parameters: dict) -> str:
  423. """执行渲染"""
  424. try:
  425. template = self.env.from_string(template_body)
  426. rendered = template.render(**parameters)
  427. paragraphs = [p.strip() for p in rendered.split("\n\n") if p.strip()]
  428. return "\n\n".join(paragraphs)
  429. except TemplateError as exc:
  430. raise HTTPException(status_code=400, detail=f"模板渲染出错: {exc}") from exc
  431. def _normalize_parameters(
  432. self,
  433. parameters: Dict[str, object],
  434. definitions: Optional[Iterable[VariableDefinition]] = None,
  435. ) -> Dict[str, object]:
  436. """参数类型转换"""
  437. type_map = {}
  438. for definition in definitions or []:
  439. if isinstance(definition, VariableDefinition):
  440. name = (definition.name or "").strip()
  441. data_type = (definition.data_type or "").lower()
  442. elif isinstance(definition, dict):
  443. name = (definition.get("name") or "").strip()
  444. data_type = (definition.get("data_type") or "").lower()
  445. else:
  446. continue
  447. if name:
  448. type_map[name] = data_type
  449. return {
  450. key: self._convert_value(value, type_map.get(key))
  451. for key, value in parameters.items()
  452. }
  453. def _convert_value(self, value: object, data_type: Optional[str]) -> object:
  454. """值类型转换"""
  455. if value is None or isinstance(value, (int, float, bool)):
  456. return value
  457. if isinstance(value, str):
  458. stripped = value.strip()
  459. if stripped == "":
  460. return None
  461. if (data_type or "").startswith("bool"):
  462. lowered = stripped.lower()
  463. if lowered in {"true", "1", "yes", "y", "on"}:
  464. return True
  465. if lowered in {"false", "0", "no", "n", "off"}:
  466. return False
  467. if (data_type or "") in {"number", "integer"} or self._looks_numeric(stripped):
  468. try:
  469. return int(stripped) if stripped.isdigit() else float(stripped)
  470. except ValueError:
  471. return value
  472. return value
  473. def _looks_numeric(self, value: str) -> bool:
  474. return bool(re.fullmatch(r"[+-]?\d+(\.\d+)?", value))
  475. renderer = TemplateRenderer()