马一丁

Add test file

  1 +#!/usr/bin/env python3
  2 +"""
  3 +生成覆盖全部允许block类型的演示 IR,用于验证 HTML 与 PDF 渲染。
  4 +
  5 +执行后会在 `final_reports/ir` 写入一份带时间戳的 IR,
  6 +并分别在 `final_reports/html` 与 `final_reports/pdf` 输出对应的渲染文件。
  7 +"""
  8 +
  9 +from __future__ import annotations
  10 +
  11 +import json
  12 +import sys
  13 +from datetime import datetime
  14 +from pathlib import Path
  15 +
  16 +# 允许直接以脚本形式运行
  17 +ROOT = Path(__file__).resolve().parents[2]
  18 +if str(ROOT) not in sys.path:
  19 + sys.path.insert(0, str(ROOT))
  20 +
  21 +from ReportEngine.core import DocumentComposer
  22 +from ReportEngine.ir import IRValidator
  23 +from ReportEngine.ir.schema import ENGINE_AGENT_TITLES
  24 +from ReportEngine.renderers import HTMLRenderer, PDFRenderer
  25 +from ReportEngine.utils.config import settings
  26 +
  27 +
  28 +def build_inline_marks_demo() -> dict:
  29 + """生成覆盖全部内联标记的 paragraph block。"""
  30 + return {
  31 + "type": "paragraph",
  32 + "inlines": [
  33 + {"text": "这一段覆盖全部内联标记:"},
  34 + {"text": "粗体", "marks": [{"type": "bold"}]},
  35 + {"text": " / 斜体", "marks": [{"type": "italic"}]},
  36 + {"text": " / 下划线", "marks": [{"type": "underline"}]},
  37 + {"text": " / 删除线", "marks": [{"type": "strike"}]},
  38 + {"text": " / 代码", "marks": [{"type": "code"}]},
  39 + {
  40 + "text": " / 链接",
  41 + "marks": [
  42 + {
  43 + "type": "link",
  44 + "href": "https://example.com/demo",
  45 + "title": "示例链接",
  46 + }
  47 + ],
  48 + },
  49 + {"text": " / 颜色", "marks": [{"type": "color", "value": "#c0392b"}]},
  50 + {
  51 + "text": " / 字体",
  52 + "marks": [
  53 + {
  54 + "type": "font",
  55 + "family": "Georgia, serif",
  56 + "size": "15px",
  57 + "weight": "600",
  58 + }
  59 + ],
  60 + },
  61 + {"text": " / 高亮", "marks": [{"type": "highlight"}]},
  62 + {"text": " / 下标", "marks": [{"type": "subscript"}]},
  63 + {"text": " / 上标", "marks": [{"type": "superscript"}]},
  64 + {"text": " / 行内公式", "marks": [{"type": "math", "value": "E=mc^2"}]},
  65 + {"text": "。"},
  66 + ],
  67 + }
  68 +
  69 +
  70 +def build_widget_block() -> dict:
  71 + """构造一个合法的 Chart.js widget block。"""
  72 + return {
  73 + "type": "widget",
  74 + "widgetId": "demo-volume-trend",
  75 + "widgetType": "chart.js/line",
  76 + "props": {
  77 + "type": "line",
  78 + "options": {
  79 + "responsive": True,
  80 + "plugins": {"legend": {"position": "bottom"}},
  81 + "scales": {"y": {"title": {"display": True, "text": "提及量"}}},
  82 + },
  83 + },
  84 + "data": {
  85 + "labels": ["T0", "T0+6h", "T0+12h", "T0+18h", "T0+24h"],
  86 + "datasets": [
  87 + {
  88 + "label": "主流媒体",
  89 + "data": [12, 18, 23, 30, 26],
  90 + "borderColor": "#2980b9",
  91 + "backgroundColor": "rgba(41,128,185,0.18)",
  92 + "tension": 0.25,
  93 + "fill": False,
  94 + },
  95 + {
  96 + "label": "社交平台",
  97 + "data": [8, 10, 15, 28, 40],
  98 + "borderColor": "#c0392b",
  99 + "backgroundColor": "rgba(192,57,43,0.2)",
  100 + "tension": 0.35,
  101 + "fill": False,
  102 + },
  103 + ],
  104 + },
  105 + }
  106 +
  107 +
  108 +def build_chapters() -> list[dict]:
  109 + """构造覆盖所有 block 类型的章节列表。"""
  110 + inline_demo = build_inline_marks_demo()
  111 +
  112 + bullet_list = {
  113 + "type": "list",
  114 + "listType": "bullet",
  115 + "items": [
  116 + [
  117 + {
  118 + "type": "paragraph",
  119 + "inlines": [{"text": "社交媒体热度在 48 小时内翻倍"}],
  120 + }
  121 + ],
  122 + [
  123 + {
  124 + "type": "paragraph",
  125 + "inlines": [{"text": "主流媒体报道集中在早间时段"}],
  126 + },
  127 + {
  128 + "type": "list",
  129 + "listType": "ordered",
  130 + "items": [
  131 + [
  132 + {
  133 + "type": "paragraph",
  134 + "inlines": [{"text": "07:00-09:00:首轮报道"}],
  135 + }
  136 + ],
  137 + [
  138 + {
  139 + "type": "paragraph",
  140 + "inlines": [{"text": "10:00-12:00:评论扩散"}],
  141 + }
  142 + ],
  143 + ],
  144 + },
  145 + ],
  146 + [
  147 + {
  148 + "type": "paragraph",
  149 + "inlines": [{"text": "地方政务号开始回应并同步线下通稿"}],
  150 + }
  151 + ],
  152 + ],
  153 + }
  154 +
  155 + task_list = {
  156 + "type": "list",
  157 + "listType": "task",
  158 + "items": [
  159 + [
  160 + {
  161 + "type": "paragraph",
  162 + "inlines": [{"text": "跟踪权威辟谣素材是否上线"}],
  163 + }
  164 + ],
  165 + [
  166 + {
  167 + "type": "paragraph",
  168 + "inlines": [{"text": "监测新增关联关键词与长尾问题"}],
  169 + }
  170 + ],
  171 + [
  172 + {
  173 + "type": "paragraph",
  174 + "inlines": [{"text": "准备 FAQ 供客服统一答复"}],
  175 + }
  176 + ],
  177 + ],
  178 + }
  179 +
  180 + table_block = {
  181 + "type": "table",
  182 + "caption": "核心信源与传播路径",
  183 + "zebra": True,
  184 + "colgroup": [{"width": "22%"}, {"width": "38%"}, {"width": "40%"}],
  185 + "rows": [
  186 + {
  187 + "cells": [
  188 + {
  189 + "align": "center",
  190 + "blocks": [
  191 + {
  192 + "type": "paragraph",
  193 + "inlines": [{"text": "时间节点", "marks": [{"type": "bold"}]}],
  194 + }
  195 + ],
  196 + },
  197 + {
  198 + "align": "center",
  199 + "blocks": [
  200 + {
  201 + "type": "paragraph",
  202 + "inlines": [{"text": "事件内容", "marks": [{"type": "bold"}]}],
  203 + }
  204 + ],
  205 + },
  206 + {
  207 + "align": "center",
  208 + "blocks": [
  209 + {
  210 + "type": "paragraph",
  211 + "inlines": [{"text": "主要渠道", "marks": [{"type": "bold"}]}],
  212 + }
  213 + ],
  214 + },
  215 + ]
  216 + },
  217 + {
  218 + "cells": [
  219 + {"blocks": [{"type": "paragraph", "inlines": [{"text": "T0"}]}]},
  220 + {
  221 + "blocks": [
  222 + {
  223 + "type": "paragraph",
  224 + "inlines": [{"text": "线下冲突视频首次上传"}],
  225 + }
  226 + ]
  227 + },
  228 + {
  229 + "blocks": [
  230 + {
  231 + "type": "paragraph",
  232 + "inlines": [{"text": "短视频平台 / 私聊转发"}],
  233 + }
  234 + ]
  235 + },
  236 + ]
  237 + },
  238 + {
  239 + "cells": [
  240 + {"blocks": [{"type": "paragraph", "inlines": [{"text": "T0+6h"}]}]},
  241 + {
  242 + "blocks": [
  243 + {
  244 + "type": "paragraph",
  245 + "inlines": [{"text": "登上热搜,出现二次剪辑"}],
  246 + }
  247 + ]
  248 + },
  249 + {
  250 + "blocks": [
  251 + {
  252 + "type": "paragraph",
  253 + "inlines": [{"text": "微博 / 朋友圈"}],
  254 + }
  255 + ]
  256 + },
  257 + ]
  258 + },
  259 + {
  260 + "cells": [
  261 + {"blocks": [{"type": "paragraph", "inlines": [{"text": "T0+18h"}]}]},
  262 + {
  263 + "blocks": [
  264 + {
  265 + "type": "paragraph",
  266 + "inlines": [{"text": "官方回应并发布事实澄清"}],
  267 + }
  268 + ]
  269 + },
  270 + {
  271 + "blocks": [
  272 + {
  273 + "type": "paragraph",
  274 + "inlines": [{"text": "政务号 / 新闻客户端"}],
  275 + }
  276 + ]
  277 + },
  278 + ]
  279 + },
  280 + {
  281 + "cells": [
  282 + {"blocks": [{"type": "paragraph", "inlines": [{"text": "T0+24h"}]}]},
  283 + {
  284 + "blocks": [
  285 + {
  286 + "type": "paragraph",
  287 + "inlines": [{"text": "专家解读,舆论重心转向责任归属"}],
  288 + }
  289 + ]
  290 + },
  291 + {
  292 + "blocks": [
  293 + {
  294 + "type": "paragraph",
  295 + "inlines": [{"text": "视频号直播 / 行业社群"}],
  296 + }
  297 + ]
  298 + },
  299 + ]
  300 + },
  301 + ],
  302 + }
  303 +
  304 + blockquote_block = {
  305 + "type": "blockquote",
  306 + "variant": "accent",
  307 + "blocks": [
  308 + {
  309 + "type": "paragraph",
  310 + "inlines": [{"text": "“公众最关心的信息是真相与责任边界。”"}],
  311 + },
  312 + {
  313 + "type": "paragraph",
  314 + "inlines": [{"text": "—— 模拟引用,验证引用块样式"}],
  315 + },
  316 + ],
  317 + }
  318 +
  319 + engine_quote_block = {
  320 + "type": "engineQuote",
  321 + "engine": "insight",
  322 + "title": ENGINE_AGENT_TITLES["insight"],
  323 + "blocks": [
  324 + {
  325 + "type": "paragraph",
  326 + "inlines": [
  327 + {
  328 + "text": "模型认为 24 小时内保持回应频次,可避免信息真空。",
  329 + "marks": [{"type": "bold"}],
  330 + }
  331 + ],
  332 + },
  333 + {
  334 + "type": "paragraph",
  335 + "inlines": [
  336 + {"text": "建议同时准备简短 FAQ,便于多渠道统一口径。"}
  337 + ],
  338 + },
  339 + ],
  340 + }
  341 +
  342 + swot_block = {
  343 + "type": "swotTable",
  344 + "title": "舆论场 SWOT 速览",
  345 + "summary": "覆盖当前情绪分布、潜在风险与机会。",
  346 + "strengths": [
  347 + {"title": "官方快速响应", "detail": "首条澄清视频 3 小时内上线"},
  348 + {"title": "同城媒体配合", "impact": "高", "score": 8},
  349 + ],
  350 + "weaknesses": [
  351 + {"title": "早期谣言存量大", "detail": "相关转发仍占 30%"},
  352 + "外部专家尚未统一口径",
  353 + ],
  354 + "opportunities": [
  355 + {
  356 + "title": "社区共建讨论",
  357 + "detail": "自发组织“辟谣志愿者”话题,情绪正向",
  358 + },
  359 + {"title": "公益合作窗口", "impact": "中"},
  360 + ],
  361 + "threats": [
  362 + {"title": "跨平台剪辑继续发酵", "impact": "高", "score": 9},
  363 + {"title": "个别自媒体煽动情绪", "evidence": "存在地域标签化倾向"},
  364 + ],
  365 + }
  366 +
  367 + callout_block = {
  368 + "type": "callout",
  369 + "tone": "warning",
  370 + "title": "排版边界提示",
  371 + "blocks": [
  372 + {
  373 + "type": "paragraph",
  374 + "inlines": [
  375 + {"text": "callout 内部仅放轻量内容,超出部分会自动溢出到外层。"}
  376 + ],
  377 + },
  378 + {
  379 + "type": "list",
  380 + "listType": "bullet",
  381 + "items": [
  382 + [
  383 + {
  384 + "type": "paragraph",
  385 + "inlines": [{"text": "支持嵌套列表 / 表格 / 数学公式"}],
  386 + }
  387 + ],
  388 + [
  389 + {
  390 + "type": "paragraph",
  391 + "inlines": [{"text": "可在这里放置提醒或操作步骤"}],
  392 + }
  393 + ],
  394 + ],
  395 + },
  396 + ],
  397 + }
  398 +
  399 + code_block = {
  400 + "type": "code",
  401 + "lang": "json",
  402 + "caption": "演示代码块",
  403 + "content": '{\n "event": "热点示例",\n "topic": "公共事件",\n "status": "monitoring"\n}',
  404 + }
  405 +
  406 + math_block = {
  407 + "type": "math",
  408 + "latex": r"E = mc^2",
  409 + "displayMode": True,
  410 + }
  411 +
  412 + figure_block = {
  413 + "type": "figure",
  414 + "img": {
  415 + "src": "https://dummyimage.com/600x320/eeeeee/333333&text=Placeholder",
  416 + "alt": "占位示意图",
  417 + "width": 600,
  418 + "height": 320,
  419 + },
  420 + "caption": "图像外链被替换为友好提示,可验证 figure 占位效果。",
  421 + "responsive": True,
  422 + }
  423 +
  424 + widget_block = build_widget_block()
  425 +
  426 + chapter_1 = {
  427 + "chapterId": "S1",
  428 + "title": "封面与目录",
  429 + "anchor": "overview",
  430 + "order": 10,
  431 + "blocks": [
  432 + {"type": "heading", "level": 2, "text": "一、封面与目录", "anchor": "overview"},
  433 + {
  434 + "type": "paragraph",
  435 + "inlines": [
  436 + {
  437 + "text": "模拟社会公共热点事件的摘要,便于快速确认排版与字体效果。",
  438 + }
  439 + ],
  440 + },
  441 + inline_demo,
  442 + {
  443 + "type": "kpiGrid",
  444 + "items": [
  445 + {"label": "24h提及量", "value": "98K", "delta": "+41%", "deltaTone": "up"},
  446 + {"label": "正向占比", "value": "32%", "delta": "+5pp", "deltaTone": "up"},
  447 + {"label": "负向占比", "value": "18%", "delta": "-3pp", "deltaTone": "down"},
  448 + {"label": "高频渠道", "value": "短视频 / 微博"},
  449 + ],
  450 + "cols": 4,
  451 + },
  452 + {"type": "toc"},
  453 + {"type": "hr"},
  454 + ],
  455 + }
  456 +
  457 + chapter_2 = {
  458 + "chapterId": "S2",
  459 + "title": "块类型演示",
  460 + "anchor": "blocks-showcase",
  461 + "order": 20,
  462 + "blocks": [
  463 + {
  464 + "type": "heading",
  465 + "level": 2,
  466 + "text": "二、块类型演示",
  467 + "anchor": "blocks-showcase",
  468 + },
  469 + {
  470 + "type": "paragraph",
  471 + "inlines": [
  472 + {"text": "以下内容逐一覆盖 paragraph/list/table/swot/table/widget 等全部块类型。"}
  473 + ],
  474 + },
  475 + {
  476 + "type": "heading",
  477 + "level": 3,
  478 + "text": "2.1 列表与表格",
  479 + "anchor": "lists-and-tables",
  480 + },
  481 + bullet_list,
  482 + task_list,
  483 + table_block,
  484 + {
  485 + "type": "heading",
  486 + "level": 3,
  487 + "text": "2.2 高阶块与富媒体",
  488 + "anchor": "advanced-blocks",
  489 + },
  490 + blockquote_block,
  491 + callout_block,
  492 + engine_quote_block,
  493 + swot_block,
  494 + widget_block,
  495 + code_block,
  496 + math_block,
  497 + figure_block,
  498 + {
  499 + "type": "hr",
  500 + "variant": "dashed",
  501 + },
  502 + {
  503 + "type": "paragraph",
  504 + "align": "justify",
  505 + "inlines": [
  506 + {
  507 + "text": "本章节的 inline math 兜底验证:",
  508 + },
  509 + {"text": "p(t)=p_0 e^{\\lambda t}", "marks": [{"type": "math"}]},
  510 + {"text": ";以上覆盖所有允许块及标记。"},
  511 + ],
  512 + },
  513 + ],
  514 + }
  515 +
  516 + return [chapter_1, chapter_2]
  517 +
  518 +
  519 +def validate_chapters(chapters: list[dict]) -> None:
  520 + """使用 IRValidator 校验章节结构,发现错误时抛出异常。"""
  521 + validator = IRValidator()
  522 + for chapter in chapters:
  523 + ok, errors = validator.validate_chapter(chapter)
  524 + if not ok:
  525 + raise ValueError(f"{chapter.get('chapterId', 'unknown')} 校验失败: {errors}")
  526 +
  527 +
  528 +def render_and_save(document_ir: dict, timestamp: str) -> tuple[Path, Path, Path]:
  529 + """将 IR 保存为 JSON,并渲染 HTML / PDF,返回三个路径。"""
  530 + ir_dir = Path(settings.DOCUMENT_IR_OUTPUT_DIR)
  531 + html_dir = Path(settings.OUTPUT_DIR) / "html"
  532 + pdf_dir = Path(settings.OUTPUT_DIR) / "pdf"
  533 + ir_dir.mkdir(parents=True, exist_ok=True)
  534 + html_dir.mkdir(parents=True, exist_ok=True)
  535 + pdf_dir.mkdir(parents=True, exist_ok=True)
  536 +
  537 + ir_path = ir_dir / f"report_ir_all_blocks_demo_{timestamp}.json"
  538 + ir_path.write_text(json.dumps(document_ir, ensure_ascii=False, indent=2), encoding="utf-8")
  539 +
  540 + html_renderer = HTMLRenderer()
  541 + html_content = html_renderer.render(document_ir)
  542 + html_path = html_dir / f"report_html_all_blocks_demo_{timestamp}.html"
  543 + html_path.write_text(html_content, encoding="utf-8")
  544 +
  545 + pdf_renderer = PDFRenderer()
  546 + pdf_path = pdf_dir / f"report_pdf_all_blocks_demo_{timestamp}.pdf"
  547 + pdf_renderer.render_to_pdf(document_ir, pdf_path)
  548 +
  549 + return ir_path, html_path, pdf_path
  550 +
  551 +
  552 +def main() -> int:
  553 + timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
  554 + report_id = f"all-blocks-demo-{timestamp}"
  555 + metadata = {
  556 + "title": "社会公共热点事件渲染测试",
  557 + "subtitle": "覆盖全部 IR 块类型的示例数据",
  558 + "query": "公共事件渲染能力自检",
  559 + "toc": {"title": "目录", "depth": 3},
  560 + "hero": {
  561 + "summary": "用于验证 Report Engine 在 HTML / PDF 渲染时对各类区块的兼容性。",
  562 + "kpis": [
  563 + {"label": "示例块数量", "value": "14", "delta": "+100%", "tone": "up"},
  564 + {"label": "图表数", "value": "1", "delta": "安全检查", "tone": "neutral"},
  565 + ],
  566 + "highlights": ["覆盖全部 block", "含行内/块级公式", "Chart.js 数据有效"],
  567 + "actions": ["重新生成", "导出 PDF"],
  568 + },
  569 + }
  570 +
  571 + chapters = build_chapters()
  572 + validate_chapters(chapters)
  573 +
  574 + composer = DocumentComposer()
  575 + document_ir = composer.build_document(report_id, metadata, chapters)
  576 +
  577 + ir_path, html_path, pdf_path = render_and_save(document_ir, timestamp)
  578 +
  579 + print("✅ 演示 IR 生成完成")
  580 + print(f"IR: {ir_path}")
  581 + print(f"HTML: {html_path}")
  582 + print(f"PDF: {pdf_path}")
  583 + return 0
  584 +
  585 +
  586 +if __name__ == "__main__":
  587 + raise SystemExit(main())