马一丁

Support SWOT Block

... ... @@ -33,6 +33,7 @@ ALLOWED_BLOCK_TYPES: List[str] = [
"paragraph",
"list",
"table",
"swotTable",
"blockquote",
"engineQuote",
"hr",
... ... @@ -169,6 +170,63 @@ table_block: Dict[str, Any] = {
"additionalProperties": True,
}
swot_item_schema: Dict[str, Any] = {
"title": "SwotItem",
"oneOf": [
{"type": "string"},
{
"type": "object",
"properties": {
"title": {"type": "string"},
"label": {"type": "string"},
"text": {"type": "string"},
"detail": {"type": "string"},
"description": {"type": "string"},
"evidence": {"type": "string"},
"impact": {"type": "string"},
"score": {"type": ["number", "string"]},
"priority": {"type": ["string", "number"]},
},
"required": [],
"additionalProperties": True,
},
],
}
swot_block: Dict[str, Any] = {
"title": "SwotTableBlock",
"type": "object",
"properties": {
"type": {"const": "swotTable"},
"title": {"type": "string"},
"summary": {"type": "string"},
"strengths": {
"type": "array",
"items": {"$ref": "#/definitions/swotItem"},
},
"weaknesses": {
"type": "array",
"items": {"$ref": "#/definitions/swotItem"},
},
"opportunities": {
"type": "array",
"items": {"$ref": "#/definitions/swotItem"},
},
"threats": {
"type": "array",
"items": {"$ref": "#/definitions/swotItem"},
},
},
"required": ["type"],
"anyOf": [
{"required": ["strengths"]},
{"required": ["weaknesses"]},
{"required": ["opportunities"]},
{"required": ["threats"]},
],
"additionalProperties": True,
}
blockquote_block: Dict[str, Any] = {
"title": "BlockquoteBlock",
"type": "object",
... ... @@ -361,6 +419,7 @@ block_variants: List[Dict[str, Any]] = [
kpi_block,
widget_block,
toc_block,
swot_block,
]
CHAPTER_JSON_SCHEMA: Dict[str, Any] = {
... ... @@ -388,6 +447,7 @@ CHAPTER_JSON_SCHEMA: Dict[str, Any] = {
"definitions": {
"inlineMark": inline_mark_schema,
"inlineRun": inline_run_schema,
"swotItem": swot_item_schema,
"block": {"oneOf": block_variants},
},
}
... ...
... ... @@ -132,6 +132,39 @@ class IRValidator:
errors,
)
def _validate_swotTable_block(self, block: Dict[str, Any], path: str, errors: List[str]):
"""SWOT表至少提供四象限之一,每象限为条目数组"""
quadrants = ("strengths", "weaknesses", "opportunities", "threats")
if not any(block.get(name) is not None for name in quadrants):
errors.append(f"{path} 需要至少包含 strengths/weaknesses/opportunities/threats 之一")
for name in quadrants:
entries = block.get(name)
if entries is None:
continue
if not isinstance(entries, list):
errors.append(f"{path}.{name} 必须是数组")
continue
for idx, entry in enumerate(entries):
self._validate_swot_item(entry, f"{path}.{name}[{idx}]", errors)
def _validate_swot_item(self, item: Any, path: str, errors: List[str]):
"""单个SWOT条目支持字符串或带字段的对象"""
if isinstance(item, str):
if not item.strip():
errors.append(f"{path} 不能为空字符串")
return
if not isinstance(item, dict):
errors.append(f"{path} 必须是字符串或对象")
return
title = None
for key in ("title", "label", "text", "detail", "description"):
value = item.get(key)
if isinstance(value, str) and value.strip():
title = value
break
if title is None:
errors.append(f"{path} 缺少 title/label/text/description 等文字字段")
def _validate_blockquote_block(
self, block: Dict[str, Any], path: str, errors: List[str]
):
... ...
... ... @@ -304,19 +304,20 @@ SYSTEM_PROMPT_CHAPTER_JSON = f"""
3. 所有段落都放入paragraph.inlines,混排样式通过marks表示(bold/italic/color/link等)。
4. 所有heading必须包含anchor,锚点与编号保持模板一致,比如section-2-1。
5. 表格需给出rows/cells/align,KPI卡请使用kpiGrid,分割线用hr。
6. 如需引用图表/交互组件,统一用widgetType表示(例如chart.js/line、chart.js/doughnut)。
7. 鼓励结合outline中列出的子标题,生成多层heading与细粒度内容,同时可补充callout、blockquote等。
8. engineQuote 仅用于呈现单Agent的原话:使用 block.type="engineQuote",engine 取值 insight/media/query,title 必须固定为对应Agent名字(insight->Insight Agent,media->Media Agent,query->Query Agent,不可自定义),内部 blocks 只允许 paragraph,paragraph.inlines 的 marks 仅可使用 bold/italic(可留空),禁止在 engineQuote 中放表格/图表/引用/公式等;当 reports 或 forumLogs 中有明确的文字段落、结论、数字/时间等可直接引用时,优先分别从 Query/Media/Insight 三个 Agent 摘出关键原文或文字版数据放入 engineQuote,尽量覆盖三类 Agent 而非只用单一来源,严禁臆造内容或把表格/图表改写进 engineQuote。
9. 如果chapterPlan中包含target/min/max或sections细分预算,请尽量贴合,必要时在notes允许的范围内突破,同时在结构上体现详略;
10. 一级标题需使用中文数字(“一、二、三”),二级标题使用阿拉伯数字(“1.1、1.2”),heading.text中直接写好编号,与outline顺序对应;
11. 严禁输出外部图片/AI生图链接,仅可使用Chart.js图表、表格、色块、callout等HTML原生组件;如需视觉辅助请改为文字描述或数据表;
12. 段落混排需通过marks表达粗体、斜体、下划线、颜色等样式,禁止残留Markdown语法(如**text**);
13. 行间公式用block.type="math"并填入math.latex,行内公式在paragraph.inlines里将文本设为Latex并加上marks.type="math",渲染层会用MathJax处理;
14. widget配色需与CSS变量兼容,不要硬编码背景色或文字色,legend/ticks由渲染层控制;
15. 善用callout、kpiGrid、表格、widget等提升版面丰富度,但必须遵守模板章节范围。
16. 输出前务必自检JSON语法:禁止出现`{{}}{{`或`][`相连缺少逗号、列表项嵌套超过一层、未闭合的括号或未转义换行,`list` block的items必须是`[[block,...], ...]`结构,若无法满足则返回错误提示而不是输出不合法JSON。
17. 所有widget块必须在顶层提供`data`或`dataRef`(可将props中的`data`上移),确保Chart.js能够直接渲染;缺失数据时宁可输出表格或段落,绝不留空。
18. 任何block都必须声明合法`type`(heading/paragraph/list/...);若需要普通文本请使用`paragraph`并给出`inlines`,禁止返回`type:null`或未知值。
6. SWOT分析必须优先使用 block.type="swotTable":分别填写 strengths/weaknesses/opportunities/threats 数组,单项至少包含 title/label/text 之一,可附加 detail/evidence/impact/score 字段;title/summary 字段用于概览说明。
7. 如需引用图表/交互组件,统一用widgetType表示(例如chart.js/line、chart.js/doughnut)。
8. 鼓励结合outline中列出的子标题,生成多层heading与细粒度内容,同时可补充callout、blockquote等。
9. engineQuote 仅用于呈现单Agent的原话:使用 block.type="engineQuote",engine 取值 insight/media/query,title 必须固定为对应Agent名字(insight->Insight Agent,media->Media Agent,query->Query Agent,不可自定义),内部 blocks 只允许 paragraph,paragraph.inlines 的 marks 仅可使用 bold/italic(可留空),禁止在 engineQuote 中放表格/图表/引用/公式等;当 reports 或 forumLogs 中有明确的文字段落、结论、数字/时间等可直接引用时,优先分别从 Query/Media/Insight 三个 Agent 摘出关键原文或文字版数据放入 engineQuote,尽量覆盖三类 Agent 而非只用单一来源,严禁臆造内容或把表格/图表改写进 engineQuote。
10. 如果chapterPlan中包含target/min/max或sections细分预算,请尽量贴合,必要时在notes允许的范围内突破,同时在结构上体现详略;
11. 一级标题需使用中文数字(“一、二、三”),二级标题使用阿拉伯数字(“1.1、1.2”),heading.text中直接写好编号,与outline顺序对应;
12. 严禁输出外部图片/AI生图链接,仅可使用Chart.js图表、表格、色块、callout等HTML原生组件;如需视觉辅助请改为文字描述或数据表;
13. 段落混排需通过marks表达粗体、斜体、下划线、颜色等样式,禁止残留Markdown语法(如**text**);
14. 行间公式用block.type="math"并填入math.latex,行内公式在paragraph.inlines里将文本设为Latex并加上marks.type="math",渲染层会用MathJax处理;
15. widget配色需与CSS变量兼容,不要硬编码背景色或文字色,legend/ticks由渲染层控制;
16. 善用callout、kpiGrid、表格、widget等提升版面丰富度,但必须遵守模板章节范围。
17. 输出前务必自检JSON语法:禁止出现`{{}}{{`或`][`相连缺少逗号、列表项嵌套超过一层、未闭合的括号或未转义换行,`list` block的items必须是`[[block,...], ...]`结构,若无法满足则返回错误提示而不是输出不合法JSON。
18. 所有widget块必须在顶层提供`data`或`dataRef`(可将props中的`data`上移),确保Chart.js能够直接渲染;缺失数据时宁可输出表格或段落,绝不留空。
19. 任何block都必须声明合法`type`(heading/paragraph/list/...);若需要普通文本请使用`paragraph`并给出`inlines`,禁止返回`type:null`或未知值。
<CHAPTER JSON SCHEMA>
{CHAPTER_JSON_SCHEMA_TEXT}
... ...
... ... @@ -48,6 +48,7 @@ class HTMLRenderer:
"math",
"figure",
"kpiGrid",
"swotTable",
"engineQuote",
}
INLINE_ARTIFACT_KEYS = {
... ... @@ -1021,6 +1022,7 @@ class HTMLRenderer:
"paragraph": self._render_paragraph,
"list": self._render_list,
"table": self._render_table,
"swotTable": self._render_swot_table,
"blockquote": self._render_blockquote,
"engineQuote": self._render_engine_quote,
"hr": lambda b: "<hr />",
... ... @@ -1173,6 +1175,117 @@ class HTMLRenderer:
caption_html = f"<caption>{self._escape_html(caption)}</caption>" if caption else ""
return f'<div class="table-wrap"><table>{caption_html}<tbody>{rows_html}</tbody></table></div>'
def _render_swot_table(self, block: Dict[str, Any]) -> str:
"""
渲染四象限的SWOT专用表格,兼顾HTML与PDF的可读性。
"""
title = block.get("title") or "SWOT 分析"
summary = block.get("summary")
quadrants = [
("strengths", "优势 Strengths", "S", "strength"),
("weaknesses", "劣势 Weaknesses", "W", "weakness"),
("opportunities", "机会 Opportunities", "O", "opportunity"),
("threats", "威胁 Threats", "T", "threat"),
]
cells_html = ""
for key, label, code, css in quadrants:
items = self._normalize_swot_items(block.get(key))
caption_text = f"{len(items)} 条要点" if items else "待补充"
list_html = "".join(self._render_swot_item(item) for item in items) if items else '<li class="swot-empty">尚未填入要点</li>'
cells_html += f"""
<div class="swot-cell {css}">
<div class="swot-cell__meta">
<span class="swot-pill {css}">{self._escape_html(code)}</span>
<div>
<div class="swot-cell__title">{self._escape_html(label)}</div>
<div class="swot-cell__caption">{self._escape_html(caption_text)}</div>
</div>
</div>
<ul class="swot-list">{list_html}</ul>
</div>"""
summary_html = f'<p class="swot-card__summary">{self._escape_html(summary)}</p>' if summary else ""
title_html = f'<div class="swot-card__title">{self._escape_html(title)}</div>' if title else ""
legend = """
<div class="swot-legend">
<span class="swot-legend__item strength">S 优势</span>
<span class="swot-legend__item weakness">W 劣势</span>
<span class="swot-legend__item opportunity">O 机会</span>
<span class="swot-legend__item threat">T 威胁</span>
</div>
"""
return f"""
<div class="swot-card">
<div class="swot-card__head">
<div>{title_html}{summary_html}</div>
{legend}
</div>
<div class="swot-grid">{cells_html}</div>
</div>
"""
def _normalize_swot_items(self, raw: Any) -> List[Dict[str, Any]]:
"""将SWOT条目规整为统一结构,兼容字符串/对象两种写法"""
normalized: List[Dict[str, Any]] = []
if raw is None:
return normalized
if isinstance(raw, (str, int, float)):
text = self._safe_text(raw).strip()
if text:
normalized.append({"title": text})
return normalized
if not isinstance(raw, list):
return normalized
for entry in raw:
if isinstance(entry, (str, int, float)):
text = self._safe_text(entry).strip()
if text:
normalized.append({"title": text})
continue
if not isinstance(entry, dict):
continue
title = entry.get("title") or entry.get("label") or entry.get("text")
detail = entry.get("detail") or entry.get("description")
evidence = entry.get("evidence") or entry.get("source")
impact = entry.get("impact") or entry.get("priority")
score = entry.get("score")
if not title and isinstance(detail, str):
title = detail
detail = None
if not (title or detail or evidence):
continue
normalized.append(
{
"title": title,
"detail": detail,
"evidence": evidence,
"impact": impact,
"score": score,
}
)
return normalized
def _render_swot_item(self, item: Dict[str, Any]) -> str:
"""输出单个SWOT条目的HTML片段"""
title = item.get("title") or item.get("label") or item.get("text") or "未命名要点"
detail = item.get("detail") or item.get("description")
evidence = item.get("evidence") or item.get("source")
impact = item.get("impact") or item.get("priority")
score = item.get("score")
tags: List[str] = []
if impact:
tags.append(f'<span class="swot-tag">{self._escape_html(impact)}</span>')
if score not in (None, ""):
tags.append(f'<span class="swot-tag neutral">评分 {self._escape_html(score)}</span>')
tags_html = f'<span class="swot-item-tags">{"".join(tags)}</span>' if tags else ""
detail_html = f'<div class="swot-item-desc">{self._escape_html(detail)}</div>' if detail else ""
evidence_html = f'<div class="swot-item-evidence">佐证:{self._escape_html(evidence)}</div>' if evidence else ""
return f"""
<li class="swot-item">
<div class="swot-item-title">{self._escape_html(title)}{tags_html}</div>
{detail_html}{evidence_html}
</li>
"""
def _normalize_table_rows(self, rows: List[Dict[str, Any]]) -> List[Dict[str, Any]]:
"""
检测并修正仅有单列的竖排表,转换为标准网格。
... ... @@ -2446,6 +2559,10 @@ class HTMLRenderer:
--engine-query-border: rgba(141, 215, 165, 0.45);
--engine-query-text: #a7e2ba;
--engine-quote-shadow: 0 12px 28px rgba(0, 0, 0, 0.35);
--swot-strength: #1c7f6e;
--swot-weakness: #c0392b;
--swot-opportunity: #1f5ab3;
--swot-threat: #b36b16;
}}
* {{ box-sizing: border-box; }}
body {{
... ... @@ -2886,6 +3003,150 @@ table th {{
}}
.align-center {{ text-align: center; }}
.align-right {{ text-align: right; }}
.swot-card {{
margin: 26px 0;
padding: 18px 18px 14px;
border-radius: 16px;
border: 1px solid var(--border-color);
background: linear-gradient(135deg, rgba(76,132,255,0.06), rgba(28,127,110,0.06)), var(--card-bg);
box-shadow: 0 12px 30px var(--shadow-color);
}}
.swot-card__head {{
display: flex;
justify-content: space-between;
gap: 16px;
align-items: flex-start;
flex-wrap: wrap;
}}
.swot-card__title {{
font-size: 1.15rem;
font-weight: 750;
margin-bottom: 4px;
}}
.swot-card__summary {{
margin: 0;
color: var(--text-color);
opacity: 0.85;
}}
.swot-legend {{
display: flex;
gap: 8px;
flex-wrap: wrap;
align-items: center;
}}
.swot-legend__item {{
padding: 6px 12px;
border-radius: 999px;
font-weight: 700;
color: #fff;
box-shadow: 0 4px 10px rgba(0,0,0,0.12);
}}
.swot-legend__item.strength {{ background: var(--swot-strength); }}
.swot-legend__item.weakness {{ background: var(--swot-weakness); }}
.swot-legend__item.opportunity {{ background: var(--swot-opportunity); }}
.swot-legend__item.threat {{ background: var(--swot-threat); }}
.swot-grid {{
display: grid;
grid-template-columns: repeat(auto-fit, minmax(240px, 1fr));
gap: 12px;
margin-top: 14px;
}}
.swot-cell {{
border-radius: 14px;
border: 1px solid rgba(0,0,0,0.05);
padding: 12px 12px 10px;
background: linear-gradient(135deg, rgba(255,255,255,0.8), rgba(255,255,255,0.4));
box-shadow: inset 0 1px 0 rgba(255,255,255,0.7);
}}
.swot-cell.strength {{ border-color: rgba(28,127,110,0.35); background: linear-gradient(135deg, rgba(28,127,110,0.08), rgba(255,255,255,0.75)), var(--card-bg); }}
.swot-cell.weakness {{ border-color: rgba(192,57,43,0.35); background: linear-gradient(135deg, rgba(192,57,43,0.08), rgba(255,255,255,0.75)), var(--card-bg); }}
.swot-cell.opportunity {{ border-color: rgba(31,90,179,0.35); background: linear-gradient(135deg, rgba(31,90,179,0.08), rgba(255,255,255,0.75)), var(--card-bg); }}
.swot-cell.threat {{ border-color: rgba(179,107,22,0.35); background: linear-gradient(135deg, rgba(179,107,22,0.08), rgba(255,255,255,0.75)), var(--card-bg); }}
.swot-cell__meta {{
display: flex;
gap: 10px;
align-items: flex-start;
margin-bottom: 8px;
}}
.swot-pill {{
display: inline-flex;
align-items: center;
justify-content: center;
width: 36px;
height: 36px;
border-radius: 12px;
font-weight: 800;
color: #fff;
box-shadow: 0 6px 16px rgba(0,0,0,0.14);
}}
.swot-pill.strength {{ background: var(--swot-strength); }}
.swot-pill.weakness {{ background: var(--swot-weakness); }}
.swot-pill.opportunity {{ background: var(--swot-opportunity); }}
.swot-pill.threat {{ background: var(--swot-threat); }}
.swot-cell__title {{
font-weight: 750;
letter-spacing: 0.01em;
}}
.swot-cell__caption {{
font-size: 0.9rem;
color: var(--text-color);
opacity: 0.7;
}}
.swot-list {{
list-style: none;
padding: 0;
margin: 0;
display: flex;
flex-direction: column;
gap: 8px;
}}
.swot-item {{
padding: 10px 12px;
border-radius: 12px;
background: rgba(0,0,0,0.02);
border: 1px dashed rgba(0,0,0,0.05);
}}
.swot-item-title {{
display: flex;
justify-content: space-between;
gap: 8px;
font-weight: 650;
}}
.swot-item-tags {{
display: inline-flex;
gap: 6px;
flex-wrap: wrap;
font-size: 0.85rem;
}}
.swot-tag {{
display: inline-block;
padding: 4px 8px;
border-radius: 10px;
background: rgba(0,0,0,0.06);
color: var(--text-color);
line-height: 1.2;
}}
.swot-tag.neutral {{
background: rgba(0,0,0,0.04);
}}
.swot-item-desc {{
margin-top: 4px;
color: var(--text-color);
opacity: 0.92;
}}
.swot-item-evidence {{
margin-top: 4px;
font-size: 0.9rem;
color: var(--secondary-color);
}}
.swot-empty {{
padding: 12px;
border-radius: 12px;
border: 1px dashed var(--border-color);
text-align: center;
color: var(--text-color);
opacity: 0.7;
}}
.callout {{
border-left: 4px solid var(--primary-color);
padding: 16px;
... ... @@ -3108,6 +3369,7 @@ pre.code-block {{
.engine-quote,
.chart-card,
.kpi-grid,
.swot-card,
.table-wrap,
figure,
blockquote {{
... ... @@ -3133,6 +3395,11 @@ pre.code-block {{
height: auto !important;
max-width: 100% !important;
}}
.swot-card,
.swot-cell {{
break-inside: avoid;
page-break-inside: avoid;
}}
.table-wrap {{
overflow-x: auto;
max-width: 100%;
... ...