Showing
6 changed files
with
324 additions
and
45 deletions
| @@ -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() |
-
Please register or login to post a comment