gongwen-skill 2.4.0 → 2.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -4,6 +4,31 @@
4
4
  Licensed under the MIT License. See the LICENSE file for details.
5
5
  -->
6
6
 
7
+ ## v2.6.0 (2026-09-02)
8
+
9
+ ### Added
10
+ - **check-update 判定渠道精简为 PyPI + GitHub 双权威源**:对齐 EasyTier 单权威源 + 加速代理设计,GitCode/AtomGit 不再参与版本判定(零增量镜像),降为 GitHub 不可达时的国内拉取镜像提示;--json 输出新增 mirrors 字段
11
+ - **GitHub 国内访问优化**:并发查询 + 分级超时(PyPI 10s / GitHub 8s 快速降级),GitHub 不可达时提示 GitHub520 hosts 方案与国内镜像地址
12
+ - **发布流程修复(三平台 Release 统一编号)**:ci.yml 新增 release job,tag 推送自动创建 GitHub Release(编号 = tag);RELEASE.md 发布流程新增 GitHub Release 自动创建 + GitCode/AtomGit Release 同步(编号与 tag 统一)
13
+ - **非必要文件不同步原则写入发布流程**:RELEASE.md 明确临时/调试文件(tmp_*、_gw_* 等)不提交、不入库、不同步三仓库
14
+
15
+ ### Changed
16
+ - check-update 头部与末尾文案同步为 PyPI + GitHub 判定;--json 的 channels 字段只含 PyPI/GitHub
17
+ - README / RELEASE.md 文档同步更新(判定渠道、镜像提示、Release 统一编号说明)
18
+
19
+ ### Fixed
20
+ - 历史遗留:v2.0.0~v2.5.0 在 GitHub/GitCode 从未创建 Release 对象(仅 tag),已补建且编号与 tag 统一
21
+ ## v2.5.0 (2026-09-01)
22
+
23
+ ### Added
24
+ - **样式学习引擎修复**:style-learn 多 section 文档只学主文档(不再把横向附件页当正文页);页边距 mm→cm 换算修复(26.9mm 不再写成 26.9cm);角色判定增强(识别红头版头/发文字号/整段日期/落款机关名);解析 styles.xml 样式链(basedOn + docDefaults),run 未指定 eastAsia 时继承段落样式字体,修复落款误学 Times New Roman
25
+ - **模板规则断链打通**:manager 新增 _sync_style_expected,将配置段权威样式值(doc_title/body/signature/date/table/page_setup 等)同步到 CHK expected 与 FIX value(标量覆盖 + 复合 dict 保守合并),使 style-learn 模板 / user_rules / config-overrides 覆盖配置段后 check/optimize 期望值自动跟随
26
+
27
+ ### Fixed
28
+ - **style-learn 学错横向附件页**:多 section 文档(正文纵向 + 附件横向)此前学到 297×210mm 横向纸张与附件页边距
29
+ - **样式继承未解析**:run 无显式 eastAsia 时直接回退 ascii=Times New Roman,现按段落样式链(basedOn → docDefaults)解析正确中文字体
30
+ - **模板样式不生效**:style-learn 生成的模板仅覆盖配置段,但 CHK expected/FIX value 硬编码在规则 YAML,导致 -t 模板时检查/修复仍用默认值——现已打通
31
+
7
32
  ## v2.4.0 (2026-09-01)
8
33
 
9
34
  ### Added
package/README.md CHANGED
@@ -45,7 +45,7 @@ Licensed under the MIT License. See the LICENSE file for details.
45
45
  | 🔍 审稿生成 | `review` | 按五角色审稿机制生成审稿意见 |
46
46
  | 🧩 完整审校 | `full-review` | 修订+批注联合命令(句子级差异修订 + 分类批注) |
47
47
  | 🎨 样式学习 | `style-learn` / `style-list` | 上传标准文档学习 Run/段落/页面三级样式(字体/字号/字间距/行距/缩进/页边距),生成命名模板持久化,后续用 `optimize -t 模板名` 套用 |
48
- | 🔄 版本自检 | `check-update` | 多渠道版本自检(GitHub/GitCode/AtomGit 三仓库比对取最新) |
48
+ | 🔄 版本自检 | `check-update` | 版本自检(PyPI + GitHub 双判定渠道,GitCode/AtomGit 作国内镜像提示) |
49
49
  | 🩺 自我诊断 | `doctor` / `repair` | 全面诊断 22 项(Python/依赖/版本一致性/字体/DSH 文件/DSH 技能 frontmatter/代码风格),自动修复常见问题 |
50
50
  | 🕵️ 文档审计 | `audit` | 检查删除线/加粗/AI 声明等痕迹 |
51
51
  | 🤝 会话交接 | `handoff` | 跨会话上下文传递(`--list` / `--latest` / Agent 长任务收尾必写) |
@@ -150,7 +150,7 @@ python -m gongwen footer 红头公文.docx --cc "各单位" --printer "XX办公
150
150
  # 注入页码(Word PAGE 域动态页码)
151
151
  python -m gongwen pagenum 红头公文.docx --alignment right
