Toggle navigation
Toggle navigation
This project
Loading...
Sign in
万朱浩
/
Venue-Ops
Go to a project
Toggle navigation
Projects
Groups
Snippets
Help
Toggle navigation pinning
Project
Activity
Repository
Pipelines
Graphs
Issues
0
Merge Requests
0
Wiki
Network
Create a new issue
Builds
Commits
Authored by
马一丁
2025-12-11 14:16:23 +0800
Browse Files
Options
Browse Files
Download
Email Patches
Plain Diff
Commit
8467fcb3b560203714c464d8ebf6b3d68f060e62
8467fcb3
1 parent
9fff1198
Support SWOT Block
Hide whitespace changes
Inline
Side-by-side
Showing
4 changed files
with
374 additions
and
13 deletions
ReportEngine/ir/schema.py
ReportEngine/ir/validator.py
ReportEngine/prompts/prompts.py
ReportEngine/renderers/html_renderer.py
ReportEngine/ir/schema.py
View file @
8467fcb
...
...
@@ -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
},
},
}
...
...
ReportEngine/ir/validator.py
View file @
8467fcb
...
...
@@ -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
]
):
...
...
ReportEngine/prompts/prompts.py
View file @
8467fcb
...
...
@@ -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}
...
...
ReportEngine/renderers/html_renderer.py
View file @
8467fcb
...
...
@@ -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
%
;
...
...
Please
register
or
login
to post a comment