Merge branch 'feat/3.0.0-beta2' into 3.0-vibe

This commit is contained in:
LineWalker
2026-08-27 20:15:39 +08:00
34 changed files with 1315 additions and 126 deletions
+3 -3
View File
@@ -86,7 +86,7 @@ services:
backend:
container_name: bisheng-backend
image: dataelement/bisheng-backend:v2.6.0-fix2
image: dataelement/bisheng-backend:v3.0.0-beta1
ports:
- "7860:7860"
environment:
@@ -128,7 +128,7 @@ services:
backend_worker:
container_name: bisheng-backend-worker
image: dataelement/bisheng-backend:v2.6.0-fix2
image: dataelement/bisheng-backend:v3.0.0-beta1
environment:
TZ: Asia/Shanghai
BS_SSO_SYNC__GATEWAY_HMAC_SECRET: "bisheng-local-hmac-20260422"
@@ -161,7 +161,7 @@ services:
frontend:
container_name: bisheng-frontend
image: dataelement/bisheng-frontend:v2.6.0-fix2
image: dataelement/bisheng-frontend:v3.0.0-beta1
ports:
- "3001:3001"
environment:
+1 -1
View File
@@ -4,7 +4,7 @@ from importlib import metadata
try:
# SetujuciGo to automatic modification
__version__ = '2.6.0-fix2'
__version__ = '3.0.0-beta1'
except metadata.PackageNotFoundError:
# Case where package metadata is not available.
__version__ = ''
@@ -7,7 +7,9 @@ description: >-
「方案」「纪要」「公文」「通知」「说明书」「docx」「写一份 X」「整理成 Word」
「导出成 Word」时触发。本技能给出 BiSheng 代码执行器里唯一可行的 python-docx 路径
(该环境没有 Node/docx-js,中文字体必须显式写 w:eastAsia 才生效),
以及中文排版规范、目录页码写法、交付前自检与渲染脚本。
内置 GB/T 9704-2012 公文版式为默认档,另有一档给简历宣传稿这类非公文文档;
还包括目录页码写法、交付前自检与渲染脚本。
本技能只负责版式落地:同时勾选了公文写作类技能时,文种、结构、文风、措辞以那个技能为准。
只是把已经写好的回答原样存成 Word、不要求目录页码表格版式的,用 export_docx 更省事。
如果用户要的是 Excel 表格、PPT 或纯 Markdown,不要用本技能。
metadata:
@@ -79,7 +81,7 @@ print("pandoc :", shutil.which("pandoc") or "无(读 .docx 用 inspect_docx.py
| 纸张 | **US Letter 21.6×27.9cm**(不是 A4),左右边距 3.2cm,改不了 | A4 纵/横随选,边距可调 |
| 标题 | 用内置 Heading 样式,中文字体已设(黑体/楷体) | 同样用内置 Heading,字体字号全可控 |
| 目录 / 页码 | **都没有**,也加不了 | `add_toc` + `add_page_number_footer` |
| 表格 | 宽度 auto,无表头底纹、无列宽、不重复表头 | 列宽写到每个单元格、表头深底白字、跨页重复表头 |
| 表格 | 宽度 auto,无表头底纹、无列宽、不重复表头 | 列宽写到每个单元格、表头按档(企业档深底白字 / 公文档素表加粗)、跨页重复表头 |
| 图片 | 固定 5.7 英寸宽 | 按版心等比缩放 + 图注 |
| 封面、分节、页眉、分隔线 | 没有 | 都有 |
| 代价 | 一次调用,零风险 | 写脚本 + 体检,2–4 轮 |
@@ -90,8 +92,41 @@ print("pandoc :", shutil.which("pandoc") or "无(读 .docx 用 inspect_docx.py
| 用户要目录 / 页码 / A4 / 表格列宽 / 封面 / 特定字体字号 | §3 从零构建 |
| 用户上传了 .docx 要改 | §5 改已有文件(`export_docx` 做不到) |
| 用户上传了模板要按样式填 | 用 `Document("uploads/模板.docx")` 打开**模板本身**,样式自动继承 |
| 还勾选了公文写作类技能 | §2.1:文种文风听它的,版式落地仍走本技能 |
| 拿不准 | 按用户原话里的名词判:出现「正式/对外/打印/汇报稿/公文/目录/页码」走本技能,否则走 `export_docx` |
### 2.1 定版式档(决定全篇字体字号,只需定一次)
本技能自带两档。`apply_chinese_defaults()` 选定后,`add_body` / `add_heading_cn` /
`add_table` / `add_toc` / 页码全部自动跟随,**不必逐处传字体**(逐处传的结果通常是
「大部分对、少数几处漏了」,这是本技能历史上最常见的翻车方式)。
| 档 | 什么时候用 | 取值 |
|---|---|---|
| **`gongwen`(默认)** | 公文、通知、报告、请示、函、纪要、制度办法,以及任何"正式行文" | GB/T 9704-2012:标题二号小标宋、正文三号仿宋_GB2312、一级黑体、二级楷体、行距固定 28 磅 → 读 `/skills/bisheng-docx/references/gongwen-gbt9704.md` |
| `modern` | 简历、宣传方案、对外提案、周报月报 —— **明确不是公文**的 | 微软雅黑 11pt 那一套 → 读 `/skills/bisheng-docx/references/design-zh.md` |
```python
apply_chinese_defaults(doc) # 公文档,默认,什么都不用做
apply_chinese_defaults(doc, profile="modern") # 简历 / 宣传稿 / 周报
```
**同时勾选了其他公文技能时**(用户导入的某单位公文规范、gongwen-draft 之类),分工是硬的:
- **文种、结构、文风、措辞听它的;版式落地永远是本技能。**
- 它若给出了明确的字体字号(本单位模板要求华文中宋之类),把取值翻成 dict 传进来,
**只写有差异的项**,其余自动沿用默认档:
```python
apply_chinese_defaults(doc, profile={"body": {"font": "华文中宋"},
"headings": {1: {"font": "方正小标宋简体"}}})
```
- 它没给版式取值就用默认公文档,**不要为此去翻它的一堆 references** —— 那会烧掉几轮还找不到。
- ❌ **不要调用其他技能的 Word 导出脚本**(`generate_docx.py`、`export.py` 之类)。
它们多是给 Windows 桌面环境写的,在这里会因为"系统未安装公文字体"直接退出 ——
而这个检查在本环境毫无意义:字体名只写进 XML,渲染发生在用户的 Word 里。
## 3. 从零构建
**第 1 步 · 定结构**。先把提纲写到 `scratch/outline.md`。
@@ -102,7 +137,8 @@ print("pandoc :", shutil.which("pandoc") or "无(读 .docx 用 inspect_docx.py
**不要把整段代码塞进代码执行器的参数里** —— 参数过长会被截断,导致反复重试却总差一截。
写法读 `/skills/bisheng-docx/references/python-docx-cookbook.md`
(中文字体、标题、目录、页码、表格、图片、列表都有可直接抄的片段),
排版规范`/skills/bisheng-docx/references/design-zh.md`
排版规范按 §2.1 定的档读:公文走 `references/gongwen-gbt9704.md`
非公文走 `references/design-zh.md`。
骨架长这样:
@@ -110,17 +146,23 @@ print("pandoc :", shutil.which("pandoc") or "无(读 .docx 用 inspect_docx.py
import sys, os
sys.path.insert(0, "skills/bisheng-docx/scripts")
from docx_helpers import setup_page, apply_chinese_defaults, add_heading_cn, add_body, \
add_table, add_toc, add_page_number_footer
add_table, add_toc, add_page_number_footer, add_gongwen_title, \
add_signature_block
from docx import Document
os.makedirs("output", exist_ok=True)
doc = Document()
section = setup_page(doc) # A4 纵向
apply_chinese_defaults(doc) # ★ 必须调:设好中文字体和标题样式
add_heading_cn(doc, "标题", 1)
add_toc(doc) # 3 页以上才需要
add_heading_cn(doc, "一、xxx", 2)
add_body(doc, "正文……")
apply_chinese_defaults(doc) # ★ 必须调,且要在 setup_page 之前:定档(默认公文)
section = setup_page(doc) # A4 纵向,页边距按档取(公文 3.7/3.5/2.8/2.6
# —— 公文档 ——
add_gongwen_title(doc, "关于××××的通知") # 标题:二号小标宋,不进目录
add_body(doc, "各有关部门:", indent=False) # 主送机关顶格
add_body(doc, "正文……") # 自动左空二字
add_heading_cn(doc, "一、xxx", 1) # 一级:三号黑体
add_heading_cn(doc, "(一)xxx", 2) # 二级:三号楷体
add_signature_block(doc, "××××公司", "2026年8月27日")
add_page_number_footer(section)
doc.save("output/xxx.docx")
print("saved")
@@ -148,13 +190,17 @@ print(r.stderr[-2000:] if r.stderr else "(no stderr)")
```python
from docx_helpers import set_run_font
set_run_font(run, "微软雅黑", size_pt=11, bold=True) # ✅
run.font.name = "微软雅黑" # ❌ 中文不生效
set_run_font(run, "仿宋_GB2312", size_pt=16, bold=True) # ✅
run.font.name = "仿宋_GB2312" # ❌ 中文不生效
```
`apply_chinese_defaults(doc)`把 Normal 和 Heading 14 一次性设好(顺带关掉 Word 内置
Heading 4 的斜体)。用 `add_body` / `add_heading_cn` 写的内容自动就是对的。
体检脚本专门抓这条。
`apply_chinese_defaults(doc)` 会按 §2.1 定的档把 Normal 和 Heading 14 一次性设好
(顺带关掉 Word 内置 Heading 4 的斜体)。用 `add_body` / `add_heading_cn` / `add_table`
写的内容自动就是对的,**不需要也不应该逐处传字体名**。体检脚本专门抓这条。
⚠️ 「用户机器可能没装仿宋_GB2312 / 方正小标宋简体」**不是弃用它们的理由**:
.docx 只把字体名写进 XML,渲染发生在用户的 Word 里,服务端装没装完全不影响产物。
公文字体是国标强制项 —— 换成微软雅黑等于交了一份不合规的公文。
## 5. 改用户上传的文档
@@ -187,8 +233,9 @@ print(r.stderr[-2000:] if r.stderr else "(no stderr)")
输出分两段:
- **`=== 内容 ===`**:页面尺寸、段落数、逐段样式与文本、逐表内容。用它核对错字、顺序、缺漏。
- **`=== 体检 ===`**ERROR 必须修完;WARN 逐条复核(确认无误可交付);INFO 是建议
覆盖中文字体缺 `w:eastAsia`(含样式继承链)、字号过小、标题层级跳级、表格超出版心、
- **`=== 体检 ===`**开头会先说明它**按哪个版式档校验**(公文 / 非公文,从文档自身推断)
ERROR 必须修完;WARN 逐条复核(确认无误可交付);INFO 是建议。
覆盖公文文档混入通用字体、中文字体缺 `w:eastAsia`(含样式继承链)、字号过小、标题层级跳级、表格超出版心、
表格首行不像表头、长表不重复表头、图片超宽、目录域没开 updateFields、目录域下没有 Heading、
残留占位符、段落里塞 `\n`、连续空段落、长文档缺页码域。
@@ -233,6 +280,8 @@ print(r.stderr[-2000:] if r.stderr else "(no stderr)")
`read_file` 读技能文档带斜杠(`/skills/bisheng-docx/references/design-zh.md`),
代码执行器里的路径一律不带(`skills/bisheng-docx/scripts/inspect_docx.py`、`output/x.docx`)。
- ❌ 只写 `run.font.name` 就以为中文字体设好了。
- ❌ 因为「用户机器上可能没装仿宋_GB2312 / 方正小标宋简体」就换成微软雅黑、宋体。
字体名只写进 XML,渲染在用户的 Word 里 —— 服务端装没装不影响产物,公文字体是国标强制项。
- ❌ 用手写的 `` / `1.` 当列表,用 `\n` 当换行。
- ❌ 用单行表格画分隔线(用 `add_hr`)。
- ❌ 把文档降级成 Markdown 或 PDF 交付。用户要的是 .docx。
@@ -1,15 +1,19 @@
# 文文档排版规范
# 非公文文档排版规范modern 档)
「要发出去给人看、可能还要打印」的 Word 文档用。
简历、宣传方案、对外提案、周报月报这类**明确不是公文**的 Word 用。
构建脚本里显式切档:`apply_chinese_defaults(doc, profile="modern")`
> **公文、通知、报告、请示、函、纪要 → 不看本文件**,走 `references/gongwen-gbt9704.md`
> (国标版式,`apply_chinese_defaults(doc)` 的默认档)。选错档的代价是整份文档字体全错。
## 1. 页面与字号
| 项 | 取值 |
|---|---|
| 纸张 | A4 纵向(21 × 29.7cm);表格多、列宽的报告可用横向 |
| 页边距 | 上下左右各 2.54cm(公文类可用 上3.7 下3.5 左2.8 右2.6 |
| 页边距 | 上下左右各 2.54cm |
| 正文 | 微软雅黑 / 宋体 11pt,行距 1.5,段后 6pt |
| 一级标题 | 20pt 加粗 |
| 一级标题 | 20pt 加粗 |
| 二级标题 | 16pt 加粗 |
| 三级标题 | 14pt 加粗 |
| 四级标题 | 12pt 加粗(**记得关斜体**,Word 内置是斜体) |
@@ -18,8 +22,11 @@
- **正文首行缩进 2 个字符**`first_line_indent`),不要用空格或 Tab 凑。
- 中文正文用两端对齐(JUSTIFY),标题左对齐;只有封面和大标题居中。
- 字体只用**微软雅黑、宋体、黑体、等线**这类用户机器一定有的。渲染在用户那边完成,
选个冷门字体等于赌它装了。
- 字体在这一档用微软雅黑、宋体、黑体、等线即可,够用且不挑机器。
**但不要把这条当成「冷门字体不能用」的通则**:.docx 只把字体名写进 XML,
渲染发生在用户的 Word 里,服务端装没装那个字体完全不影响产物 ——
公文四体(方正小标宋简体 / 仿宋_GB2312 / 黑体 / 楷体_GB2312)是国标强制项,
在公文档里必须照写,不许替换成通用字体。
## 2. 结构
@@ -34,6 +41,7 @@
- 列宽合计不能超过版心宽度(A4 + 2.54cm 边距 ≈ **15.9cm**),否则右侧列会被截出页面。
`content_width_cm(section)` 取准确值。
- 表头深底白字加粗居中;数据行 10pt;隔行浅底(`F2F6FA`)提升可读性。
(公文档没有这层装饰,`add_table` 会按当前档自动切换。)
- **数字右对齐,文字左对齐**,同一列对齐方式一致。
- 数字带千分位;单位写在表头(`收入(万元)`),不要每格拼「万元」。
- 表格上方给一句话说明它在说什么,下方必要时给数据来源。
@@ -46,14 +54,7 @@
- 每张图配图注(`图 1 xxx`),居中 9pt 灰字。
- 图表优先用 matplotlib 生成 PNG 再插入;记得设中文字体,否则全是方框。
## 5. 语言
- 术语全篇统一。用户叫「营收」就别一会儿写「营业收入」一会儿写「主营收入」。
- 数字、日期、单位格式统一(`2024-01-15``2024年1月15日`,挑一种)。
- 中英文之间不用手动加空格(Word 会自动处理),但中文标点要用全角。
- 不要出现「本文档由 AI 生成」之类的话,除非用户要求。
## 6. 交付前
## 5. 交付前
`inspect_docx.py`:ERROR 清零到打出「结论: 通过」,WARN 逐条复核。
`render_docx.py` 渲成图看是**可选**的 —— ⚠️ 仅在你确知当前模型支持读图时才做,
@@ -61,7 +62,7 @@
即使能看:**预览的字体和用户 Word 里不一样**(服务端只有文泉驿正黑),
行长松紧只作参考,不要为了预览效果反复微调字号。
## 7. 常见"一眼就知道是机器做的"
## 6. 常见"一眼就知道是机器做的"
- 标题用手写加粗的普通段落,不是 Heading 样式 → 没有目录、没有导航。
- 中文字体没设 `w:eastAsia`,正文一半宋体一半雅黑。
@@ -0,0 +1,87 @@
# 公文版式规范(GB/T 9704-2012
《党政机关公文格式》是国标,**这里的取值是强制项,不是审美偏好**。
`apply_chinese_defaults(doc)` 默认就是这一档,照着 helper 写自然合规;
只有用户或另一个公文技能给了本单位的模板取值时才覆盖它。
> 本文件只管**版式**。文种怎么选、正文怎么写、措辞轻重——那是公文写作技能的事,
> 本技能不下判断。
## 1. 版式取值
| 项 | 国标取值 | 折算 |
|---|---|---|
| 用纸 | A4210 × 297mm | — |
| 版心 | 上白边 37mm、下 35mm、左 28mm、右 26mm | 页边距 3.7 / 3.5 / 2.8 / 2.6 cm |
| 行数字数 | 每面 22 行、每行 28 字 | 正文行距固定值 **28 磅**`line_pt`,不是倍数) |
| 标题 | 二号方正小标宋简体,居中 | 22pt,**不加粗**(小标宋本身就重,再加粗糊成一团) |
| 主送机关 | 三号仿宋_GB2312**顶格** | 16pt`add_body(..., indent=False)` |
| 正文 | 三号仿宋_GB2312,每自然段左空二字、回行顶格 | 16pt,首行缩进 2 字 |
| 一级标题「一、」 | 三号**黑体** | 16pt,不加粗 |
| 二级标题「(一)」 | 三号**楷体_GB2312** | 16pt,不加粗 |
| 三级标题「1.」 | 三号仿宋_GB2312,可加粗 | 16pt |
| 四级标题「(1)」 | 三号仿宋_GB2312,可加粗 | 16pt |
| 附件说明 | 三号仿宋,正文下空一行、左空二字 | 「附件:」后接名称,全角冒号 |
| 发文机关署名 | 右空**二**字 | 见 §2 |
| 成文日期 | 右空**四**字,阿拉伯数字 | 「2026年8月27日」 |
| 页码 | 四号半角宋体阿拉伯数字 | 14pt |
| 表格文字 | 国标未规定,惯例四号或小四仿宋 | 14pt / 12pt,无深底白字 |
字号名对照:二号22 · 三号16 · 四号14 · 小四12 · 五号10.5 pt。
**四级标题全是三号,靠字体区分层级,不靠字号递减。** 这是公文和普通报告最大的排版差异,
别把它「修正」成 20/16/14/12 的字号阶梯——那一眼就不是公文。
## 2. 格式要素排列
自上而下,缺项就整块省略,**不要用空段落占位**:
```
关于××××的通知 ← 标题:二号小标宋,居中,回行不断词
← 空一行
各有关部门: ← 主送机关:三号仿宋,顶格,全角冒号
××××××××××××××××××××××××××××。 ← 正文:三号仿宋,左空二字,回行顶格
一、×××× ← 一级:三号黑体
(一)×××× ← 二级:三号楷体
1.×××× ← 三级:三号仿宋加粗
← 空一行
附件:1.×××× ← 附件说明:左空二字
2.×××× ← 多个附件时序号对齐
← 空一行
××××××公司 ← 发文机关署名:右空二字
2026年8月27日 ← 成文日期:右空四字
```
对应 helper
```python
add_gongwen_title(doc, "关于××××的通知") # 标题,不进目录
add_body(doc, "各有关部门:", indent=False) # 主送机关,顶格
add_body(doc, "为规范……,现将……印发,请遵照执行。") # 正文,自动左空二字
add_heading_cn(doc, "一、××××", 1) # 一级,黑体
add_heading_cn(doc, "(一)××××", 2) # 二级,楷体
add_body(doc, "附件:电力行业映射表 V2.0", indent=False)
add_signature_block(doc, "××××××公司", "2026年8月27日") # 两个缩进国标不同,helper 已处理
```
几条硬约束:
- **标题不能用 Heading 样式。** 公文标题不在标题层级里,用 `add_heading_cn(..., 1)` 会把它拉进目录。
`add_gongwen_title()`
- **发文机关、发文字号、签发人、印章一律不得编造。** 用户没给就整块省略,
或用 `[发文机关]` 这类方括号占位——体检脚本认得占位符,不会当成错误。
- **附件另面编排**:附件正文另起一页,首行顶格写「附件1」(不带冒号)。
- 页码国标要求单页居右、双页居左,`add_page_number_footer()` 默认居中。多数内部行文不深究;
确需严格合规时开 `w:evenAndOddHeaders` 分别设奇偶页脚。
## 3. 常见误区
- **标题用黑体** —— 黑体是一级标题的字体,公文标题是**小标宋**。这是最常见的一处错。
- **正文用宋体或微软雅黑** —— 正文是**仿宋_GB2312**。「用户机器可能没装这些字体」不成立:
.docx 只把字体名写进 XML,渲染发生在用户的 Word 里,服务端装没装完全不影响产物。
国标字体是强制项,不要替换成通用字体。
- **一二级标题字号递减** —— 全是三号,见 §1。
- **正文用 1.5 倍行距** —— 公文是固定值 28 磅。倍数行距在字号变化时撑不住每面 22 行。
- **署名和日期缩进一样** —— 二字 / 四字,见 §2。
- **表格套深底白字表头** —— 那是企业文档的做法。公文表格用素表,表头靠加粗区分。
- **空段落凑版式** —— 空一行是版式要素、有确切位置;连打空段落会在分页后炸出大片空白。
@@ -11,8 +11,10 @@ sys.path.insert(0, "skills/bisheng-docx/scripts")
from docx_helpers import (
setup_page, apply_chinese_defaults, set_run_font, add_heading_cn, add_body,
add_table, add_toc, add_page_number_footer, add_hr, add_image_fitted,
set_cell_shading, content_width_cm, first_line_indent, CN_FONT,
set_cell_shading, content_width_cm, first_line_indent,
add_gongwen_title, add_signature_block,
)
from style_profiles import active_profile # 需要取当前档的字体/字号时
from docx import Document
from docx.shared import Pt, Cm, RGBColor
from docx.enum.text import WD_ALIGN_PARAGRAPH
@@ -21,8 +23,22 @@ os.makedirs("output", exist_ok=True)
os.makedirs("scratch", exist_ok=True)
doc = Document()
section = setup_page(doc) # A4 纵向,2.54cm 页边距
apply_chinese_defaults(doc) # ★ 必须调,见下
apply_chinese_defaults(doc) # ★ 必须调,且在 setup_page 之前:定档(默认公文)
section = setup_page(doc) # A4 纵向,页边距按档取
```
**版式档决定全篇字体字号**`apply_chinese_defaults()` 定一次,下面所有 helper 自动跟随:
```python
apply_chinese_defaults(doc) # 公文档(默认):三号仿宋_GB2312 / 黑体 / 楷体
apply_chinese_defaults(doc, profile="modern") # 简历、宣传稿:微软雅黑 11pt
```
要在片段里手工取当前档的取值(而不是写死字体名):
```python
P = active_profile()
body_font, body_pt = P["body"]["font"], P["body"]["pt"]
```
---
@@ -34,13 +50,14 @@ apply_chinese_defaults(doc) # ★ 必须调,见下
用户打开一看就是"字体乱了"。
```python
set_run_font(run, "微软雅黑", size_pt=11, bold=True, color="1F1F1F") # ✅ 三个
run.font.name = "微软雅黑" # ❌ 中文不生效
set_run_font(run, "仿宋_GB2312", size_pt=16, bold=True) # ✅ 三个字段一起
run.font.name = "仿宋_GB2312" # ❌ 中文不生效
```
`apply_chinese_defaults(doc)` 会把 Normal 和 Heading 14 的 `w:eastAsia` 一次性设好
`apply_chinese_defaults(doc)`按当前档把 Normal 和 Heading 14 的 `w:eastAsia` 一次性设好
(顺带把 Word 内置 Heading 4 的**斜体**关掉 —— 不关的话四级标题会渲成斜体衬线)。
之后用 `add_body` / `add_heading_cn` 写的内容就都是对的
之后用 `add_body` / `add_heading_cn` / `add_table` 写的内容就都是对的
**不必也不该逐处写死字体名** —— 写死就会在切档时留下几处漏网的。
体检脚本会专门抓这条。
@@ -82,11 +99,12 @@ add_body(doc, "这段不缩进。", indent=False)
手写段落时:
```python
P = active_profile()["body"]
p = doc.add_paragraph()
set_run_font(p.add_run("加粗片段"), CN_FONT, 11, bold=True)
set_run_font(p.add_run(",普通片段。"), CN_FONT, 11)
set_run_font(p.add_run("加粗片段"), P["font"], P["pt"], bold=True)
set_run_font(p.add_run(",普通片段。"), P["font"], P["pt"])
p.alignment = WD_ALIGN_PARAGRAPH.JUSTIFY # 中文正文常用两端对齐
first_line_indent(p, 2, 11)
first_line_indent(p) # 缩进字数与字号都按当前档
```
> **绝不要用 `\n`**。python-docx 不会把它变成换行,要么用多个 `Paragraph`
@@ -186,7 +204,7 @@ new_section.page_width, new_section.page_height = section.page_height, section.p
```python
header = section.header
p = header.paragraphs[0]
set_run_font(p.add_run("XX 公司 · 内部资料"), CN_FONT, 9, color="808080")
set_run_font(p.add_run("XX 公司 · 内部资料"), active_profile()["caption"]["font"], 9, color="808080")
p.alignment = WD_ALIGN_PARAGRAPH.RIGHT
```
@@ -16,16 +16,34 @@ wrong or cannot express the construct at all:
column object alone is silently ignored by Word.
* A table of contents, page numbers, and a horizontal rule are all field codes
or borders with no python-docx API at all.
**Which face and size a helper renders in lives in ``style_profiles.py``, not
here.** Call ``apply_chinese_defaults(doc)`` once — it selects the GB/T 9704
公文 profile by default — and every helper below follows it. Pass an explicit
``font=`` / ``size_pt=`` only where a single run genuinely differs.
"""
import os
import sys
from docx.enum.table import WD_TABLE_ALIGNMENT
from docx.enum.text import WD_ALIGN_PARAGRAPH
from docx.enum.text import WD_ALIGN_PARAGRAPH, WD_LINE_SPACING
from docx.oxml import OxmlElement
from docx.oxml.ns import qn
from docx.shared import Cm, Pt, RGBColor
# Fonts a Chinese user's Word certainly has. Rendering happens on their machine,
# so an exotic face here becomes a substitution there.
try:
from style_profiles import active_profile, heading_spec, resolve_profile, set_active_profile
except ImportError:
# Loaded without the bundle's scripts/ dir on sys.path (importlib spec, exec,
# a build script that forgot the sys.path.insert). Find our sibling ourselves
# rather than failing the whole build over an import line.
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
from style_profiles import active_profile, heading_spec, resolve_profile, set_active_profile
# Kept for build scripts that name a face directly. These are the "modern"
# profile's values; helpers no longer reference them — they read the active
# profile instead, so a 公文 does not silently come out in 微软雅黑.
CN_FONT = "微软雅黑"
CN_FONT_SERIF = "宋体"
CN_FONT_HEADING = "微软雅黑"
@@ -75,40 +93,128 @@ def _style_font(style, cn_name: str, size_pt: float | None = None):
style.font.size = Pt(size_pt)
def apply_chinese_defaults(doc, body_font: str = CN_FONT, heading_font: str = CN_FONT_HEADING, body_pt: float = 11):
"""Make Normal and Heading 14 render correctly in Chinese.
def _apply_line_spacing(paragraph_format, spec: dict):
"""公文 leading is a fixed point value; the corporate profile uses a multiple.
A multiple cannot hold GB/T 9704's "22 lines per page" once the body size
moves, so the two are expressed differently and only one is ever set.
"""
if spec.get("line_pt"):
paragraph_format.line_spacing = Pt(spec["line_pt"])
paragraph_format.line_spacing_rule = WD_LINE_SPACING.EXACTLY
elif spec.get("line_multiple"):
paragraph_format.line_spacing = spec["line_multiple"]
def apply_chinese_defaults(
doc,
profile=None,
body_font: str | None = None,
heading_font: str | None = None,
body_pt: float | None = None,
**overrides,
):
"""Set Normal and Heading 1-4, and make this profile the default for every
other helper in the package.
Defaults to the GB/T 9704 公文 profile. For a CV, a marketing one-pager or an
outward-facing proposal — anything explicitly not an official document — pass
``profile="modern"``.
``body_font`` / ``heading_font`` / ``body_pt`` stay supported as shorthand for
a one-off tweak on top of the chosen profile.
Word's stock Heading styles are Calibri Light in a blue nobody asked for;
left alone they make every generated document look identically foreign.
"""
_style_font(doc.styles["Normal"], body_font, body_pt)
doc.styles["Normal"].paragraph_format.line_spacing = 1.5
doc.styles["Normal"].paragraph_format.space_after = Pt(6)
tweaks: dict = {}
if body_font:
tweaks.setdefault("body", {})["font"] = body_font
if body_pt is not None:
tweaks.setdefault("body", {})["pt"] = body_pt
if heading_font:
tweaks["headings"] = {level: {"font": heading_font} for level in (1, 2, 3, 4)}
for key, value in overrides.items():
tweaks[key] = value
sizes = {1: 20, 2: 16, 3: 14, 4: 12}
for level, size in sizes.items():
resolved = set_active_profile(profile, tweaks or None)
body = resolved["body"]
# Margins belong to the profile too, and this function is usually called
# *after* setup_page (that is the order SKILL.md's skeleton shows), so a
# profile switch has to reach back and correct the page it already laid out
# — otherwise a "modern" document keeps 公文's 37/35/28/26mm 版心.
# To override margins deliberately, call setup_page(margin_cm=...) *after*
# this function.
page = resolved["page"]
for section in doc.sections:
section.top_margin = Cm(page["margin_top_cm"])
section.bottom_margin = Cm(page["margin_bottom_cm"])
section.left_margin = Cm(page["margin_left_cm"])
section.right_margin = Cm(page["margin_right_cm"])
normal = doc.styles["Normal"]
_style_font(normal, body["font"], body["pt"])
_apply_line_spacing(normal.paragraph_format, body)
normal.paragraph_format.space_after = Pt(body.get("space_after_pt", 0))
if body.get("color"):
normal.font.color.rgb = RGBColor.from_string(body["color"])
headings = resolved["headings"]
for level in (1, 2, 3, 4):
try:
style = doc.styles[f"Heading {level}"]
except KeyError:
continue
_style_font(style, heading_font, size)
style.font.bold = True
spec = heading_spec(level, resolved)
_style_font(style, spec["font"], spec["pt"])
style.font.bold = spec.get("bold", True)
# Word's stock Heading 4 (and several template variants) are italic.
# Left alone, a level-4 Chinese heading renders as slanted serif and
# looks like a different document.
style.font.italic = False
style.font.color.rgb = RGBColor.from_string("1F1F1F")
style.paragraph_format.space_before = Pt(12 if level > 1 else 18)
style.paragraph_format.space_after = Pt(6)
if spec.get("color"):
style.font.color.rgb = RGBColor.from_string(spec["color"])
before = headings.get("space_before_pt", 12)
style.paragraph_format.space_before = Pt(before + 6 if level == 1 and before else before)
style.paragraph_format.space_after = Pt(headings.get("space_after_pt", 6))
return resolved
def setup_page(doc, width_cm: float = 21.0, height_cm: float = 29.7, margin_cm: float = 2.54, landscape: bool = False):
"""A4 portrait by default; pass landscape=True to swap the dimensions."""
def apply_gongwen_defaults(doc, **overrides):
"""GB/T 9704 公文 typography. Same as ``apply_chinese_defaults(doc)`` — named
explicitly for build scripts that want the intent on the page."""
return apply_chinese_defaults(doc, profile="gongwen", **overrides)
def setup_page(
doc,
width_cm: float = 21.0,
height_cm: float = 29.7,
margin_cm: float | None = None,
landscape: bool = False,
profile=None,
):
"""A4 portrait by default; pass landscape=True to swap the dimensions.
Margins come from the active profile (公文 uses the standard's uneven
37/35/28/26mm 版心). Pass ``margin_cm`` to force one value on all four sides.
"""
page = (resolve_profile(profile) if profile is not None else active_profile())["page"]
if margin_cm is not None:
top = bottom = left = right = margin_cm
else:
top = page["margin_top_cm"]
bottom = page["margin_bottom_cm"]
left = page["margin_left_cm"]
right = page["margin_right_cm"]
for section in doc.sections:
section.page_width = Cm(height_cm if landscape else width_cm)
section.page_height = Cm(width_cm if landscape else height_cm)
section.left_margin = section.right_margin = Cm(margin_cm)
section.top_margin = section.bottom_margin = Cm(margin_cm)
section.left_margin = Cm(left)
section.right_margin = Cm(right)
section.top_margin = Cm(top)
section.bottom_margin = Cm(bottom)
return doc.sections[0]
@@ -159,12 +265,13 @@ def enable_update_fields(doc):
existing.set(qn("w:val"), "true")
def add_toc(doc, levels: str = "1-3", title: str = "目录"):
def add_toc(doc, levels: str = "1-3", title: str = "目录", font: str | None = None, size_pt: float | None = None):
"""Insert a table of contents field. Requires built-in Heading styles."""
spec = active_profile()["toc"]
if title:
heading = doc.add_paragraph()
run = heading.add_run(title)
set_run_font(run, CN_FONT_HEADING, 18, bold=True)
set_run_font(run, font or spec["font"], size_pt or spec["pt"], bold=True, color=spec.get("color"))
heading.alignment = WD_ALIGN_PARAGRAPH.CENTER
paragraph = doc.add_paragraph()
_field(paragraph, f' TOC \\o "{levels}" \\h \\z \\u ', "(打开文档时自动生成;若未显示请按 Ctrl+A 后 F9)")
@@ -172,8 +279,14 @@ def add_toc(doc, levels: str = "1-3", title: str = "目录"):
return paragraph
def add_page_number_footer(section, template: str = "{PAGE} 页 / 共 {NUMPAGES}", size_pt: float = 9):
def add_page_number_footer(
section,
template: str = "{PAGE} 页 / 共 {NUMPAGES}",
size_pt: float | None = None,
font: str | None = None,
):
"""Page numbers in the footer, as live fields rather than baked-in text."""
spec = active_profile()["footer"]
footer = section.footer
paragraph = footer.paragraphs[0] if footer.paragraphs else footer.add_paragraph()
paragraph.alignment = WD_ALIGN_PARAGRAPH.CENTER
@@ -183,7 +296,7 @@ def add_page_number_footer(section, template: str = "第 {PAGE} 页 / 共 {NUMPA
else:
paragraph.add_run(chunk)
for run in paragraph.runs:
set_run_font(run, CN_FONT, size_pt, color="808080")
set_run_font(run, font or spec["font"], size_pt or spec["pt"], color=spec.get("color"))
return paragraph
@@ -215,13 +328,32 @@ def set_cell_shading(cell, hex_fill: str):
tc_pr.append(shd)
def add_table(doc, headers, rows, widths_cm=None, style: str = "Table Grid", font_pt: float = 10, banded=True):
def add_table(
doc,
headers,
rows,
widths_cm=None,
style: str = "Table Grid",
font_pt: float | None = None,
banded: bool | None = None,
font: str | None = None,
size_pt: float | None = None,
):
"""Add a table with widths Word will actually honour.
python-docx exposes ``column.width``, but Word ignores it unless the same
width is written to every cell in that column and autofit is off — the
single most common "why is my table squashed" bug.
Face, size and header treatment come from the active profile: the corporate
profile gives a dark header band with white text, 公文 gives a plain bold
header row (an official document does not carry colour blocks).
"""
spec = active_profile()["table"]
face = font or spec["font"]
pt = size_pt if size_pt is not None else (font_pt if font_pt is not None else spec["pt"])
striped = spec.get("banded", True) if banded is None else banded
table = doc.add_table(rows=1, cols=len(headers))
if style:
try:
@@ -236,9 +368,10 @@ def add_table(doc, headers, rows, widths_cm=None, style: str = "Table Grid", fon
cell = header_cells[index]
cell.text = ""
run = cell.paragraphs[0].add_run(str(title))
set_run_font(run, CN_FONT, font_pt, bold=True, color="FFFFFF")
set_run_font(run, face, pt, bold=spec.get("header_bold", True), color=spec.get("header_color"))
cell.paragraphs[0].alignment = WD_ALIGN_PARAGRAPH.CENTER
set_cell_shading(cell, HEADER_FILL)
if spec.get("header_fill"):
set_cell_shading(cell, spec["header_fill"])
for row_index, record in enumerate(rows):
cells = table.add_row().cells
@@ -246,11 +379,11 @@ def add_table(doc, headers, rows, widths_cm=None, style: str = "Table Grid", fon
cell = cells[index]
cell.text = ""
run = cell.paragraphs[0].add_run("" if value is None else str(value))
set_run_font(run, CN_FONT, font_pt)
set_run_font(run, face, pt)
if isinstance(value, (int, float)):
cell.paragraphs[0].alignment = WD_ALIGN_PARAGRAPH.RIGHT
if banded and row_index % 2 == 1:
set_cell_shading(cell, BAND_FILL)
if striped and spec.get("band_fill") and row_index % 2 == 1:
set_cell_shading(cell, spec["band_fill"])
if widths_cm:
for row in table.rows:
@@ -309,33 +442,105 @@ def add_image_fitted(doc, image_path: str, section=None, max_width_cm: float | N
picture.height = int(picture.height * limit / picture.width)
picture.width = limit
if caption:
spec = active_profile()["caption"]
cap = doc.add_paragraph()
cap.alignment = WD_ALIGN_PARAGRAPH.CENTER
set_run_font(cap.add_run(caption), CN_FONT, 9, color="808080")
set_run_font(cap.add_run(caption), spec["font"], spec["pt"], color=spec.get("color"))
return paragraph
def first_line_indent(paragraph, chars: float = 2, font_pt: float = 11):
def first_line_indent(paragraph, chars: float | None = None, font_pt: float | None = None):
"""Chinese body text is indented by two characters, not by a tab."""
body = active_profile()["body"]
chars = body.get("indent_chars", 2) if chars is None else chars
font_pt = body["pt"] if font_pt is None else font_pt
paragraph.paragraph_format.first_line_indent = Pt(chars * font_pt)
return paragraph
def add_body(doc, text: str, indent: bool = True, font_pt: float = 11):
def add_body(
doc,
text: str,
indent: bool = True,
font_pt: float | None = None,
font: str | None = None,
size_pt: float | None = None,
):
"""A body paragraph with the Chinese conventions already applied."""
body = active_profile()["body"]
face = font or body["font"]
pt = size_pt if size_pt is not None else (font_pt if font_pt is not None else body["pt"])
paragraph = doc.add_paragraph()
set_run_font(paragraph.add_run(text), CN_FONT, font_pt)
set_run_font(paragraph.add_run(text), face, pt, color=body.get("color"))
_apply_line_spacing(paragraph.paragraph_format, body)
if indent:
first_line_indent(paragraph, 2, font_pt)
first_line_indent(paragraph, body.get("indent_chars", 2), pt)
return paragraph
def add_heading_cn(doc, text: str, level: int = 1):
def add_heading_cn(doc, text: str, level: int = 1, font: str | None = None, size_pt: float | None = None):
"""A heading that keeps the built-in style (so the TOC finds it) but renders
in a Chinese face."""
in the active profile's face for that level.
Under 公文 all four levels are 三号 and differ by face (黑体 / 楷体 / 仿宋加粗)
rather than by size — do not "fix" that into a descending size ladder.
"""
spec = heading_spec(level)
heading = doc.add_heading(level=level)
run = set_run_font(
heading.add_run(text), CN_FONT_HEADING, {1: 20, 2: 16, 3: 14, 4: 12}.get(level, 12), bold=True, color="1F1F1F"
heading.add_run(text),
font or spec["font"],
size_pt if size_pt is not None else spec["pt"],
bold=spec.get("bold", True),
color=spec.get("color"),
)
run.font.italic = False # stock Heading 4 is italic; override at run level too
return heading
def add_gongwen_title(doc, text: str, font: str | None = None, size_pt: float | None = None):
"""The document title of an official document (标题) — 二号方正小标宋简体, centred.
Not a Heading style: GB/T 9704's 标题 sits above the 主送机关 and is not part
of the heading ladder, so it must not land in the table of contents.
Long titles wrap on meaning; do not split a word or a book-title mark.
"""
spec = active_profile()["title"]
paragraph = doc.add_paragraph()
paragraph.alignment = WD_ALIGN_PARAGRAPH.CENTER
paragraph.paragraph_format.first_line_indent = Pt(0)
set_run_font(
paragraph.add_run(text),
font or spec["font"],
size_pt if size_pt is not None else spec["pt"],
bold=False, # 小标宋 is already heavy; bolding it renders as a smear
color=spec.get("color"),
)
return paragraph
def add_signature_block(doc, org: str | None = None, date: str | None = None, blank_lines: int = 1):
"""发文机关署名 + 成文日期, set back from the right margin per GB/T 9704.
The two indents differ and the standard is explicit about it: on a document
that carries no seal, 发文机关署名 is set back **two** characters and 成文日期
**four**. Equalising them is the giveaway that a template was eyeballed.
Leave ``org`` empty when the user has not supplied an authorised issuer —
never invent a real 机关名称.
"""
body = active_profile()["body"]
for _ in range(max(0, blank_lines)):
doc.add_paragraph()
paragraphs = []
for text, back_chars in ((org, 2), (date, 4)):
if not text:
continue
paragraph = doc.add_paragraph()
paragraph.alignment = WD_ALIGN_PARAGRAPH.RIGHT
paragraph.paragraph_format.first_line_indent = Pt(0)
paragraph.paragraph_format.right_indent = Pt(back_chars * body["pt"])
_apply_line_spacing(paragraph.paragraph_format, body)
set_run_font(paragraph.add_run(text), body["font"], body["pt"], color=body.get("color"))
paragraphs.append(paragraph)
return paragraphs
@@ -27,8 +27,10 @@ Always exits 0 — the executor discards stdout on a non-zero exit.
import argparse
import os
import re
import sys
import traceback
from itertools import pairwise
try:
from docx import Document
@@ -37,12 +39,29 @@ except ImportError: # pragma: no cover
print("[FATAL] python-docx 不可用;无法体检。")
sys.exit(0)
try:
from style_profiles import GENERIC_CN_FONTS, GONGWEN, MODERN
except ImportError:
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
try:
from style_profiles import GENERIC_CN_FONTS, GONGWEN, MODERN
except ImportError: # pragma: no cover - script copied out of the bundle
GENERIC_CN_FONTS = ("微软雅黑", "等线", "宋体", "SimSun", "Microsoft YaHei", "DengXian")
GONGWEN = MODERN = None
PLACEHOLDERS = ["待填", "待补", "TODO", "XXX", "占位", "请填写", "TBD", "lorem ipsum", "示例文字"]
CJK_RANGE = ("", "鿿")
MIN_RUN_PT = 9.0 # design-zh: 图注 9pt 是最小合法字号
MIN_BODY_PT = 10.5 # design-zh: 正文 11pt
MIN_RUN_PT = 9.0 # 图注 9pt 是最小合法字号
REPEAT_HEADER_ROWS = 12 # 超过这个行数的表跨页时必须重复表头
# Faces no corporate document reaches for by accident. Their presence is the
# most reliable signal that this document is meant to be an official one.
GONGWEN_ONLY_FONTS = ("方正小标宋简体", "小标宋", "仿宋_GB2312", "楷体_GB2312")
# 「关于××的通知」and its siblings — a narrow shape, so a CV or a deck title
# cannot trip it. Catches the case where every face was already swapped out and
# GONGWEN_ONLY_FONTS finds nothing.
GONGWEN_TITLE_RE = re.compile(r"^关于.{2,60}的(通知|报告|请示|函|意见|批复|决定|通报|纪要|公告|通告)$")
findings: list[tuple[str, str]] = []
@@ -185,7 +204,72 @@ def dump_content(path: str, max_paras: int) -> None:
# --------------------------------------------------------------------------- #
def check_fonts(doc) -> None:
def document_faces(doc) -> set[str]:
"""Every CJK face the document actually names, styles and runs alike."""
faces: set[str] = set()
for style_name in ("Normal", "Heading 1", "Heading 2", "Heading 3", "Heading 4"):
try:
face = style_east_asia(doc.styles[style_name])
except KeyError:
continue
if face:
faces.add(face)
for paragraph, _ in iter_block_paragraphs(doc):
for run in paragraph.runs:
face = run_east_asia(run)
if face:
faces.add(face)
return faces
def looks_like_gongwen(doc, faces: set[str]) -> bool:
"""Is this document *meant* to be an official document?
Deliberately not "what does Normal say" — if the body face was swapped for
微软雅黑 that is exactly the defect being looked for, and keying off it would
make the check disappear precisely when it matters.
"""
if any(face in GONGWEN_ONLY_FONTS for face in faces):
return True
# A 公文 title wraps on meaning rather than running to the margin, so it is
# routinely split across two paragraphs («关于××映射表 V2.0» / «变量个数情况的报告»).
# Test the first few leading paragraphs both alone and pairwise joined.
leading = []
for paragraph in doc.paragraphs:
text = paragraph.text.strip().replace(" ", "")
if text:
leading.append(text)
if len(leading) >= 3:
break
candidates = list(leading)
candidates += [a + b for a, b in pairwise(leading)]
return any(GONGWEN_TITLE_RE.match(text) for text in candidates)
def check_profile_fonts(doc, faces: set[str], is_gongwen: bool) -> None:
"""公文 faces are a national-standard requirement, not a preference.
Reported as WARN, never ERROR: the user's own unit template legitimately
overrides GB/T 9704, and an unsatisfiable checker sends the model into a
repair loop that burns the remaining rounds.
"""
if not is_gongwen or GONGWEN is None:
return
generic = sorted(face for face in faces if face in GENERIC_CN_FONTS)
if not generic:
return
add(
"WARN",
f"这份文档看着是公文,却用到了通用字体:{', '.join(generic)}"
"GB/T 9704 规定标题二号方正小标宋简体、正文三号仿宋_GB2312、一级黑体、二级楷体_GB2312。"
"调 apply_chinese_defaults(doc)(默认就是公文档)而不是逐处写字体名;"
"「用户机器可能没装」不成立 —— 字体名只写进 XML,渲染在用户的 Word 里完成。"
"若这是本单位模板要求的字体,忽略本条。",
)
def check_fonts(doc, profile=None) -> None:
"""The headline check: every Chinese run must end up with a w:eastAsia face."""
if not style_east_asia(doc.styles["Normal"]):
add(
@@ -259,12 +343,13 @@ def check_fonts(doc) -> None:
f"{'' if len(small) > 6 else ''}。打印后基本读不清 —— "
f"正文 11pt、表格 10pt、图注 9pt,最小不要低于 {MIN_RUN_PT:g}pt。",
)
expected_pt = (profile or MODERN or {}).get("body", {}).get("pt", 11)
normal_pt = style_size_pt(doc.styles["Normal"])
if normal_pt is not None and normal_pt < MIN_BODY_PT:
if normal_pt is not None and normal_pt < expected_pt - 1:
add(
"WARN",
f"Normal 样式字号 {normal_pt:g}pt 偏小(正文规范 11pt)。"
"在 apply_chinese_defaults(doc, body_pt=11) 里调回来。",
f"Normal 样式字号 {normal_pt:g}pt 偏小(当前版式档的正文是 {expected_pt:g}pt)。"
f"在 apply_chinese_defaults(doc, body_pt={expected_pt:g}) 里调回来。",
)
@@ -368,7 +453,7 @@ def check_images(doc, section) -> None:
)
def check_text(doc) -> None:
def check_text(doc, is_gongwen: bool = False) -> None:
empty_streak = 0
for paragraph in doc.paragraphs:
text = paragraph.text.strip()
@@ -386,7 +471,15 @@ def check_text(doc) -> None:
prefix = f"{where} " if where else ""
for token in PLACEHOLDERS:
if token in text:
add("WARN", f"{prefix}残留占位文本「{token}」:{text[:40]} —— 换成真实内容,或整段删掉")
# In an official document a bracketed 发文机关 is the *correct*
# output when the user gave no authorised issuer — telling the
# model to "replace with real content" invites it to invent one.
advice = (
"确认是否应换成真实机关名;用户没给授权名称时保留占位是对的,不要编造"
if is_gongwen
else "换成真实内容,或整段删掉"
)
add("WARN", f"{prefix}残留占位文本「{token}」:{text[:40]} —— {advice}")
break
if "\n" in paragraph.text:
add(
@@ -422,15 +515,20 @@ def check_fields(doc) -> None:
def run_checks(path: str) -> None:
doc = Document(path)
section = doc.sections[0]
check_fonts(doc)
faces = document_faces(doc)
is_gongwen = looks_like_gongwen(doc, faces)
profile = GONGWEN if is_gongwen else MODERN
check_fonts(doc, profile)
check_profile_fonts(doc, faces, is_gongwen)
check_headings(doc)
check_tables(doc, section)
check_images(doc, section)
check_text(doc)
check_text(doc, is_gongwen)
check_fields(doc)
print()
print("=== 体检 ===")
print(f"(按「{'公文' if is_gongwen else '非公文'}」版式档校验)")
order = {"ERROR": 0, "WARN": 1, "INFO": 2}
counts = {"ERROR": 0, "WARN": 0, "INFO": 0}
@@ -0,0 +1,185 @@
"""Typography profiles — the *values* half of this skill, kept apart from the code.
``docx_helpers`` owns the mechanics (``w:eastAsia``, field codes, cell widths).
This module owns what those mechanics render *as*: which face, which size, which
leading. Splitting them is what lets an imported third-party 公文 skill override
the look without reimplementing the plumbing — it hands over a dict, not a script.
Two built-in profiles:
* ``gongwen`` — GB/T 9704-2012《党政机关公文格式》. **The default.**
* ``modern`` — the generic corporate look (微软雅黑 11pt). For CVs, marketing
one-pagers, outward-facing proposals — anything explicitly *not* an official
document.
A profile is a plain dict so a caller can pass only the keys that differ::
resolve_profile("gongwen", {"body": {"font": "华文中宋"}})
Chinese size names, since every 公文 spec is written in them:
初号42 · 小初36 · 一号26 · 小一24 · **二号22** · 小二18 · **三号16** · 小三15
· **四号14** · 小四12 · 五号10.5 · 小五9
"""
from copy import deepcopy
# GB/T 9704-2012. Sizes are the Chinese names resolved to points; the margins are
# the standard's 版心 (37/35/28/26 mm). Body leading is fixed 28pt rather than a
# multiple — the standard specifies 22 lines per page, which a multiple cannot
# hold once the font size moves.
GONGWEN = {
"name": "gongwen",
"page": {
"margin_top_cm": 3.7,
"margin_bottom_cm": 3.5,
"margin_left_cm": 2.8,
"margin_right_cm": 2.6,
},
"body": {
"font": "仿宋_GB2312", # 三号仿宋
"pt": 16,
"line_pt": 28, # fixed leading, not a multiple
"space_after_pt": 0, # 公文 runs continuous; spacing comes from leading
"indent_chars": 2,
"color": "000000",
},
# The document title (标题), not a Heading style: 二号方正小标宋简体, centred.
"title": {"font": "方正小标宋简体", "pt": 22, "color": "000000"},
# All four levels are 三号 — the standard separates them by *face*, not size.
"headings": {
1: {"font": "黑体", "pt": 16, "bold": False, "color": "000000"}, # 一、
2: {"font": "楷体_GB2312", "pt": 16, "bold": False, "color": "000000"}, # (一)
3: {"font": "仿宋_GB2312", "pt": 16, "bold": True, "color": "000000"}, # 1.
4: {"font": "仿宋_GB2312", "pt": 16, "bold": True, "color": "000000"}, # 1
"space_before_pt": 0,
"space_after_pt": 0,
},
"toc": {"font": "黑体", "pt": 16, "color": "000000"},
"table": {
"font": "仿宋_GB2312",
"pt": 14, # 四号
"header_fill": None, # 公文 tables carry no dark header band…
"header_bold": True, # …so the header row has to be bold, or it reads as data
"header_color": "000000",
"banded": False,
"band_fill": None,
},
"footer": {"font": "宋体", "pt": 14, "color": "000000"}, # 页码 四号宋体
"caption": {"font": "楷体_GB2312", "pt": 14, "color": "000000"},
}
# The look this skill shipped with. Values are carried over unchanged so a
# document built before profiles existed renders identically under "modern".
MODERN = {
"name": "modern",
"page": {
"margin_top_cm": 2.54,
"margin_bottom_cm": 2.54,
"margin_left_cm": 2.54,
"margin_right_cm": 2.54,
},
"body": {
"font": "微软雅黑",
"pt": 11,
"line_multiple": 1.5,
"space_after_pt": 6,
"indent_chars": 2,
"color": None,
},
"title": {"font": "微软雅黑", "pt": 20, "color": "1F1F1F"},
"headings": {
1: {"font": "微软雅黑", "pt": 20, "bold": True, "color": "1F1F1F"},
2: {"font": "微软雅黑", "pt": 16, "bold": True, "color": "1F1F1F"},
3: {"font": "微软雅黑", "pt": 14, "bold": True, "color": "1F1F1F"},
4: {"font": "微软雅黑", "pt": 12, "bold": True, "color": "1F1F1F"},
"space_before_pt": 12,
"space_after_pt": 6,
},
"toc": {"font": "微软雅黑", "pt": 18, "color": "1F1F1F"},
"table": {
"font": "微软雅黑",
"pt": 10,
"header_fill": "1F4E79",
"header_bold": True,
"header_color": "FFFFFF",
"banded": True,
"band_fill": "F2F6FA",
},
"footer": {"font": "微软雅黑", "pt": 9, "color": "808080"},
"caption": {"font": "微软雅黑", "pt": 9, "color": "808080"},
}
BUILTIN = {"gongwen": GONGWEN, "modern": MODERN}
# Generic Chinese faces. Perfectly fine in a corporate document; in a 公文 they
# mean the mandated face was silently swapped out. ``inspect_docx.py`` reads this.
GENERIC_CN_FONTS = ("微软雅黑", "等线", "宋体", "SimSun", "Microsoft YaHei", "DengXian", "Noto Sans CJK SC")
_ACTIVE = deepcopy(GONGWEN)
def _deep_merge(base: dict, extra: dict) -> dict:
"""Merge ``extra`` into a copy of ``base``, recursing into nested dicts.
Nested rather than flat so a caller can say ``{"body": {"font": "华文中宋"}}``
and keep the profile's size, leading and indent.
"""
merged = deepcopy(base)
for key, value in extra.items():
if isinstance(value, dict) and isinstance(merged.get(key), dict):
merged[key] = _deep_merge(merged[key], value)
else:
merged[key] = deepcopy(value)
return merged
def resolve_profile(profile=None, overrides: dict | None = None) -> dict:
"""Resolve a profile name, a dict of overrides, or both, into a full profile.
resolve_profile() -> gongwen
resolve_profile("modern") -> modern
resolve_profile({"body": {"font": "华文中宋"}}) -> gongwen + that change
resolve_profile("modern", {"body": {"pt": 12}}) -> modern + that change
An unknown name falls back to gongwen rather than raising: a build script that
dies on a typo costs the user a whole round, a slightly wrong face does not.
"""
if profile is None:
resolved = deepcopy(GONGWEN)
elif isinstance(profile, str):
resolved = deepcopy(BUILTIN.get(profile, GONGWEN))
elif isinstance(profile, dict):
# A bare dict is treated as overrides on top of the default profile,
# unless it names a base itself.
base = BUILTIN.get(profile.get("name"), GONGWEN)
resolved = _deep_merge(base, profile)
else:
resolved = deepcopy(GONGWEN)
if overrides:
resolved = _deep_merge(resolved, overrides)
return resolved
def set_active_profile(profile=None, overrides: dict | None = None) -> dict:
"""Make a profile the default for every helper in this package.
``apply_chinese_defaults()`` calls this, so a build script picks its profile
once and every later ``add_body`` / ``add_heading_cn`` / ``add_table`` follows
without being told again. Passing the profile to each call individually is the
reliable way to end up with a document that is 90% right.
"""
global _ACTIVE
_ACTIVE = resolve_profile(profile, overrides)
return _ACTIVE
def active_profile() -> dict:
"""The profile helpers fall back to when no explicit font/size is given."""
return _ACTIVE
def heading_spec(level: int, profile: dict | None = None) -> dict:
"""Per-level heading values, falling back to level 4 beyond the fourth level."""
headings = (profile or _ACTIVE)["headings"]
return headings.get(level) or headings[4]
@@ -297,6 +297,7 @@ class F048ResourcePermissionApi:
"source": {
"type": row.source_type,
"include_children": row.include_children,
"userset_relation": row.userset_relation,
},
"scope": row.scope,
"inherited_from": row.inherited_from,
@@ -313,6 +313,7 @@ class GrantModelDTO(BaseModel):
class GrantSourceDTO(BaseModel):
type: str
include_children: bool = False
userset_relation: str | None = None
class GrantAssigneeDTO(BaseModel):
@@ -93,22 +93,25 @@ class ToolExecutor(BaseTool):
tool_instance: BaseTool = Field(..., description="Langchain Tool Instance")
@staticmethod
def _build_permission_user(
async def _build_permission_user(
user_id: int,
tenant_id: int,
) -> UserPayload:
from bisheng.utils.http_middleware import _check_is_global_super
return UserPayload(
user_id=user_id,
user_name="",
user_role=[],
tenant_id=tenant_id,
is_global_super=await _check_is_global_super(user_id),
)
@classmethod
async def _ensure_use_permission_async(cls, tool_type: GptsToolsType, user_id: int) -> None:
if not tool_type.id or not tool_type.tenant_id:
raise PermissionError("Permission denied: use")
login_user = cls._build_permission_user(
login_user = await cls._build_permission_user(
user_id,
int(tool_type.tenant_id),
)
+1 -1
View File
@@ -1,6 +1,6 @@
[project]
name = "backend"
version = "2.6.0"
version = "3.0.0-beta1"
description = "BISHENG backend service"
readme = "README.md"
license = "Apache 2.0"
@@ -28,6 +28,7 @@ the contract that matters.
from __future__ import annotations
import ast
import importlib.util
import os
import re
@@ -232,6 +233,27 @@ def _docx_helpers():
return module
def _style_profiles():
"""Import the bundle's style_profiles by path, without polluting sys.path."""
source = BUILTIN_SKILLS_DIR / "bisheng-docx" / "scripts" / "style_profiles.py"
spec = importlib.util.spec_from_file_location("builtin_style_profiles", source)
module = importlib.util.module_from_spec(spec)
spec.loader.exec_module(module)
return module
def _style_face(doc, style_name: str) -> str | None:
"""The ``w:eastAsia`` face a style actually carries — the only field that
decides how a Chinese glyph renders."""
from docx.oxml.ns import qn
rpr = doc.styles[style_name].element.find(qn("w:rPr"))
if rpr is None:
return None
rfonts = rpr.find(qn("w:rFonts"))
return rfonts.get(qn("w:eastAsia")) if rfonts is not None else None
def build_good_docx(path: Path) -> Path:
helpers = _docx_helpers()
doc = Document()
@@ -471,6 +493,162 @@ def test_docx_content_dump_prints_paragraphs_and_tables(samples):
assert_mentions(out, "=== 内容 ===", "电力工程年度经营报告", "## 表 1")
# --------------------------------------------------------------------------- #
# docx · 版式档 (style profiles)
# --------------------------------------------------------------------------- #
def test_docx_defaults_to_the_gongwen_profile(tmp_path):
"""Calling ``apply_chinese_defaults(doc)`` with no argument must produce a
GB/T 9704 document.
Two sessions shipped 公文 in 微软雅黑 because the helper hardcoded a corporate
face and the model, told by design-zh.md to avoid "exotic" fonts, went along
with it. The default is the whole fix — parameterising the helpers while
leaving 微软雅黑 as the default would have changed nothing.
"""
helpers = _docx_helpers()
doc = Document()
helpers.apply_chinese_defaults(doc)
section = helpers.setup_page(doc)
path = tmp_path / "gongwen.docx"
doc.save(path)
saved = Document(path)
assert _style_face(saved, "Normal") == "仿宋_GB2312"
assert _style_face(saved, "Heading 1") == "黑体"
assert _style_face(saved, "Heading 2") == "楷体_GB2312"
# 三号 throughout; the standard separates levels by face, not by size
assert saved.styles["Normal"].font.size.pt == 16
assert saved.styles["Heading 1"].font.size.pt == 16
assert saved.styles["Heading 2"].font.size.pt == 16
# 版心: 37 / 35 / 28 / 26 mm
assert round(section.top_margin.cm, 2) == 3.7
assert round(section.left_margin.cm, 2) == 2.8
def test_docx_modern_profile_restores_the_corporate_look(tmp_path):
"""A CV or a marketing one-pager must still be able to opt out."""
helpers = _docx_helpers()
doc = Document()
helpers.apply_chinese_defaults(doc, profile="modern")
section = helpers.setup_page(doc)
path = tmp_path / "modern.docx"
doc.save(path)
saved = Document(path)
assert _style_face(saved, "Normal") == "微软雅黑"
assert saved.styles["Normal"].font.size.pt == 11
assert saved.styles["Heading 1"].font.size.pt == 20
assert round(section.top_margin.cm, 2) == 2.54
def test_setup_page_before_apply_defaults_still_lands_on_profile_margins(tmp_path):
"""SKILL.md's skeleton used to call ``setup_page`` first, and a model that
copies the old order must not end up with 公文 margins on a modern document.
``apply_chinese_defaults`` therefore rewrites the margins of the sections
already laid out, rather than trusting the call order.
"""
helpers = _docx_helpers()
doc = Document()
section = helpers.setup_page(doc) # laid out under whatever profile was active
helpers.apply_chinese_defaults(doc, profile="modern")
path = tmp_path / "order.docx"
doc.save(path)
assert round(Document(path).sections[0].top_margin.cm, 2) == 2.54
assert round(section.left_margin.cm, 2) == 2.54
def test_profile_overrides_replace_only_the_keys_given():
"""The override dict is the seam an imported third-party 公文 skill hands its
unit template through, so it has to merge deeply — a caller naming one face
must not lose the size, leading and indent that came with the profile."""
profiles = _style_profiles()
merged = profiles.resolve_profile("gongwen", {"body": {"font": "华文中宋"}})
assert merged["body"]["font"] == "华文中宋"
assert merged["body"]["pt"] == 16 # untouched
assert merged["body"]["line_pt"] == 28 # untouched
assert merged["headings"][1]["font"] == "黑体" # untouched
# A bare dict is read as overrides on the default (公文) profile.
bare = profiles.resolve_profile({"headings": {1: {"font": "方正小标宋简体"}}})
assert bare["headings"][1]["font"] == "方正小标宋简体"
assert bare["headings"][2]["font"] == "楷体_GB2312"
assert bare["body"]["font"] == "仿宋_GB2312"
# Resolving must not mutate the module-level profile.
assert profiles.GONGWEN["body"]["font"] == "仿宋_GB2312"
def test_helpers_follow_the_active_profile_without_being_told(tmp_path):
"""Passing the profile to every call is how a document ends up 90% right:
``apply_chinese_defaults`` sets it once and the rest follow."""
helpers = _docx_helpers()
doc = Document()
helpers.apply_chinese_defaults(doc)
helpers.add_gongwen_title(doc, "关于××××的通知")
helpers.add_body(doc, "正文内容。")
helpers.add_heading_cn(doc, "一、总体情况", 1)
helpers.add_table(doc, ["列一", "列二"], [["", ""]])
path = tmp_path / "follow.docx"
doc.save(path)
from docx.oxml.ns import qn
faces = {
run._element.find(qn("w:rPr")).find(qn("w:rFonts")).get(qn("w:eastAsia"))
for paragraph in Document(path).paragraphs
for run in paragraph.runs
if run.text.strip()
}
assert faces == {"方正小标宋简体", "仿宋_GB2312", "黑体"}, faces
def test_gongwen_document_built_with_generic_fonts_is_warned(tmp_path):
"""Regression for the two shipped sessions: a document that is plainly an
official one, rendered in 微软雅黑, must be called out.
Detection keys off the 公文 *intent* (a 「关于××的通知」title, or any of the
four mandated faces appearing somewhere) rather than off the body face —
keying off the body face would make the check vanish exactly when the body
face is the thing that was replaced.
"""
helpers = _docx_helpers()
doc = Document()
helpers.apply_chinese_defaults(doc, profile="modern")
helpers.add_gongwen_title(doc, "关于下发《电力行业映射表 V2.0》的通知")
helpers.add_body(doc, "各有关部门:", indent=False)
helpers.add_body(doc, "现将映射表予以印发,请遵照执行。")
path = tmp_path / "gongwen_in_yahei.docx"
doc.save(path)
out = run_script("bisheng-docx", "inspect_docx.py", path, "--check-only").stdout
assert_mentions(out, "按「公文」版式档校验", "看着是公文,却用到了通用字体", "微软雅黑")
# WARN, never ERROR: a unit template legitimately overrides the standard, and
# an unsatisfiable checker turns the model's repair loop into an infinite one.
assert PASS in out, out
assert "合计: 0 ERROR" in out
def test_a_corporate_document_is_not_judged_as_gongwen(tmp_path):
"""The 公文 detector must stay narrow — a weekly report in 微软雅黑 is fine."""
helpers = _docx_helpers()
doc = Document()
helpers.apply_chinese_defaults(doc, profile="modern")
helpers.add_heading_cn(doc, "本周工作进展", 1)
helpers.add_body(doc, "本周完成三项交付,下周进入联调。")
path = tmp_path / "weekly.docx"
doc.save(path)
out = run_script("bisheng-docx", "inspect_docx.py", path, "--check-only").stdout
assert "按「非公文」版式档校验" in out, out
assert "通用字体" not in out, out
assert PASS in out, out
def test_docx_render_degrades_without_libreoffice(samples, no_soffice_path):
out = run_script(
"bisheng-docx",
@@ -588,6 +766,38 @@ def test_only_the_inspect_scripts_may_print_the_stop_string(script):
assert owner or not printed, f"{script} prints {PASS!r}, which only the inspect_* script may claim: {printed}"
_CN_FACE_LITERAL = re.compile(r"微软雅黑|仿宋|小标宋|楷体|黑体|等线|宋体")
_COMPAT_CONSTANTS = {"CN_FONT", "CN_FONT_SERIF", "CN_FONT_HEADING"}
def test_docx_helpers_read_faces_from_the_profile_not_from_literals():
"""Static half of the profile fix: no helper may name a Chinese face itself.
The original defect was exactly this — ``add_heading_cn`` wrote
``CN_FONT_HEADING`` into the run, at the highest precedence there is, so a
build script could set every style correctly and still get 微软雅黑 headings.
The module-level compat constants may stay (build scripts reference them);
what may not come back is a *helper* reading one, or inlining a face name.
"""
source = (BUILTIN_SKILLS_DIR / "bisheng-docx" / "scripts" / "docx_helpers.py").read_text(encoding="utf-8")
tree = ast.parse(source)
offenders: list[str] = []
for node in ast.walk(tree):
if isinstance(node, ast.Name) and isinstance(node.ctx, ast.Load) and node.id in _COMPAT_CONSTANTS:
offenders.append(f"line {node.lineno}: reads {node.id}")
for func in [n for n in ast.walk(tree) if isinstance(n, ast.FunctionDef)]:
body = func.body[1:] if ast.get_docstring(func) else func.body
for statement in body:
for node in ast.walk(statement):
if isinstance(node, ast.Constant) and isinstance(node.value, str):
if _CN_FACE_LITERAL.search(node.value):
offenders.append(f"line {node.lineno}: {func.name}() inlines {node.value!r}")
assert not offenders, "faces must come from style_profiles, not from docx_helpers: " + "; ".join(offenders)
def test_warnings_alone_do_not_block_xlsx_delivery(tmp_path):
"""WARN must never withhold the stop string, or the loop cannot terminate.
@@ -124,11 +124,11 @@ class _Runtime:
PermissionSourceExplanation(
source_id=91,
source_version=2,
subject_type="user",
subject_id="8",
userset_relation=None,
subject_type="user_group",
subject_id="2",
userset_relation="admin",
include_children=False,
source_type="DIRECT",
source_type="USER_GROUP",
model_key="viewer",
model_level=1,
scope="LOCAL",
@@ -156,7 +156,10 @@ class _Subjects:
)
async def display_names(self, subjects):
return {("user", "8"): "Member 8"}
return {
("user", "8"): "Member 8",
("user_group", "2"): "Reviewers",
}
async def actor_projected_subjects(self, actor):
return frozenset({f"user:{actor.user_id}"})
@@ -518,7 +521,16 @@ async def test_roster_uses_bounded_sql_page_instead_of_full_explanation() -> Non
page_size=25,
)
assert result["data"][0]["subject"]["name"] == "Member 8"
assert result["data"][0]["subject"] == {
"type": "user_group",
"id": "2",
"name": "Reviewers",
}
assert result["data"][0]["source"] == {
"type": "USER_GROUP",
"include_children": False,
"userset_relation": "admin",
}
assert result["has_more"] is True
assert result["next_cursor"]
assert runtime.page_calls == [
@@ -12,10 +12,15 @@ from bisheng.tool.domain.services.executor import ToolExecutor, ToolInitializati
@pytest.mark.asyncio
async def test_preset_tool_execution_still_checks_exact_use_action(monkeypatch):
check_action = AsyncMock(return_value=True)
check_global_super = AsyncMock(return_value=True)
monkeypatch.setattr(
"bisheng.tool.domain.services.executor.check_business_action",
check_action,
)
monkeypatch.setattr(
"bisheng.utils.http_middleware._check_is_global_super",
check_global_super,
)
tool_type = SimpleNamespace(
id=10,
tenant_id=5,
@@ -27,6 +32,8 @@ async def test_preset_tool_execution_still_checks_exact_use_action(monkeypatch):
login_user = check_action.await_args.args[0]
assert login_user.user_id == 7
assert login_user.tenant_id == 5
assert login_user.is_global_super is True
check_global_super.assert_awaited_once_with(7)
assert check_action.await_args.kwargs == {
"resource_type": "tool",
"resource_id": 10,
@@ -39,6 +46,10 @@ async def test_preset_tool_execution_never_falls_back_when_fga_fails(
monkeypatch,
):
check_action = AsyncMock(side_effect=PermissionFGAUnavailableError())
monkeypatch.setattr(
"bisheng.utils.http_middleware._check_is_global_super",
AsyncMock(return_value=False),
)
monkeypatch.setattr(
"bisheng.tool.domain.services.executor.check_business_action",
check_action,
@@ -52,6 +63,9 @@ async def test_preset_tool_execution_never_falls_back_when_fga_fails(
with pytest.raises(PermissionFGAUnavailableError):
await ToolExecutor._ensure_use_permission_async(tool_type, user_id=7)
login_user = check_action.await_args.args[0]
assert login_user.is_global_super is False
@pytest.mark.asyncio
async def test_init_by_tool_ids_can_skip_unauthorized_tools(monkeypatch):
+1 -1
View File
@@ -299,7 +299,7 @@
},
"src/components/Chat/ChatView.tsx": {
"@typescript-eslint/no-explicit-any": {
"count": 28
"count": 27
},
"@typescript-eslint/no-unused-vars": {
"count": 2
@@ -77,6 +77,7 @@ export interface PermissionGrantSubject {
export interface PermissionGrantSource {
type: string;
include_children: boolean;
userset_relation?: string | null;
}
export interface PermissionGrantAssignee {
@@ -12,7 +12,7 @@ import AiChatMessages from '~/components/Chat/AiChatMessages';
import { PinnedTaskPanel } from '~/components/Linsight/Execution/PinnedTaskPanel';
import { WorkspacePanel } from '~/components/Linsight/Artifacts/WorkspacePanel';
import { useWorkspacePanel } from '~/components/Linsight/Artifacts/useWorkspacePanel';
import { type ArtifactFile, toUploadedArtifacts } from '~/components/Linsight/Artifacts/artifactUtils';
import { collectConversationWorkspaceFiles } from '~/components/Linsight/Artifacts/artifactUtils';
import { useLinsightManager } from '~/hooks/useLinsightManager';
import { userStopLinsightEvent } from '~/api/linsight';
import { SopStatus, taskModeState } from '~/store/linsight';
@@ -363,15 +363,20 @@ const ChatView = ({ id = '', index = 0, shareToken = '' }: { id?: string, index?
// 12221-40080) for the conversation's latest task turn — it tracks that turn's
// execution detail from the linsight store rather than scrolling away in the
// message stream.
const latestTaskVersionId = useMemo(() => {
for (let i = messages.length - 1; i >= 0; i--) {
const m = messages[i] as any;
if (m?.category === 'task' && m?.linsightSessionVersionId) {
return m.linsightSessionVersionId as string;
const taskVersionIds = useMemo(() => {
const ids: string[] = [];
const seen = new Set<string>();
for (const message of messages) {
const m = message as any;
const versionId = m?.category === 'task' ? m?.linsightSessionVersionId : '';
if (versionId && !seen.has(versionId)) {
seen.add(versionId);
ids.push(versionId);
}
}
return '';
return ids;
}, [messages]);
const latestTaskVersionId = taskVersionIds[taskVersionIds.length - 1] || '';
// F035: a conversation that already holds a task turn IS a task-mode
// conversation. Guard on the message's own conversationId so the stale
@@ -451,10 +456,11 @@ const ChatView = ({ id = '', index = 0, shareToken = '' }: { id?: string, index?
// F035: workspace drawer for the chat-embedded task mode. Lifted to ChatView
// (the task turn renders inline per message, but the entry button lives in the
// shared header) and bound to the LATEST task turn. Shows uploaded sources +
// generated deliverables. The drawer only opens on the header button — no
// auto-expand (the entry icon appearing is enough).
const { getLinsight, updateLinsight } = useLinsightManager();
// shared header). Execution state remains bound to the LATEST task turn, while
// the file list accumulates formal artifacts from every task turn because the
// workspace itself belongs to the conversation. The drawer only opens on the
// header button — no auto-expand (the entry icon appearing is enough).
const { getLinsight, updateLinsight, linsightMap } = useLinsightManager();
const taskArtifacts = useWorkspacePanel(latestTaskVersionId);
// F035: enter/exit animation for the fullscreen workspace overlay. The overlay
@@ -559,10 +565,8 @@ const ChatView = ({ id = '', index = 0, shareToken = '' }: { id?: string, index?
const taskLinsight = latestTaskVersionId ? getLinsight(latestTaskVersionId) : null;
const taskWorkspaceFiles = useMemo(() => {
const uploaded = toUploadedArtifacts(taskLinsight?.files as any[]);
const generated = (taskLinsight?.file_list as ArtifactFile[]) || [];
return [...uploaded, ...generated];
}, [taskLinsight?.files, taskLinsight?.file_list]);
return collectConversationWorkspaceFiles(taskVersionIds.map((versionId) => linsightMap.get(versionId)));
}, [taskVersionIds, linsightMap]);
// F035: while the latest task round is in a non-terminal state (generating /
// running / queued), the input stays editable but the send button is disabled
@@ -116,6 +116,55 @@ export function toUploadedArtifacts(files: any[] | undefined): ArtifactFile[] {
});
}
export interface WorkspaceArtifactSnapshot {
files?: Parameters<typeof toUploadedArtifacts>[0];
file_list?: ArtifactFile[];
history?: Array<{ file_list?: ArtifactFile[] }>;
}
function getWorkspaceArtifactKey(file: ArtifactFile): string {
const source = file.source || 'output';
const normalizedPath = decodeSafe(file.file_path || '').replace(/\\/g, '/');
const workspacePath = normalizedPath.match(/(?:^|\/)(output|uploads)\/(.+)$/i);
const identity = workspacePath
? `${workspacePath[1].toLowerCase()}/${workspacePath[2]}`
: decodeSafe(file.file_name || '').toLowerCase();
return `${source}:${identity}`;
}
/**
* Build the conversation-scoped workspace list from per-round result snapshots.
*
* `final_files` describes what one task round delivered; it is not the workspace
* itself. A later greeting can legitimately have an empty `final_files` array
* while still inheriting every prior deliverable. Accumulate snapshots in
* chronological order so the drawer keeps those files, with a newer artifact
* replacing an older entry at the same workspace path.
*/
export function collectConversationWorkspaceFiles(
snapshots: Array<WorkspaceArtifactSnapshot | null | undefined>,
): ArtifactFile[] {
const collected = new Map<string, ArtifactFile>();
const addFiles = (files: ArtifactFile[] | undefined) => {
for (const file of files || []) {
if (!file?.file_name || !file?.file_url) continue;
collected.set(getWorkspaceArtifactKey(file), file);
}
};
for (const snapshot of snapshots) {
if (!snapshot) continue;
addFiles(toUploadedArtifacts(snapshot.files));
for (const round of snapshot.history || []) {
addFiles(round.file_list);
}
addFiles(snapshot.file_list);
}
return Array.from(collected.values());
}
/**
* Resolve a MinIO share url into a same-origin fetchable path.
*
@@ -0,0 +1,67 @@
/** @jest-environment node */
import {
type ArtifactFile,
collectConversationWorkspaceFiles,
} from './artifactUtils';
const makeArtifact = (overrides: Partial<ArtifactFile>): ArtifactFile => ({
file_id: overrides.file_id || Math.random().toString(36).slice(2),
file_name: overrides.file_name || 'artifact.md',
file_url: overrides.file_url || 'linsight/final_result/version/artifact.md',
...overrides,
});
describe('collectConversationWorkspaceFiles', () => {
it('keeps prior deliverables when the latest round has no final files', () => {
const report = makeArtifact({ file_name: 'report.md', file_url: 'final/old/report.md' });
expect(collectConversationWorkspaceFiles([
{ file_list: [report] },
{ file_list: [] },
])).toEqual([report]);
});
it('lets a newer round replace the same workspace path', () => {
const oldReport = makeArtifact({
file_name: 'report.md',
file_url: 'final/old/report.md',
file_path: '/cache/old/output/report.md',
});
const newReport = makeArtifact({
file_name: 'report.md',
file_url: 'final/new/report.md',
file_path: '/cache/new/output/report.md',
});
expect(collectConversationWorkspaceFiles([
{ file_list: [oldReport] },
{ file_list: [newReport] },
])).toEqual([newReport]);
});
it('includes completed history rounds when multiple rounds share one version', () => {
const first = makeArtifact({ file_name: 'round-one.md' });
const second = makeArtifact({ file_name: 'round-two.docx' });
expect(collectConversationWorkspaceFiles([{
history: [{ file_list: [first] }],
file_list: [second],
}])).toEqual([first, second]);
});
it('keeps uploaded sources separate from generated files with the same name', () => {
const output = makeArtifact({ file_name: 'requirements.docx', source: 'output' });
const files = collectConversationWorkspaceFiles([{
files: [{
file_id: 'upload-1',
file_name: 'requirements.docx',
markdown_file_path: 'workspace/version/uploads/requirements.md',
}],
file_list: [output],
}]);
expect(files).toHaveLength(2);
expect(files.map((file) => file.source)).toEqual(['upload', 'output']);
});
});
@@ -65,7 +65,22 @@ jest.mock("./SubjectSearchDepartment", () => ({
}));
jest.mock("./SubjectSearchUserGroup", () => ({
SubjectSearchUserGroup: () => <div>group picker</div>,
SubjectSearchUserGroup: ({
onChange,
}: {
onChange: (
subjects: Array<{ type: "user_group"; id: number; name: string }>,
) => void;
}) => (
<button
type="button"
onClick={() =>
onChange([{ type: "user_group", id: 77, name: "Reviewers" }])
}
>
select group
</button>
),
}));
const mockedGetModels = getGrantablePermissionModels as jest.MockedFunction<
@@ -256,6 +271,37 @@ describe("F048 Client PermissionGrantTab", () => {
).toBeDisabled();
});
it("shows an existing user-group administrator grant", async () => {
render(
<PermissionGrantTab
resourceType="channel"
resourceId="channel-1"
context={context}
assignees={[
existing("77", {
subject: {
type: "user_group",
id: "77",
name: "Reviewers",
},
source: {
type: "USER_GROUP",
include_children: false,
userset_relation: "admin",
},
}),
]}
onSuccess={jest.fn()}
/>,
);
expect(
await screen.findByText(
/f048_permission\.source\.user_group_admin/,
),
).toBeInTheDocument();
});
it("keeps an inactive existing row read-only when it is not grantable", async () => {
render(
<PermissionGrantTab
@@ -427,6 +473,47 @@ describe("F048 Client PermissionGrantTab", () => {
);
});
it("does not expose or submit a user-group administrator grant", async () => {
render(
<PermissionGrantTab
resourceType="channel"
resourceId="channel-1"
context={context}
onSuccess={jest.fn()}
/>,
);
await screen.findByLabelText("f048_permission.grant.add_model");
fireEvent.click(
screen.getByRole("button", {
name: "f048_permission.subject.user_group",
}),
);
fireEvent.click(screen.getByRole("button", { name: "select group" }));
fireEvent.click(
screen.getByRole("button", { name: "f048_permission.grant.add" }),
);
fireEvent.click(
screen.getByRole("button", { name: "f048_permission.grant.submit" }),
);
await waitFor(() => expect(mockedMutate).toHaveBeenCalledTimes(1));
expect(mockedMutate.mock.calls[0][2]).toEqual(
expect.objectContaining({
changes: [
{
op: "ADD",
model_key: "viewer",
subject: {
type: "user_group",
id: "77",
},
},
],
}),
);
});
it("fails closed on a version conflict", async () => {
mockedMutate.mockRejectedValueOnce(new Error("version conflict"));
@@ -17,6 +17,7 @@ import type {
import { Button, Checkbox } from "~/components/ui";
import { useLocalize } from "~/hooks";
import { canMutatePermissionAssignee } from "./assigneePolicy";
import { SourceBadge } from "./SourceBadge";
import { SubjectSearchDepartment } from "./SubjectSearchDepartment";
import { SubjectSearchUser } from "./SubjectSearchUser";
import { SubjectSearchUserGroup } from "./SubjectSearchUserGroup";
@@ -398,15 +399,15 @@ export function PermissionGrantTab({
{assignee.subject.name ||
`${assignee.subject.type}:${assignee.subject.id}`}
</p>
<p className="mt-1 text-xs text-[#818181]">
{assignee.source.type}
<div className="mt-1 flex flex-wrap items-center gap-1 text-xs text-[#818181]">
<SourceBadge source={assignee.source} />
{assignee.protected && (
<span className="ml-2 inline-flex items-center gap-1">
<LockKeyhole aria-hidden="true" className="size-3" />
{localize("f048_permission.roster.protected")}
</span>
)}
</p>
</div>
</div>
<select
aria-label={`${localize(
@@ -31,6 +31,11 @@ export function SourceBadge({ source }: SourceBadgeProps) {
· {localize("f048_permission.source.include_children")}
</span>
)}
{source.userset_relation === "admin" && (
<span className="font-normal">
· {localize("f048_permission.source.user_group_admin")}
</span>
)}
</span>
);
}
@@ -2170,6 +2170,7 @@
"shared": "Shared Source",
"system": "System Source",
"include_children": "Include sub-departments",
"user_group_admin": "User group administrators",
"snapshot_from_parent": "Copied on inherit",
"space_membership": "Space member",
"channel_membership": "Channel member",
@@ -2093,6 +2093,7 @@
"shared": "共有元",
"system": "システム元",
"include_children": "サブ部門を含む",
"user_group_admin": "ユーザーグループ管理者",
"snapshot_from_parent": "継承時にコピー",
"space_membership": "スペースメンバー",
"channel_membership": "チャンネルメンバー",
@@ -2099,6 +2099,7 @@
"shared": "共享来源",
"system": "系统来源",
"include_children": "包含子部门",
"user_group_admin": "用户组管理员",
"snapshot_from_parent": "继承时复制",
"space_membership": "空间成员",
"channel_membership": "频道成员",
@@ -213,6 +213,7 @@
"shared": "Shared Source",
"system": "System Source",
"includeChildren": "Include sub-departments",
"userGroupAdmin": "User group administrators",
"snapshot_from_parent": "Copied on inherit",
"space_membership": "Space member",
"channel_membership": "Channel member",
@@ -213,6 +213,7 @@
"shared": "共有元",
"system": "システム元",
"includeChildren": "サブ部門を含む",
"userGroupAdmin": "ユーザーグループ管理者",
"snapshot_from_parent": "継承時にコピー",
"space_membership": "スペースメンバー",
"channel_membership": "チャンネルメンバー",
@@ -213,6 +213,7 @@
"shared": "共享来源",
"system": "系统来源",
"includeChildren": "包含子部门",
"userGroupAdmin": "用户组管理员",
"snapshot_from_parent": "继承时复制",
"space_membership": "空间成员",
"channel_membership": "频道成员",
@@ -29,6 +29,9 @@ export function SourceBadge({ source }: SourceBadgeProps) {
{source.include_children && (
<span className="font-normal">· {t("source.includeChildren")}</span>
)}
{source.userset_relation === "admin" && (
<span className="font-normal">· {t("source.userGroupAdmin")}</span>
)}
</span>
)
}
@@ -169,6 +169,7 @@ export interface PermissionGrantSubject {
export interface PermissionGrantSource {
type: string
include_children: boolean
userset_relation?: string | null
}
export interface PermissionGrantAssignee {
@@ -36,7 +36,23 @@ vi.mock("@/components/bs-comp/permission/SubjectSearchDepartment", () => ({
}))
vi.mock("@/components/bs-comp/permission/SubjectSearchUserGroup", () => ({
SubjectSearchUserGroup: () => <div>group-picker</div>,
SubjectSearchUserGroup: (props: {
value: Array<{ type: "user_group"; id: number; name: string }>
onChange: (
value: Array<{ type: "user_group"; id: number; name: string }>,
) => void
}) => (
<button
type="button"
onClick={() =>
props.onChange([
{ type: "user_group", id: 77, name: "Reviewers" },
])
}
>
select-group
</button>
),
}))
const context: ResourcePermissionContext = {
@@ -209,6 +225,71 @@ describe("F048 PermissionGrantTab", () => {
expect(screen.getByRole("button", { name: "grant.remove.42" })).toBeDisabled()
})
it("shows an existing user-group administrator grant", async () => {
render(
<PermissionGrantTab
resourceType="workflow"
resourceId="flow-1"
context={context}
assignees={[
{
...editableAssignee,
subject: {
type: "user_group",
id: "77",
name: "Reviewers",
},
source: {
type: "USER_GROUP",
include_children: false,
userset_relation: "admin",
},
},
]}
onSuccess={vi.fn()}
/>,
)
expect(
await screen.findByText(/source\.userGroupAdmin/),
).toBeInTheDocument()
})
it("does not expose or submit a user-group administrator grant", async () => {
render(
<PermissionGrantTab
resourceType="workflow"
resourceId="flow-1"
context={context}
onSuccess={vi.fn()}
/>,
)
await screen.findByLabelText("grant.addModel")
fireEvent.click(screen.getByRole("button", { name: "subject.userGroup" }))
fireEvent.click(screen.getByRole("button", { name: "select-group" }))
fireEvent.click(screen.getByRole("button", { name: "grant.submit" }))
await waitFor(() => {
expect(mutateResourceGrantsApi).toHaveBeenCalledWith(
"workflow",
"flow-1",
expect.objectContaining({
changes: [
{
op: "ADD",
model_key: "viewer",
subject: {
type: "user_group",
id: "77",
},
},
],
}),
)
})
})
it("removes exactly one editable assignee and reports stale conflicts", async () => {
vi.mocked(mutateResourceGrantsApi).mockRejectedValueOnce(
new Error("stale resource version"),
+2 -2
View File
@@ -1,7 +1,7 @@
#! /bin/bash
old_version="2.6.0-fix"
new_version="2.6.0-fix2"
old_version="2.6.0-fix2"
new_version="3.0.0-beta1"
sed -i.bak "s/$old_version/$new_version/g" ./docker/docker-compose.yml
sed -i.bak "s/$old_version/$new_version/g" ./src/backend/pyproject.toml
sed -i.bak "s/$old_version/$new_version/g" ./src/backend/bisheng/__init__.py