152
152
 
153
- # 多渠道版本自检
153
+ # 版本自检(PyPI + GitHub 双判定渠道)
154
154
  python -m gongwen check-update
155
155
 
156
156
  # 安装公文标准字体(方正小标宋简体/仿宋_GB2312/楷体_GB2312)
@@ -317,7 +317,7 @@ python -m gongwen rule-list notice
317
317
 
318
318
  Agent 加载 skill 后**必须执行版本追新自检**,确保使用最新版本:
319
319
 
320
- 1. **多渠道远程自检**(首选):`python -m gongwen check-update`——自动查询 GitHub/GitCode/AtomGit 三仓库最新 tag,取最高版本比对本地;任一渠道可达即不遗漏,全部不可达时明确告知"版本自检跳过"
320
+ 1. **远程自检**(首选):`python -m gongwen check-update`——以 **PyPI(发布源)+ GitHub(代码权威源)双判定渠道**并发查询,取最高版本比对本地;任一判定渠道可达即不遗漏,全部不可达时明确告知"版本自检跳过"。GitHub 为海外渠道(国内常超时)采用短超时快速降级;GitHub 不可达时自动提示国内代码镜像(GitCode/AtomGit,与 GitHub 同源 tag)与 GitHub520 hosts 加速方案
321
321
  2. **本地 git tag 对比**(补充):对 skill 安装目录执行 `git -C "<skill安装目录>" describe --tags --abbrev=0`;若安装目录不在 git 管理下,应告知用户"无法执行版本对比,建议手动检查 GitHub 更新"
322
322
  3. **落后则警告**:发现本地版本落后于最新 tag 时,**必须在执行前警告用户**并提示更新(`cd <gongwen-skill目录> && git pull && git fetch --tags`),不得静默使用旧版本
323
323
 
@@ -337,7 +337,7 @@ DSH 采用 **Cordis 模块化微内核架构**:技能体系基于本地文件
337
337
  git clone https://github.com/linhut/gongwen-skill.git
338
338
  cd gongwen-skill
339
339
  pip install -r requirements.txt # 或 pip install gongwen-skill(已上 PyPI)
