马一丁

Support SWOT Block

@@ -33,6 +33,7 @@ ALLOWED_BLOCK_TYPES: List[str] = [ @@ -33,6 +33,7 @@ ALLOWED_BLOCK_TYPES: List[str] = [
33 "paragraph", 33 "paragraph",
34 "list", 34 "list",
35 "table", 35 "table",
  36 + "swotTable",
36 "blockquote", 37 "blockquote",
37 "engineQuote", 38 "engineQuote",
38 "hr", 39 "hr",
@@ -169,6 +170,63 @@ table_block: Dict[str, Any] = { @@ -169,6 +170,63 @@ table_block: Dict[str, Any] = {
169 "additionalProperties": True, 170 "additionalProperties": True,
170 } 171 }
171 172
  173 +swot_item_schema: Dict[str, Any] = {
  174 + "title": "SwotItem",
  175 + "oneOf": [
  176 + {"type": "string"},
  177 + {
  178 + "type": "object",
  179 + "properties": {
  180 + "title": {"type": "string"},
  181 + "label": {"type": "string"},
  182 + "text": {"type": "string"},
  183 + "detail": {"type": "string"},
  184 + "description": {"type": "string"},
  185 + "evidence": {"type": "string"},
  186 + "impact": {"type": "string"},
  187 + "score": {"type": ["number", "string"]},
  188 + "priority": {"type": ["string", "number"]},
  189 + },
  190 + "required": [],
  191 + "additionalProperties": True,
  192 + },
  193 + ],
  194 +}
  195 +
  196 +swot_block: Dict[str, Any] = {
  197 + "title": "SwotTableBlock",
  198 + "type": "object",
  199 + "properties": {
  200 + "type": {"const": "swotTable"},
  201 + "title": {"type": "string"},
  202 + "summary": {"type": "string"},
  203 + "strengths": {
  204 + "type": "array",
  205 + "items": {"$ref": "#/definitions/swotItem"},
  206 + },
  207 + "weaknesses": {
  208 + "type": "array",
  209 + "items": {"$ref": "#/definitions/swotItem"},
  210 + },
  211 + "opportunities": {
  212 + "type": "array",
  213 + "items": {"$ref": "#/definitions/swotItem"},
  214 + },
  215 + "threats": {
  216 + "type": "array",
  217 + "items": {"$ref": "#/definitions/swotItem"},
  218 + },
  219 + },
  220 + "required": ["type"],
  221 + "anyOf": [
  222 + {"required": ["strengths"]},
  223 + {"required": ["weaknesses"]},
  224 + {"required": ["opportunities"]},
  225 + {"required": ["threats"]},
  226 + ],
  227 + "additionalProperties": True,
  228 +}
  229 +
172 blockquote_block: Dict[str, Any] = { 230 blockquote_block: Dict[str, Any] = {
173 "title": "BlockquoteBlock", 231 "title": "BlockquoteBlock",
174 "type": "object", 232 "type": "object",
@@ -361,6 +419,7 @@ block_variants: List[Dict[str, Any]] = [ @@ -361,6 +419,7 @@ block_variants: List[Dict[str, Any]] = [
361 kpi_block, 419 kpi_block,
362 widget_block, 420 widget_block,
363 toc_block, 421 toc_block,
  422 + swot_block,
364 ] 423 ]
365 424
366 CHAPTER_JSON_SCHEMA: Dict[str, Any] = { 425 CHAPTER_JSON_SCHEMA: Dict[str, Any] = {
@@ -388,6 +447,7 @@ CHAPTER_JSON_SCHEMA: Dict[str, Any] = { @@ -388,6 +447,7 @@ CHAPTER_JSON_SCHEMA: Dict[str, Any] = {
388 "definitions": { 447 "definitions": {
389 "inlineMark": inline_mark_schema, 448 "inlineMark": inline_mark_schema,
390 "inlineRun": inline_run_schema, 449 "inlineRun": inline_run_schema,
  450 + "swotItem": swot_item_schema,
391 "block": {"oneOf": block_variants}, 451 "block": {"oneOf": block_variants},
392 }, 452 },
393 } 453 }
@@ -132,6 +132,39 @@ class IRValidator: @@ -132,6 +132,39 @@ class IRValidator:
132 errors, 132 errors,
133 ) 133 )
134 134
  135 + def _validate_swotTable_block(self, block: Dict[str, Any], path: str, errors: List[str]):
  136 + """SWOT表至少提供四象限之一,每象限为条目数组"""
  137 + quadrants = ("strengths", "weaknesses", "opportunities", "threats")
  138 + if not any(block.get(name) is not None for name in quadrants):
  139 + errors.append(f"{path} 需要至少包含 strengths/weaknesses/opportunities/threats 之一")
  140 + for name in quadrants:
  141 + entries = block.get(name)
  142 + if entries is None:
  143 + continue
  144 + if not isinstance(entries, list):
  145 + errors.append(f"{path}.{name} 必须是数组")
  146 + continue
  147 + for idx, entry in enumerate(entries):
  148 + self._validate_swot_item(entry, f"{path}.{name}[{idx}]", errors)
  149 +
  150 + def _validate_swot_item(self, item: Any, path: str, errors: List[str]):
  151 + """单个SWOT条目支持字符串或带字段的对象"""
  152 + if isinstance(item, str):
  153 + if not item.strip():
  154 + errors.append(f"{path} 不能为空字符串")
  155 + return
  156 + if not isinstance(item, dict):
  157 + errors.append(f"{path} 必须是字符串或对象")
  158 + return
  159 + title = None
  160 + for key in ("title", "label", "text", "detail", "description"):
  161 + value = item.get(key)
  162 + if isinstance(value, str) and value.strip():
  163 + title = value
  164 + break
  165 + if title is None:
  166 + errors.append(f"{path} 缺少 title/label/text/description 等文字字段")
  167 +
135 def _validate_blockquote_block( 168 def _validate_blockquote_block(
136 self, block: Dict[str, Any], path: str, errors: List[str] 169 self, block: Dict[str, Any], path: str, errors: List[str]
137 ): 170 ):
@@ -304,19 +304,20 @@ SYSTEM_PROMPT_CHAPTER_JSON = f""" @@ -304,19 +304,20 @@ SYSTEM_PROMPT_CHAPTER_JSON = f"""
304 3. 所有段落都放入paragraph.inlines,混排样式通过marks表示(bold/italic/color/link等)。 304 3. 所有段落都放入paragraph.inlines,混排样式通过marks表示(bold/italic/color/link等)。
305 4. 所有heading必须包含anchor,锚点与编号保持模板一致,比如section-2-1。 305 4. 所有heading必须包含anchor,锚点与编号保持模板一致,比如section-2-1。
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)。  
308 -7. 鼓励结合outline中列出的子标题,生成多层heading与细粒度内容,同时可补充callout、blockquote等。  
309 -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。  
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`或未知值。 307 +6. SWOT分析必须优先使用 block.type="swotTable":分别填写 strengths/weaknesses/opportunities/threats 数组,单项至少包含 title/label/text 之一,可附加 detail/evidence/impact/score 字段;title/summary 字段用于概览说明。
  308 +7. 如需引用图表/交互组件,统一用widgetType表示(例如chart.js/line、chart.js/doughnut)。
  309 +8. 鼓励结合outline中列出的子标题,生成多层heading与细粒度内容,同时可补充callout、blockquote等。
  310 +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。
  311 +10. 如果chapterPlan中包含target/min/max或sections细分预算,请尽量贴合,必要时在notes允许的范围内突破,同时在结构上体现详略;
  312 +11. 一级标题需使用中文数字(“一、二、三”),二级标题使用阿拉伯数字(“1.1、1.2”),heading.text中直接写好编号,与outline顺序对应;
  313 +12. 严禁输出外部图片/AI生图链接,仅可使用Chart.js图表、表格、色块、callout等HTML原生组件;如需视觉辅助请改为文字描述或数据表;
  314 +13. 段落混排需通过marks表达粗体、斜体、下划线、颜色等样式,禁止残留Markdown语法(如**text**);
  315 +14. 行间公式用block.type="math"并填入math.latex,行内公式在paragraph.inlines里将文本设为Latex并加上marks.type="math",渲染层会用MathJax处理;
  316 +15. widget配色需与CSS变量兼容,不要硬编码背景色或文字色,legend/ticks由渲染层控制;
  317 +16. 善用callout、kpiGrid、表格、widget等提升版面丰富度,但必须遵守模板章节范围。
  318 +17. 输出前务必自检JSON语法:禁止出现`{{}}{{`或`][`相连缺少逗号、列表项嵌套超过一层、未闭合的括号或未转义换行,`list` block的items必须是`[[block,...], ...]`结构,若无法满足则返回错误提示而不是输出不合法JSON。
  319 +18. 所有widget块必须在顶层提供`data`或`dataRef`(可将props中的`data`上移),确保Chart.js能够直接渲染;缺失数据时宁可输出表格或段落,绝不留空。
  320 +19. 任何block都必须声明合法`type`(heading/paragraph/list/...);若需要普通文本请使用`paragraph`并给出`inlines`,禁止返回`type:null`或未知值。
320 321
321 <CHAPTER JSON SCHEMA> 322 <CHAPTER JSON SCHEMA>
322 {CHAPTER_JSON_SCHEMA_TEXT} 323 {CHAPTER_JSON_SCHEMA_TEXT}
@@ -48,6 +48,7 @@ class HTMLRenderer: @@ -48,6 +48,7 @@ class HTMLRenderer:
48 "math", 48 "math",
49 "figure", 49 "figure",
50 "kpiGrid", 50 "kpiGrid",
  51 + "swotTable",
51 "engineQuote", 52 "engineQuote",
52 } 53 }
53 INLINE_ARTIFACT_KEYS = { 54 INLINE_ARTIFACT_KEYS = {
@@ -1021,6 +1022,7 @@ class HTMLRenderer: @@ -1021,6 +1022,7 @@ class HTMLRenderer:
1021 "paragraph": self._render_paragraph, 1022 "paragraph": self._render_paragraph,
1022 "list": self._render_list, 1023 "list": self._render_list,
1023 "table": self._render_table, 1024 "table": self._render_table,
  1025 + "swotTable": self._render_swot_table,
1024 "blockquote": self._render_blockquote, 1026 "blockquote": self._render_blockquote,
1025 "engineQuote": self._render_engine_quote, 1027 "engineQuote": self._render_engine_quote,
1026 "hr": lambda b: "<hr />", 1028 "hr": lambda b: "<hr />",
@@ -1173,6 +1175,117 @@ class HTMLRenderer: @@ -1173,6 +1175,117 @@ class HTMLRenderer:
1173 caption_html = f"<caption>{self._escape_html(caption)}</caption>" if caption else "" 1175 caption_html = f"<caption>{self._escape_html(caption)}</caption>" if caption else ""
1174 return f'<div class="table-wrap"><table>{caption_html}<tbody>{rows_html}</tbody></table></div>' 1176 return f'<div class="table-wrap"><table>{caption_html}<tbody>{rows_html}</tbody></table></div>'
1175 1177
  1178 + def _render_swot_table(self, block: Dict[str, Any]) -> str:
  1179 + """
  1180 + 渲染四象限的SWOT专用表格,兼顾HTML与PDF的可读性。
  1181 + """
  1182 + title = block.get("title") or "SWOT 分析"
  1183 + summary = block.get("summary")
  1184 + quadrants = [
  1185 + ("strengths", "优势 Strengths", "S", "strength"),
  1186 + ("weaknesses", "劣势 Weaknesses", "W", "weakness"),
  1187 + ("opportunities", "机会 Opportunities", "O", "opportunity"),
  1188 + ("threats", "威胁 Threats", "T", "threat"),
  1189 + ]
  1190 + cells_html = ""
  1191 + for key, label, code, css in quadrants:
  1192 + items = self._normalize_swot_items(block.get(key))
  1193 + caption_text = f"{len(items)} 条要点" if items else "待补充"
  1194 + list_html = "".join(self._render_swot_item(item) for item in items) if items else '<li class="swot-empty">尚未填入要点</li>'
  1195 + cells_html += f"""
  1196 + <div class="swot-cell {css}">
  1197 + <div class="swot-cell__meta">
  1198 + <span class="swot-pill {css}">{self._escape_html(code)}</span>
  1199 + <div>
  1200 + <div class="swot-cell__title">{self._escape_html(label)}</div>
  1201 + <div class="swot-cell__caption">{self._escape_html(caption_text)}</div>
  1202 + </div>
  1203 + </div>
  1204 + <ul class="swot-list">{list_html}</ul>
  1205 + </div>"""
  1206 + summary_html = f'<p class="swot-card__summary">{self._escape_html(summary)}</p>' if summary else ""
  1207 + title_html = f'<div class="swot-card__title">{self._escape_html(title)}</div>' if title else ""
  1208 + legend = """
  1209 + <div class="swot-legend">
  1210 + <span class="swot-legend__item strength">S 优势</span>
  1211 + <span class="swot-legend__item weakness">W 劣势</span>
  1212 + <span class="swot-legend__item opportunity">O 机会</span>
  1213 + <span class="swot-legend__item threat">T 威胁</span>
  1214 + </div>
  1215 + """
  1216 + return f"""
  1217 + <div class="swot-card">
  1218 + <div class="swot-card__head">
  1219 + <div>{title_html}{summary_html}</div>
  1220 + {legend}
  1221 + </div>
  1222 + <div class="swot-grid">{cells_html}</div>
  1223 + </div>
  1224 + """
  1225 +
  1226 + def _normalize_swot_items(self, raw: Any) -> List[Dict[str, Any]]:
  1227 + """将SWOT条目规整为统一结构,兼容字符串/对象两种写法"""
  1228 + normalized: List[Dict[str, Any]] = []
  1229 + if raw is None:
  1230 + return normalized
  1231 + if isinstance(raw, (str, int, float)):
  1232 + text = self._safe_text(raw).strip()
  1233 + if text:
  1234 + normalized.append({"title": text})
  1235 + return normalized
  1236 + if not isinstance(raw, list):
  1237 + return normalized
  1238 + for entry in raw:
  1239 + if isinstance(entry, (str, int, float)):
  1240 + text = self._safe_text(entry).strip()
  1241 + if text:
  1242 + normalized.append({"title": text})
  1243 + continue
  1244 + if not isinstance(entry, dict):
  1245 + continue
  1246 + title = entry.get("title") or entry.get("label") or entry.get("text")
  1247 + detail = entry.get("detail") or entry.get("description")
  1248 + evidence = entry.get("evidence") or entry.get("source")
  1249 + impact = entry.get("impact") or entry.get("priority")
  1250 + score = entry.get("score")
  1251 + if not title and isinstance(detail, str):
  1252 + title = detail
  1253 + detail = None
  1254 + if not (title or detail or evidence):
  1255 + continue
  1256 + normalized.append(
  1257 + {
  1258 + "title": title,
  1259 + "detail": detail,
  1260 + "evidence": evidence,
  1261 + "impact": impact,
  1262 + "score": score,
  1263 + }
  1264 + )
  1265 + return normalized
  1266 +
  1267 + def _render_swot_item(self, item: Dict[str, Any]) -> str:
  1268 + """输出单个SWOT条目的HTML片段"""
  1269 + title = item.get("title") or item.get("label") or item.get("text") or "未命名要点"
  1270 + detail = item.get("detail") or item.get("description")
  1271 + evidence = item.get("evidence") or item.get("source")
  1272 + impact = item.get("impact") or item.get("priority")
  1273 + score = item.get("score")
  1274 + tags: List[str] = []
  1275 + if impact:
  1276 + tags.append(f'<span class="swot-tag">{self._escape_html(impact)}</span>')
  1277 + if score not in (None, ""):
  1278 + tags.append(f'<span class="swot-tag neutral">评分 {self._escape_html(score)}</span>')
  1279 + tags_html = f'<span class="swot-item-tags">{"".join(tags)}</span>' if tags else ""
  1280 + detail_html = f'<div class="swot-item-desc">{self._escape_html(detail)}</div>' if detail else ""
  1281 + evidence_html = f'<div class="swot-item-evidence">佐证:{self._escape_html(evidence)}</div>' if evidence else ""
  1282 + return f"""
  1283 + <li class="swot-item">
  1284 + <div class="swot-item-title">{self._escape_html(title)}{tags_html}</div>
  1285 + {detail_html}{evidence_html}
  1286 + </li>
  1287 + """
  1288 +
1176 def _normalize_table_rows(self, rows: List[Dict[str, Any]]) -> List[Dict[str, Any]]: 1289 def _normalize_table_rows(self, rows: List[Dict[str, Any]]) -> List[Dict[str, Any]]:
1177 """ 1290 """
1178 检测并修正仅有单列的竖排表,转换为标准网格。 1291 检测并修正仅有单列的竖排表,转换为标准网格。
@@ -2446,6 +2559,10 @@ class HTMLRenderer: @@ -2446,6 +2559,10 @@ class HTMLRenderer:
2446 --engine-query-border: rgba(141, 215, 165, 0.45); 2559 --engine-query-border: rgba(141, 215, 165, 0.45);
2447 --engine-query-text: #a7e2ba; 2560 --engine-query-text: #a7e2ba;
2448 --engine-quote-shadow: 0 12px 28px rgba(0, 0, 0, 0.35); 2561 --engine-quote-shadow: 0 12px 28px rgba(0, 0, 0, 0.35);
  2562 + --swot-strength: #1c7f6e;
  2563 + --swot-weakness: #c0392b;
  2564 + --swot-opportunity: #1f5ab3;
  2565 + --swot-threat: #b36b16;
2449 }} 2566 }}
2450 * {{ box-sizing: border-box; }} 2567 * {{ box-sizing: border-box; }}
2451 body {{ 2568 body {{
@@ -2886,6 +3003,150 @@ table th {{ @@ -2886,6 +3003,150 @@ table th {{
2886 }} 3003 }}
2887 .align-center {{ text-align: center; }} 3004 .align-center {{ text-align: center; }}
2888 .align-right {{ text-align: right; }} 3005 .align-right {{ text-align: right; }}
  3006 +.swot-card {{
  3007 + margin: 26px 0;
  3008 + padding: 18px 18px 14px;
  3009 + border-radius: 16px;
  3010 + border: 1px solid var(--border-color);
  3011 + background: linear-gradient(135deg, rgba(76,132,255,0.06), rgba(28,127,110,0.06)), var(--card-bg);
  3012 + box-shadow: 0 12px 30px var(--shadow-color);
  3013 +}}
  3014 +.swot-card__head {{
  3015 + display: flex;
  3016 + justify-content: space-between;
  3017 + gap: 16px;
  3018 + align-items: flex-start;
  3019 + flex-wrap: wrap;
  3020 +}}
  3021 +.swot-card__title {{
  3022 + font-size: 1.15rem;
  3023 + font-weight: 750;
  3024 + margin-bottom: 4px;
  3025 +}}
  3026 +.swot-card__summary {{
  3027 + margin: 0;
  3028 + color: var(--text-color);
  3029 + opacity: 0.85;
  3030 +}}
  3031 +.swot-legend {{
  3032 + display: flex;
  3033 + gap: 8px;
  3034 + flex-wrap: wrap;
  3035 + align-items: center;
  3036 +}}
  3037 +.swot-legend__item {{
  3038 + padding: 6px 12px;
  3039 + border-radius: 999px;
  3040 + font-weight: 700;
  3041 + color: #fff;
  3042 + box-shadow: 0 4px 10px rgba(0,0,0,0.12);
  3043 +}}
  3044 +.swot-legend__item.strength {{ background: var(--swot-strength); }}
  3045 +.swot-legend__item.weakness {{ background: var(--swot-weakness); }}
  3046 +.swot-legend__item.opportunity {{ background: var(--swot-opportunity); }}
  3047 +.swot-legend__item.threat {{ background: var(--swot-threat); }}
  3048 +.swot-grid {{
  3049 + display: grid;
  3050 + grid-template-columns: repeat(auto-fit, minmax(240px, 1fr));
  3051 + gap: 12px;
  3052 + margin-top: 14px;
  3053 +}}
  3054 +.swot-cell {{
  3055 + border-radius: 14px;
  3056 + border: 1px solid rgba(0,0,0,0.05);
  3057 + padding: 12px 12px 10px;
  3058 + background: linear-gradient(135deg, rgba(255,255,255,0.8), rgba(255,255,255,0.4));
  3059 + box-shadow: inset 0 1px 0 rgba(255,255,255,0.7);
  3060 +}}
  3061 +.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); }}
  3062 +.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); }}
  3063 +.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); }}
  3064 +.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); }}
  3065 +.swot-cell__meta {{
  3066 + display: flex;
  3067 + gap: 10px;
  3068 + align-items: flex-start;
  3069 + margin-bottom: 8px;
  3070 +}}
  3071 +.swot-pill {{
  3072 + display: inline-flex;
  3073 + align-items: center;
  3074 + justify-content: center;
  3075 + width: 36px;
  3076 + height: 36px;
  3077 + border-radius: 12px;
  3078 + font-weight: 800;
  3079 + color: #fff;
  3080 + box-shadow: 0 6px 16px rgba(0,0,0,0.14);
  3081 +}}
  3082 +.swot-pill.strength {{ background: var(--swot-strength); }}
  3083 +.swot-pill.weakness {{ background: var(--swot-weakness); }}
  3084 +.swot-pill.opportunity {{ background: var(--swot-opportunity); }}
  3085 +.swot-pill.threat {{ background: var(--swot-threat); }}
  3086 +.swot-cell__title {{
  3087 + font-weight: 750;
  3088 + letter-spacing: 0.01em;
  3089 +}}
  3090 +.swot-cell__caption {{
  3091 + font-size: 0.9rem;
  3092 + color: var(--text-color);
  3093 + opacity: 0.7;
  3094 +}}
  3095 +.swot-list {{
  3096 + list-style: none;
  3097 + padding: 0;
  3098 + margin: 0;
  3099 + display: flex;
  3100 + flex-direction: column;
  3101 + gap: 8px;
  3102 +}}
  3103 +.swot-item {{
  3104 + padding: 10px 12px;
  3105 + border-radius: 12px;
  3106 + background: rgba(0,0,0,0.02);
  3107 + border: 1px dashed rgba(0,0,0,0.05);
  3108 +}}
  3109 +.swot-item-title {{
  3110 + display: flex;
  3111 + justify-content: space-between;
  3112 + gap: 8px;
  3113 + font-weight: 650;
  3114 +}}
  3115 +.swot-item-tags {{
  3116 + display: inline-flex;
  3117 + gap: 6px;
  3118 + flex-wrap: wrap;
  3119 + font-size: 0.85rem;
  3120 +}}
  3121 +.swot-tag {{
  3122 + display: inline-block;
  3123 + padding: 4px 8px;
  3124 + border-radius: 10px;
  3125 + background: rgba(0,0,0,0.06);
  3126 + color: var(--text-color);
  3127 + line-height: 1.2;
  3128 +}}
  3129 +.swot-tag.neutral {{
  3130 + background: rgba(0,0,0,0.04);
  3131 +}}
  3132 +.swot-item-desc {{
  3133 + margin-top: 4px;
  3134 + color: var(--text-color);
  3135 + opacity: 0.92;
  3136 +}}
  3137 +.swot-item-evidence {{
  3138 + margin-top: 4px;
  3139 + font-size: 0.9rem;
  3140 + color: var(--secondary-color);
  3141 +}}
  3142 +.swot-empty {{
  3143 + padding: 12px;
  3144 + border-radius: 12px;
  3145 + border: 1px dashed var(--border-color);
  3146 + text-align: center;
  3147 + color: var(--text-color);
  3148 + opacity: 0.7;
  3149 +}}
2889 .callout {{ 3150 .callout {{
2890 border-left: 4px solid var(--primary-color); 3151 border-left: 4px solid var(--primary-color);
2891 padding: 16px; 3152 padding: 16px;
@@ -3108,6 +3369,7 @@ pre.code-block {{ @@ -3108,6 +3369,7 @@ pre.code-block {{
3108 .engine-quote, 3369 .engine-quote,
3109 .chart-card, 3370 .chart-card,
3110 .kpi-grid, 3371 .kpi-grid,
  3372 + .swot-card,
3111 .table-wrap, 3373 .table-wrap,
3112 figure, 3374 figure,
3113 blockquote {{ 3375 blockquote {{
@@ -3133,6 +3395,11 @@ pre.code-block {{ @@ -3133,6 +3395,11 @@ pre.code-block {{
3133 height: auto !important; 3395 height: auto !important;
3134 max-width: 100% !important; 3396 max-width: 100% !important;
3135 }} 3397 }}
  3398 + .swot-card,
  3399 + .swot-cell {{
  3400 + break-inside: avoid;
  3401 + page-break-inside: avoid;
  3402 + }}
3136 .table-wrap {{ 3403 .table-wrap {{
3137 overflow-x: auto; 3404 overflow-x: auto;
3138 max-width: 100%; 3405 max-width: 100%;