马一丁

Single-Agent Speaking Blocks Available

@@ -34,6 +34,7 @@ ALLOWED_BLOCK_TYPES: List[str] = [ @@ -34,6 +34,7 @@ ALLOWED_BLOCK_TYPES: List[str] = [
34 "list", 34 "list",
35 "table", 35 "table",
36 "blockquote", 36 "blockquote",
  37 + "engineQuote",
37 "hr", 38 "hr",
38 "code", 39 "code",
39 "math", 40 "math",
@@ -177,6 +178,22 @@ blockquote_block: Dict[str, Any] = { @@ -177,6 +178,22 @@ blockquote_block: Dict[str, Any] = {
177 "additionalProperties": True, 178 "additionalProperties": True,
178 } 179 }
179 180
  181 +engine_quote_block: Dict[str, Any] = {
  182 + "title": "EngineQuoteBlock",
  183 + "type": "object",
  184 + "properties": {
  185 + "type": {"const": "engineQuote"},
  186 + "engine": {"type": "string", "enum": ["insight", "media", "query"]},
  187 + "title": {"type": "string"},
  188 + "blocks": {
  189 + "type": "array",
  190 + "items": {"$ref": "#/definitions/block"},
  191 + },
  192 + },
  193 + "required": ["type", "engine", "blocks"],
  194 + "additionalProperties": True,
  195 +}
  196 +
180 hr_block: Dict[str, Any] = { 197 hr_block: Dict[str, Any] = {
181 "title": "HorizontalRuleBlock", 198 "title": "HorizontalRuleBlock",
182 "type": "object", 199 "type": "object",
@@ -315,6 +332,7 @@ block_variants: List[Dict[str, Any]] = [ @@ -315,6 +332,7 @@ block_variants: List[Dict[str, Any]] = [
315 list_block, 332 list_block,
316 table_block, 333 table_block,
317 blockquote_block, 334 blockquote_block,
  335 + engine_quote_block,
318 hr_block, 336 hr_block,
319 code_block, 337 code_block,
320 math_block, 338 math_block,
@@ -138,6 +138,45 @@ class IRValidator: @@ -138,6 +138,45 @@ class IRValidator:
138 for idx, sub_block in enumerate(inner): 138 for idx, sub_block in enumerate(inner):
139 self._validate_block(sub_block, f"{path}.blocks[{idx}]", errors) 139 self._validate_block(sub_block, f"{path}.blocks[{idx}]", errors)
140 140
  141 + def _validate_engineQuote_block(
  142 + self, block: Dict[str, Any], path: str, errors: List[str]
  143 + ):
  144 + """单引擎发言块需标注engine并包含子blocks"""
  145 + engine = block.get("engine")
  146 + if engine not in {"insight", "media", "query"}:
  147 + errors.append(f"{path}.engine 取值非法: {engine}")
  148 + inner = block.get("blocks")
  149 + if not isinstance(inner, list) or not inner:
  150 + errors.append(f"{path}.blocks 必须是非空数组")
  151 + return
  152 + for idx, sub_block in enumerate(inner):
  153 + sub_path = f"{path}.blocks[{idx}]"
  154 + if not isinstance(sub_block, dict):
  155 + errors.append(f"{sub_path} 必须是对象")
  156 + continue
  157 + if sub_block.get("type") != "paragraph":
  158 + errors.append(f"{sub_path}.type 仅允许 paragraph")
  159 + continue
  160 + # 复用 paragraph 结构校验,但限制 marks
  161 + inlines = sub_block.get("inlines")
  162 + if not isinstance(inlines, list) or not inlines:
  163 + errors.append(f"{sub_path}.inlines 必须是非空数组")
  164 + continue
  165 + for ridx, run in enumerate(inlines):
  166 + self._validate_inline_run(run, f"{sub_path}.inlines[{ridx}]", errors)
  167 + if not isinstance(run, dict):
  168 + continue
  169 + marks = run.get("marks") or []
  170 + if not isinstance(marks, list):
  171 + errors.append(f"{sub_path}.inlines[{ridx}].marks 必须是数组")
  172 + continue
  173 + for midx, mark in enumerate(marks):
  174 + mark_type = mark.get("type") if isinstance(mark, dict) else None
  175 + if mark_type not in {"bold", "italic"}:
  176 + errors.append(
  177 + f"{sub_path}.inlines[{ridx}].marks[{midx}].type 仅允许 bold/italic"
  178 + )
  179 +
141 def _validate_callout_block(self, block: Dict[str, Any], path: str, errors: List[str]): 180 def _validate_callout_block(self, block: Dict[str, Any], path: str, errors: List[str]):
142 """callout需声明tone,并至少有一个子block""" 181 """callout需声明tone,并至少有一个子block"""
143 tone = block.get("tone") 182 tone = block.get("tone")
@@ -889,7 +889,7 @@ class ChapterGenerationNode(BaseNode): @@ -889,7 +889,7 @@ class ChapterGenerationNode(BaseNode):
889 block["items"] = normalized 889 block["items"] = normalized
890 for entry in block.get("items", []): 890 for entry in block.get("items", []):
891 walk(entry) 891 walk(entry)
892 - elif block_type in {"callout", "blockquote"}: 892 + elif block_type in {"callout", "blockquote", "engineQuote"}:
893 walk(block.get("blocks")) 893 walk(block.get("blocks"))
894 elif block_type == "table": 894 elif block_type == "table":
895 for row in block.get("rows", []): 895 for row in block.get("rows", []):
@@ -994,7 +994,7 @@ class ChapterGenerationNode(BaseNode): @@ -994,7 +994,7 @@ class ChapterGenerationNode(BaseNode):
994 total += walk(item) 994 total += walk(item)
995 return total 995 return total
996 996
997 - if block_type in {"blockquote", "callout"}: 997 + if block_type in {"blockquote", "callout", "engineQuote"}:
998 return walk(node.get("blocks")) 998 return walk(node.get("blocks"))
999 999
1000 if block_type == "table": 1000 if block_type == "table":
@@ -1015,7 +1015,7 @@ class ChapterGenerationNode(BaseNode): @@ -1015,7 +1015,7 @@ class ChapterGenerationNode(BaseNode):
1015 1015
1016 def _count_narrative_characters(self, blocks: Any) -> int: 1016 def _count_narrative_characters(self, blocks: Any) -> int:
1017 """ 1017 """
1018 - 统计paragraph/callout/list/blockquote等叙述性结构的字符数,避免被表格/图表“刷长”。 1018 + 统计paragraph/callout/list/blockquote/engineQuote等叙述性结构的字符数,避免被表格/图表“刷长”。
1019 """ 1019 """
1020 1020
1021 def walk(node: Any) -> int: 1021 def walk(node: Any) -> int:
@@ -1037,7 +1037,7 @@ class ChapterGenerationNode(BaseNode): @@ -1037,7 +1037,7 @@ class ChapterGenerationNode(BaseNode):
1037 for item in node.get("items", []): 1037 for item in node.get("items", []):
1038 total += walk(item) 1038 total += walk(item)
1039 return total 1039 return total
1040 - if block_type in {"callout", "blockquote"}: 1040 + if block_type in {"callout", "blockquote", "engineQuote"}:
1041 return walk(node.get("blocks")) 1041 return walk(node.get("blocks"))
1042 1042
1043 # list项可能是匿名dict,兼容性遍历 1043 # list项可能是匿名dict,兼容性遍历
@@ -1072,12 +1072,60 @@ class ChapterGenerationNode(BaseNode): @@ -1072,12 +1072,60 @@ class ChapterGenerationNode(BaseNode):
1072 self._normalize_paragraph_block(block) 1072 self._normalize_paragraph_block(block)
1073 elif block_type == "table": 1073 elif block_type == "table":
1074 self._sanitize_table_block(block) 1074 self._sanitize_table_block(block)
  1075 + elif block_type == "engineQuote":
  1076 + self._sanitize_engine_quote_block(block)
1075 1077
1076 def _sanitize_table_block(self, block: Dict[str, Any]): 1078 def _sanitize_table_block(self, block: Dict[str, Any]):
1077 """保证表格的rows/cells结构合法且每个单元格包含至少一个block""" 1079 """保证表格的rows/cells结构合法且每个单元格包含至少一个block"""
1078 rows = self._normalize_table_rows(block.get("rows")) 1080 rows = self._normalize_table_rows(block.get("rows"))
1079 block["rows"] = rows 1081 block["rows"] = rows
1080 1082
  1083 + def _sanitize_engine_quote_block(self, block: Dict[str, Any]):
  1084 + """engineQuote内部仅允许paragraph,且仅保留bold/italic样式"""
  1085 + allowed_marks = {"bold", "italic"}
  1086 + raw_blocks = block.get("blocks")
  1087 + candidates = raw_blocks if isinstance(raw_blocks, list) else ([raw_blocks] if raw_blocks else [])
  1088 + sanitized_blocks: List[Dict[str, Any]] = []
  1089 +
  1090 + for item in candidates:
  1091 + if isinstance(item, dict) and item.get("type") == "paragraph":
  1092 + para = dict(item)
  1093 + else:
  1094 + text = self._extract_block_text(item) if isinstance(item, dict) else (item or "")
  1095 + para = self._as_paragraph_block(str(text))
  1096 +
  1097 + inlines = para.get("inlines")
  1098 + if not isinstance(inlines, list) or not inlines:
  1099 + inlines = [self._as_inline_run(self._extract_block_text(para))]
  1100 +
  1101 + cleaned_inlines: List[Dict[str, Any]] = []
  1102 + for run in inlines:
  1103 + if isinstance(run, dict):
  1104 + text_val = run.get("text")
  1105 + text_str = text_val if isinstance(text_val, str) else ("" if text_val is None else str(text_val))
  1106 + marks_raw = run.get("marks") if isinstance(run.get("marks"), list) else []
  1107 + marks_filtered: List[Dict[str, Any]] = []
  1108 + for mark in marks_raw:
  1109 + if not isinstance(mark, dict):
  1110 + continue
  1111 + mark_type = mark.get("type")
  1112 + if mark_type in allowed_marks:
  1113 + marks_filtered.append({"type": mark_type})
  1114 + cleaned_inlines.append({"text": text_str, "marks": marks_filtered})
  1115 + else:
  1116 + cleaned_inlines.append(self._as_inline_run(str(run)))
  1117 +
  1118 + if not cleaned_inlines:
  1119 + cleaned_inlines.append(self._as_inline_run(""))
  1120 + para["inlines"] = cleaned_inlines
  1121 + para["type"] = "paragraph"
  1122 + para.pop("blocks", None)
  1123 + sanitized_blocks.append(para)
  1124 +
  1125 + if not sanitized_blocks:
  1126 + sanitized_blocks.append(self._as_paragraph_block(""))
  1127 + block["blocks"] = sanitized_blocks
  1128 +
1081 def _normalize_table_rows(self, rows: Any) -> List[Dict[str, Any]]: 1129 def _normalize_table_rows(self, rows: Any) -> List[Dict[str, Any]]:
1082 """确保rows始终是由row对象组成的列表""" 1130 """确保rows始终是由row对象组成的列表"""
1083 if rows is None: 1131 if rows is None:
@@ -1250,9 +1298,9 @@ class ChapterGenerationNode(BaseNode): @@ -1250,9 +1298,9 @@ class ChapterGenerationNode(BaseNode):
1250 return merged 1298 return merged
1251 1299
1252 def _merge_nested_fragments(self, block: Dict[str, Any]) -> Dict[str, Any]: 1300 def _merge_nested_fragments(self, block: Dict[str, Any]) -> Dict[str, Any]:
1253 - """对嵌套结构(callout/list/table)递归处理片段合并""" 1301 + """对嵌套结构(callout/blockquote/engineQuote/list/table)递归处理片段合并"""
1254 block_type = block.get("type") 1302 block_type = block.get("type")
1255 - if block_type in {"callout", "blockquote"}: 1303 + if block_type in {"callout", "blockquote", "engineQuote"}:
1256 nested = block.get("blocks") 1304 nested = block.get("blocks")
1257 if isinstance(nested, list): 1305 if isinstance(nested, list):
1258 block["blocks"] = self._merge_fragment_sequences(nested) 1306 block["blocks"] = self._merge_fragment_sequences(nested)
@@ -306,16 +306,17 @@ SYSTEM_PROMPT_CHAPTER_JSON = f""" @@ -306,16 +306,17 @@ SYSTEM_PROMPT_CHAPTER_JSON = f"""
306 5. 表格需给出rows/cells/align,KPI卡请使用kpiGrid,分割线用hr。 306 5. 表格需给出rows/cells/align,KPI卡请使用kpiGrid,分割线用hr。
307 6. 如需引用图表/交互组件,统一用widgetType表示(例如chart.js/line、chart.js/doughnut)。 307 6. 如需引用图表/交互组件,统一用widgetType表示(例如chart.js/line、chart.js/doughnut)。
308 7. 鼓励结合outline中列出的子标题,生成多层heading与细粒度内容,同时可补充callout、blockquote等。 308 7. 鼓励结合outline中列出的子标题,生成多层heading与细粒度内容,同时可补充callout、blockquote等。
309 -8. 如果chapterPlan中包含target/min/max或sections细分预算,请尽量贴合,必要时在notes允许的范围内突破,同时在结构上体现详略;  
310 -9. 一级标题需使用中文数字(“一、二、三”),二级标题使用阿拉伯数字(“1.1、1.2”),heading.text中直接写好编号,与outline顺序对应;  
311 -10. 严禁输出外部图片/AI生图链接,仅可使用Chart.js图表、表格、色块、callout等HTML原生组件;如需视觉辅助请改为文字描述或数据表;  
312 -11. 段落混排需通过marks表达粗体、斜体、下划线、颜色等样式,禁止残留Markdown语法(如**text**);  
313 -12. 行间公式用block.type="math"并填入math.latex,行内公式在paragraph.inlines里将文本设为Latex并加上marks.type="math",渲染层会用MathJax处理;  
314 -13. widget配色需与CSS变量兼容,不要硬编码背景色或文字色,legend/ticks由渲染层控制;  
315 -14. 善用callout、kpiGrid、表格、widget等提升版面丰富度,但必须遵守模板章节范围。  
316 -15. 输出前务必自检JSON语法:禁止出现`{{}}{{`或`][`相连缺少逗号、列表项嵌套超过一层、未闭合的括号或未转义换行,`list` block的items必须是`[[block,...], ...]`结构,若无法满足则返回错误提示而不是输出不合法JSON。  
317 -16. 所有widget块必须在顶层提供`data`或`dataRef`(可将props中的`data`上移),确保Chart.js能够直接渲染;缺失数据时宁可输出表格或段落,绝不留空。  
318 -17. 任何block都必须声明合法`type`(heading/paragraph/list/...);若需要普通文本请使用`paragraph`并给出`inlines`,禁止返回`type:null`或未知值。 309 +8. 如需标注某个引擎的原话,请用 block.type="engineQuote",engine 取值 insight/media/query(仅限这三种),内部 blocks 只允许 paragraph,paragraph.inlines 的 marks 仅可使用 bold/italic(可留空),禁止在 engineQuote 中放表格/图表/引用/公式等。
  310 +9. 如果chapterPlan中包含target/min/max或sections细分预算,请尽量贴合,必要时在notes允许的范围内突破,同时在结构上体现详略;
  311 +10. 一级标题需使用中文数字(“一、二、三”),二级标题使用阿拉伯数字(“1.1、1.2”),heading.text中直接写好编号,与outline顺序对应;
  312 +11. 严禁输出外部图片/AI生图链接,仅可使用Chart.js图表、表格、色块、callout等HTML原生组件;如需视觉辅助请改为文字描述或数据表;
  313 +12. 段落混排需通过marks表达粗体、斜体、下划线、颜色等样式,禁止残留Markdown语法(如**text**);
  314 +13. 行间公式用block.type="math"并填入math.latex,行内公式在paragraph.inlines里将文本设为Latex并加上marks.type="math",渲染层会用MathJax处理;
  315 +14. widget配色需与CSS变量兼容,不要硬编码背景色或文字色,legend/ticks由渲染层控制;
  316 +15. 善用callout、kpiGrid、表格、widget等提升版面丰富度,但必须遵守模板章节范围。
  317 +16. 输出前务必自检JSON语法:禁止出现`{{}}{{`或`][`相连缺少逗号、列表项嵌套超过一层、未闭合的括号或未转义换行,`list` block的items必须是`[[block,...], ...]`结构,若无法满足则返回错误提示而不是输出不合法JSON。
  318 +17. 所有widget块必须在顶层提供`data`或`dataRef`(可将props中的`data`上移),确保Chart.js能够直接渲染;缺失数据时宁可输出表格或段落,绝不留空。
  319 +18. 任何block都必须声明合法`type`(heading/paragraph/list/...);若需要普通文本请使用`paragraph`并给出`inlines`,禁止返回`type:null`或未知值。
319 320
320 <CHAPTER JSON SCHEMA> 321 <CHAPTER JSON SCHEMA>
321 {CHAPTER_JSON_SCHEMA_TEXT} 322 {CHAPTER_JSON_SCHEMA_TEXT}
@@ -47,6 +47,7 @@ class HTMLRenderer: @@ -47,6 +47,7 @@ class HTMLRenderer:
47 "math", 47 "math",
48 "figure", 48 "figure",
49 "kpiGrid", 49 "kpiGrid",
  50 + "engineQuote",
50 } 51 }
51 INLINE_ARTIFACT_KEYS = { 52 INLINE_ARTIFACT_KEYS = {
52 "props", 53 "props",
@@ -1020,6 +1021,7 @@ class HTMLRenderer: @@ -1020,6 +1021,7 @@ class HTMLRenderer:
1020 "list": self._render_list, 1021 "list": self._render_list,
1021 "table": self._render_table, 1022 "table": self._render_table,
1022 "blockquote": self._render_blockquote, 1023 "blockquote": self._render_blockquote,
  1024 + "engineQuote": self._render_engine_quote,
1023 "hr": lambda b: "<hr />", 1025 "hr": lambda b: "<hr />",
1024 "code": self._render_code, 1026 "code": self._render_code,
1025 "math": self._render_math, 1027 "math": self._render_math,
@@ -1282,6 +1284,29 @@ class HTMLRenderer: @@ -1282,6 +1284,29 @@ class HTMLRenderer:
1282 inner = self._render_blocks(block.get("blocks", [])) 1284 inner = self._render_blocks(block.get("blocks", []))
1283 return f"<blockquote>{inner}</blockquote>" 1285 return f"<blockquote>{inner}</blockquote>"
1284 1286
  1287 + def _render_engine_quote(self, block: Dict[str, Any]) -> str:
  1288 + """渲染单Engine发言块,带独立配色与标题"""
  1289 + engine_raw = (block.get("engine") or "").lower()
  1290 + engine = engine_raw if engine_raw in {"insight", "media", "query"} else "insight"
  1291 + title = (
  1292 + block.get("title")
  1293 + or {
  1294 + "insight": "Insight Engine 发言",
  1295 + "media": "Media Engine 发言",
  1296 + "query": "Query Engine 发言",
  1297 + }.get(engine, "Engine 发言")
  1298 + )
  1299 + inner = self._render_blocks(block.get("blocks", []))
  1300 + return (
  1301 + f'<div class="engine-quote engine-{self._escape_attr(engine)}">'
  1302 + f' <div class="engine-quote__header">'
  1303 + f' <span class="engine-quote__dot"></span>'
  1304 + f' <span class="engine-quote__title">{self._escape_html(title)}</span>'
  1305 + f' </div>'
  1306 + f' <div class="engine-quote__body">{inner}</div>'
  1307 + f'</div>'
  1308 + )
  1309 +
1285 def _render_code(self, block: Dict[str, Any]) -> str: 1310 def _render_code(self, block: Dict[str, Any]) -> str:
1286 """渲染代码块,附带语言信息""" 1311 """渲染代码块,附带语言信息"""
1287 lang = block.get("lang") or "" 1312 lang = block.get("lang") or ""
@@ -2392,6 +2417,16 @@ class HTMLRenderer: @@ -2392,6 +2417,16 @@ class HTMLRenderer:
2392 --card-bg: {card}; 2417 --card-bg: {card};
2393 --border-color: {border}; 2418 --border-color: {border};
2394 --shadow-color: {shadow}; 2419 --shadow-color: {shadow};
  2420 + --engine-insight-bg: #f4f7ff;
  2421 + --engine-insight-border: #dce7ff;
  2422 + --engine-insight-text: #1f4b99;
  2423 + --engine-media-bg: #fff6ec;
  2424 + --engine-media-border: #ffd9b3;
  2425 + --engine-media-text: #b65a1a;
  2426 + --engine-query-bg: #f1fbf5;
  2427 + --engine-query-border: #c7ebd6;
  2428 + --engine-query-text: #1d6b3f;
  2429 + --engine-quote-shadow: 0 12px 30px rgba(0,0,0,0.04);
2395 }} 2430 }}
2396 .dark-mode {{ 2431 .dark-mode {{
2397 --bg-color: #121212; 2432 --bg-color: #121212;
@@ -2405,6 +2440,16 @@ class HTMLRenderer: @@ -2405,6 +2440,16 @@ class HTMLRenderer:
2405 --card-bg: #1f1f1f; 2440 --card-bg: #1f1f1f;
2406 --border-color: #2c2c2c; 2441 --border-color: #2c2c2c;
2407 --shadow-color: rgba(0, 0, 0, 0.4); 2442 --shadow-color: rgba(0, 0, 0, 0.4);
  2443 + --engine-insight-bg: rgba(145, 202, 255, 0.08);
  2444 + --engine-insight-border: rgba(145, 202, 255, 0.45);
  2445 + --engine-insight-text: #9dc2ff;
  2446 + --engine-media-bg: rgba(255, 196, 138, 0.08);
  2447 + --engine-media-border: rgba(255, 196, 138, 0.45);
  2448 + --engine-media-text: #ffcb9b;
  2449 + --engine-query-bg: rgba(141, 215, 165, 0.08);
  2450 + --engine-query-border: rgba(141, 215, 165, 0.45);
  2451 + --engine-query-text: #a7e2ba;
  2452 + --engine-quote-shadow: 0 12px 28px rgba(0, 0, 0, 0.35);
2408 }} 2453 }}
2409 * {{ box-sizing: border-box; }} 2454 * {{ box-sizing: border-box; }}
2410 body {{ 2455 body {{
@@ -2416,7 +2461,7 @@ body {{ @@ -2416,7 +2461,7 @@ body {{
2416 min-height: 100vh; 2461 min-height: 100vh;
2417 transition: background-color 0.45s ease, color 0.45s ease; 2462 transition: background-color 0.45s ease, color 0.45s ease;
2418 }} 2463 }}
2419 -.report-header, main, .hero-section, .chapter, .chart-card, .callout, .kpi-card, .toc, .table-wrap {{ 2464 +.report-header, main, .hero-section, .chapter, .chart-card, .callout, .engine-quote, .kpi-card, .toc, .table-wrap {{
2420 transition: background-color 0.45s ease, color 0.45s ease, border-color 0.45s ease, box-shadow 0.45s ease; 2465 transition: background-color 0.45s ease, color 0.45s ease, border-color 0.45s ease, box-shadow 0.45s ease;
2421 }} 2466 }}
2422 .report-header {{ 2467 .report-header {{
@@ -2785,6 +2830,49 @@ blockquote {{ @@ -2785,6 +2830,49 @@ blockquote {{
2785 background: rgba(0,0,0,0.04); 2830 background: rgba(0,0,0,0.04);
2786 border-radius: 0 8px 8px 0; 2831 border-radius: 0 8px 8px 0;
2787 }} 2832 }}
  2833 +.engine-quote {{
  2834 + --engine-quote-bg: var(--engine-insight-bg);
  2835 + --engine-quote-border: var(--engine-insight-border);
  2836 + --engine-quote-text: var(--engine-insight-text);
  2837 + margin: 22px 0;
  2838 + padding: 16px 18px;
  2839 + border-radius: 14px;
  2840 + border: 1px solid var(--engine-quote-border);
  2841 + background: var(--engine-quote-bg);
  2842 + box-shadow: var(--engine-quote-shadow);
  2843 + line-height: 1.65;
  2844 +}}
  2845 +.engine-quote__header {{
  2846 + display: flex;
  2847 + align-items: center;
  2848 + gap: 10px;
  2849 + font-weight: 650;
  2850 + color: var(--engine-quote-text);
  2851 + margin-bottom: 8px;
  2852 + letter-spacing: 0.02em;
  2853 +}}
  2854 +.engine-quote__dot {{
  2855 + width: 10px;
  2856 + height: 10px;
  2857 + border-radius: 50%;
  2858 + background: var(--engine-quote-text);
  2859 + box-shadow: 0 0 0 8px rgba(0,0,0,0.02);
  2860 +}}
  2861 +.engine-quote__title {{
  2862 + font-size: 0.98rem;
  2863 +}}
  2864 +.engine-quote__body > *:first-child {{ margin-top: 0; }}
  2865 +.engine-quote__body > *:last-child {{ margin-bottom: 0; }}
  2866 +.engine-quote.engine-media {{
  2867 + --engine-quote-bg: var(--engine-media-bg);
  2868 + --engine-quote-border: var(--engine-media-border);
  2869 + --engine-quote-text: var(--engine-media-text);
  2870 +}}
  2871 +.engine-quote.engine-query {{
  2872 + --engine-quote-bg: var(--engine-query-bg);
  2873 + --engine-quote-border: var(--engine-query-border);
  2874 + --engine-quote-text: var(--engine-query-text);
  2875 +}}
2788 .table-wrap {{ 2876 .table-wrap {{
2789 overflow-x: auto; 2877 overflow-x: auto;
2790 margin: 20px 0; 2878 margin: 20px 0;
@@ -3020,34 +3108,35 @@ pre.code-block {{ @@ -3020,34 +3108,35 @@ pre.code-block {{
3020 }} 3108 }}
3021 .chapter > *, 3109 .chapter > *,
3022 .hero-section, 3110 .hero-section,
3023 -.callout,  
3024 -.chart-card,  
3025 -.kpi-grid,  
3026 -.table-wrap,  
3027 -figure,  
3028 -blockquote {{  
3029 - break-inside: avoid;  
3030 - page-break-inside: avoid;  
3031 - max-width: 100%;  
3032 -}}  
3033 -.chapter h2,  
3034 -.chapter h3,  
3035 -.chapter h4 {{  
3036 - break-after: avoid;  
3037 - page-break-after: avoid;  
3038 - break-inside: avoid;  
3039 -}}  
3040 -.chart-card,  
3041 -.table-wrap {{  
3042 - overflow: visible !important;  
3043 - max-width: 100% !important;  
3044 - box-sizing: border-box;  
3045 -}}  
3046 -.chart-card canvas {{  
3047 - width: 100% !important;  
3048 - height: auto !important;  
3049 - max-width: 100% !important;  
3050 -}} 3111 + .callout,
  3112 + .engine-quote,
  3113 + .chart-card,
  3114 + .kpi-grid,
  3115 + .table-wrap,
  3116 + figure,
  3117 + blockquote {{
  3118 + break-inside: avoid;
  3119 + page-break-inside: avoid;
  3120 + max-width: 100%;
  3121 + }}
  3122 + .chapter h2,
  3123 + .chapter h3,
  3124 + .chapter h4 {{
  3125 + break-after: avoid;
  3126 + page-break-after: avoid;
  3127 + break-inside: avoid;
  3128 + }}
  3129 + .chart-card,
  3130 + .table-wrap {{
  3131 + overflow: visible !important;
  3132 + max-width: 100% !important;
  3133 + box-sizing: border-box;
  3134 + }}
  3135 + .chart-card canvas {{
  3136 + width: 100% !important;
  3137 + height: auto !important;
  3138 + max-width: 100% !important;
  3139 + }}
3051 .table-wrap {{ 3140 .table-wrap {{
3052 overflow-x: auto; 3141 overflow-x: auto;
3053 max-width: 100%; 3142 max-width: 100%;
@@ -52,6 +52,90 @@ class ChapterSanitizationTestCase(unittest.TestCase): @@ -52,6 +52,90 @@ class ChapterSanitizationTestCase(unittest.TestCase):
52 "全国趋势", 52 "全国趋势",
53 ) 53 )
54 54
  55 + def test_engine_quote_validation(self):
  56 + validator = IRValidator()
  57 + chapter = {
  58 + "chapterId": "S1",
  59 + "title": "Engine 引用校验",
  60 + "anchor": "section-1",
  61 + "order": 1,
  62 + "blocks": [
  63 + {
  64 + "type": "engineQuote",
  65 + "engine": "insight",
  66 + "blocks": [
  67 + {
  68 + "type": "paragraph",
  69 + "inlines": [{"text": "来自 Insight Engine 的观点"}],
  70 + }
  71 + ],
  72 + }
  73 + ],
  74 + }
  75 + valid, errors = validator.validate_chapter(chapter)
  76 + self.assertTrue(valid, errors)
  77 + self.assertFalse(errors)
  78 +
  79 + def test_engine_quote_rejects_disallowed_marks_and_blocks(self):
  80 + validator = IRValidator()
  81 + chapter = {
  82 + "chapterId": "S1",
  83 + "title": "Engine 引用校验",
  84 + "anchor": "section-1",
  85 + "order": 1,
  86 + "blocks": [
  87 + {
  88 + "type": "engineQuote",
  89 + "engine": "media",
  90 + "blocks": [
  91 + {"type": "math", "latex": "x=y"},
  92 + {
  93 + "type": "paragraph",
  94 + "inlines": [
  95 + {"text": "test", "marks": [{"type": "color"}]}
  96 + ],
  97 + },
  98 + ],
  99 + }
  100 + ],
  101 + }
  102 + valid, errors = validator.validate_chapter(chapter)
  103 + self.assertFalse(valid)
  104 + self.assertTrue(any("仅允许 paragraph" in err for err in errors))
  105 + self.assertTrue(any("仅允许 bold/italic" in err for err in errors))
  106 +
  107 + def test_engine_quote_sanitization_strips_disallowed(self):
  108 + chapter = {
  109 + "blocks": [
  110 + {
  111 + "type": "engineQuote",
  112 + "engine": "query",
  113 + "blocks": [
  114 + {"type": "list", "items": [["非法"]]},
  115 + {
  116 + "type": "paragraph",
  117 + "inlines": [
  118 + {
  119 + "text": "abc",
  120 + "marks": [{"type": "bold"}, {"type": "highlight"}],
  121 + }
  122 + ],
  123 + },
  124 + ],
  125 + }
  126 + ]
  127 + }
  128 + node = self.node
  129 + node._sanitize_chapter_blocks(chapter)
  130 + eq_block = chapter["blocks"][0]
  131 + self.assertEqual(eq_block["type"], "engineQuote")
  132 + inner_blocks = eq_block.get("blocks")
  133 + self.assertTrue(all(b.get("type") == "paragraph" for b in inner_blocks))
  134 + marks = inner_blocks[0]["inlines"][0].get("marks")
  135 + self.assertEqual(marks, [])
  136 + marks2 = inner_blocks[1]["inlines"][0].get("marks")
  137 + self.assertEqual(marks2, [{"type": "bold"}])
  138 +
55 139
56 if __name__ == "__main__": 140 if __name__ == "__main__":
57 unittest.main() 141 unittest.main()