340
- python -m gongwen --version # 检验:gongwen-skill v2.4.0
340
+ python -m gongwen --version # 检验:gongwen-skill v2.6.0
341
341
  ```
342
342
 
343
343
  ### 方式一:作为 DSH Skill 注册(基于本地文件系统)
@@ -393,7 +393,7 @@ pnpm add -w gongwen-skill
393
393
  "dependencies": {
394
394
  "@deepseek-ai/dsh-base": "...",
395
395
  "@deepseek-ai/dsh-web-app": "...",
396
- "gongwen-skill": "^2.4.0"
396
+ "gongwen-skill": "^2.6.0"
397
397
  },
398
398
  "dsh": {
399
399
  "profile": {
@@ -448,9 +448,9 @@ dsh --profile web
448
448
  | CLI 独立可执行(`python -m gongwen <命令>`) | ✅ |
449
449
  | PyPI 上架(`pip install gongwen-skill`) | ✅ |
450
450
  | 零外部运行时依赖(仅 python-docx/pydantic/pyyaml) | ✅ |
451
- | DSH 配置化排版参数(页边距/行距/字体/默认模板版本) | ✅ v2.4.0+ |
451
+ | DSH 配置化排版参数(页边距/行距/字体/默认模板版本) | ✅ v2.6.0+ |
452
452
 
453
- ### DSH 插件配置化(v2.4.0+)
453
+ ### DSH 插件配置化(v2.6.0+)
454
454
 
455
455
  DSH 插件支持通过配置文件管理排版参数,Agent 调用时自动注入,纯 CLI 用户不受影响。
456
456
 
@@ -574,7 +574,7 @@ pip install -r requirements.txt
574
574
  用户:帮我优化这份会议通知的第二章节措辞
575
575
 
576
576
  Agent:📋 合规自检报告
577
- Skill 版本: v2.4.0(多渠道自检已确认最新)
577
+ Skill 版本: v2.6.0(版本自检已确认最新)
578
578
  路径判定: B(内容优化)
579
579
  依据: 用户指定了已有文档,且要求"优化措辞"
580
580
  命令调用: 1. python -m gongwen optimize-content 会议通知.docx --changes changes.json --apply --paragraphs "5-8"
package/dsh/index.js CHANGED
@@ -1,4 +1,4 @@
1
- // 公文全流程处理工具 - DSH plugin bridge (gongwen-skill, v2.4.0+)
1
+ // 公文全流程处理工具 - DSH plugin bridge (gongwen-skill, v2.6.0+)
2
2
  // (c) 2026 Jose AI (https://www.linhut.cn)
3
3
  // https://github.com/linhut/gongwen-skill
4
4
  // Licensed under the MIT License. See the LICENSE file for details.
@@ -80,6 +80,8 @@ def load_rules_merged(doc_type: str = "") -> dict[str, Any]:
80
80
  # Merge fix_rules and check_rules as distinct lists, not overwritten
81
81
  merged.setdefault("fix_rules", [])
82
82
  merged.setdefault("check_rules", [])
83
+ # 配置段 → 规则期望值同步:使模板/用户规则覆盖配置段后 check/optimize 自动跟随
84
+ _sync_style_expected(merged)
83
85
  return merged
84
86
 
85
87
 
@@ -117,6 +119,93 @@ def _deep_merge(base: dict, overlay: dict) -> None:
117
119
  base[key] = copy.deepcopy(val)
118
120
 
119
121
 
122
+ # 字段前缀 → 配置段键(配置段是样式的权威定义)
123
+ _FIELD_CFG_SECTION = {
124
+ 'title': 'doc_title', 'doc_title': 'doc_title', 'heading_0': 'doc_title',
125
+ 'heading_1': 'heading_1', 'heading_2': 'heading_2',
126
+ 'heading_3': 'heading_3', 'heading_4': 'heading_4',
127
+ 'body': 'body', 'signature': 'signature', 'date': 'date',
128
+ 'meeting_date': 'meeting_date', 'salutation': 'salutation',
129
+ 'introduction': 'introduction', 'transition': 'transition',
130
+ }
131
+ # 可直接同名映射的样式子键
132
+ _STYLE_KEYS = {'font', 'size', 'line_spacing', 'align', 'bold',
133
+ 'first_line_indent', 'fill'}
134
+
135
+
136
+ def _resolve_style_config(rules: dict[str, Any], field: str):
137
+ """按 field 路径取配置段的权威样式值;无法解析返回 None。
138
+
139
+ 样式配置段(doc_title/body/signature/date/heading_*/table/page_setup)
140
+ 是样式的权威定义;check_rules.expected 与 fix_rules.value 默认是它的快照副本。
141
+ 模板/用户规则覆盖配置段后,此函数负责把覆盖值回填到检查/修复规则。
142
+ """
143
+ if not field or '.' not in field:
144
+ return None
145
+ prefix, sub = field.split('.', 1)
146
+
147
+ # table.* / page_setup.* 直接按路径取(table.header.font → table.header.font)
148
+ if prefix in ('table', 'page_setup'):
149
+ node = rules
150
+ for part in field.split('.'):
151
+ if not isinstance(node, dict) or part not in node:
152
+ return None
153
+ node = node[part]
154
+ if isinstance(node, (dict, list)):
155
+ return None
156
+ return node
157
+
158
+ section = _FIELD_CFG_SECTION.get(prefix)
159
+ if section is None or sub not in _STYLE_KEYS:
160
+ return None
161
+ cfg = rules.get(section)
162
+ if not isinstance(cfg, dict) or sub not in cfg:
163
+ return None
164
+ val = cfg[sub]
165
+ if isinstance(val, (dict, list)):
166
+ return None
167
+ return val
168
+
169
+
170
+ def _sync_style_expected(rules: dict[str, Any]) -> None:
171
+ """把配置段的权威样式值同步到 CHK expected 与 FIX value(就地修改)。
172
+
173
+ 使 style-learn 模板 / user_rules / config-overrides 覆盖配置段后,
174
+ check 与 optimize 的期望值自动跟随,而默认配置下幂等(配置段值==快照值)。
175
+ """
176
+ # 1) 同步 check_rules.expected
177
+ for rule in rules.get('check_rules', []):
178
+ resolved = _resolve_style_config(rules, rule.get('field', ''))
179
+ if resolved is not None:
180
+ rule['expected'] = resolved
181
+
182
+ # 2) 同步 fix_rules.value(经 ref_check 定位 CHK → field → 配置值)
183
+ # 保守策略:标量 value 可直接覆盖;复合 dict value(一次修复多属性)仅合并
184
+ # 格式一致的键(alignment/bold/fill),避免破坏 FIX-C013/C041 等复合修复。
185
+ chk_by_id = {r.get('id'): r for r in rules.get('check_rules', []) if r.get('id')}
186
+ for rule in rules.get('fix_rules', []):
187
+ ref = rule.get('ref_check')
188
+ if not ref or ref not in chk_by_id:
189
+ continue
190
+ resolved = _resolve_style_config(rules, chk_by_id[ref].get('field', ''))
191
+ if resolved is None or rule.get('value') is None:
192
+ continue
193
+ value = rule['value']
194
+ # 样式配置子键(如 body.align → align)→ FIX value dict 中的键名映射
195
+ _FIX_DICT_KEY = {
196
+ 'align': 'alignment',
197
+ 'bold': 'bold',
198
+ 'fill': 'fill',
199
+ }
200
+ if isinstance(value, dict):
201
+ sub_field = chk_by_id[ref].get('field', '').split('.', 1)[-1]
202
+ dk = _FIX_DICT_KEY.get(sub_field)
203
+ if dk and dk in value and not isinstance(resolved, (dict, list)):
204
+ value[dk] = resolved
205
+ else:
206
+ rule['value'] = resolved
207
+
208
+
120
209
  def _dedup_extend(base_list: list, new_items: list, dedup_key) -> None:
121
210
  """Extend base_list with new_items, replacing duplicates by dedup_key."""
122
211
  # P2-25 修复:dedup_key 返回 None 时给出 warning,避免静默追加重复项
@@ -327,6 +416,8 @@ def apply_config_overrides(rules: dict[str, Any], overrides: dict[str, Any]) ->
327
416
  if not overrides or not isinstance(overrides, dict):
328
417
  return rules
329
418
  _deep_merge(rules, copy.deepcopy(overrides))
419
+ # 配置段 → 规则期望值同步:config-overrides 覆盖后 check/optimize 自动跟随
420
+ _sync_style_expected(rules)
330
421
  return rules
331
422
 
332
423
 
@@ -110,7 +110,9 @@ def _extract_run_style(rPr) -> Dict[str, Any]:
110
110
  v = rFonts.get(f'{{{W}}}{attr}')
111
111
  if v:
112
112
  style[f'font_{attr}'] = v
113
- style['font'] = (rFonts.get(f'{{{W}}}eastAsia') or rFonts.get(f'{{{W}}}ascii') or '')
113
+ # font 仅取 eastAsia(中文字体);ascii 仅用于西文 run,
114
+ # 避免中文 run 无 eastAsia 时误学成 Times New Roman 等默认拉丁字体
115
+ style['font'] = (rFonts.get(f'{{{W}}}eastAsia') or '')
114
116
 
115
117
  sz = rPr.find(f'{{{W}}}sz')
116
118
  if sz is not None:
@@ -181,6 +183,64 @@ def _extract_para_style(pPr) -> Dict[str, Any]:
181
183
  return style
182
184
 
183
185
 
186
+ def _load_style_fonts(docx_path: str | Path) -> Dict[str, Dict[str, Any]]:
187
+ """解析 styles.xml,构建 styleId → 解析后的中文字体/字号映射。
188
+
189
+ 沿 basedOn 样式链向上解析(含环保护),最终回退到 docDefaults,
190
+ 供 run 未显式指定 eastAsia 字体时继承段落样式的中文字体。
191
+ """
192
+ fonts: Dict[str, Dict[str, Any]] = {}
193
+ with zipfile.ZipFile(docx_path) as z:
194
+ if 'word/styles.xml' not in z.namelist():
195
+ return fonts
196
+ styles = etree.fromstring(z.read('word/styles.xml'))
197
+
198
+ raw: Dict[str, Dict[str, Any]] = {}
199
+ for st in styles.findall(f'{{{W}}}style'):
200
+ sid = st.get(f'{{{W}}}styleId')
201
+ if not sid:
202
+ continue
203
+ rPr = st.find(f'{{{W}}}rPr')
204
+ rf = rPr.find(f'{{{W}}}rFonts') if rPr is not None else None
205
+ sz = rPr.find(f'{{{W}}}sz') if rPr is not None else None
206
+ bo = st.find(f'{{{W}}}basedOn')
207
+ sz_val = sz.get(f'{{{W}}}val') if sz is not None else None
208
+ raw[sid] = {
209
+ 'eastAsia': rf.get(f'{{{W}}}eastAsia') if rf is not None else None,
210
+ 'size_pt': int(sz_val) / 2.0 if sz_val else None,
211
+ 'basedOn': bo.get(f'{{{W}}}val') if bo is not None else None,
212
+ }
213
+
214
+ # docDefaults(文档默认字体)
215
+ defaults: Dict[str, Any] = {'eastAsia': None, 'size_pt': None}
216
+ dd = styles.find(f'{{{W}}}docDefaults')
217
+ if dd is not None:
218
+ rf = dd.find(f'{{{W}}}rPrDefault/{{{W}}}rPr/{{{W}}}rFonts')
219
+ if rf is not None:
220
+ defaults['eastAsia'] = rf.get(f'{{{W}}}eastAsia')
221
+ sz = dd.find(f'{{{W}}}rPrDefault/{{{W}}}rPr/{{{W}}}sz')
222
+ sz_val = sz.get(f'{{{W}}}val') if sz is not None else None
223
+ if sz_val:
224
+ defaults['size_pt'] = int(sz_val) / 2.0
225
+
226
+ def resolve(sid: str, seen: frozenset) -> Dict[str, Any]:
227
+ if sid in fonts:
228
+ return fonts[sid]
229
+ if sid in seen or sid not in raw:
230
+ return dict(defaults)
231
+ cur = raw[sid]
232
+ parent = resolve(cur.get('basedOn') or '', seen | {sid}) if cur.get('basedOn') else dict(defaults)
233
+ return {
234
+ 'eastAsia': cur.get('eastAsia') or parent.get('eastAsia'),
235
+ 'size_pt': cur.get('size_pt') if cur.get('size_pt') is not None else parent.get('size_pt'),
236
+ }
237
+
238
+ for sid in raw:
239
+ if sid not in fonts:
240
+ fonts[sid] = resolve(sid, frozenset())
241
+ return fonts
242
+
243
+
184
244
  # ---------------------------------------------------------------------------
185
245
  # 主学习流程
186
246
  # ---------------------------------------------------------------------------
@@ -199,8 +259,30 @@ def learn_style_profile(docx_path: str | Path) -> StyleProfile:
199
259
 
200
260
  profile = StyleProfile()
201
261
 
262
+ # 加载 styles.xml 样式链字体映射(供 run 未显式指定 eastAsia 时继承段落样式字体)
263
+ style_fonts = _load_style_fonts(docx_path)
264
+
202
265
  # ---- 页面设置 ----
203
- sectPr = body.find(f'{{{W}}}sectPr')
266
+ def _find_main_sect_pr(body_el) -> Optional[etree._Element]:
267
+ """定位主文档(第一节)的 sectPr。
268
+
269
+ 多 section 文档(如正文纵向 + 附件横向)第一节的 sectPr 内嵌于
270
+ 首个分节符段落 pPr;单 section 文档即 body 末尾 sectPr。
271
+ 避免用 body.find() 只能取到最后一节(如横向附件页)而学错纸张/边距。
272
+ """
273
+ for child in body_el:
274
+ tag = etree.QName(child.tag).localname if child.tag else ''
275
+ if tag == 'p':
276
+ pPr = child.find(f'{{{W}}}pPr')
277
+ if pPr is not None:
278
+ s = pPr.find(f'{{{W}}}sectPr')
279
+ if s is not None:
280
+ return s
281
+ elif tag == 'sectPr':
282
+ return child
283
+ return body_el.find(f'{{{W}}}sectPr')
284
+
285
+ sectPr = _find_main_sect_pr(body)
204
286
  if sectPr is not None:
205
287
  pgSz = sectPr.find(f'{{{W}}}pgSz')
206
288
  if pgSz is not None:
@@ -232,12 +314,33 @@ def learn_style_profile(docx_path: str | Path) -> StyleProfile:
232
314
  if not texts:
233
315
  continue
234
316
  pPr = p.find(f'{{{W}}}pPr')
317
+ # 段落样式 id(用于 run 无显式 eastAsia 时的样式继承解析)
318
+ p_style_id = None
319
+ if pPr is not None:
320
+ _ps = pPr.find(f'{{{W}}}pStyle')
321
+ if _ps is not None:
322
+ p_style_id = _ps.get(f'{{{W}}}val')
323
+ para_style_font = style_fonts.get(p_style_id, {}) if p_style_id else {}
324
+
235
325
  runs = p.findall(f'{{{W}}}r')
236
326
  run_styles = []
237
327
  for r in runs:
328
+ # 跳过无文本或纯空白 run(如落款/署名前导空格产生的空 run),
329
+ # 避免把 Word 默认 Latin 字体(Times New Roman)误学成中文样式
330
+ r_text = ''.join(t.text or '' for t in r.iter(f'{{{W}}}t')).strip()
331
+ if not r_text:
332
+ continue
238
333
  rPr = r.find(f'{{{W}}}rPr')
239
334
  if rPr is not None:
240
335
  rs = _extract_run_style(rPr)
336
+ # 样式继承:run 未显式指定 eastAsia/字号时,从段落样式链回退
337
+ # (font 可能被 _extract_run_style 置为空串 '',需用直接赋值覆盖)
338
+ if rs.get('font_eastAsia') is None and para_style_font.get('eastAsia'):
339
+ if not rs.get('font'):
340
+ rs['font'] = para_style_font['eastAsia']
341
+ rs['font_eastAsia'] = para_style_font['eastAsia']
342
+ if rs.get('size_pt') is None and para_style_font.get('size_pt'):
343
+ rs['size_pt'] = para_style_font['size_pt']
241
344
  if rs:
242
345
  run_styles.append(rs)
243
346
 
@@ -251,7 +354,14 @@ def learn_style_profile(docx_path: str | Path) -> StyleProfile:
251
354
  size = first_run.get('size_pt', 0)
252
355
  _font = first_run.get('font', '') # noqa: F841
253
356
 
254
- if idx == 0 and align == 'center':
357
+ # 红头版头:超大字号(>=36pt)居中,跳过学习(不计入 doc_title/body)
358
+ if size >= 36 and align == 'center':
359
+ role = 'letterhead'
360
+ # 发文字号:居中、含〔〕或【】、字小、文本短(如 民筹办函〔2026〕 号),跳过学习
361
+ elif align == 'center' and ('〔' in texts or '【' in texts) \
362
+ and size <= 18 and len(texts) < 30:
363
+ role = 'letterhead'
364
+ elif idx == 0 and align == 'center':
255
365
  role = 'doc_title'
256
366
  elif size >= 20 and align == 'center':
257
367
  role = 'doc_title'
@@ -261,10 +371,15 @@ def learn_style_profile(docx_path: str | Path) -> StyleProfile:
261
371
  role = 'heading_2'
262
372
  elif re.match(r'^\d+[\.、]', texts):
263
373
  role = 'heading_3'
264
- elif align == 'right' and ('年' in texts and '月' in texts and '日' in texts):
374
+ # 成文日期:整段为 YYYY年M月D日(与对齐无关,优先于落款判定)
375
+ elif re.fullmatch(r'[\s\u3000]*\d{4}年\d{1,2}月\d{1,2}日[\s\u3000]*', texts):
265
376
  role = 'date'
266
377
  elif align == 'right':
267
378
  role = 'signature'
379
+ # 落款机关名:右对齐或居中且以机关特征词结尾
380
+ elif align in ('right', 'center') and re.search(
381
+ r'(办公室|委员会|运动会|管理局|人民政府|厅|局)$', texts.strip()):
382
+ role = 'signature'
268
383
  elif texts.endswith((':', ':')):
269
384
  role = 'recipient' # 与 parser 一致:冒号结尾为主送机关
270
385
 
@@ -341,10 +456,11 @@ def build_user_rule_yaml(profile: StyleProfile, template_name: str) -> str:
341
456
  'paper_width_mm': profile.page.get('width_mm'),
342
457
  'paper_height_mm': profile.page.get('height_mm'),
343
458
  'margins': {
344
- 'top': f"{profile.margins.get('top', 2.8)}cm",
345
- 'bottom': f"{profile.margins.get('bottom', 2.8)}cm",
346
- 'left': f"{profile.margins.get('left', 2.7)}cm",
347
- 'right': f"{profile.margins.get('right', 2.7)}cm",
459
+ # margins 值存的是 mm,此处换算为 cm(与 _common.yaml 单位一致)
460
+ 'top': f"{profile.margins.get('top', 28.0) / 10.0:.1f}cm",
461
+ 'bottom': f"{profile.margins.get('bottom', 28.0) / 10.0:.1f}cm",
462
+ 'left': f"{profile.margins.get('left', 27.0) / 10.0:.1f}cm",
463
+ 'right': f"{profile.margins.get('right', 27.0) / 10.0:.1f}cm",
348
464
  },
349
465
  }
350
466
 
@@ -7,7 +7,7 @@
7
7
  #
8
8
  # 公文全流程处理工具 - gongwen-skill Python package
9
9
 
10
- __version__ = "2.4.0"
10
+ __version__ = "2.6.0"
11
11
 
12
12
  # Re-export everything from the legacy module for backward compatibility
13
13
  # This allows: from gongwen import main, cmd_check, etc.
@@ -45,7 +45,7 @@ from gongwen.cli.helpers import (
45
45
  parse_config_overrides as _parse_config_overrides,
46
46
  load_rules_with_overrides as _load_rules_with_overrides,
47
47
  )
48
- __version__ = "2.4.0"
48
+ __version__ = "2.6.0"
49
49
  # 版本号应与 gongwen/__init__.py 保持一致,每次发版同步更新
50
50
  """
51
51
  中文公文全流程处理工具 —— 基于 GB/T 9704《党政机关公文格式》国家标准。
@@ -857,7 +857,7 @@ _STYLE_DEVIATION_HINTS = {
857
857
  # _VALID_STYLES 已迁移到 gongwen/cli/style_helpers.py
858
858
 
859
859
 
860
- # 官方镜像仓库(多渠道版本自检用)
860
+ # 官方镜像仓库(GitHub 为 check-update 判定渠道,GitCode/AtomGit 作国内镜像)
861
861
  # PyPI JSON API(无需 git,pip 用户首选渠道)
862
862
  # ---------------------------------------------------------------------------
863
863
  # 字体管理(install/list/check)
@@ -1076,8 +1076,8 @@ def main():
1076
1076
  p = sub.add_parser("style-list", help="列出所有通过 style-learn 学习的自定义样式模板")
1077
1077
  p.set_defaults(func=cmd_style_list)
1078
1078
 
1079
- # ---- 多渠道版本自检(PyPI/GitHub/GitCode/AtomGit 比对取最新) ----
1080
- p = sub.add_parser("check-update", help="多渠道版本自检:查询 PyPI/GitHub/GitCode/AtomGit 四渠道最新版本,取最高版本比对本地")
1079
+ # ---- 版本自检(PyPI + GitHub 双判定渠道,GitCode/AtomGit 作国内镜像) ----
1080
+ p = sub.add_parser("check-update", help="版本自检:PyPI + GitHub 双判定渠道比对本地版本(GitCode/AtomGit 作国内镜像提示)")
1081
1081
  p.add_argument("--json", action="store_true",
1082
1082
  help="输出 JSON 格式结果(便于 Agent 解析)")
1083
1083
  p.set_defaults(func=cmd_check_update)
@@ -38,7 +38,7 @@ TYPE_KEYWORDS = {
38
38
  "讲话稿": "speech", "主持词": "speech", "新闻稿": "news", "简报": "news",
39
39
  }
40
40
 
41
- # 官方镜像仓库(多渠道版本自检用)
41
+ # 官方镜像仓库(GitHub 为 check-update 判定渠道,GitCode/AtomGit 作国内镜像)
42
42
  REPO_MIRRORS = {
43
43
  "GitHub": "https://github.com/linhut/gongwen-skill.git",
44
44
  "GitCode": "https://gitcode.com/linhut/gongwen-skill.git",
@@ -57,11 +57,45 @@ def _latest_tag_from_remote(remote_url: str, timeout: int = 15) -> tuple[bool, s
57
57
  return False, str(e)[:120]
58
58
 
59
59
 
60
+ # 判定渠道(版本从哪来):
61
+ # - PyPI :发布源(pip 用户首选,国内有镜像)
62
+ # - GitHub :代码仓库权威源(git 用户,CI 触发源)
63
+ # GitCode/AtomGit 与 GitHub 是同一份 tag 的镜像(发布时同步 push),
64
+ # 参与版本判定零增量,不列为判定渠道;仅在 GitHub 不可达时作为
65
+ # 国内拉取/加速代理提示(参考 EasyTier「单权威源 + 加速代理」设计)。
66
+ _JUDGMENT_CHANNELS = ("PyPI", "GitHub")
67
+
68
+ # 渠道分级超时(秒):GitHub 为海外渠道,国内常超时,用短超时快速降级;
69
+ # PyPI 有国内镜像 CDN,可用较长超时。
70
+ _CHANNEL_TIMEOUTS = {
71
+ "PyPI": 10,
72
+ "GitHub": 8,
73
+ }
74
+
75
+ # 国内代码镜像(加速代理层,非判定渠道):GitHub 不可达时的 git 拉取替代。
76
+ # 从 REPO_MIRRORS 派生(排除 GitHub),避免 URL 重复维护。
77
+ _GIT_MIRRORS = {k: v for k, v in REPO_MIRRORS.items() if k != "GitHub"}
78
+
79
+
80
+ def _fetch_channel(name: str, url: str | None, timeout: int) -> tuple[str, bool, str]:
81
+ """查询单个渠道,返回 (渠道名, 是否成功, 版本或错误信息)。
82
+
83
+ 在线程池中执行;内部已捕获异常,不会向外抛出。
84
+ """
85
+ if name == "PyPI":
86
+ ok, val = _latest_version_from_pypi(timeout=timeout)
87
+ else:
88
+ ok, val = _latest_tag_from_remote(url, timeout=timeout)
89
+ return name, ok, val
90
+
91
+
60
92
  def cmd_check_update(args):
61
- """多渠道版本自检:查询 PyPI/GitHub/GitCode/AtomGit 四渠道最新版本,取最高版本比对本地。
93
+ """版本自检:以 PyPI + GitHub 双判定渠道取最高版本比对本地。
62
94
 
63
- 渠道优先级:PyPI(pip 用户首选)> GitHub > GitCode > AtomGit
64
- 全部不可达时明确告知并返回退出码 2。
95
+ 判定渠道:PyPI(发布源,pip 用户首选)+ GitHub(代码仓库权威源,CI 触发源)。
96
+ GitCode/AtomGit 与 GitHub 同步同 tag,不参与版本判定(零增量),
97
+ 仅在 GitHub 不可达时作为国内拉取镜像提示(JSON 输出于 mirrors 字段)。
98
+ 全部判定渠道不可达时明确告知并返回退出码 2。
65
99
  支持 --json 输出结构化结果,便于 Agent 解析。
66
100
  """
67
101
  import json as _json
@@ -71,36 +105,40 @@ def cmd_check_update(args):
71
105
  local_ver = __version__
72
106
 
73
107
  if not use_json:
74
- print(f"🔍 版本自检(多渠道,本地 v{local_ver})")
108
+ print(f"🔍 版本自检(PyPI + GitHub 判定,本地 v{local_ver})")
75
109
  print(f"{'─' * 50}")
76
110
 
77
111
  results: dict[str, str] = {}
78
112
  ok_count = 0
79
113
 
80
- # 渠道1:PyPI(无需 git,pip 用户首选)
81
- ok, val = _latest_version_from_pypi()
82
- if ok:
83
- results["PyPI"] = val
84
- ok_count += 1
85
- if not use_json:
86
- print(f" ✅ PyPI 最新: {val}")
87
- else:
88
- results["PyPI"] = ""
89
- if not use_json:
90
- print(f" ⚠️ PyPI 不可达: {val}")
91
-
92
- # 渠道2-4:Git 仓库
93
- for name, url in REPO_MIRRORS.items():
94
- ok, val = _latest_tag_from_remote(url)
95
- if ok:
96
- results[name] = val
97
- ok_count += 1
98
- if not use_json:
99
- print(f" ✅ {name:<8} 最新: {val}")
100
- else:
101
- results[name] = ""
102
- if not use_json:
103
- print(f" ⚠️ {name:<8} 不可达: {val}")
114
+ # 判定渠道并发查询:PyPI(发布源)+ GitHub(代码权威源)。
115
+ # 两渠道并发,总耗时 = 最坏单渠道超时(GitHub 短超时快速降级)。
116
+ from concurrent.futures import ThreadPoolExecutor
117
+ channels = [
118
+ (name, REPO_MIRRORS[name] if name in REPO_MIRRORS else None)
119
+ for name in _JUDGMENT_CHANNELS
120
+ ]
121
+ with ThreadPoolExecutor(max_workers=len(channels)) as pool:
122
+ futures = [pool.submit(_fetch_channel, name, url, _CHANNEL_TIMEOUTS[name])
123
+ for name, url in channels]
124
+ for fut in futures:
125
+ name, ok, val = fut.result()
126
+ if ok:
127
+ results[name] = val
128
+ ok_count += 1
129
+ if not use_json:
130
+ print(f" ✅ {name:<8} 最新: {val}")
131
+ else:
132
+ results[name] = ""
133
+ if not use_json:
134
+ print(f" ⚠️ {name:<8} 不可达: {val}")
135
+
136
+ # GitHub 海外渠道不可达时,给出国内加速指引(EasyTier 式加速代理 + GitHub520 hosts)
137
+ if not use_json and results.get("GitHub", "") == "" and ok_count > 0:
138
+ print(" 💡 国内访问 GitHub 常超时,可用国内代码镜像:")
139
+ for _name, _url in _GIT_MIRRORS.items():
140
+ print(f" - {_name}: {_url}")
141
+ print(" 或参考 GitHub520 hosts 加速方案:https://github.com/521xueweihan/GitHub520")
104
142
 
105
143
  if not use_json:
106
144
  print(f"{'─' * 50}")
@@ -111,18 +149,22 @@ def cmd_check_update(args):
111
149
  print(" ⚠️ 版本自检因无法访问远程而跳过,本地版本可能不是最新")
112
150
  print(" 💡 拉取地址:")
113
151
  print(" - PyPI: pip install --upgrade gongwen-skill")
114
- for name, url in REPO_MIRRORS.items():
152
+ for name in _JUDGMENT_CHANNELS:
153
+ if name == "PyPI":
154
+ continue
155
+ print(f" - {name}: {REPO_MIRRORS[name]}")
156
+ for name, url in _GIT_MIRRORS.items():
115
157
  print(f" - {name}: {url}")
116
158
  return 2
117
159
 
118
- # 取多渠道中的最高版本
160
+ # 取判定渠道中的最高版本
119
161
  valid = [v for v in results.values() if v]
120
162
  latest = max(valid, key=_parse_version)
121
163
 
122
164
  # 判断安装方式(用于给出对应的更新命令)
123
165
  # pip 安装的用户应使用 pip install --upgrade,git clone 的用户应使用 git pull
124
166
  pypi_ok = bool(results.get("PyPI"))
125
- git_ok = any(results.get(name) for name in REPO_MIRRORS)
167
+ git_ok = bool(results.get("GitHub"))
126
168
 
127
169
  has_update = _parse_version(latest) > _parse_version(local_ver)
128
170
 
@@ -136,6 +178,7 @@ def cmd_check_update(args):
136
178
  name: {"reachable": bool(v), "version": v.lstrip("v") if v else None}
137
179
  for name, v in results.items()
138
180
  },
181
+ "mirrors": {name: url for name, url in _GIT_MIRRORS.items()},
139
182
  "reachable_channels": ok_count,
140
183
  "elapsed_seconds": round(time.time() - t0, 1),
141
184
  }
@@ -153,7 +196,10 @@ def cmd_check_update(args):
153
196
  print(f"⏱️ 自检耗时 {time.time() - t0:.1f}s")
154
197
  return 1
155
198
  elif _parse_version(latest) == _parse_version(local_ver):
156
- print(f"✅ 已是最新版本:v{local_ver}(多渠道一致)")
199
+ if ok_count >= 2:
200
+ print(f"✅ 已是最新版本:v{local_ver}(PyPI 与 GitHub 一致)")
201
+ else:
202
+ print(f"✅ 已是最新版本:v{local_ver}(单渠道确认)")
157
203
  else:
158
204
  print(f"ℹ️ 本地版本 v{local_ver} 高于远程 {latest}(本地领先或渠道不同步)")
159
205
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "gongwen-skill",
3
- "version": "2.4.0",
3
+ "version": "2.6.0",
4
4
  "description": "公文全流程处理工具 - GB/T 9704 格式检查/修复/内容优化/模板生成/版式注入",
5
5
  "type": "module",
6
6
  "main": "dsh/index.js",
@@ -201,7 +201,7 @@ python -m gongwen optimize 文件.docx -o 优化版.docx
201
201
  **Agent 响应**:
202
202
  ```
203
203
  📋 合规自检报告
204
- Skill 版本: v2.4.0
204
+ Skill 版本: v2.6.0
205
205
  路径判定: B(内容优化)
206
206
  依据: 用户提供纯文本+优化要求,无已有文档
207
207
  命令调用: 将原文保存为临时文件后执行 optimize-content --apply --paragraphs "1-3"
package/pyproject.toml CHANGED
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "gongwen-skill"
7
- version = "2.4.0"
7
+ version = "2.6.0"
8
8
  description = "公文全流程处理工具 - GB/T 9704 格式检查/修复/内容优化/模板生成/版式注入"
9
9
  readme = "README.md"
10
10
  license = {text = "MIT"}