@clawhub-spzwin-1ae0f5894d
组织健康度与员工敬业度调研全流程管理 Agent。功能包括:①员工名单批量导入问卷系统;②追加人员(增量导入);③发送调研通知(支持自定义通知模板);④追踪填答状态;⑤截止前自动催办;⑥拉取答卷数据(API直连);⑦计算本批次基准均值;⑧按模板生成部门/集团诊断报告。问卷包含麦肯锡组织健康度37题(10维度)+...
---
name: survey-workflow
description: 组织健康度与员工敬业度调研全流程管理 Agent。功能包括:①员工名单批量导入问卷系统;②追加人员(增量导入);③发送调研通知(支持自定义通知模板);④追踪填答状态;⑤截止前自动催办;⑥拉取答卷数据(API直连);⑦计算本批次基准均值;⑧按模板生成部门/集团诊断报告。问卷包含麦肯锡组织健康度37题(10维度)+ 北森敬业度29题(核心敬业度+驱动满意度+开放问题)。
skillcode: survey-workflow
version: 1.2.0
---
# survey-workflow Agent
## 概述
本 Agent 负责**组织健康度与员工敬业度联合调研**的全流程管理,从发送调研通知、追踪填答状态、自动催办,到数据分析与报告生成。
---
## 📋 问卷结构
### 麦肯锡组织健康度 (OHI) — 37题,10个维度
| 维度 | 题数 | 题目代码 |
|------|------|----------|
| 发展方向 | 3题 | Q_OHI_001~003 |
| 领导力 | 4题 | Q_OHI_004~007 |
| 工作氛围 | 4题 | Q_OHI_008~011 |
| 责任制度 | 3题 | Q_OHI_012~014 |
| 运营体系 | 5题 | Q_OHI_015~019 |
| 组织能力 | 4题 | Q_OHI_020~023 |
| 员工激励 | 4题 | Q_OHI_024~027 |
| 外部导向 | 4题 | Q_OHI_028~031 |
| 创新和学习 | 4题 | Q_OHI_032~035 |
| 赋能和心理安全 | 2题 | Q_OHI_036~037 |
### 北森敬业度及满意度 — 29题
**核心指标(敬业度8题)**
| 维度 | 题数 | 题目代码 |
|------|------|----------|
| 留任 | 2题 | Q_ENG_001~002 |
| 努力 | 2题 | Q_ENG_003~004 |
| 挑战 | 2题 | Q_ENG_005~006 |
| 组织赋能感 | 2题 | Q_ENG_007~008 |
**关键驱动因素(满意度19题)**
| 序号 | 维度 | 题目代码 | 题目内容 |
|------|------|----------|----------|
| 1 | 自主性 | Q_ENG_009 | 在工作中,我有充分的机会展现自己的能力所长 |
| 2 | 重视员工 | Q_ENG_010 | 公司在制定政策和制度的过程中,重视员工的意见和建议 |
| 3 | 职业发展 | Q_ENG_011 | 我相信我能在公司实现自己的职业目标 |
| 4 | 直接上级_沟通期望 | Q_ENG_012 | 我的直接上级能与我清晰沟通工作期望 |
| 5 | 直接上级_工作支持 | Q_ENG_013 | 我的直接上级能为我提供必要的工作支持 |
| 6 | 赞扬认可 | Q_ENG_014 | 当表现出色时,我能得到上级或同事们的肯定 |
| 7 | 薪酬福利 | Q_ENG_015 | 相对于我的付出,公司的薪酬福利水平是合理的 |
| 8 | 同事关系 | Q_ENG_016 | 同事间的沟通是坦诚的 |
| 9 | 挑战性 | Q_ENG_017 | 我的工作内容要求我不断提高自己的知识和技能 |
| 10 | 企业愿景 | Q_ENG_018 | 我清楚我的工作与公司发展目标间的关联 |
| 11 | 培训学习 | Q_ENG_019 | 公司鼓励员工进行持续性学习 |
| 12 | 绩效管理 | Q_ENG_020 | 我能定期收到清晰的工作反馈 |
| 13 | 沟通协作_团队合作 | Q_ENG_021 | 团队各成员能为了共同目标而紧密合作 |
| 14 | 工作资源 | Q_ENG_022 | 我能及时获取到工作所需的必要资源 |
| 15 | 工作生活平衡 | Q_ENG_023 | 在非工作时间也会积极参加各项工作,以保证工作的及时性 |
| 16 | 工作环境 | Q_ENG_024 | 公司提供了舒适的办公环境 |
| 17 | 创新 | Q_ENG_025 | 公司支持员工将新想法、新技能运用到工作中 |
| 18 | 推荐意愿 | Q_ENG_026 | 我愿意介绍正在求职的朋友加入公司 |
| 19 | 沟通协作_跨部门 | Q_ENG_027 | 公司鼓励跨部门间协作事务、共享信息与资源 |
**开放式问答(2题)**
- Q_OPEN_001: 您认为公司在未来发展中,最需要保持或加强的一项能力是什么?
- Q_OPEN_002: 您对公司的组织氛围或管理方式,还有哪些具体的改进建议?
---
## 核心能力
### 1. 导入活动名单(前置动作)
通过 `importSurveyTargets` 接口将员工名单批量导入问卷系统。
**API 信息**:
- 路径:`POST /questionnaire/admin/surveys/targets/import`
- 生产环境:`https://sg-al-cwork-web.mediportal.com.cn/open-api/questionnaire/admin/surveys/targets/import`
- 测试环境:`https://cwork-api-test.xgjktech.com.cn/open-api/questionnaire/admin/surveys/targets/import`
**请求参数**:
```json
{
"surveyId": "问卷ID",
"employeeIdList": ["员工ID列表"],
"replace": false,
"sourceType": "来源类型"
}
```
| 参数 | 说明 |
|------|------|
| `replace=true` | 全量覆盖(替换已有名单) |
| `replace=false` | 增量导入(追加到已有名单)**← 追加人员时使用此参数** |
| `employeeIdList` | 不可为空 |
**⚠️** 需要有效员工身份;导入完成后可衔接 `sendNotify` 发送通知。
---
### 1.1 追加人员流程(增量导入)
当发现调研名单有遗漏,需要追加人员时,按以下流程操作:
**步骤1:导入追加人员**
- 调用 `importSurveyTargets` 接口
- 设置 `replace=false`(关键参数,表示增量追加)
- 传入需要追加的员工ID列表
**步骤2:向追加人员发送通知**
- 调用 `sendNotify` 接口
- 传入新追加的员工ID列表
- 使用与初始通知相同或略作调整的通知文案
**完整示例**:
```python
import requests
BASE_URL = "https://cwork-api-test.xgjktech.com.cn/open-api"
HEADERS = {"access-token": "your_token"}
survey_id = "200003"
new_employees = [1512393075401125890, 1512393196394213378] # 新追加的员工
# Step 1:导入追加人员(增量)
import_url = f"{BASE_URL}/questionnaire/admin/surveys/targets/import"
import_resp = requests.post(import_url, json={
"surveyId": survey_id,
"employeeIdList": new_employees,
"replace": False, # ← 关键:设置为 False 表示追加
"sourceType": "手动追加"
}, headers=HEADERS)
if import_resp.json()["resultCode"] == 1:
print("✅ 追加人员导入成功")
# Step 2:向追加人员发送通知
notify_url = f"{BASE_URL}/questionnaire/notify/send"
notify_resp = requests.post(notify_url, json={
"surveyId": survey_id,
"employeeIds": new_employees,
"notifyTitle": "关于开展集团总部2026年度组织健康度与员工敬业度联合调研的通知",
"notifyMarkdown": "【追加通知】\n\n您已加入本次调研名单,请点击以下链接完成填答:\n\nhttps://cwork-web-test.xgjktech.com.cn/questionnaire-web/web/dist/#/survey?surveyId=200003"
}, headers=HEADERS)
if notify_resp.json()["resultCode"] == 1:
print("✅ 追加人员通知发送成功")
print(f"批次号:{notify_resp.json()['data']['batchNo']}")
print(f"成功:{notify_resp.json()['data']['successCount']} 人")
```
**注意事项**:
- ⚠️ `replace=false` 是追加人员的关键参数,不能遗漏
- ⚠️ 追加后建议单独发送通知,并在文案中标注"追加通知"或"补发"
- ⚠️ 追加人员的填答截止时间与初始调研保持一致
- 💡 建议将追加记录归档到对应批次的 `input/notification_records.json` 中
---
### 2. 发送调研通知(发汇报)
通过 `sendNotify` 接口发送调研通知,支持自定义 Markdown 通知文案。
**API 信息**:
- 路径:`POST /questionnaire/notify/send`
- 生产环境:`https://sg-al-cwork-web.mediportal.com.cn/open-api/questionnaire/notify/send`
- 测试环境:`https://cwork-api-test.xgjktech.com.cn/open-api/questionnaire/notify/send`
**请求参数**:
```json
{
"surveyId": "问卷ID",
"employeeIds": ["员工ID列表"],
"notifyMarkdown": "通知文案(支持 Markdown,不传则用系统默认文案)"
}
```
**典型调用流程**:
1. 调 `listUnsubmittedParticipants`(5.9)获取未通知人员
2. 调 `sendNotify`(5.5)发送通知
3. 记录发送结果
**响应结构**:
```json
{
"resultCode": 1,
"resultMsg": null,
"data": {
"batchNo": "批次号",
"successCount": 3,
"failCount": 0
}
}
```
---
### 3. 追踪填答状态
通过 API 实时查询员工的提交状态,支持按部门/名单维度查看填答率。
**相关 API**:
| 操作 | 路径 | 说明 |
|------|------|------|
| 查已提交名单 | `GET /questionnaire/surveys/participants/submitted/list?surveyId={id}` | 活动名单口径-已提交 |
| 查未提交名单 | `GET /questionnaire/surveys/participants/unsubmitted/list?surveyId={id}` | 活动名单口径-未提交 |
| 查单个提交状态 | `GET /questionnaire/submission/status?surveyId={id}&employeeId={empId}` | 查询某员工是否已提交 |
| 分页提交列表 | `POST /questionnaire/submission/list` | 按部门筛选分页查看 |
---
### 4. 自动催办提醒
截止前定时检查未提交人员,向未提交对象发送催办通知。
**API 信息**:
- 路径:`GET /questionnaire/notify/pressure`
- 生产环境:`https://sg-al-cwork-web.mediportal.com.cn/open-api/questionnaire/notify/pressure`
- 测试环境:`https://cwork-api-test.xgjktech.com.cn/open-api/questionnaire/notify/pressure`
**请求参数**:
```
GET /questionnaire/notify/pressure?surveyId={surveyId}&employeeIds={id1,id2,...}
```
**典型催办闭环流程**:
1. 调 `listUnsubmittedParticipants`(5.9)→ 获取未提交人员名单
2. 调 `pressureNotify`(5.6)→ 对未提交人员发起催办
3. 调 `listSubmittedParticipants`(5.8)/ `listUnsubmittedParticipants`(5.9)→ 复盘本次催办效果
**⚠️** 催办接口虽为 GET,业务语义为写操作,请控制调用频率。
---
### 5. 拉取答卷数据
通过 `getSubmissionDetail` 接口拉取每位提交者的完整答卷数据。
**API 信息**:
- 路径:`GET /questionnaire/submission/detail`
- 生产环境:`https://sg-al-cwork-web.mediportal.com.cn/open-api/questionnaire/submission/detail`
- 测试环境:`https://cwork-api-test.xgjktech.com.cn/open-api/questionnaire/submission/detail`
**请求参数**:
```
GET /questionnaire/submission/detail?surveyId={surveyId}&submissionId={submissionId}
```
**返回字段**:
- `answers[]`:每题答题记录,含 `questionId`、`dimension`、`scoreValue`、`textAnswer`、`supplementaryText`
- `durationSec`:填答时长
- `submitTimeMillis`:提交时间
---
### 6. 统计分析
通过 `getStatistics` 接口获取整卷或分维度的评分统计。
**API 信息**:
- 路径:`GET /questionnaire/statistics`
- 生产环境:`https://sg-al-cwork-web.mediportal.com.cn/open-api/questionnaire/statistics`
- 测试环境:`https://cwork-api-test.xgjktech.com.cn/open-api/questionnaire/statistics`
**请求参数**:
```
GET /questionnaire/statistics?surveyId={surveyId}&groupBy={survey|dimension}
```
**`groupBy` 取值**:
- `survey`:获取整卷分值分布
- `dimension`:获取分维度分值分布
---
### 7. 生成分析报告
- 按模板输出结构化分析报告
- 包含数据概览、维度得分、问题诊断、改进建议
#### ⚠️ 报告模板强制要求(必须严格遵守)
报告分两类:**部门报告(7章)** 和 **集团报告(8章)**,两者章节结构不同,生成前必须确认报告类型并严格按对应模板输出。
---
##### 部门报告(7章)— 必须完整包含:
| 章节 | 必须包含的内容 |
|------|--------------|
| 第1章 | 核心结论速览:含 OHI 总分/敬业度总分/满意度总分 + 与总部平均分对比表;Top3 优势维度 + Top3 改进维度;与总部差距最大的维度 |
| 第2章 | 10维度得分概览表(含本部门得分/总部平均/差距/评级)+ Mermaid 雷达图(用 `mermaid` 格式,dept vs 总部平均两条曲线) |
| 第3章 | 核心敬业度4子维度(含总部对比)+ 驱动满意度全部20项维度(含总部对比),缺一不可 |
| 第4章 | 优势保持(详细说明)+ 改进方向(根因 + 措施 + 时间三要素必须齐全) |
| 第5章 | 情感分析表(负面/中性/正面数量和占比)+ 高频主题与典型原话表 |
| 第6章 | 部门负责人行动清单(行动项/负责人/完成时间/成功标准,四列必须完整) |
| 第7章 | HRBP支持资源 + 人力资源中心资源 + 建议下一步动作 |
---
##### 集团报告(8章)— 必须完整包含:
| 章节 | 必须包含的内容 |
|------|--------------|
| 第1章 | 核心结论速览:含 OHI 总分/敬业度总分;Top3 优势维度 + Top3 改进维度;员工最关注的3个问题;核心建议 |
| 第2章 | 调研概况与样本结构:填答概况表(发放数/回收数/填答率/平均时长)+ 样本分布(按部门/司龄/职级) |
| 第3章 | OHI分析:Mermaid雷达图(集团 vs 行业基准)+ 10维度得分详表(排名 + 评价)+ 题目级详细分析 |
| 第4章 | 敬业度分析:核心敬业度4子维度得分 + 驱动满意度全部维度 + 与敬业度相关性标注 + 结论 |
| 第5章 | 交叉分析:各部门得分对比表 + 管理者vs员工对比(如有数据)+ 不同司龄群体对比(如数据支持) |
| 第6章 | 开放式反馈分析:情感分析(正面/中性/负面占比)+ 高频主题词Top10 + 各主题提及频次与情感倾向表 |
| 第7章 | 核心发现与改进建议:优势领域 + 主要短板 + 根因 + 改进优先级排序表(P0/P1/P2,含责任主体/措施/预期效果/建议时间) |
| 第8章 | 后续行动计划:调研结果沟通会 + 部门级反馈工作坊 + 专项改进小组 + 季度脉冲调研计划 + 下一次年度调研计划 |
---
##### 常见错误(禁止再犯):
- ❌ **部门报告**不写总部平均分对比 → 每表必须有"总部平均"列
- ❌ **部门报告**缺 Mermaid 雷达图 → 第2章必须包含
- ❌ **部门报告**满意度只写部分维度 → 20项全部列出
- ❌ **部门报告**改进建议缺时间/根因 → 根因+措施+时间三要素必须齐全
- ❌ **部门报告**缺行动清单第6章 → 行动项/负责人/时间/成功标准四列必须完整
- ❌ **集团报告**第5章缺各部门对比表 → 至少包含各部门 OHI + 敬业度得分对比
- ❌ **集团报告**第7章缺优先级排序 → P0/P1/P2 + 责任主体/措施/效果/时间缺一不可
- ❌ 生成前不确认报告类型 → 先确认是"部门报告"还是"集团报告"再开始写
#### 报告生成脚本(使用 Jinja2 模板)
报告生成采用「**模板 + 数据分离**」架构,确保报告结构不会在代码修改中丢失。
```
workspace-survey/
├── templates/
│ ├── dept_report.md.j2 ← 部门报告模板(Jinja2)
│ └── group_report.md.j2 ← 集团报告模板(Jinja2)
└── scripts/
├── generate_dept_reports_v4.py ← 部门报告生成
└── generate_group_report.py ← 集团报告生成
```
**运行命令**:
```bash
# 部门报告(3份,每个部门一份)
python3 scripts/generate_dept_reports_v4.py --analysis-file data/分析结果.json
# 集团报告(1份)
python3 scripts/generate_group_report.py --analysis-file data/分析结果.json
```
**⚠️ 关键词提取规则(必须遵守)**:
开放式反馈的高频关键词必须是**「动词+名词」的完整搭配**,拒绝单独的形容词/动词。
✅ 合格关键词示例:`提高薪酬`、`减少加班`、`加强团队建设`、`改善工作环境`、`明确职业发展`
❌ 不合格关键词(会被过滤):`改善`、`加强`、`提高`、`减少`(必须搭配名词才有意义)
实现逻辑见 `generate_group_report.py` 中 `_extract_keywords` 函数,核心约束:
1. 动作词列表 + 名词列表,通过子串匹配提取完整搭配
2. 按频次排序,长词覆盖短词
3. 单独的语气词/形容词不进入结果
**复验方法**:生成报告后执行 `grep -A15 "高频主题词" output/集团报告_*.md`,确认关键词均为有效搭配。
---
## 使用方式
### 方式一:脚本式(本地数据)
```bash
# 1. 员工匹配校验
python3 scripts/match_employees.py --excel-path data/员工名单.xlsx
# 2. 发送调研通知
python3 scripts/send_notification.py --employees data/匹配结果.json \
--survey-url "https://xxx.com/survey" --deadline 2026-04-30
# 3. 追踪填答状态
python3 scripts/track_submissions.py --employees data/匹配结果.json \
--data-file data/问卷数据.xlsx
# 4. 发送催办提醒
python3 scripts/send_reminder.py --employees data/匹配结果.json \
--data-file data/问卷数据.xlsx --days-before 3
# 5. 加载问卷数据
python3 scripts/load_survey_data.py --data-file data/问卷数据.xlsx
# 6. 三层分析
python3 scripts/analyze_data.py --data-file data/问卷数据.xlsx --level all
# 7. 生成报告
python3 scripts/generate_report.py --data-file data/问卷数据.xlsx --level all
```
### 方式二:API 直连(实时数据)
直接调用问卷系统 Open API,无需本地 Excel 数据。
**推荐流程(通知 → 催办 → 复盘)**:
```python
import requests
BASE_URL = "https://sg-al-cwork-web.mediportal.com.cn/open-api"
HEADERS = {"appKey": "你的appKey"}
survey_id = "问卷ID"
# Step 1:获取未提交人员名单
unsubmitted_url = f"{BASE_URL}/questionnaire/surveys/participants/unsubmitted/list"
resp = requests.get(unsubmitted_url, params={"surveyId": survey_id}, headers=HEADERS)
unsubmitted_list = resp.json()["data"] # 人员列表
# Step 2:发送通知(发汇报)
notify_url = f"{BASE_URL}/questionnaire/notify/send"
requests.post(notify_url, json={
"surveyId": survey_id,
"employeeIds": [p["employeeId"] for p in unsubmitted_list]
}, headers=HEADERS)
# Step 3:催办未提交人员
pressure_url = f"{BASE_URL}/questionnaire/notify/pressure"
requests.get(pressure_url, params={
"surveyId": survey_id,
"employeeIds": ",".join([p["employeeId"] for p in unsubmitted_list])
}, headers=HEADERS)
# Step 4:复盘提交情况
submitted_url = f"{BASE_URL}/questionnaire/surveys/participants/submitted/list"
resp_sub = requests.get(submitted_url, params={"surveyId": survey_id}, headers=HEADERS)
submitted_list = resp_sub.json()["data"]
print(f"已提交: {len(submitted_list)} 人,未提交: {len(unsubmitted_list)} 人")
```
**API 索引速查**:
| 功能 | 方法 | 路径 | 小节 |
|------|------|------|------|
| 查询提交状态 | GET | `/questionnaire/submission/status` | 5.1 |
| 查询提交详情 | GET | `/questionnaire/submission/detail` | 5.2 |
| 提交列表分页 | POST | `/questionnaire/submission/list` | 5.3 |
| 评分统计 | GET | `/questionnaire/statistics` | 5.4 |
| **发送通知(发汇报)** | POST | `/questionnaire/notify/send` | **5.5** |
| **催办** | GET | `/questionnaire/notify/pressure` | **5.6** |
| 条件已提交名单 | POST | `/questionnaire/surveys/submitted` | 5.7 |
| **已提交人员列表** | GET | `/questionnaire/surveys/participants/submitted/list` | **5.8** |
| **未提交人员列表** | GET | `/questionnaire/surveys/participants/unsubmitted/list` | **5.9** |
| 导入活动名单 | POST | `/questionnaire/admin/surveys/targets/import` | 5.10 |
> 详细字段结构见 `docs/问卷系统API调用说明.md`
---
## 配置文件 (config/config.json)
关键配置项:
- `survey.deadline`: 调研截止日期
- `survey.min_response_rate`: 最低填答率要求 (85%)
- `survey.survey_url`: 调研H5页面链接
- `reminder.schedule`: 催办提醒规则
---
## 数据文件格式
### 员工名单 Excel
| 工号 | 姓名 | 部门 | 中心 |
|------|------|------|------|
| 001 | 张三 | 人力资源部 | 专业责任中心 |
### 问卷数据 Excel
| 工号 | 姓名 | 部门 | 中心 | Q_OHI_001 | Q_OHI_002 | ... | Q_ENG_001 | ... | 提交时间 |
|------|------|------|------|-----------|-----------|-----|-----------|-----|----------|
---
## 批次归档规范(强制执行)
每次调研视为一个独立批次,**所有操作必须归档到对应批次目录**,不得散落在 `latest/` 或其他位置。
### 目录结构规范
```
output/archive/{surveyId}/{日期}/
├── input/
│ ├── employee_list.json # 本批次导入的员工名单
│ └── notification_records.json # 发通知/催办记录(按时间顺序)
├── data/
│ ├── submissions/ # 每位提交者的原始答卷(一人一个文件)
│ │ └── {employeeId}_{姓名}.json
│ └── benchmark.json # 本批次全体均值(生成报告前必须先有)
└── reports/
├── 集团报告_{日期}.md
└── {部门名}_诊断报告_{日期}.md
```
### 关键规则
1. **先建档,再操作**:每个新批次开始时,先创建目录结构
2. **发名单导入 → 写入 `input/employee_list.json`**
3. **发通知/催办 → 追加到 `input/notification_records.json`**
4. **拉取答卷数据 → 存入 `data/submissions/{employeeId}_{姓名}.json`**
5. **计算均值 → 生成 `data/benchmark.json`**(所有报告对比基准的唯一来源)
6. **生成报告 → 存入 `reports/`**,同时同步到 `output/latest/`
7. **报告中的对比值**:统一使用"**本批次全体均值**",不得写"总部平均"(因为只有一批数据时这就是本批均值)
### benchmark.json 必须字段
```json
{
"surveyId": "问卷ID",
"surveyDate": "YYYY-MM-DD",
"totalEmployees": 3,
"totalSubmissions": 1,
"responseRate": 0.333,
"ohi_avg": 3.45,
"engagement_avg": 3.62,
"satisfaction_avg": 3.07,
"ohi_dimensions": { "发展方向": 3.00, ... },
"engagement_core": { "留任": 3.00, ... },
"satisfaction": { "薪酬福利": 3.00, ... }
}
```
### 常见错误(禁止再犯)
- ❌ 报告对比值写"总部平均" → 统一写"本批次全体均值"
- ❌ 数据散落在 latest/ 不归档 → 操作后立即写入对应批次目录
- ❌ 不生成 benchmark.json 就出报告 → benchmark 是所有报告的对比基准
- ❌ 多人数据混在一个文件 → 一人一个 JSON 文件,方便独立引用
## 认证说明(重要,必须阅读)
**🏭 默认使用生产环境**,除非明确指定测试环境。
生产环境调用问卷系统 API 时,**必须使用 APP Key 换取 access-token**,不能直接用 token。
### 认证流程
```
XG_BIZ_API_KEY (APP Key)
↓ cms-auth-skills/login.py
access-token
↓
问卷系统 API 请求头
```
### 认证命令
```bash
python3 skills/cms-auth-skills/scripts/auth/login.py \
--ensure \
--app-key "$XG_BIZ_API_KEY"
```
### QuestionnaireClient 自动鉴权
`scripts/utils/questionnaire_client.py` 已内置自动换 token 逻辑:
- 初始化时若有 `XG_BIZ_API_KEY` 但无 `access_token`,自动调用 `login.py` 换取 token
- 脚本调用时只需指定 `base_url` 为生产地址即可,无需手动传 token
```python
from questionnaire_client import QuestionnaireClient
# 生产环境(自动用 XG_BIZ_API_KEY 换 token)
client = QuestionnaireClient(
base_url="https://sg-al-cwork-web.mediportal.com.cn"
)
# 发送通知
client.send_notify(survey_id=200003, notify_title="...", ...)
```
### 常见错误
| 错误 | 原因 | 解决方法 |
|------|------|----------|
| `401: 缺少访问凭证` | 直接用 app_key 当 token | 用 login.py 换成 access-token |
| `401: Token校验失败` | token 已过期或用错了 | 重新用 login.py 换取新 token |
| `活动名单为空` | 未导入名单 | 先调 `import_targets` 再发通知 |
## 依赖
- Python 3.9+ (标准库)
- CWork API (通过 cms-auth-skills 鉴权)
- 问卷系统 Open API(路径 `/questionnaire/**`,见 `docs/问卷系统API调用说明.md`)
---
## 维护说明
- 配置文件:`workspace-survey/config/config.json`(权威版本,含题目原文、催办模板、数据质量规则、AI分析规则)
- 问卷维度配置已内置到 config.json 中
- 截止日期等参数可随时修改配置文件调整
FILE:open API 文件/问卷系统API调用说明.md
# 问卷系统 Open API 调用说明
**配套文档**:
- [《问卷开放接口API说明》](./问卷开放接口API说明.md)(questionnaire 下游原始接口与数据结构定义)
- [《BP系统API调用说明》](./BP系统API调用说明.md)(调用说明体例参考)
- [《OPS系统API调用说明》](./OPS系统API调用说明.md)(本仓新增文档体例参考)
本文承担:**修订记录、能力概述、通用约定、接口索引、编排场景、注意事项**;字段级结构以《问卷开放接口API说明》为准。
---
## 修订记录
| 版本 | 日期 | 变更摘要 | 变更人 |
|------|------|----------|--------|
| 1.0 | 2026-04-21 | 基于本次新增 `QuestionnaireController` + `QuestionnaireFeign` 生成调用说明 | OpenAPI-Agent |
| 1.1 | 2026-04-21 | 新增管理端导入接口(`/questionnaire/admin/surveys/targets/import`)调用说明 | OpenAPI-Agent |
| 1.2 | 2026-04-21 | `sendNotify` 请求体新增通知文案字段 `notifyMarkdown` | OpenAPI-Agent |
| 1.3 | 2026-04-21 | `sendNotify` 请求体新增通知标题字段 `notifyTitle`(sendV2 场景必传) | OpenAPI-Agent |
---
## 一、概述
本服务在 `open-api` 网关层新增了问卷聚合控制器:`QuestionnaireController`,对外统一暴露 `/questionnaire/**` 路径,并透传到下游 `questionnaire` 服务的 `/open/**` 接口。
当前开放能力共 10 个(与当前代码一致):
1. `getSubmissionStatus` — 查询提交状态
2. `getSubmissionDetail` — 查询提交详情
3. `listSubmissions` — 查询提交列表(分页)
4. `getStatistics` — 查询统计数据
5. `sendNotify` — 发送问卷活动通知
6. `pressureNotify` — 问卷活动催办
7. `listSubmittedByCondition` — 查询已提交人员(按条件,不分页)
8. `listSubmittedParticipants` — 活动名单维度-已提交人员列表
9. `listUnsubmittedParticipants` — 活动名单维度-未提交人员列表
10. `importSurveyTargets` — 导入问卷活动名单(管理端补充)
---
## 二、给 AI / Agent 的阅读顺序
1. 先看本文 **四、通用说明**(鉴权头、统一响应结构、路径规则)。
2. 再看本文 **五、接口清单(索引)**(本服务路径与下游路径映射)。
3. 需要字段细节时,再看 [《问卷开放接口API说明》](./问卷开放接口API说明.md) 的“六、公共数据结构”。
4. 需要调用链路编排时,看本文 **七、关键业务流程说明**。
---
## 三、关键词与别名
| 用语 | 英文路径片段 | 说明 |
|------|--------------|------|
| 问卷 | `survey` | 问卷主标识为 `surveyId` |
| 提交记录 | `submission` | 提交主键为 `submissionId` |
| 已提交名单 | `submitted` | 可按条件或按活动名单口径查询 |
| 未提交名单 | `unsubmitted` | 活动名单口径下的差集人员 |
| 通知/催办 | `notify` / `pressure` | 问卷活动触达能力 |
| 名单导入 | `targets/import` | 活动名单导入,支持增量与全量覆盖 |
---
## 四、通用说明
### 4.1 访问地址
```text
https://{域名}/open-api/{接口地址}
```
### 4.2 环境信息
| 环境 | 域名 |
|------|---------------------------------------------|
| 生产环境 | `https://sg-al-cwork-web.mediportal.com.cn` |
| 测试环境 | `https://cwork-api-test.xgjktech.com.cn` |
### 4.3 公共请求头
| Header | 说明 | 是否必填 |
|--------|------|----------|
| `appKey` | 应用访问凭证(网关统一校验) | 是(兼容性见下) |
**兼容性**:部分场景下若传入合法 `access-token`,即使不带 `appKey` 也可兼容通过(由网关鉴权链路处理)。
### 4.4 通用响应结构
所有接口统一返回 `Result<T>`:
```json
{
"resultCode": 1,
"resultMsg": null,
"data": null
}
```
| 字段 | 类型 | 说明 |
|------|------|------|
| `resultCode` | Integer | `1` 表示成功,其他值表示失败 |
| `resultMsg` | String | 失败时返回可读错误信息 |
| `data` | T | 业务数据 |
### 4.5 时间字段口径
- 与下游文档一致:问卷域时间字段使用**毫秒时间戳**(例如 `submitTimeMillis`)。
---
## 五、接口清单(索引)
> 下表左侧“本服务路径”为 `open-api` 暴露路径(`QuestionnaireController`),右侧“下游路径”为 `questionnaire` 服务路径(`QuestionnaireFeign`)。
| 小节 | 规范命名 | 方法 | 本服务路径 | 下游路径 | 读写 | 摘要 |
|------|----------|------|------------|----------|------|------|
| 5.1 | getSubmissionStatus | GET | `/questionnaire/submission/status` | `/open/submission/status` | 读 | 查询提交状态 |
| 5.2 | getSubmissionDetail | GET | `/questionnaire/submission/detail` | `/open/submission/detail` | 读 | 查询提交详情 |
| 5.3 | listSubmissions | POST | `/questionnaire/submission/list` | `/open/submission/list` | 读 | 提交列表分页 |
| 5.4 | getStatistics | GET | `/questionnaire/statistics` | `/open/statistics` | 读 | 评分统计 |
| 5.5 | sendNotify | POST | `/questionnaire/notify/send` | `/open/notify/send` | 写 | 发送活动通知 |
| 5.6 | pressureNotify | GET | `/questionnaire/notify/pressure` | `/open/notify/pressure` | 写 | 问卷催办 |
| 5.7 | listSubmittedByCondition | POST | `/questionnaire/surveys/submitted` | `/open/surveys/submitted` | 读 | 条件已提交名单 |
| 5.8 | listSubmittedParticipants | GET | `/questionnaire/surveys/participants/submitted/list` | `/open/surveys/participants/submitted/list` | 读 | 活动名单口径已提交 |
| 5.9 | listUnsubmittedParticipants | GET | `/questionnaire/surveys/participants/unsubmitted/list` | `/open/surveys/participants/unsubmitted/list` | 读 | 活动名单口径未提交 |
| 5.10 | importSurveyTargets | POST | `/questionnaire/admin/surveys/targets/import` | `/admin/surveys/targets/import` | 写 | 导入活动名单(管理端) |
---
## 六、推荐拉取路径(Agent)
**路径 A(状态先行)**:`5.1 -> 5.2`
适合已知员工/提交记录,先判定是否提交,再拉详情。
**路径 B(统计看板)**:`5.4`
直接获取整卷或维度统计,用于看板与 AI 摘要。
**路径 C(名单运营)**:`5.5 -> 5.6 -> 5.8/5.9`
先通知,再催办,最后复盘已提交与未提交名单。
**路径 D(明细导出)**:`5.3` 或 `5.7`
- 需要分页:走 `5.3`
- 需要按条件全量已提交:走 `5.7`
**路径 E(名单初始化)**:`5.10 -> 5.5 -> 5.6`
先导入活动名单,再发送通知,最后对未提交对象催办。
---
## 七、关键业务流程说明
### 场景一:查询某员工是否已完成问卷
1. 调 `5.1`:`GET /questionnaire/submission/status?surveyId={id}&employeeId={empId}`
2. 若 `submitted=true` 且返回 `submissionId`,继续调 `5.2` 拉取答卷详情
### 场景二:按部门筛选提交明细并分页查看
1. 调 `5.3`:`POST /questionnaire/submission/list`
2. 请求体带 `surveyId + department/departmentId + pageNum/pageSize`
3. 基于响应中的 `records` 渲染列表
### 场景三:问卷活动通知与催办闭环
1. 调 `5.5` 发送通知(可按员工或部门筛选触达范围;可通过 `notifyTitle`/`notifyMarkdown` 自定义标题与文案)
2. 调 `5.6` 对未提交且已通知人员发起催办
3. 调 `5.8` / `5.9` 分别获取活动名单口径下的已提交/未提交名单做复盘
### 场景四:统计分析(整卷/维度)
1. 调 `5.4`,传 `groupBy=survey` 获取整卷分值分布
2. 调 `5.4`,传 `groupBy=dimension` 获取分维度分值分布
### 场景五:管理端导入活动名单
1. 调 `5.10`:`POST /questionnaire/admin/surveys/targets/import`
2. 请求体包含:`surveyId`、`employeeIdList`、`replace`、`sourceType`
3. 当 `replace=true` 时按“全量覆盖”导入;`replace=false` 时按增量导入
4. 导入完成后可衔接 `5.5` 发送通知
---
## 八、注意事项
1. **路径不要混淆**:外部调用本服务必须用 `/questionnaire/**`,`/open/**` 是下游 `questionnaire` 内部路径。
2. **ID 精度**:`surveyId/submissionId/employeeId` 等 Long 字段在 JS 端建议按字符串处理。
3. **筛选条件优先级**:提交状态查询中 `employeeId` 与 `employeeName` 同时传入时,按下游约定优先工号。
4. **名单口径**:活动名单维度接口(`5.8/5.9`)与条件筛选接口(`5.7`)口径不同,报表侧不要混用。
5. **催办属于副作用接口**:`5.6` 虽为 GET,但业务语义为“触发催办”,请按写操作治理调用频率与审计。
6. **导入接口需要有效员工身份**:`5.10` 若缺少登录员工上下文,可能返回“未登录或缺少员工身份”。
7. **导入列表不能为空**:`employeeIdList` 为空会触发业务错误“员工ID列表不能为空”。
8. **通知文案字段**:`5.5` 新增 `notifyMarkdown`,建议控制文本长度并使用简洁 Markdown(纯文本同样可用)。
9. **通知标题字段**:`5.5` 新增 `notifyTitle`,控制工作汇报 `main`;在 sendV2 场景应作为必填参数处理。
---
## 九、附录:与下游接口编号对照
本服务 10 个接口与 [《问卷开放接口API说明》](./问卷开放接口API说明.md) 第四章编号一一对应:
- 本文 `5.1~5.10` 对应下游文档 `4.1~4.10`。
为 AI Agent 提供企业知识库的完整操作能力:浏览空间与目录结构、搜索文件并读取内容、归档纯文本或物理文件、对已有文件保存新版本(禁止覆盖以保留溯源历史)、删除文件、重命名/移动文件及版本定稿
---
name: cms-docdb
description: 为 AI Agent 提供企业知识库的完整操作能力:浏览空间与目录结构、搜索文件并读取内容、归档纯文本或物理文件、对已有文件保存新版本(禁止覆盖以保留溯源历史)、删除文件、重命名/移动文件及版本定稿
skillcode: cms-docdb
github: https://github.com/liuyanhua1222/cms-docdb
dependencies:
- cms-auth-skills
---
# cms-docdb — 索引
本文件提供能力边界、路由规则与使用约束。详细说明见 `references/`,实际执行见 `scripts/`。
**当前版本**: 1.0.1
**接口版本**: 所有业务接口统一使用 `/open-api/*` 前缀,鉴权类型全部为 `appKey`。
**能力概览(5 块能力)**:
- `browse`:发现可用空间、获取个人空间 ID、浏览目录结构、查看最近上传
- `query`:搜索文件,找到文件后获取内容、下载链接或预览链接
- `upload`:新建文件——上传纯文本或物理文件到知识库(仅用于新建)
- `delete`:删除指定文件(高风险,需用户确认)
- `manage`:重命名/移动文件;更新已有文件内容(版本管理);查看历史版本;版本定稿
统一规范:
- 鉴权依赖:`cms-auth-skills/SKILL.md`
- 运行日志:`.cms-log/`
授权依赖:
- 需要鉴权时先读取 `cms-auth-skills/SKILL.md`
- 如果未安装,先安装依赖,再继续执行
输入完整性规则(强制):
1. 浏览目录必须提供 parentId(根目录传 0)或 projectId
2. 搜索文件必须提供关键词
3. 上传文件必须提供文件名和内容(纯文本)或 resourceId(物理文件)
4. 删除/重命名/移动文件必须提供 fileId
5. 版本更新必须提供目标文件的 fileId(纯文本)或文件 id + projectId + resourceId(物理文件)
版本管理强制规则(最高优先级):
- **禁止直接覆盖已有文件内容**:对已存在文件的任何内容更新,必须通过版本管理接口保存为新版本,不得使用覆盖方式。直接覆盖无法溯源,违反本规则。
- **保存前必须判断文件是否已存在**:执行任何"保存/上传/更新"动作前,必须先通过 `searchFile` 或 `getChildFiles` 确认目标文件是否已存在。
- 若**不存在**:路由到 `upload` 模块走新建流程
- 若**已存在**:路由到 `manage` 模块走版本更新流程,禁止新建同名文件或覆盖
- **不得询问用户是否覆盖**:版本管理是默认且唯一的更新方式,无需向用户确认,直接执行版本更新。
建议工作流(简版):
1. 先读取 `SKILL.md`,确认能力边界和限制
2. 根据用户意图定位模块,读取对应 `references/<module>/README.md`
3. 确认具体动作后,读取 `scripts/<module>/README.md` 了解脚本入参
4. **保存/上传前必须执行存在性检查**:通过 `search.py` 或 `browse.py` 确认目标文件是否已存在。已存在则切换为版本更新流程(manage 模块),不存在才新建(upload 模块)
5. 补齐用户必需输入
6. 执行对应脚本
脚本使用规则(强制):
1. **每个动作必须有对应脚本**:不允许"暂无脚本"
2. **脚本可独立执行**:所有 `scripts/` 下的脚本均可脱离 AI Agent 直接在命令行运行
3. **先读模块说明再执行**:执行脚本前,必须先阅读对应模块的 `references/<module>/README.md`
4. **鉴权一致**:涉及 appKey 时,统一依赖 `cms-auth-skills`
意图路由与加载规则(强制):
1. **先路由再加载**:必须先判定模块,再打开该模块的 `references/<module>/README.md`
2. **先读说明再调用**:在执行前,必须加载对应模块说明
3. **脚本必须执行**:所有接口调用必须通过脚本执行,不允许跳过
4. **不猜测**:若意图不明确,必须追问澄清
宪章(必须遵守):
1. **只读索引**:`SKILL.md` 只描述"能做什么"和"去哪里读",不写具体接口参数
2. **按需加载**:默认只读 `SKILL.md` + `cms-auth-skills/SKILL.md`,只有触发某模块时才加载该模块的 `references` 与 `scripts`
3. **对外克制**:对用户只输出"可用能力、必要输入、结果链接或摘要",不暴露鉴权细节与内部字段
4. **素材优先级**:用户给了文件或 URL,必须先提取内容再确认,确认后才触发生成或写入
5. **生产约束**:仅允许生产域名与生产协议,不引入任何测试地址
6. **危险操作**:删除文件等高风险操作应礼貌确认,不直接执行
7. **脚本语言限制**:所有脚本必须使用 Python 编写
8. **重试策略**:出错时间隔 1 秒、最多重试 3 次,超过后终止并上报
9. **禁止无限重试**:严禁无限循环重试
10. **输出规范**:脚本输出优先按 `resultCode`、`resultMsg`、`data` 读取,对用户输出最小必要信息:摘要/必要输入/链接,不回显完整 JSON 响应
模块路由与能力索引:
| 用户意图 | 模块 | 能力摘要 | 模块说明 | 脚本 |
|---|---|---|---|---|
| "帮我看看这个目录下有什么"、"浏览一下xxx文件夹"、"帮我看看知识库里有什么"、"查看某个目录的内容" | `browse` | 发现空间、浏览目录结构、查看最近上传 | `./references/browse/README.md` | `./scripts/browse/browse.py` |
| "找一下xxx文件"、"搜索xxx"、"看看这个文件的内容"、"帮我读取xxx文件"、"帮我总结一下xxx文件" | `query` | 搜索文件并读取内容、下载链接或预览链接 | `./references/query/README.md` | `./scripts/query/search.py` |
| "帮我把这个存到知识库"、"上传xxx到知识库"、"把这份文档归档"、"帮我保存这个文件" | `upload` | 新建文件到知识库(仅用于新建,已存在则路由到 manage 走版本更新) | `./references/upload/README.md` | `./scripts/upload/upload-content.py` |
| "帮我把xxx删了"、"删除xxx文件"、"把xxx文件移除" | `delete` | 删除指定文件(高风险,需确认) | `./references/delete/README.md` | `./scripts/delete/delete-file.py` |
| "帮我把xxx重命名"、"把xxx改名为yyy"、"把这个文件移到xxx文件夹"、"更新一下知识库里的xxx"、"把最新内容存进去"、"这个文档有更新,存一下"、"查看xxx文件的历史版本"、"把这个版本定稿"、"这个文件改了,保留旧的"、"不要覆盖,存成新版本" | `manage` | 重命名/移动文件;更新已有文件内容(版本管理);查看历史版本;版本定稿 | `./references/manage/README.md` | 见 `./scripts/manage/README.md`(按意图选择对应脚本) |
能力树:
```text
cms-docdb/
├── SKILL.md
├── references/
│ ├── browse/README.md
│ ├── query/README.md
│ ├── upload/README.md
│ ├── delete/README.md
│ └── manage/README.md
└── scripts/
├── browse/
│ ├── README.md
│ ├── browse.py
│ ├── get-level1-folders.py
│ ├── get-personal-project-id.py
│ ├── get-project-list.py
│ ├── get-recent-files.py
│ └── get-uploadable-list.py
├── query/
│ ├── README.md
│ ├── search.py
│ ├── get-full-content.py
│ ├── get-download-info.py
│ ├── get-file-content.py
│ └── batch-get-content.py
├── upload/
│ ├── README.md
│ ├── upload-content.py
│ ├── save-file-by-path.py
│ ├── save-file-by-parent-id.py
│ ├── upload-whole-file.py
│ ├── check-slice.py
│ ├── register-slice.py
│ ├── merge-resource.py
│ └── get-file-download-info.py
├── delete/
│ ├── README.md
│ └── delete-file.py
└── manage/
├── README.md
├── update-file-property.py
├── update-file-version.py
├── get-version-list.py
├── get-last-version.py
└── finalize-version.py
```
FILE:references/browse/README.md
# browse — 模块说明
## 适用场景
- 用户说"帮我看看知识库里有什么"、"列出我的空间"
- 用户想浏览某个目录下的内容
- 用户想了解可以访问哪些空间
- 用户需要在保存文件前确定目标空间
## 鉴权模式
所有动作统一使用 `appKey` 鉴权,通过 `cms-auth-skills` 获取。
## 输入要求
| 动作 | 必填输入 | 可选输入 |
|---|---|---|
| 获取个人空间 ID | 无 | appCode |
| 列出所有可访问空间 | 无 | appCode, nameKey, bizCode |
| 列出可写空间 | 无 | appCode, nameKey, bizCode |
| 浏览项目根目录 | projectId | order, permissionQuery |
| 浏览指定目录 | parentId | type, order, excludeFileTypes, excludeFolderNames, returnFileDesc |
| 获取最近上传文件 | 无 | limit, searchKey |
## 动作列表
### 1. 获取个人空间 ID
- **脚本**: `get-personal-project-id.py`
- **用途**: 快速获取当前用户的个人知识库空间 ID
- **输出**: 返回 projectId(Long)
### 2. 列出所有可访问空间
- **脚本**: `get-project-list.py`
- **用途**: 获取当前账号有权限访问的所有空间列表
- **输出**: 返回空间列表,每个空间包含 id、name、remark、type、role 等信息
### 3. 列出可写空间
- **脚本**: `get-uploadable-list.py`
- **用途**: 获取当前账号有上传/编辑权限的空间列表(保存文件前必须调用)
- **输出**: 返回可写空间列表
### 4. 浏览项目根目录
- **脚本**: `get-level1-folders.py`
- **用途**: 拉取指定项目空间的绝对顶层(根目录)下的所有文件夹及文件
- **输出**: 返回文件和文件夹列表
### 5. 浏览指定目录
- **脚本**: `browse.py`
- **用途**: 浏览指定目录下的直接子项(文件和文件夹)
- **输出**: 返回文件和文件夹列表,支持类型过滤、排序、排除规则
### 6. 获取最近上传文件
- **脚本**: `get-recent-files.py`
- **用途**: 获取当前用户最近上传的文件列表
- **输出**: 返回文件列表,支持数量限制和关键字搜索
## 输出说明
所有脚本输出统一为 JSON 格式,包含:
- `resultCode`: 1 表示成功,非 1 表示失败
- `resultMsg`: 错误信息(成功时为 null)
- `data`: 业务数据
文件/文件夹对象包含字段:
- `id`: 文件/文件夹 ID
- `name`: 名称
- `type`: 1 文件夹,2 文件
- `parentId`: 父目录 ID
- `hasChild`: 是否有子项
- `size`: 文件大小(字节)
- `suffix`: 文件后缀
- `fileType`: 业务类型(doc/file/work_report 等)
- `ancestorNames`: 完整路径(斜杠分隔)
- `createTime`: 创建时间戳
- `createTimeStr`: 格式化时间
## 标准流程
1. **空间发现**:
- 快速获取个人空间 → `get-personal-project-id.py`
- 查看所有可访问空间 → `get-project-list.py`
- 保存文件前查看可写空间 → `get-uploadable-list.py`
2. **目录浏览**:
- 浏览项目根目录 → `get-level1-folders.py` + projectId
- 浏览子目录 → `browse.py` + parentId
- 继续下钻 → 递归调用 `browse.py`
3. **快速访问**:
- 查看最近上传 → `get-recent-files.py`
## 用户话术示例
- "帮我看看个人知识库里有什么"
- "浏览一下根目录"
- "查看 AI 研发中心这个空间"
- "我想保存文件,先看看有哪些空间可以写"
FILE:references/delete/README.md
# delete — 模块说明
## 适用场景
- 用户说"帮我把 xxx 文件删了"、"删除这个文件"
- 用户明确要求移除某个文件
## 鉴权模式
所有动作统一使用 `appKey` 鉴权,通过 `cms-auth-skills` 获取。
## 输入要求
| 动作 | 必填输入 | 可选输入 |
|---|---|---|
| 删除文件 | fileId | isPhysical |
## 动作列表
### 1. 删除文件
- **脚本**: `delete-file.py`
- **用途**: 删除指定文件,支持逻辑删除(移入回收站)或物理彻底删除
- **⚠️ 高风险操作**:执行前必须获得用户明确确认
- **输出**: 返回 Boolean,表示操作是否成功
## 输出说明
所有脚本输出统一为 JSON 格式,包含:
- `resultCode`: 1 表示成功,非 1 表示失败
- `resultMsg`: 错误信息(成功时为 null)
- `data`: true 表示删除成功
## 删除模式
| 模式 | 参数 | 说明 |
|---|---|---|
| 逻辑删除(默认) | 不传 isPhysical | 文件移入回收站,可从回收站恢复 |
| 物理彻底删除 | isPhysical=true | 从回收站彻底抹除,**不可恢复** |
## 危险操作确认流程
1. 鉴权预检(通过 `cms-auth-skills` 获取 appKey)
2. **先向用户确认**:"确认要删除文件 [文件名] 吗?此操作[可/不可]撤销。"
3. 用户明确确认后,调用 `delete-file.py`
4. 返回删除结果
## 用户话术示例
- "帮我把这份文档删了"
- "删除周报 xxx"
- "彻底删除这个文件"
FILE:references/manage/README.md
# manage — 模块说明
## 适用场景
- 用户说"帮我把 xxx 文件重命名"、"把这个文件改个名字"
- 用户说"帮我把 xxx 文件移到 yyy 文件夹"
- 用户说"更新一下知识库里的 xxx"、"把最新内容存进去"(已有文件的内容更新)
- 用户说"查看 xxx 文件的历史版本"、"把这个版本定稿"
## 鉴权模式
所有动作统一使用 `appKey` 鉴权,通过 `cms-auth-skills` 获取。
## 输入要求
| 动作 | 必填输入 | 可选输入 |
|---|---|---|
| 重命名/移动文件 | fileId | newName, targetParentId, cover, autoRename |
| 纯文本版本更新 | updateFileId, content, fileName | fileSuffix, versionName, versionRemark |
| 物理文件版本更新 | id, projectId, resourceId | versionStatus, versionName, versionRemark, suffix, size |
| 查看版本历史 | fileId | — |
| 获取最新版本 | fileId | — |
| 版本定稿 | fileId | versionNumber |
## 动作列表
### 1. 重命名/移动文件
- **脚本**: `update-file-property.py`
- **用途**: 更新文件属性,支持重命名和跨目录移动
- **输出**: 返回 Boolean,表示操作是否成功
### 2. 纯文本版本更新
- **脚本**: `upload-content.py`(位于 scripts/upload/,通过 updateFileId 参数触发版本更新模式)
- **用途**: 将新的纯文本内容保存为已有文件的新版本,适合 AI 生成内容的迭代更新
- **注意**: 传入 updateFileId 时自动切换为版本更新模式,folderName 参数无效
- **输出**: 返回 `{ fileId, fileName }`(精简结果,不含 projectId/folderId/downloadUrl)
### 3. 物理文件版本更新
- **脚本**: `update-file-version.py`
- **用途**: 将新上传的物理文件资源绑定到已有文件,产生新版本记录
- **versionStatus 说明**: 1=覆盖当前草稿,2=强制新建版本,3=新建并立即定稿(推荐默认)
- **输出**: 返回文件 ID
### 4. 查看版本历史
- **脚本**: `get-version-list.py`
- **用途**: 获取指定文件的完整版本历史列表
- **输出**: 返回版本列表,每个版本包含 versionNumber、versionName、status、remark、creator、createTime、lastVersion
### 5. 获取最新版本信息
- **脚本**: `get-last-version.py`
- **用途**: 快速获取文件当前最新版本的详细信息
- **输出**: 返回单个版本对象
### 6. 版本定稿
- **脚本**: `finalize-version.py`
- **用途**: 将文件的某个版本标记为正式定稿状态(status 从 1 变为 2)
- **注意**: 不传 versionNumber 则定稿最新版本;定稿后再次更新会自动创建新版本
- **输出**: 返回 Boolean,表示操作是否成功
## 输出说明
所有脚本输出统一为 JSON 格式,包含:
- `resultCode`: 1 表示成功,非 1 表示失败
- `resultMsg`: 错误信息(成功时为 null)
- `data`: 业务数据
版本对象字段:
- `id`: 版本记录 ID
- `fileId`: 文件 ID
- `versionNumber`: 版本号(从 1 开始递增)
- `versionName`: 版本名称(如 V2.0)
- `status`: 1=未定稿(草稿),2=已定稿
- `remark`: 版本说明
- `creator`: 创建人姓名
- `createTime`: 创建时间戳
- `lastVersion`: 是否为最新版本
## 版本更新决策流程(强制)
```
用户发起保存/上传请求
→ 通过 searchFile 或 getChildFiles 检查目标文件是否已存在
→ 不存在:路由到 upload 模块走新建流程
→ 已存在:
→ 纯文本内容:upload-content.py(传 updateFileId)
→ 物理文件:update-file-version.py
→ 禁止:新建同名文件 / 直接覆盖已有文件
```
## 冲突处理(重命名/移动)
同名冲突时有三种策略:
| 策略 | 参数 | 说明 |
|---|---|---|
| 静默覆盖 | cover=true | 直接覆盖已有文件 |
| 自动重命名 | autoRename=true | 自动追加数字后缀,如 `文件名(1).pdf` |
| 报错 | 二者都不传 | 后端报错,Agent 需处理 |
## 用户话术示例
- "帮我把这份文档改个名"
- "把这个文件移到 AI 生成文件夹"
- "更新一下知识库里的那个报告"
- "这个文件改了,保留旧的,存成新版本"
- "查看这个文件有几个版本"
- "把最新版本定稿"
FILE:references/query/README.md
# query — 模块说明
## 适用场景
- 用户说"帮我找一下 xxx 文件"、"搜索 xxx"
- 用户想找到某个文件并获取其内容、下载链接或预览链接
- AI Agent 需要读取文件内容进行分析、总结或 RAG 消费
## 鉴权模式
所有动作统一使用 `appKey` 鉴权,通过 `cms-auth-skills` 获取。
## 输入要求
| 动作 | 必填输入 | 可选输入 |
|---|---|---|
| 搜索文件 | nameKey(关键词) | projectId, rootFileId, startTime, endTime, excludeFileTypes |
| 获取文件全文 | fileId | — |
| 获取下载/预览凭据 | fileId | forceDownload, versionNumber |
| 分页读取文件内容 | fileId | pageNumber |
| 批量获取文件全文 | files(fileId 列表) | — |
## 动作列表
### 1. 搜索文件
- **脚本**: `search.py`
- **用途**: 根据关键词搜索文件或目录,返回匹配的文件和文件夹列表
- **注意**: 中文关键词必须 URL 编码(UTF-8)
- **输出**: 返回 `{ folders: [...], files: [...] }`
### 2. 获取文件全文(AI 摘要/RAG 首选)
- **脚本**: `get-full-content.py`
- **用途**: 获取文件的全局提纯文本(Markdown 格式),面向 AI Agent 的智能全文提取
- **适用**: 所有文件类型(doc/file/work_report 等)
- **输出**: 返回 Markdown 格式全文字符串
### 3. 获取下载/预览凭据
- **脚本**: `get-download-info.py`
- **用途**: 获取文件的下载链接或在线预览凭据
- **注意**: 返回的 downloadUrl 为临时签名链接,有时效性
- **输出**: 返回 downloadUrl、openWith(打开方式)、lazyLoad 等
### 4. 分页读取文件内容
- **脚本**: `get-file-content.py`
- **用途**: 分页获取文件的排版文本内容,主要用于 UI 界面流式展示
- **注意**: 物理文件(fileType=file)请使用 `get-full-content.py`,本接口对物理文件返回空
- **输出**: 返回该页的排版文本字符串
### 5. 批量获取文件全文
- **脚本**: `batch-get-content.py`
- **用途**: 批量获取多个文件的全文内容,减少往返次数,提升 RAG 效率
- **注意**: 建议单次不超过 10 个文件
- **输出**: 返回每个文件的 `{ fileId, content, status, message }`
## 输出说明
所有脚本输出统一为 JSON 格式,包含:
- `resultCode`: 1 表示成功,非 1 表示失败
- `resultMsg`: 错误信息(成功时为 null)
- `data`: 业务数据
`openWith` 打开方式枚举:
- `0`: 默认/下载
- `1`: WPS
- `2`: PDF
- `3`: 畅写
- `4`: HTML
- `5`: 工作协同
- `6`: PDF-v5
## 标准流程
1. 鉴权预检(通过 `cms-auth-skills` 获取 appKey)
2. 调用 `search.py` 搜索文件
3. 根据搜索结果数量处理:
- 多个结果:返回文件列表,告知用户可以进一步操作
- 单个结果:直接提供操作选项
4. 用户确定目标文件后,根据需求调用:
- AI 分析/总结 → `get-full-content.py`
- 下载/预览 → `get-download-info.py`
- 分页读取大文件 → `get-file-content.py`
- 批量读取多文件 → `batch-get-content.py`
## 用户话术示例
- "帮我找一下周报 xxx"
- "搜索一下有没有这份文档"
- "找到这个文件后帮我总结一下"
- "帮我下载这个文件"
- "直接打开让我看看内容"
FILE:references/upload/README.md
# upload — 模块说明
## 适用场景
- 用户说"帮我把这个文档存到知识库"、"上传 xxx 到知识库"、"把这份报告归档"
- 用户想让 AI 分析完某个内容后自动保存结果
- **仅用于新建文件**:若目标文件已存在,必须路由到 `manage` 模块走版本更新,禁止在此模块覆盖
## 鉴权模式
所有动作统一使用 `appKey` 鉴权,通过 `cms-auth-skills` 获取。
## 输入要求
| 动作 | 必填输入 | 可选输入 |
|---|---|---|
| 纯文本上传 | content, fileName | fileSuffix, folderName |
| 物理文件整传 | 本地文件路径 | — |
| 按父 ID 保存 | projectId, parentId, resourceId, name, fileType | suffix, size, isSensitive |
| 按路径保存 | projectId, resourceId, name, fileType | path, suffix, size, isSensitive |
| 分片预检 | md5, size, suffix | — |
| 注册分片 | filePath, md5, size, storageType | — |
| 合并分片 | name, sliceIds | suffix, size |
## 动作列表
### 1. 纯文本上传(AI 内容入库首选)
- **脚本**: `upload-content.py`
- **用途**: 一键保存纯文本/Markdown/HTML 内容到个人知识库,无需关心 projectId
- **注意**: 仅支持纯文本,不支持二进制文件;不传 fileSuffix 时默认为 md
- **新建模式响应**: 返回 `{ projectId, projectName, folderId, folderName, fileId, fileName, downloadUrl }`
- **版本更新模式响应**: 传入 `--update-file-id` 时,仅返回 `{ fileId, fileName }`
### 2. 物理文件整传
- **脚本**: `upload-whole-file.py`
- **用途**: 小文件(≤20MB)整体上传,返回 resourceId
- **输出**: 返回 resourceId(用于后续绑定到知识库)
### 3. 按父 ID 保存到项目目录
- **脚本**: `save-file-by-parent-id.py`
- **用途**: 已知目标文件夹 ID 时,将物理文件资源绑定到项目知识库
- **输出**: 返回新建文件的 fileId
### 4. 按路径保存到项目目录
- **脚本**: `save-file-by-path.py`
- **用途**: 通过逻辑路径保存物理文件,路径不存在时自动递归创建目录
- **输出**: 返回新建文件的 fileId
### 5. 大文件分片预检
- **脚本**: `check-slice.py`
- **用途**: 大文件(>20MB)上传前预检,支持秒传判定
- **输出**: 返回 sliceId(秒传命中)或 uploadUrl + fullPath(需上传)
### 6. 注册分片
- **脚本**: `register-slice.py`
- **用途**: 注册分片元信息,换取 sliceId
- **输出**: 返回 sliceId
### 7. 合并分片
- **脚本**: `merge-resource.py`
- **用途**: 合并所有分片生成最终 resourceId
- **输出**: 返回 resourceId
### 8. 获取资源下载链接
- **脚本**: `get-file-download-info.py`
- **用途**: 根据 resourceId 获取下载 URL(有效期 1 小时)
- **输出**: 返回 downloadUrl
## 输出说明
所有脚本输出统一为 JSON 格式,包含:
- `resultCode`: 1 表示成功,非 1 表示失败
- `resultMsg`: 错误信息(成功时为 null)
- `data`: 业务数据
## 标准流程
### 纯文本上传(推荐用于 AI 生成内容)
1. 鉴权预检(通过 `cms-auth-skills` 获取 appKey)
2. 确认文件名(建议带扩展名)和内容
3. 调用 `upload-content.py`
4. 自动归档至个人空间"和AI的对话"目录(或指定目录)
5. 返回 fileId
### 物理文件上传(PDF/DOCX 等)
1. 鉴权预检
2. 小文件(≤20MB):调用 `upload-whole-file.py` → 获得 resourceId
3. 大文件(>20MB):`check-slice.py` → `register-slice.py` → `merge-resource.py` → 获得 resourceId
4. 调用 `save-file-by-path.py` 或 `save-file-by-parent-id.py` 绑定到知识库
5. 返回 fileId
## 用户话术示例
- "帮我把这段总结存到知识库"
- "上传这份 PDF 到 AI 生成文件夹"
- "把这份 Markdown 文档归档"
- "帮我保存这个文件"
FILE:scripts/browse/README.md
# 脚本清单 — browse
## 共享依赖
无
## 脚本列表
| 脚本 | 对应接口 | 用途 |
|---|---|---|
| `get-project-list.py` | `GET /open-api/document-database/project/list` | 获取有权限访问的所有空间列表 |
| `get-personal-project-id.py` | `GET /open-api/document-database/project/personal/getProjectId` | 获取当前用户的个人知识库空间 ID |
| `get-uploadable-list.py` | `GET /open-api/document-database/project/uploadableList` | 获取有上传/编辑权限的空间列表 |
| `get-level1-folders.py` | `GET /open-api/document-database/file/getLevel1Folders` | 拉取项目空间根目录下的所有内容 |
| `browse.py` | `GET /open-api/document-database/file/getChildFiles` | 浏览指定目录下的直接子项 |
| `get-recent-files.py` | `POST /open-api/document-database/project/personal/getRecentFiles` | 获取当前用户最近上传的文件列表 |
## 使用方式
```bash
export XG_BIZ_API_KEY="your-app-key"
# 或
export XG_APP_KEY="your-app-key"
# 发现可用空间
python3 scripts/browse/get-project-list.py
python3 scripts/browse/get-personal-project-id.py
python3 scripts/browse/get-uploadable-list.py
# 浏览项目根目录
python3 scripts/browse/get-level1-folders.py <project_id>
# 浏览指定目录(parentId = 0 为绝对根目录)
python3 scripts/browse/browse.py <parent_id> [--type 1|2] [--order 1-6]
# 最近上传文件
python3 scripts/browse/get-recent-files.py [--limit 10] [--search-key "关键词"]
```
## 输出说明
所有脚本的输出均为 **JSON 格式**。
## 规范
1. **必须使用 Python** 编写
2. **鉴权遵循** `cms-auth-skills/SKILL.md` 规范
3. **入参定义以** `openapi/` 文档为准
FILE:scripts/browse/browse.py
#!/usr/bin/env python3
"""
browse / browse 脚本
用途:浏览指定目录下的直接子项(文件和文件夹)
使用方式:
python3 scripts/browse/browse.py
环境变量:
XG_BIZ_API_KEY / XG_APP_KEY — appKey(由 cms-auth-skills 预先准备)
"""
import sys
import os
import json
import urllib.request
import urllib.parse
import urllib.error
import ssl
# 接口完整 URL(与 openapi/browse/browse.md 中声明的一致)
API_URL = "https://sg-al-cwork-web.mediportal.com.cn/open-api/document-database/file/getChildFiles"
AUTH_MODE = "appKey"
def build_headers() -> dict:
"""根据鉴权模式构造请求头"""
headers = {"Content-Type": "application/json"}
if AUTH_MODE == "appKey":
app_key = os.environ.get("XG_BIZ_API_KEY") or os.environ.get("XG_APP_KEY")
if not app_key:
print("错误: 请设置环境变量 XG_BIZ_API_KEY 或 XG_APP_KEY", file=sys.stderr)
sys.exit(1)
headers["appKey"] = app_key
return headers
def call_api(parent_id: int, type: int = None, order: int = None,
exclude_file_types: str = None, exclude_folder_names: str = None,
return_file_desc: bool = True) -> dict:
"""调用浏览目录接口,返回原始 JSON 响应"""
headers = build_headers()
params = [("parentId", str(parent_id))]
if type is not None:
params.append(("type", str(type)))
if order is not None:
params.append(("order", str(order)))
if exclude_file_types:
params.append(("excludeFileTypes", exclude_file_types))
if exclude_folder_names:
params.append(("excludeFolderNames", exclude_folder_names))
if return_file_desc:
params.append(("returnFileDesc", "true"))
url = f"{API_URL}?{urllib.parse.urlencode(params)}"
req = urllib.request.Request(url, headers=headers, method="GET")
ctx = ssl.create_default_context()
ctx.check_hostname = False
ctx.verify_mode = ssl.CERT_NONE
for attempt in range(3):
try:
with urllib.request.urlopen(req, context=ctx, timeout=60) as resp:
return json.loads(resp.read().decode("utf-8"))
except urllib.error.HTTPError as e:
if attempt < 2:
import time
time.sleep(1)
else:
print(f"错误: HTTP {e.code} - {e.reason}", file=sys.stderr)
sys.exit(1)
except Exception as e:
if attempt < 2:
import time
time.sleep(1)
else:
print(f"错误: {e}", file=sys.stderr)
sys.exit(1)
def process_result(result):
"""处理 API 响应结果,优先按 resultCode、resultMsg、data 读取"""
if isinstance(result, dict):
# 优先读取 resultCode、resultMsg、data
result_code = result.get('resultCode')
result_msg = result.get('resultMsg')
data = result.get('data')
# 构建标准化输出
processed = {
'resultCode': result_code,
'resultMsg': result_msg,
'data': data
}
return processed
return result
def main():
import argparse
parser = argparse.ArgumentParser(description="浏览目录下的文件和文件夹")
parser.add_argument("parent_id", type=int, help="父目录 ID(根目录传 0)")
parser.add_argument("--type", type=int, choices=[1, 2], help="1 只查文件夹,2 只查文件")
parser.add_argument("--order", type=int, choices=[1, 2, 3, 4, 5, 6], help="排序规则")
parser.add_argument("--exclude-file-types", type=str, help="排除的文件类型,逗号分隔")
parser.add_argument("--exclude-folder-names", type=str, help="排除的文件夹名称,逗号分隔")
parser.add_argument("--no-return-file-desc", action="store_true", help="不返回文件描述")
args = parser.parse_args()
result = call_api(
parent_id=args.parent_id,
type=args.type,
order=args.order,
exclude_file_types=args.exclude_file_types,
exclude_folder_names=args.exclude_folder_names,
return_file_desc=not args.no_return_file_desc
)
processed_result = process_result(result)
print(json.dumps(processed_result, ensure_ascii=False))
if __name__ == "__main__":
main()
FILE:scripts/browse/get-level1-folders.py
#!/usr/bin/env python3
"""
browse / getLevel1Folders 脚本
用途:拉取指定项目空间的根目录下的所有文件夹及文件
使用方式:
python3 scripts/browse/get-level1-folders.py <project_id> [--order 1]
环境变量:
XG_BIZ_API_KEY / XG_APP_KEY — appKey(由 cms-auth-skills 预先准备)
"""
import sys
import os
import json
import urllib.request
import urllib.parse
import urllib.error
import ssl
# 接口完整 URL(与 openapi/browse/get-level1-folders.md 中声明的一致)
API_URL = "https://sg-al-cwork-web.mediportal.com.cn/open-api/document-database/file/getLevel1Folders"
AUTH_MODE = "appKey"
def build_headers() -> dict:
"""根据鉴权模式构造请求头"""
headers = {"Content-Type": "application/json"}
if AUTH_MODE == "appKey":
app_key = os.environ.get("XG_BIZ_API_KEY") or os.environ.get("XG_APP_KEY")
if not app_key:
print("错误: 请设置环境变量 XG_BIZ_API_KEY 或 XG_APP_KEY", file=sys.stderr)
sys.exit(1)
headers["appKey"] = app_key
return headers
def call_api(project_id: int, order: int = None, permission_query: str = None) -> dict:
"""调用获取一级目录接口,返回原始 JSON 响应"""
headers = build_headers()
params = [("projectId", str(project_id))]
if order is not None:
params.append(("order", str(order)))
if permission_query:
params.append(("permissionQuery", permission_query))
url = f"{API_URL}?{urllib.parse.urlencode(params)}"
req = urllib.request.Request(url, headers=headers, method="GET")
ctx = ssl.create_default_context()
ctx.check_hostname = False
ctx.verify_mode = ssl.CERT_NONE
for attempt in range(3):
try:
with urllib.request.urlopen(req, context=ctx, timeout=60) as resp:
return json.loads(resp.read().decode("utf-8"))
except urllib.error.HTTPError as e:
if attempt < 2:
import time
time.sleep(1)
else:
print(f"错误: HTTP {e.code} - {e.reason}", file=sys.stderr)
sys.exit(1)
except Exception as e:
if attempt < 2:
import time
time.sleep(1)
else:
print(f"错误: {e}", file=sys.stderr)
sys.exit(1)
def process_result(result):
"""处理 API 响应结果,优先按 resultCode、resultMsg、data 读取"""
if isinstance(result, dict):
# 优先读取 resultCode、resultMsg、data
result_code = result.get('resultCode')
result_msg = result.get('resultMsg')
data = result.get('data')
# 构建标准化输出
processed = {
'resultCode': result_code,
'resultMsg': result_msg,
'data': data
}
return processed
return result
def main():
import argparse
parser = argparse.ArgumentParser(description="拉取指定项目空间的根目录内容")
parser.add_argument("project_id", type=int, help="项目/空间 ID")
parser.add_argument("--order", type=int, choices=[1, 2, 5, 6], help="排序规则:1 更新倒序,2 更新顺序,5 名字倒序,6 名字顺序")
parser.add_argument("--permission-query", type=str, help="权限查询条件")
args = parser.parse_args()
result = call_api(
project_id=args.project_id,
order=args.order,
permission_query=args.permission_query
)
processed_result = process_result(result)
print(json.dumps(processed_result, ensure_ascii=False))
if __name__ == "__main__":
main()
FILE:scripts/browse/get-personal-project-id.py
#!/usr/bin/env python3
"""
browse / getPersonalProjectId 脚本
用途:获取当前用户的个人知识库空间 ID
使用方式:
python3 scripts/browse/get-personal-project-id.py [--app-code kz_doc]
环境变量:
XG_BIZ_API_KEY / XG_APP_KEY — appKey(由 cms-auth-skills 预先准备)
"""
import sys
import os
import json
import urllib.request
import urllib.parse
import urllib.error
import ssl
# 接口完整 URL(与 openapi/browse/get-personal-project-id.md 中声明的一致)
API_URL = "https://sg-al-cwork-web.mediportal.com.cn/open-api/document-database/project/personal/getProjectId"
AUTH_MODE = "appKey"
def build_headers() -> dict:
"""根据鉴权模式构造请求头"""
headers = {"Content-Type": "application/json"}
if AUTH_MODE == "appKey":
app_key = os.environ.get("XG_BIZ_API_KEY") or os.environ.get("XG_APP_KEY")
if not app_key:
print("错误: 请设置环境变量 XG_BIZ_API_KEY 或 XG_APP_KEY", file=sys.stderr)
sys.exit(1)
headers["appKey"] = app_key
return headers
def call_api(app_code: str = None) -> dict:
"""调用获取个人空间 ID 接口,返回原始 JSON 响应"""
headers = build_headers()
params = []
if app_code:
params.append(("appCode", app_code))
url = f"{API_URL}?{urllib.parse.urlencode(params)}"
req = urllib.request.Request(url, headers=headers, method="GET")
ctx = ssl.create_default_context()
ctx.check_hostname = False
ctx.verify_mode = ssl.CERT_NONE
for attempt in range(3):
try:
with urllib.request.urlopen(req, context=ctx, timeout=60) as resp:
return json.loads(resp.read().decode("utf-8"))
except urllib.error.HTTPError as e:
if attempt < 2:
import time
time.sleep(1)
else:
print(f"错误: HTTP {e.code} - {e.reason}", file=sys.stderr)
sys.exit(1)
except Exception as e:
if attempt < 2:
import time
time.sleep(1)
else:
print(f"错误: {e}", file=sys.stderr)
sys.exit(1)
def process_result(result):
"""处理 API 响应结果,优先按 resultCode、resultMsg、data 读取"""
if isinstance(result, dict):
# 优先读取 resultCode、resultMsg、data
result_code = result.get('resultCode')
result_msg = result.get('resultMsg')
data = result.get('data')
# 构建标准化输出
processed = {
'resultCode': result_code,
'resultMsg': result_msg,
'data': data
}
return processed
return result
def main():
import argparse
parser = argparse.ArgumentParser(description="获取当前用户的个人知识库空间 ID")
parser.add_argument("--app-code", type=str, help="应用编码")
args = parser.parse_args()
result = call_api(app_code=args.app_code)
processed_result = process_result(result)
print(json.dumps(processed_result, ensure_ascii=False))
if __name__ == "__main__":
main()
FILE:scripts/browse/get-project-list.py
#!/usr/bin/env python3
"""
browse / getProjectList 脚本
用途:获取当前账号有权限访问的所有空间列表
使用方式:
python3 scripts/browse/get-project-list.py [--name-key "关键词"] [--biz-code pmo]
环境变量:
XG_BIZ_API_KEY / XG_APP_KEY — appKey(由 cms-auth-skills 预先准备)
"""
import sys
import os
import json
import urllib.request
import urllib.parse
import urllib.error
import ssl
# 接口完整 URL(与 openapi/browse/get-project-list.md 中声明的一致)
API_URL = "https://sg-al-cwork-web.mediportal.com.cn/open-api/document-database/project/list"
AUTH_MODE = "appKey"
def build_headers() -> dict:
"""根据鉴权模式构造请求头"""
headers = {"Content-Type": "application/json"}
if AUTH_MODE == "appKey":
app_key = os.environ.get("XG_BIZ_API_KEY") or os.environ.get("XG_APP_KEY")
if not app_key:
print("错误: 请设置环境变量 XG_BIZ_API_KEY 或 XG_APP_KEY", file=sys.stderr)
sys.exit(1)
headers["appKey"] = app_key
return headers
def call_api(app_code: str = None, name_key: str = None, biz_code: str = None) -> dict:
"""调用获取空间列表接口,返回原始 JSON 响应"""
headers = build_headers()
params = []
if app_code:
params.append(("appCode", app_code))
if name_key:
params.append(("nameKey", name_key))
if biz_code:
params.append(("bizCode", biz_code))
url = f"{API_URL}?{urllib.parse.urlencode(params)}"
req = urllib.request.Request(url, headers=headers, method="GET")
ctx = ssl.create_default_context()
ctx.check_hostname = False
ctx.verify_mode = ssl.CERT_NONE
for attempt in range(3):
try:
with urllib.request.urlopen(req, context=ctx, timeout=60) as resp:
return json.loads(resp.read().decode("utf-8"))
except urllib.error.HTTPError as e:
if attempt < 2:
import time
time.sleep(1)
else:
print(f"错误: HTTP {e.code} - {e.reason}", file=sys.stderr)
sys.exit(1)
except Exception as e:
if attempt < 2:
import time
time.sleep(1)
else:
print(f"错误: {e}", file=sys.stderr)
sys.exit(1)
def process_result(result):
"""处理 API 响应结果,优先按 resultCode、resultMsg、data 读取"""
if isinstance(result, dict):
# 优先读取 resultCode、resultMsg、data
result_code = result.get('resultCode')
result_msg = result.get('resultMsg')
data = result.get('data')
# 构建标准化输出
processed = {
'resultCode': result_code,
'resultMsg': result_msg,
'data': data
}
return processed
return result
def main():
import argparse
parser = argparse.ArgumentParser(description="获取当前账号有权限访问的空间列表")
parser.add_argument("--app-code", type=str, help="应用编码(默认 kz_doc)")
parser.add_argument("--name-key", type=str, help="空间名称模糊搜索关键词")
parser.add_argument("--biz-code", type=str, help="业务线编码过滤")
args = parser.parse_args()
result = call_api(
app_code=args.app_code,
name_key=args.name_key,
biz_code=args.biz_code
)
processed_result = process_result(result)
print(json.dumps(processed_result, ensure_ascii=False))
if __name__ == "__main__":
main()
FILE:scripts/browse/get-recent-files.py
#!/usr/bin/env python3
"""
browse / getRecentFiles 脚本
用途:获取当前用户最近上传的文件列表
使用方式:
python3 scripts/browse/get-recent-files.py [--limit 10] [--search-key "关键词"]
环境变量:
XG_BIZ_API_KEY / XG_APP_KEY — appKey(由 cms-auth-skills 预先准备)
"""
import sys
import os
import json
import urllib.request
import urllib.error
import ssl
# 接口完整 URL(与 openapi/browse/get-recent-files.md 中声明的一致)
API_URL = "https://sg-al-cwork-web.mediportal.com.cn/open-api/document-database/project/personal/getRecentFiles"
AUTH_MODE = "appKey"
def build_headers() -> dict:
"""根据鉴权模式构造请求头"""
headers = {"Content-Type": "application/json"}
if AUTH_MODE == "appKey":
app_key = os.environ.get("XG_BIZ_API_KEY") or os.environ.get("XG_APP_KEY")
if not app_key:
print("错误: 请设置环境变量 XG_BIZ_API_KEY 或 XG_APP_KEY", file=sys.stderr)
sys.exit(1)
headers["appKey"] = app_key
return headers
def call_api(limit: int = None, search_key: str = None) -> dict:
"""调用获取最近文件接口,返回原始 JSON 响应"""
headers = build_headers()
body = {}
if limit is not None:
body["limit"] = limit
if search_key:
body["searchKey"] = search_key
req = urllib.request.Request(
API_URL,
data=json.dumps(body).encode("utf-8") if body else None,
headers=headers,
method="POST"
)
ctx = ssl.create_default_context()
ctx.check_hostname = False
ctx.verify_mode = ssl.CERT_NONE
for attempt in range(3):
try:
with urllib.request.urlopen(req, context=ctx, timeout=60) as resp:
return json.loads(resp.read().decode("utf-8"))
except urllib.error.HTTPError as e:
if attempt < 2:
import time
time.sleep(1)
else:
print(f"错误: HTTP {e.code} - {e.reason}", file=sys.stderr)
sys.exit(1)
except Exception as e:
if attempt < 2:
import time
time.sleep(1)
else:
print(f"错误: {e}", file=sys.stderr)
sys.exit(1)
def process_result(result):
"""处理 API 响应结果,优先按 resultCode、resultMsg、data 读取"""
if isinstance(result, dict):
# 优先读取 resultCode、resultMsg、data
result_code = result.get('resultCode')
result_msg = result.get('resultMsg')
data = result.get('data')
# 构建标准化输出
processed = {
'resultCode': result_code,
'resultMsg': result_msg,
'data': data
}
return processed
return result
def main():
import argparse
parser = argparse.ArgumentParser(description="获取当前用户最近上传的文件列表")
parser.add_argument("--limit", type=int, help="返回数量限制")
parser.add_argument("--search-key", type=str, help="搜索关键词")
args = parser.parse_args()
result = call_api(limit=args.limit, search_key=args.search_key)
processed_result = process_result(result)
print(json.dumps(processed_result, ensure_ascii=False))
if __name__ == "__main__":
main()
FILE:scripts/browse/get-uploadable-list.py
#!/usr/bin/env python3
"""
browse / getUploadableList 脚本
用途:获取当前账号有上传/编辑权限的空间列表
使用方式:
python3 scripts/browse/get-uploadable-list.py [--name-key "关键词"] [--biz-code pmo]
环境变量:
XG_BIZ_API_KEY / XG_APP_KEY — appKey(由 cms-auth-skills 预先准备)
"""
import sys
import os
import json
import urllib.request
import urllib.parse
import urllib.error
import ssl
# 接口完整 URL(与 openapi/browse/get-uploadable-list.md 中声明的一致)
API_URL = "https://sg-al-cwork-web.mediportal.com.cn/open-api/document-database/project/uploadableList"
AUTH_MODE = "appKey"
def build_headers() -> dict:
"""根据鉴权模式构造请求头"""
headers = {"Content-Type": "application/json"}
if AUTH_MODE == "appKey":
app_key = os.environ.get("XG_BIZ_API_KEY") or os.environ.get("XG_APP_KEY")
if not app_key:
print("错误: 请设置环境变量 XG_BIZ_API_KEY 或 XG_APP_KEY", file=sys.stderr)
sys.exit(1)
headers["appKey"] = app_key
return headers
def call_api(app_code: str = None, name_key: str = None, biz_code: str = None) -> dict:
"""调用获取有上传权限空间列表接口,返回原始 JSON 响应"""
headers = build_headers()
params = []
if app_code:
params.append(("appCode", app_code))
if name_key:
params.append(("nameKey", name_key))
if biz_code:
params.append(("bizCode", biz_code))
url = f"{API_URL}?{urllib.parse.urlencode(params)}"
req = urllib.request.Request(url, headers=headers, method="GET")
ctx = ssl.create_default_context()
ctx.check_hostname = False
ctx.verify_mode = ssl.CERT_NONE
for attempt in range(3):
try:
with urllib.request.urlopen(req, context=ctx, timeout=60) as resp:
return json.loads(resp.read().decode("utf-8"))
except urllib.error.HTTPError as e:
if attempt < 2:
import time
time.sleep(1)
else:
print(f"错误: HTTP {e.code} - {e.reason}", file=sys.stderr)
sys.exit(1)
except Exception as e:
if attempt < 2:
import time
time.sleep(1)
else:
print(f"错误: {e}", file=sys.stderr)
sys.exit(1)
def process_result(result):
"""处理 API 响应结果,优先按 resultCode、resultMsg、data 读取"""
if isinstance(result, dict):
# 优先读取 resultCode、resultMsg、data
result_code = result.get('resultCode')
result_msg = result.get('resultMsg')
data = result.get('data')
# 构建标准化输出
processed = {
'resultCode': result_code,
'resultMsg': result_msg,
'data': data
}
return processed
return result
def main():
import argparse
parser = argparse.ArgumentParser(description="获取当前账号有上传/编辑权限的空间列表")
parser.add_argument("--app-code", type=str, help="应用编码")
parser.add_argument("--name-key", type=str, help="空间名称模糊搜索关键词")
parser.add_argument("--biz-code", type=str, help="业务线编码过滤")
args = parser.parse_args()
result = call_api(
app_code=args.app_code,
name_key=args.name_key,
biz_code=args.biz_code
)
processed_result = process_result(result)
print(json.dumps(processed_result, ensure_ascii=False))
if __name__ == "__main__":
main()
FILE:scripts/delete/README.md
# 脚本清单 — delete
## 共享依赖
无
## 脚本列表
| 脚本 | 对应接口 | 用途 |
|---|---|---|
| `delete-file.py` | `POST /open-api/document-database/file/deleteFile` | 删除指定文件,输出 JSON 结果 |
## 使用方式
```bash
# 先优先读取 cms-auth-skills/SKILL.md 获取 appKey;如未安装先安装
export XG_BIZ_API_KEY="your-app-key"
# 或
export XG_APP_KEY="your-app-key"
# 删除文件(默认逻辑删除,移入回收站)
python3 scripts/delete/delete-file.py <file_id>
# 物理彻底删除(不可恢复)
python3 scripts/delete/delete-file.py <file_id> --physical
```
## ⚠️ 高风险操作
删除文件前必须获得用户明确确认。
## 输出说明
所有脚本的输出均为 **JSON 格式**。
## 规范
1. **必须使用 Python** 编写
2. **鉴权遵循** `cms-auth-skills/SKILL.md` 规范
3. **入参定义以** `openapi/` 文档为准
FILE:scripts/delete/delete-file.py
#!/usr/bin/env python3
"""
delete / deleteFile 脚本
用途:删除指定文件(支持逻辑删除或物理彻底删除)
使用方式:
python3 scripts/delete/delete-file.py <file_id> [--physical]
环境变量:
XG_BIZ_API_KEY / XG_APP_KEY — appKey(由 cms-auth-skills 预先准备)
"""
import sys
import os
import json
import urllib.request
import urllib.error
import ssl
# 接口完整 URL(与 openapi/delete/delete-file.md 中声明的一致)
API_URL = "https://sg-al-cwork-web.mediportal.com.cn/open-api/document-database/file/deleteFile"
AUTH_MODE = "appKey"
def build_headers() -> dict:
"""根据鉴权模式构造请求头"""
headers = {"Content-Type": "application/json"}
if AUTH_MODE == "appKey":
app_key = os.environ.get("XG_BIZ_API_KEY") or os.environ.get("XG_APP_KEY")
if not app_key:
print("错误: 请设置环境变量 XG_BIZ_API_KEY 或 XG_APP_KEY", file=sys.stderr)
sys.exit(1)
headers["appKey"] = app_key
return headers
def call_api(file_id: int, is_physical: bool = False) -> dict:
"""调用删除文件接口,返回原始 JSON 响应"""
headers = build_headers()
body = {"fileId": file_id}
if is_physical:
body["isPhysical"] = True
req = urllib.request.Request(
API_URL,
data=json.dumps(body).encode("utf-8"),
headers=headers,
method="POST"
)
ctx = ssl.create_default_context()
ctx.check_hostname = False
ctx.verify_mode = ssl.CERT_NONE
for attempt in range(3):
try:
with urllib.request.urlopen(req, context=ctx, timeout=60) as resp:
return json.loads(resp.read().decode("utf-8"))
except urllib.error.HTTPError as e:
if attempt < 2:
import time
time.sleep(1)
else:
print(f"错误: HTTP {e.code} - {e.reason}", file=sys.stderr)
sys.exit(1)
except Exception as e:
if attempt < 2:
import time
time.sleep(1)
else:
print(f"错误: {e}", file=sys.stderr)
sys.exit(1)
def process_result(result):
"""处理 API 响应结果,优先按 resultCode、resultMsg、data 读取"""
if isinstance(result, dict):
# 优先读取 resultCode、resultMsg、data
result_code = result.get('resultCode')
result_msg = result.get('resultMsg')
data = result.get('data')
# 构建标准化输出
processed = {
'resultCode': result_code,
'resultMsg': result_msg,
'data': data
}
return processed
return result
def main():
import argparse
parser = argparse.ArgumentParser(description="删除指定文件")
parser.add_argument("file_id", type=int, help="要删除的文件 ID")
parser.add_argument("--physical", action="store_true", help="加上此参数则物理彻底删除,否则移入回收站")
args = parser.parse_args()
result = call_api(file_id=args.file_id, is_physical=args.physical)
processed_result = process_result(result)
print(json.dumps(processed_result, ensure_ascii=False))
if __name__ == "__main__":
main()
FILE:scripts/manage/README.md
# 脚本清单 — manage
## 共享依赖
无
## 鉴权前置条件
所有脚本统一使用 `appKey` 鉴权,通过 `cms-auth-skills` 获取:
```bash
export XG_BIZ_API_KEY="your-app-key"
# 或
export XG_APP_KEY="your-app-key"
```
## 脚本列表
| 脚本 | 对应接口 | 用途 |
|---|---|---|
| `update-file-property.py` | `POST /open-api/document-database/file/updateFileProperty` | 更新文件属性(重命名/移动) |
| `update-file-version.py` | `POST /open-api/document-database/file/updateFileVersion` | 物理文件版本更新(绑定新资源产生新版本) |
| `get-version-list.py` | `GET /open-api/document-database/file/getVersionList` | 获取文件完整版本历史列表 |
| `get-last-version.py` | `GET /open-api/document-database/file/getLastVersion` | 获取文件最新版本信息 |
| `finalize-version.py` | `POST /open-api/document-database/file/finalizeVersion` | 将指定版本标记为定稿 |
## 使用方式
```bash
# === 重命名/移动 ===
python3 scripts/manage/update-file-property.py <file_id> --new-name "新文件名.pdf"
python3 scripts/manage/update-file-property.py <file_id> --target-parent-id <parent_id>
python3 scripts/manage/update-file-property.py <file_id> --new-name "同名文件.pdf" --auto-rename
# === 物理文件版本更新 ===
# versionStatus: 1=覆盖草稿, 2=强制新建, 3=新建并立即定稿(推荐)
python3 scripts/manage/update-file-version.py <file_id> <project_id> <resource_id> \
--version-status 3 --version-name "V2.0" --version-remark "修订内容"
# === 查看版本历史 ===
python3 scripts/manage/get-version-list.py <file_id>
# === 获取最新版本 ===
python3 scripts/manage/get-last-version.py <file_id>
# === 版本定稿 ===
# 定稿最新版本
python3 scripts/manage/finalize-version.py <file_id>
# 定稿指定版本号
python3 scripts/manage/finalize-version.py <file_id> --version-number 3
```
## 返回说明
所有脚本输出均为 JSON 格式:
- `resultCode`: 1 成功,非 1 失败
- `resultMsg`: 错误信息(成功时为 null)
- `data`: 业务数据
版本对象关键字段:`versionNumber`、`versionName`、`status`(1草稿/2定稿)、`remark`、`creator`、`lastVersion`
## ⚠️ 注意事项
- 重命名/移动文件前应确认用户意图
- 纯文本内容的版本更新请使用 `scripts/upload/upload-content.py`(传 `--update-file-id`)
- 物理文件版本更新推荐使用 `--version-status 3`(新建并立即定稿)
FILE:scripts/manage/finalize-version.py
#!/usr/bin/env python3
"""
manage / finalizeVersion 脚本
用途:将文件的某个版本标记为正式定稿状态(status 从 1 变为 2)。
不传 version_number 则定稿最新版本。
使用方式:
# 定稿最新版本
python3 scripts/manage/finalize-version.py <file_id>
# 定稿指定版本号
python3 scripts/manage/finalize-version.py <file_id> --version-number 3
环境变量:
XG_BIZ_API_KEY / XG_APP_KEY — appKey(由 cms-auth-skills 预先准备)
"""
import sys
import os
import json
import time
import argparse
import urllib.request
import urllib.error
import ssl
API_URL = "https://sg-al-cwork-web.mediportal.com.cn/open-api/document-database/file/finalizeVersion"
AUTH_MODE = "appKey"
TIMEOUT = 60
MAX_RETRIES = 3
RETRY_INTERVAL = 1
def build_headers() -> dict:
headers = {"Content-Type": "application/json"}
app_key = os.environ.get("XG_BIZ_API_KEY") or os.environ.get("XG_APP_KEY")
if not app_key:
print("错误: 请设置环境变量 XG_BIZ_API_KEY 或 XG_APP_KEY", file=sys.stderr)
sys.exit(1)
headers["appKey"] = app_key
return headers
def call_api(payload: dict) -> dict:
headers = build_headers()
req = urllib.request.Request(
API_URL,
data=json.dumps(payload).encode("utf-8"),
headers=headers,
method="POST"
)
ctx = ssl.create_default_context()
ctx.check_hostname = False
ctx.verify_mode = ssl.CERT_NONE
for attempt in range(MAX_RETRIES):
try:
with urllib.request.urlopen(req, context=ctx, timeout=TIMEOUT) as resp:
return json.loads(resp.read().decode("utf-8"))
except urllib.error.HTTPError as e:
if attempt < MAX_RETRIES - 1:
time.sleep(RETRY_INTERVAL)
else:
print(f"错误: HTTP {e.code} - {e.reason}", file=sys.stderr)
sys.exit(1)
except Exception as e:
if attempt < MAX_RETRIES - 1:
time.sleep(RETRY_INTERVAL)
else:
print(f"错误: {e}", file=sys.stderr)
sys.exit(1)
def main() -> None:
parser = argparse.ArgumentParser(description="将文件版本标记为定稿")
parser.add_argument("file_id", type=int, help="文件 ID")
parser.add_argument("--version-number", type=int, default=0,
help="要定稿的版本号(不传或传 0 则定稿最新版本)")
args = parser.parse_args()
payload = {"fileId": args.file_id}
if args.version_number:
payload["versionNumber"] = args.version_number
result = call_api(payload)
output = {
"resultCode": result.get("resultCode"),
"resultMsg": result.get("resultMsg"),
"data": result.get("data"),
}
print(json.dumps(output, ensure_ascii=False))
if __name__ == "__main__":
main()
FILE:scripts/manage/get-last-version.py
#!/usr/bin/env python3
"""
manage / getLastVersion 脚本
用途:快速获取文件当前最新版本的详细信息。
使用方式:
python3 scripts/manage/get-last-version.py <file_id>
环境变量:
XG_BIZ_API_KEY / XG_APP_KEY — appKey(由 cms-auth-skills 预先准备)
"""
import sys
import os
import json
import time
import argparse
import urllib.request
import urllib.error
import urllib.parse
import ssl
API_URL = "https://sg-al-cwork-web.mediportal.com.cn/open-api/document-database/file/getLastVersion"
AUTH_MODE = "appKey"
TIMEOUT = 60
MAX_RETRIES = 3
RETRY_INTERVAL = 1
def build_headers() -> dict:
headers = {"Content-Type": "application/json"}
app_key = os.environ.get("XG_BIZ_API_KEY") or os.environ.get("XG_APP_KEY")
if not app_key:
print("错误: 请设置环境变量 XG_BIZ_API_KEY 或 XG_APP_KEY", file=sys.stderr)
sys.exit(1)
headers["appKey"] = app_key
return headers
def call_api(file_id: int) -> dict:
headers = build_headers()
params = urllib.parse.urlencode({"fileId": file_id})
url = f"{API_URL}?{params}"
req = urllib.request.Request(url, headers=headers, method="GET")
ctx = ssl.create_default_context()
ctx.check_hostname = False
ctx.verify_mode = ssl.CERT_NONE
for attempt in range(MAX_RETRIES):
try:
with urllib.request.urlopen(req, context=ctx, timeout=TIMEOUT) as resp:
return json.loads(resp.read().decode("utf-8"))
except urllib.error.HTTPError as e:
if attempt < MAX_RETRIES - 1:
time.sleep(RETRY_INTERVAL)
else:
print(f"错误: HTTP {e.code} - {e.reason}", file=sys.stderr)
sys.exit(1)
except Exception as e:
if attempt < MAX_RETRIES - 1:
time.sleep(RETRY_INTERVAL)
else:
print(f"错误: {e}", file=sys.stderr)
sys.exit(1)
def main() -> None:
parser = argparse.ArgumentParser(description="获取文件最新版本信息")
parser.add_argument("file_id", type=int, help="文件 ID")
args = parser.parse_args()
result = call_api(args.file_id)
v = result.get("data") or {}
output = {
"resultCode": result.get("resultCode"),
"resultMsg": result.get("resultMsg"),
"data": {
"id": v.get("id"),
"fileId": v.get("fileId"),
"versionNumber": v.get("versionNumber"),
"versionName": v.get("versionName"),
"status": v.get("status"),
"remark": v.get("remark"),
"creator": v.get("creator"),
"createTime": v.get("createTime"),
"lastVersion": v.get("lastVersion"),
} if isinstance(v, dict) else v,
}
print(json.dumps(output, ensure_ascii=False))
if __name__ == "__main__":
main()
FILE:scripts/manage/get-version-list.py
#!/usr/bin/env python3
"""
manage / getVersionList 脚本
用途:获取指定文件的完整版本历史列表。
使用方式:
python3 scripts/manage/get-version-list.py <file_id>
环境变量:
XG_BIZ_API_KEY / XG_APP_KEY — appKey(由 cms-auth-skills 预先准备)
"""
import sys
import os
import json
import time
import argparse
import urllib.request
import urllib.error
import urllib.parse
import ssl
API_URL = "https://sg-al-cwork-web.mediportal.com.cn/open-api/document-database/file/getVersionList"
AUTH_MODE = "appKey"
TIMEOUT = 60
MAX_RETRIES = 3
RETRY_INTERVAL = 1
def build_headers() -> dict:
headers = {"Content-Type": "application/json"}
app_key = os.environ.get("XG_BIZ_API_KEY") or os.environ.get("XG_APP_KEY")
if not app_key:
print("错误: 请设置环境变量 XG_BIZ_API_KEY 或 XG_APP_KEY", file=sys.stderr)
sys.exit(1)
headers["appKey"] = app_key
return headers
def call_api(file_id: int) -> dict:
headers = build_headers()
params = urllib.parse.urlencode({"fileId": file_id})
url = f"{API_URL}?{params}"
req = urllib.request.Request(url, headers=headers, method="GET")
ctx = ssl.create_default_context()
ctx.check_hostname = False
ctx.verify_mode = ssl.CERT_NONE
for attempt in range(MAX_RETRIES):
try:
with urllib.request.urlopen(req, context=ctx, timeout=TIMEOUT) as resp:
return json.loads(resp.read().decode("utf-8"))
except urllib.error.HTTPError as e:
if attempt < MAX_RETRIES - 1:
time.sleep(RETRY_INTERVAL)
else:
print(f"错误: HTTP {e.code} - {e.reason}", file=sys.stderr)
sys.exit(1)
except Exception as e:
if attempt < MAX_RETRIES - 1:
time.sleep(RETRY_INTERVAL)
else:
print(f"错误: {e}", file=sys.stderr)
sys.exit(1)
def main() -> None:
parser = argparse.ArgumentParser(description="获取文件版本历史列表")
parser.add_argument("file_id", type=int, help="文件 ID")
args = parser.parse_args()
result = call_api(args.file_id)
versions = result.get("data") or []
output = {
"resultCode": result.get("resultCode"),
"resultMsg": result.get("resultMsg"),
"data": [
{
"id": v.get("id"),
"fileId": v.get("fileId"),
"versionNumber": v.get("versionNumber"),
"versionName": v.get("versionName"),
"status": v.get("status"),
"remark": v.get("remark"),
"creator": v.get("creator"),
"createTime": v.get("createTime"),
"lastVersion": v.get("lastVersion"),
}
for v in versions
] if isinstance(versions, list) else versions,
}
print(json.dumps(output, ensure_ascii=False))
if __name__ == "__main__":
main()
FILE:scripts/manage/update-file-property.py
#!/usr/bin/env python3
"""
manage / updateFileProperty 脚本
用途:更新文件属性(重命名/移动)
使用方式:
python3 scripts/manage/update-file-property.py <file_id> [--new-name "新文件名"] [--target-parent-id 123]
环境变量:
XG_BIZ_API_KEY / XG_APP_KEY — appKey(由 cms-auth-skills 预先准备)
"""
import sys
import os
import json
import urllib.request
import urllib.error
import ssl
# 接口完整 URL(与 openapi/manage/update-file-property.md 中声明的一致)
API_URL = "https://sg-al-cwork-web.mediportal.com.cn/open-api/document-database/file/updateFileProperty"
AUTH_MODE = "appKey"
def build_headers() -> dict:
"""根据鉴权模式构造请求头"""
headers = {"Content-Type": "application/json"}
if AUTH_MODE == "appKey":
app_key = os.environ.get("XG_BIZ_API_KEY") or os.environ.get("XG_APP_KEY")
if not app_key:
print("错误: 请设置环境变量 XG_BIZ_API_KEY 或 XG_APP_KEY", file=sys.stderr)
sys.exit(1)
headers["appKey"] = app_key
return headers
def call_api(file_id: int, new_name: str = None, target_parent_id: int = None,
cover: bool = None, auto_rename: bool = None) -> dict:
"""调用更新文件属性接口,返回原始 JSON 响应"""
headers = build_headers()
body = {"fileId": file_id}
if new_name is not None:
body["newName"] = new_name
if target_parent_id is not None:
body["targetParentId"] = target_parent_id
if cover is not None:
body["cover"] = cover
if auto_rename is not None:
body["autoRename"] = auto_rename
req = urllib.request.Request(
API_URL,
data=json.dumps(body).encode("utf-8"),
headers=headers,
method="POST"
)
ctx = ssl.create_default_context()
ctx.check_hostname = False
ctx.verify_mode = ssl.CERT_NONE
for attempt in range(3):
try:
with urllib.request.urlopen(req, context=ctx, timeout=60) as resp:
return json.loads(resp.read().decode("utf-8"))
except urllib.error.HTTPError as e:
if attempt < 2:
import time
time.sleep(1)
else:
print(f"错误: HTTP {e.code} - {e.reason}", file=sys.stderr)
sys.exit(1)
except Exception as e:
if attempt < 2:
import time
time.sleep(1)
else:
print(f"错误: {e}", file=sys.stderr)
sys.exit(1)
def process_result(result):
"""处理 API 响应结果,优先按 resultCode、resultMsg、data 读取"""
if isinstance(result, dict):
# 优先读取 resultCode、resultMsg、data
result_code = result.get('resultCode')
result_msg = result.get('resultMsg')
data = result.get('data')
# 构建标准化输出
processed = {
'resultCode': result_code,
'resultMsg': result_msg,
'data': data
}
return processed
return result
def main():
import argparse
parser = argparse.ArgumentParser(description="更新文件属性(重命名/移动)")
parser.add_argument("file_id", type=int, help="文件 ID")
parser.add_argument("--new-name", type=str, help="新文件名")
parser.add_argument("--target-parent-id", type=int, help="目标父目录 ID")
parser.add_argument("--cover", action="store_true", help="同名冲突时覆盖")
parser.add_argument("--auto-rename", action="store_true", help="同名冲突时自动追加数字后缀")
args = parser.parse_args()
if not args.new_name and not args.target_parent_id:
print("错误: 必须提供 --new-name 或 --target-parent-id 之一", file=sys.stderr)
sys.exit(1)
result = call_api(
file_id=args.file_id,
new_name=args.new_name,
target_parent_id=args.target_parent_id,
cover=args.cover if args.cover else None,
auto_rename=args.auto_rename if args.auto_rename else None
)
processed_result = process_result(result)
print(json.dumps(processed_result, ensure_ascii=False))
if __name__ == "__main__":
main()
FILE:scripts/manage/update-file-version.py
#!/usr/bin/env python3
"""
manage / updateFileVersion 脚本
用途:将已上传的物理文件资源绑定到已有文件,产生新版本记录。
使用方式:
python3 scripts/manage/update-file-version.py <file_id> <project_id> <resource_id> \
[--version-status 3] [--version-name "V2.0"] [--version-remark "修订内容"] \
[--suffix pdf] [--size 204800]
versionStatus 说明:
1 = 覆盖当前草稿(默认)
2 = 强制新建版本
3 = 新建版本并立即定稿(推荐)
环境变量:
XG_BIZ_API_KEY / XG_APP_KEY — appKey(由 cms-auth-skills 预先准备)
"""
import sys
import os
import json
import time
import argparse
import urllib.request
import urllib.error
import ssl
API_URL = "https://sg-al-cwork-web.mediportal.com.cn/open-api/document-database/file/updateFileVersion"
AUTH_MODE = "appKey"
TIMEOUT = 60
MAX_RETRIES = 3
RETRY_INTERVAL = 1
def build_headers() -> dict:
headers = {"Content-Type": "application/json"}
app_key = os.environ.get("XG_BIZ_API_KEY") or os.environ.get("XG_APP_KEY")
if not app_key:
print("错误: 请设置环境变量 XG_BIZ_API_KEY 或 XG_APP_KEY", file=sys.stderr)
sys.exit(1)
headers["appKey"] = app_key
return headers
def call_api(payload: dict) -> dict:
headers = build_headers()
req = urllib.request.Request(
API_URL,
data=json.dumps(payload).encode("utf-8"),
headers=headers,
method="POST"
)
ctx = ssl.create_default_context()
ctx.check_hostname = False
ctx.verify_mode = ssl.CERT_NONE
for attempt in range(MAX_RETRIES):
try:
with urllib.request.urlopen(req, context=ctx, timeout=TIMEOUT) as resp:
return json.loads(resp.read().decode("utf-8"))
except urllib.error.HTTPError as e:
if attempt < MAX_RETRIES - 1:
time.sleep(RETRY_INTERVAL)
else:
print(f"错误: HTTP {e.code} - {e.reason}", file=sys.stderr)
sys.exit(1)
except Exception as e:
if attempt < MAX_RETRIES - 1:
time.sleep(RETRY_INTERVAL)
else:
print(f"错误: {e}", file=sys.stderr)
sys.exit(1)
def main() -> None:
parser = argparse.ArgumentParser(description="物理文件版本更新")
parser.add_argument("file_id", type=int, help="要更新的文件 ID")
parser.add_argument("project_id", type=int, help="文件所在空间 ID")
parser.add_argument("resource_id", type=int, help="新上传的物理资源 ID")
parser.add_argument("--version-status", type=int, default=3,
help="版本行为:1=覆盖草稿,2=强制新建,3=新建并立即定稿(默认 3)")
parser.add_argument("--version-name", type=str, help="版本名称,如 V2.0")
parser.add_argument("--version-remark", type=str, help="版本说明")
parser.add_argument("--suffix", type=str, help="文件后缀")
parser.add_argument("--size", type=int, help="文件大小(字节)")
args = parser.parse_args()
payload = {
"id": args.file_id,
"projectId": args.project_id,
"resourceId": args.resource_id,
"versionStatus": args.version_status,
}
if args.version_name:
payload["versionName"] = args.version_name
if args.version_remark:
payload["versionRemark"] = args.version_remark
if args.suffix:
payload["suffix"] = args.suffix
if args.size:
payload["size"] = args.size
result = call_api(payload)
output = {
"resultCode": result.get("resultCode"),
"resultMsg": result.get("resultMsg"),
"data": result.get("data"),
}
print(json.dumps(output, ensure_ascii=False))
if __name__ == "__main__":
main()
FILE:scripts/query/README.md
# 脚本清单 — query
## 共享依赖
无
## 脚本列表
| 脚本 | 对应接口 | 用途 |
|---|---|---|
| `search.py` | `GET /open-api/document-database/file/searchFile` | 搜索文件或目录 |
| `get-full-content.py` | `GET /open-api/document-database/file/getFullFileContent` | 获取文件全局提纯文本(Markdown),RAG 入口 |
| `get-download-info.py` | `GET /open-api/document-database/file/getDownloadInfo` | 获取文件下载/预览凭据 |
| `get-file-content.py` | `GET /open-api/document-database/file/getFileContent` | 分页获取文件文本内容 |
| `batch-get-content.py` | `POST /open-api/document-database/ai/batchGetContent` | 批量获取多个文件全文,建议≤10个 |
## 使用方式
```bash
export XG_BIZ_API_KEY="your-app-key"
# 或
export XG_APP_KEY="your-app-key"
# 搜索文件
python3 scripts/query/search.py "关键词" [--project-id 123]
# 获取文件全文(AI 摘要/RAG)
python3 scripts/query/get-full-content.py <file_id>
# 获取下载/预览凭据
python3 scripts/query/get-download-info.py <file_id> [--force-download]
# 分页获取文件内容
python3 scripts/query/get-file-content.py <file_id> [--page-number 1]
# 批量获取文件全文(RAG 场景)
python3 scripts/query/batch-get-content.py '[{"fileId":123},{"fileId":456}]'
```
## 输出说明
所有脚本的输出均为 **JSON 格式**。
## 规范
1. **必须使用 Python** 编写
2. **鉴权遵循** `cms-auth-skills/SKILL.md` 规范
3. **入参定义以** `openapi/` 文档为准
FILE:scripts/query/batch-get-content.py
#!/usr/bin/env python3
"""
query / batchGetContent 脚本
用途:批量获取多个文件的全文内容,减少 RAG 场景交互次数
使用方式:
python3 scripts/query/batch-get-content.py "[{\"fileId\":123},{\"fileId\":456}]"
环境变量:
XG_BIZ_API_KEY / XG_APP_KEY — appKey(由 cms-auth-skills 预先准备)
"""
import sys
import os
import json
import urllib.request
import urllib.error
import ssl
# 接口完整 URL(与 openapi/query/batch-get-content.md 中声明的一致)
API_URL = "https://sg-al-cwork-web.mediportal.com.cn/open-api/document-database/ai/batchGetContent"
AUTH_MODE = "appKey"
def build_headers() -> dict:
"""根据鉴权模式构造请求头"""
headers = {"Content-Type": "application/json"}
if AUTH_MODE == "appKey":
app_key = os.environ.get("XG_BIZ_API_KEY") or os.environ.get("XG_APP_KEY")
if not app_key:
print("错误: 请设置环境变量 XG_BIZ_API_KEY 或 XG_APP_KEY", file=sys.stderr)
sys.exit(1)
headers["appKey"] = app_key
return headers
def call_api(files: list) -> dict:
"""调用批量获取文件内容接口,返回原始 JSON 响应"""
headers = build_headers()
body = json.dumps({"files": files}).encode("utf-8")
req = urllib.request.Request(
API_URL,
data=body,
headers=headers,
method="POST"
)
ctx = ssl.create_default_context()
ctx.check_hostname = False
ctx.verify_mode = ssl.CERT_NONE
for attempt in range(3):
try:
with urllib.request.urlopen(req, context=ctx, timeout=60) as resp:
return json.loads(resp.read().decode("utf-8"))
except urllib.error.HTTPError as e:
if attempt < 2:
import time
time.sleep(1)
else:
print(f"错误: HTTP {e.code} - {e.reason}", file=sys.stderr)
sys.exit(1)
except Exception as e:
if attempt < 2:
import time
time.sleep(1)
else:
print(f"错误: {e}", file=sys.stderr)
sys.exit(1)
def process_result(result):
"""处理 API 响应结果,优先按 resultCode、resultMsg、data 读取"""
if isinstance(result, dict):
# 优先读取 resultCode、resultMsg、data
result_code = result.get('resultCode')
result_msg = result.get('resultMsg')
data = result.get('data')
# 构建标准化输出
processed = {
'resultCode': result_code,
'resultMsg': result_msg,
'data': data
}
return processed
return result
def main():
import argparse
parser = argparse.ArgumentParser(description="批量获取多个文件的全文内容")
parser.add_argument("files_json", type=str, help='文件列表 JSON,如 [{"fileId":123},{"fileId":456}]')
args = parser.parse_args()
files = json.loads(args.files_json)
result = call_api(files)
processed_result = process_result(result)
print(json.dumps(processed_result, ensure_ascii=False))
if __name__ == "__main__":
main()
FILE:scripts/query/get-download-info.py
#!/usr/bin/env python3
"""
query / getDownloadInfo 脚本
用途:获取文件的下载链接或在线预览凭据
使用方式:
python3 scripts/query/get-download-info.py <file_id> [--force-download] [--see-original]
环境变量:
XG_BIZ_API_KEY / XG_APP_KEY — appKey(由 cms-auth-skills 预先准备)
"""
import sys
import os
import json
import urllib.request
import urllib.parse
import urllib.error
import ssl
# 接口完整 URL(与 openapi/query/get-download-info.md 中声明的一致)
API_URL = "https://sg-al-cwork-web.mediportal.com.cn/open-api/document-database/file/getDownloadInfo"
AUTH_MODE = "appKey"
def build_headers() -> dict:
"""根据鉴权模式构造请求头"""
headers = {"Content-Type": "application/json"}
if AUTH_MODE == "appKey":
app_key = os.environ.get("XG_BIZ_API_KEY") or os.environ.get("XG_APP_KEY")
if not app_key:
print("错误: 请设置环境变量 XG_BIZ_API_KEY 或 XG_APP_KEY", file=sys.stderr)
sys.exit(1)
headers["appKey"] = app_key
return headers
def call_api(file_id: int, force_download: bool = False, see_original: bool = None,
source: str = None, version_number: int = None, bypass_risk: bool = None) -> dict:
"""调用获取下载/预览凭据接口,返回原始 JSON 响应"""
headers = build_headers()
params = [("fileId", str(file_id)), ("forceDownload", "true" if force_download else "false")]
if see_original is not None:
params.append(("seeOriginal", "true" if see_original else "false"))
if source:
params.append(("source", source))
if version_number is not None:
params.append(("versionNumber", str(version_number)))
if bypass_risk is not None:
params.append(("bypassRisk", "true" if bypass_risk else "false"))
url = f"{API_URL}?{urllib.parse.urlencode(params)}"
req = urllib.request.Request(url, headers=headers, method="GET")
ctx = ssl.create_default_context()
ctx.check_hostname = False
ctx.verify_mode = ssl.CERT_NONE
for attempt in range(3):
try:
with urllib.request.urlopen(req, context=ctx, timeout=60) as resp:
return json.loads(resp.read().decode("utf-8"))
except urllib.error.HTTPError as e:
if attempt < 2:
import time
time.sleep(1)
else:
print(f"错误: HTTP {e.code} - {e.reason}", file=sys.stderr)
sys.exit(1)
except Exception as e:
if attempt < 2:
import time
time.sleep(1)
else:
print(f"错误: {e}", file=sys.stderr)
sys.exit(1)
def process_result(result):
"""处理 API 响应结果,优先按 resultCode、resultMsg、data 读取"""
if isinstance(result, dict):
# 优先读取 resultCode、resultMsg、data
result_code = result.get('resultCode')
result_msg = result.get('resultMsg')
data = result.get('data')
# 构建标准化输出
processed = {
'resultCode': result_code,
'resultMsg': result_msg,
'data': data
}
return processed
return result
def main():
import argparse
parser = argparse.ArgumentParser(description="获取文件下载或在线预览凭据")
parser.add_argument("file_id", type=int, help="文件 ID")
parser.add_argument("--force-download", action="store_true", help="true 则返回下载链接,false 则返回预览凭据")
parser.add_argument("--see-original", action="store_true", help="预览是否查看原文")
parser.add_argument("--source", type=str, help="来源")
parser.add_argument("--version-number", type=int, help="版本号")
parser.add_argument("--bypass-risk", action="store_true", help="是否绕过风险检查")
args = parser.parse_args()
result = call_api(
file_id=args.file_id,
force_download=args.force_download,
see_original=args.see_original if "--see-original" in sys.argv else None,
source=args.source if args.source else None,
version_number=args.version_number if args.version_number else None,
bypass_risk=args.bypass_risk if args.bypass_risk else None
)
processed_result = process_result(result)
print(json.dumps(processed_result, ensure_ascii=False))
if __name__ == "__main__":
main()
FILE:scripts/query/get-file-content.py
#!/usr/bin/env python3
"""
query / getFileContent 脚本
用途:分页获取文件的文本内容,用于大文件的分段流式读取
使用方式:
python3 scripts/query/get-file-content.py <file_id> [--page-number 1]
环境变量:
XG_BIZ_API_KEY / XG_APP_KEY — appKey(由 cms-auth-skills 预先准备)
"""
import sys
import os
import json
import urllib.request
import urllib.parse
import urllib.error
import ssl
# 接口完整 URL(与 openapi/query/get-file-content.md 中声明的一致)
API_URL = "https://sg-al-cwork-web.mediportal.com.cn/open-api/document-database/file/getFileContent"
AUTH_MODE = "appKey"
def build_headers() -> dict:
"""根据鉴权模式构造请求头"""
headers = {"Content-Type": "application/json"}
if AUTH_MODE == "appKey":
app_key = os.environ.get("XG_BIZ_API_KEY") or os.environ.get("XG_APP_KEY")
if not app_key:
print("错误: 请设置环境变量 XG_BIZ_API_KEY 或 XG_APP_KEY", file=sys.stderr)
sys.exit(1)
headers["appKey"] = app_key
return headers
def call_api(file_id: int, page_number: int = 1) -> dict:
"""调用分页获取文件内容接口,返回原始 JSON 响应"""
headers = build_headers()
params = [("fileId", str(file_id)), ("pageNumber", str(page_number))]
url = f"{API_URL}?{urllib.parse.urlencode(params)}"
req = urllib.request.Request(url, headers=headers, method="GET")
ctx = ssl.create_default_context()
ctx.check_hostname = False
ctx.verify_mode = ssl.CERT_NONE
for attempt in range(3):
try:
with urllib.request.urlopen(req, context=ctx, timeout=60) as resp:
return json.loads(resp.read().decode("utf-8"))
except urllib.error.HTTPError as e:
if attempt < 2:
import time
time.sleep(1)
else:
print(f"错误: HTTP {e.code} - {e.reason}", file=sys.stderr)
sys.exit(1)
except Exception as e:
if attempt < 2:
import time
time.sleep(1)
else:
print(f"错误: {e}", file=sys.stderr)
sys.exit(1)
def process_result(result):
"""处理 API 响应结果,优先按 resultCode、resultMsg、data 读取"""
if isinstance(result, dict):
# 优先读取 resultCode、resultMsg、data
result_code = result.get('resultCode')
result_msg = result.get('resultMsg')
data = result.get('data')
# 构建标准化输出
processed = {
'resultCode': result_code,
'resultMsg': result_msg,
'data': data
}
return processed
return result
def main():
import argparse
parser = argparse.ArgumentParser(description="分页获取文件文本内容")
parser.add_argument("file_id", type=int, help="文件 ID")
parser.add_argument("--page-number", type=int, default=1, help="页码,从 1 开始")
args = parser.parse_args()
result = call_api(file_id=args.file_id, page_number=args.page_number)
processed_result = process_result(result)
print(json.dumps(processed_result, ensure_ascii=False))
if __name__ == "__main__":
main()
FILE:scripts/query/get-full-content.py
#!/usr/bin/env python3
"""
query / getFullFileContent 脚本
用途:获取文件的全局提纯文本(Markdown 格式),面向 AI 摘要/分析/RAG 消费
使用方式:
python3 scripts/query/get-full-content.py <file_id>
环境变量:
XG_BIZ_API_KEY / XG_APP_KEY — appKey(由 cms-auth-skills 预先准备)
"""
import sys
import os
import json
import urllib.request
import urllib.parse
import urllib.error
import ssl
# 接口完整 URL(与 openapi/query/get-full-content.md 中声明的一致)
API_URL = "https://sg-al-cwork-web.mediportal.com.cn/open-api/document-database/file/getFullFileContent"
AUTH_MODE = "appKey"
def build_headers() -> dict:
"""根据鉴权模式构造请求头"""
headers = {"Content-Type": "application/json"}
if AUTH_MODE == "appKey":
app_key = os.environ.get("XG_BIZ_API_KEY") or os.environ.get("XG_APP_KEY")
if not app_key:
print("错误: 请设置环境变量 XG_BIZ_API_KEY 或 XG_APP_KEY", file=sys.stderr)
sys.exit(1)
headers["appKey"] = app_key
return headers
def call_api(file_id: int) -> dict:
"""调用全局提纯文本接口,返回原始 JSON 响应"""
headers = build_headers()
url = f"{API_URL}?fileId={file_id}"
req = urllib.request.Request(url, headers=headers, method="GET")
ctx = ssl.create_default_context()
ctx.check_hostname = False
ctx.verify_mode = ssl.CERT_NONE
for attempt in range(3):
try:
with urllib.request.urlopen(req, context=ctx, timeout=60) as resp:
return json.loads(resp.read().decode("utf-8"))
except urllib.error.HTTPError as e:
if attempt < 2:
import time
time.sleep(1)
else:
print(f"错误: HTTP {e.code} - {e.reason}", file=sys.stderr)
sys.exit(1)
except Exception as e:
if attempt < 2:
import time
time.sleep(1)
else:
print(f"错误: {e}", file=sys.stderr)
sys.exit(1)
def process_result(result):
"""处理 API 响应结果,优先按 resultCode、resultMsg、data 读取"""
if isinstance(result, dict):
# 优先读取 resultCode、resultMsg、data
result_code = result.get('resultCode')
result_msg = result.get('resultMsg')
data = result.get('data')
# 构建标准化输出
processed = {
'resultCode': result_code,
'resultMsg': result_msg,
'data': data
}
return processed
return result
def main():
if len(sys.argv) < 2:
print("错误: 请提供文件 ID", file=sys.stderr)
sys.exit(1)
file_id = int(sys.argv[1])
result = call_api(file_id)
processed_result = process_result(result)
print(json.dumps(processed_result, ensure_ascii=False))
if __name__ == "__main__":
main()
FILE:scripts/query/search.py
#!/usr/bin/env python3
"""
query / search 脚本
用途:根据关键词搜索文件或目录
使用方式:
python3 scripts/query/search.py "搜索关键词"
环境变量:
XG_BIZ_API_KEY / XG_APP_KEY — appKey(由 cms-auth-skills 预先准备)
"""
import sys
import os
import json
import urllib.request
import urllib.parse
import urllib.error
import ssl
# 接口完整 URL(与 openapi/query/search.md 中声明的一致)
API_URL = "https://sg-al-cwork-web.mediportal.com.cn/open-api/document-database/file/searchFile"
AUTH_MODE = "appKey"
def build_headers() -> dict:
"""根据鉴权模式构造请求头"""
headers = {"Content-Type": "application/json"}
if AUTH_MODE == "appKey":
app_key = os.environ.get("XG_BIZ_API_KEY") or os.environ.get("XG_APP_KEY")
if not app_key:
print("错误: 请设置环境变量 XG_BIZ_API_KEY 或 XG_APP_KEY", file=sys.stderr)
sys.exit(1)
headers["appKey"] = app_key
return headers
def call_api(name_key: str, project_id: int = None, root_file_id: int = None,
start_time: int = None, end_time: int = None,
is_file_storage: bool = None,
exclude_file_types: str = None, exclude_folder_names: str = None) -> dict:
"""调用文件搜索接口,返回原始 JSON 响应"""
headers = build_headers()
# nameKey 必须 URL 编码
params = [("nameKey", name_key)]
if project_id is not None:
params.append(("projectId", str(project_id)))
if root_file_id is not None:
params.append(("rootFileId", str(root_file_id)))
if start_time is not None:
params.append(("startTime", str(start_time)))
if end_time is not None:
params.append(("endTime", str(end_time)))
if is_file_storage is not None:
params.append(("isFileStorage", "true" if is_file_storage else "false"))
if exclude_file_types:
params.append(("excludeFileTypes", exclude_file_types))
if exclude_folder_names:
params.append(("excludeFolderNames", exclude_folder_names))
url = f"{API_URL}?{urllib.parse.urlencode(params)}"
req = urllib.request.Request(url, headers=headers, method="GET")
ctx = ssl.create_default_context()
ctx.check_hostname = False
ctx.verify_mode = ssl.CERT_NONE
for attempt in range(3):
try:
with urllib.request.urlopen(req, context=ctx, timeout=60) as resp:
return json.loads(resp.read().decode("utf-8"))
except urllib.error.HTTPError as e:
if attempt < 2:
import time
time.sleep(1)
else:
print(f"错误: HTTP {e.code} - {e.reason}", file=sys.stderr)
sys.exit(1)
except Exception as e:
if attempt < 2:
import time
time.sleep(1)
else:
print(f"错误: {e}", file=sys.stderr)
sys.exit(1)
def process_result(result):
"""处理 API 响应结果,优先按 resultCode、resultMsg、data 读取"""
if isinstance(result, dict):
# 优先读取 resultCode、resultMsg、data
result_code = result.get('resultCode')
result_msg = result.get('resultMsg')
data = result.get('data')
# 构建标准化输出
processed = {
'resultCode': result_code,
'resultMsg': result_msg,
'data': data
}
return processed
return result
def main():
import argparse
parser = argparse.ArgumentParser(description="搜索文件或目录")
parser.add_argument("name_key", type=str, help="搜索关键词")
parser.add_argument("--project-id", type=int, help="项目/空间 ID")
parser.add_argument("--root-file-id", type=int, help="指定根目录 ID")
parser.add_argument("--start-time", type=int, help="开始时间戳(毫秒)")
parser.add_argument("--end-time", type=int, help="结束时间戳(毫秒)")
parser.add_argument("--is-file-storage", action="store_true", help="文件存储范围")
parser.add_argument("--exclude-file-types", type=str, help="排除的文件类型,逗号分隔")
parser.add_argument("--exclude-folder-names", type=str, help="排除的文件夹名称,逗号分隔")
args = parser.parse_args()
result = call_api(
name_key=args.name_key,
project_id=args.project_id,
root_file_id=args.root_file_id,
start_time=args.start_time,
end_time=args.end_time,
is_file_storage=args.is_file_storage,
exclude_file_types=args.exclude_file_types,
exclude_folder_names=args.exclude_folder_names
)
processed_result = process_result(result)
print(json.dumps(processed_result, ensure_ascii=False))
if __name__ == "__main__":
main()
FILE:scripts/upload/README.md
# 脚本清单 — upload
## 共享依赖
无
## 脚本列表
| 脚本 | 对应接口 | 用途 |
|---|---|---|
| `upload-content.py` | `POST /open-api/document-database/file/uploadContent` | 一键保存纯文本到个人知识库 |
| `save-file-by-path.py` | `POST /open-api/document-database/file/saveFileByPath` | 按逻辑路径保存物理文件到项目空间 |
| `save-file-by-parent-id.py` | `POST /open-api/document-database/file/saveFileByParentId` | 已知父目录 ID 时保存物理文件 |
| `upload-whole-file.py` | `POST /open-api/cwork-file/uploadWholeFile` | 小文件整传(≤20MB),返回 resourceId |
| `check-slice.py` | `GET /open-api/document-database/file/getSliceIdByMd5V2` | 大文件分片预检,支持秒传判定 |
| `register-slice.py` | `POST /open-api/document-database/file/uploadFileSliceV2` | 注册分片元信息,换取 sliceId |
| `merge-resource.py` | `POST /open-api/document-database/file/saveResource` | 合并分片生成最终 resourceId |
| `get-file-download-info.py` | `GET /open-api/cwork-file/getDownloadInfo` | 根据 resourceId 获取下载 URL(有效期 1 小时) |
## 使用方式
```bash
export XG_BIZ_API_KEY="your-app-key"
# 或
export XG_APP_KEY="your-app-key"
# === 纯文本上传(AI 内容入库首选)===
python3 scripts/upload/upload-content.py "内容" "文件名.md" [--file-suffix md] [--folder-name "AI生成/周报"]
# === 物理文件上传 ===
# 小文件(≤20MB)
python3 scripts/upload/upload-whole-file.py <file_path>
# → 获得 resourceId
# 大文件(>20MB)
python3 scripts/upload/check-slice.py <md5> --size <size> --suffix <suffix>
# → 秒传命中用 sliceId;未命中需 PUT 上传
python3 scripts/upload/register-slice.py <full_path> <md5> <size> MINIO
python3 scripts/upload/merge-resource.py "文件名.pdf" "sliceId1,sliceId2,..." --suffix pdf --size <size>
# → 获得 resourceId
# === 绑定到知识库 ===
# 已知父目录 ID(推荐,跳过路径解析)
python3 scripts/upload/save-file-by-parent-id.py <project_id> <parent_id> <resource_id> "文件名.pdf" --suffix pdf
# 按逻辑路径(路径不存在自动创建)
python3 scripts/upload/save-file-by-path.py <project_id> "文件名.pdf" <resource_id> --path "目录" --suffix pdf
# === 获取下载链接 ===
python3 scripts/upload/get-file-download-info.py <resource_id>
```
## 输出说明
所有脚本的输出均为 **JSON 格式**。
## 规范
1. **必须使用 Python** 编写
2. **鉴权遵循** `cms-auth-skills/SKILL.md` 规范
3. **入参定义以** `openapi/` 文档为准
FILE:scripts/upload/check-slice.py
#!/usr/bin/env python3
"""
upload / check-slice 脚本
用途:大文件分片上传前的 MD5 预检,支持秒传判定
使用方式:
python3 scripts/upload/check-slice.py <md5> [--size 12345] [--suffix pdf]
环境变量:
XG_BIZ_API_KEY / XG_APP_KEY — appKey(由 cms-auth-skills 预先准备)
"""
import sys
import os
import json
import urllib.request
import urllib.parse
import urllib.error
import ssl
# 接口完整 URL(与 openapi/upload/check-slice.md 中声明的一致)
API_URL = "https://sg-al-cwork-web.mediportal.com.cn/open-api/document-database/file/getSliceIdByMd5V2"
AUTH_MODE = "appKey"
def build_headers() -> dict:
"""根据鉴权模式构造请求头"""
headers = {"Content-Type": "application/json"}
if AUTH_MODE == "appKey":
app_key = os.environ.get("XG_BIZ_API_KEY") or os.environ.get("XG_APP_KEY")
if not app_key:
print("错误: 请设置环境变量 XG_BIZ_API_KEY 或 XG_APP_KEY", file=sys.stderr)
sys.exit(1)
headers["appKey"] = app_key
return headers
def call_api(md5: str, size: int = None, suffix: str = None) -> dict:
"""调用分片预检接口,返回原始 JSON 响应"""
headers = build_headers()
params = [("md5", md5)]
if size is not None:
params.append(("size", str(size)))
if suffix:
params.append(("suffix", suffix))
url = f"{API_URL}?{urllib.parse.urlencode(params)}"
req = urllib.request.Request(url, headers=headers, method="GET")
ctx = ssl.create_default_context()
ctx.check_hostname = False
ctx.verify_mode = ssl.CERT_NONE
for attempt in range(3):
try:
with urllib.request.urlopen(req, context=ctx, timeout=60) as resp:
return json.loads(resp.read().decode("utf-8"))
except urllib.error.HTTPError as e:
if attempt < 2:
import time
time.sleep(1)
else:
print(f"错误: HTTP {e.code} - {e.reason}", file=sys.stderr)
sys.exit(1)
except Exception as e:
if attempt < 2:
import time
time.sleep(1)
else:
print(f"错误: {e}", file=sys.stderr)
sys.exit(1)
def process_result(result):
"""处理 API 响应结果,优先按 resultCode、resultMsg、data 读取"""
if isinstance(result, dict):
# 优先读取 resultCode、resultMsg、data
result_code = result.get('resultCode')
result_msg = result.get('resultMsg')
data = result.get('data')
# 构建标准化输出
processed = {
'resultCode': result_code,
'resultMsg': result_msg,
'data': data
}
return processed
return result
def main():
import argparse
parser = argparse.ArgumentParser(description="大文件分片预检(支持秒传判定)")
parser.add_argument("md5", type=str, help="文件/分片的 MD5(hex 字符串)")
parser.add_argument("--size", type=int, help="文件总大小(字节)")
parser.add_argument("--suffix", type=str, help="文件后缀")
args = parser.parse_args()
result = call_api(md5=args.md5, size=args.size, suffix=args.suffix)
processed_result = process_result(result)
print(json.dumps(processed_result, ensure_ascii=False))
if __name__ == "__main__":
main()
FILE:scripts/upload/get-file-download-info.py
#!/usr/bin/env python3
"""
upload / get-file-download-info 脚本
用途:根据 resourceId 获取文件下载信息(临时下载 URL,有效期 1 小时)
使用方式:
python3 scripts/upload/get-file-download-info.py <resource_id>
环境变量:
XG_BIZ_API_KEY / XG_APP_KEY — appKey(由 cms-auth-skills 预先准备)
"""
import sys
import os
import json
import urllib.request
import urllib.parse
import urllib.error
import ssl
# 接口完整 URL(与 openapi/upload/get-file-download-info.md 中声明的一致)
API_URL = "https://sg-al-cwork-web.mediportal.com.cn/open-api/cwork-file/getDownloadInfo"
AUTH_MODE = "appKey"
def build_headers() -> dict:
"""根据鉴权模式构造请求头"""
headers = {"Content-Type": "application/json"}
if AUTH_MODE == "appKey":
app_key = os.environ.get("XG_BIZ_API_KEY") or os.environ.get("XG_APP_KEY")
if not app_key:
print("错误: 请设置环境变量 XG_BIZ_API_KEY 或 XG_APP_KEY", file=sys.stderr)
sys.exit(1)
headers["appKey"] = app_key
return headers
def call_api(resource_id: int) -> dict:
"""调用获取文件下载信息接口,返回原始 JSON 响应"""
headers = build_headers()
params = [("resourceId", str(resource_id))]
url = f"{API_URL}?{urllib.parse.urlencode(params)}"
req = urllib.request.Request(url, headers=headers, method="GET")
ctx = ssl.create_default_context()
ctx.check_hostname = False
ctx.verify_mode = ssl.CERT_NONE
for attempt in range(3):
try:
with urllib.request.urlopen(req, context=ctx, timeout=60) as resp:
return json.loads(resp.read().decode("utf-8"))
except urllib.error.HTTPError as e:
if attempt < 2:
import time
time.sleep(1)
else:
print(f"错误: HTTP {e.code} - {e.reason}", file=sys.stderr)
sys.exit(1)
except Exception as e:
if attempt < 2:
import time
time.sleep(1)
else:
print(f"错误: {e}", file=sys.stderr)
sys.exit(1)
def main():
if len(sys.argv) < 2:
print("错误: 请提供 resourceId", file=sys.stderr)
sys.exit(1)
resource_id = int(sys.argv[1])
result = call_api(resource_id)
print(json.dumps(result, ensure_ascii=False))
if __name__ == "__main__":
main()
FILE:scripts/upload/merge-resource.py
#!/usr/bin/env python3
"""
upload / merge-resource 脚本
用途:合并所有已注册的分片,生成最终的 resourceId
使用方式:
python3 scripts/upload/merge-resource.py "文件名.pdf" "<slice_id1,slice_id2,..." [--suffix pdf] [--size 12345]
环境变量:
XG_BIZ_API_KEY / XG_APP_KEY — appKey(由 cms-auth-skills 预先准备)
"""
import sys
import os
import json
import urllib.request
import urllib.error
import ssl
# 接口完整 URL(与 openapi/upload/merge-resource.md 中声明的一致)
API_URL = "https://sg-al-cwork-web.mediportal.com.cn/open-api/document-database/file/saveResource"
AUTH_MODE = "appKey"
def build_headers() -> dict:
"""根据鉴权模式构造请求头"""
headers = {"Content-Type": "application/json"}
if AUTH_MODE == "appKey":
app_key = os.environ.get("XG_BIZ_API_KEY") or os.environ.get("XG_APP_KEY")
if not app_key:
print("错误: 请设置环境变量 XG_BIZ_API_KEY 或 XG_APP_KEY", file=sys.stderr)
sys.exit(1)
headers["appKey"] = app_key
return headers
def call_api(name: str, slice_ids: list, suffix: str = None, size: int = None) -> dict:
"""调用合并分片接口,返回原始 JSON 响应"""
headers = build_headers()
body = {
"name": name,
"sliceIds": slice_ids
}
if suffix:
body["suffix"] = suffix
if size is not None:
body["size"] = size
req = urllib.request.Request(
API_URL,
data=json.dumps(body).encode("utf-8"),
headers=headers,
method="POST"
)
ctx = ssl.create_default_context()
ctx.check_hostname = False
ctx.verify_mode = ssl.CERT_NONE
for attempt in range(3):
try:
with urllib.request.urlopen(req, context=ctx, timeout=60) as resp:
return json.loads(resp.read().decode("utf-8"))
except urllib.error.HTTPError as e:
if attempt < 2:
import time
time.sleep(1)
else:
print(f"错误: HTTP {e.code} - {e.reason}", file=sys.stderr)
sys.exit(1)
except Exception as e:
if attempt < 2:
import time
time.sleep(1)
else:
print(f"错误: {e}", file=sys.stderr)
sys.exit(1)
def process_result(result):
"""处理 API 响应结果,优先按 resultCode、resultMsg、data 读取"""
if isinstance(result, dict):
# 优先读取 resultCode、resultMsg、data
result_code = result.get('resultCode')
result_msg = result.get('resultMsg')
data = result.get('data')
# 构建标准化输出
processed = {
'resultCode': result_code,
'resultMsg': result_msg,
'data': data
}
return processed
return result
def main():
import argparse
parser = argparse.ArgumentParser(description="合并分片生成最终 resourceId")
parser.add_argument("name", type=str, help="文件名(含后缀)")
parser.add_argument("slice_ids", type=str, help="分片 ID 列表,逗号分隔")
parser.add_argument("--suffix", type=str, help="文件后缀")
parser.add_argument("--size", type=int, help="文件总大小(字节)")
args = parser.parse_args()
slice_ids = [int(x.strip()) for x in args.slice_ids.split(",")]
result = call_api(name=args.name, slice_ids=slice_ids, suffix=args.suffix, size=args.size)
processed_result = process_result(result)
print(json.dumps(processed_result, ensure_ascii=False))
if __name__ == "__main__":
main()
FILE:scripts/upload/register-slice.py
#!/usr/bin/env python3
"""
upload / register-slice 脚本
用途:在分片物理上传到 MinIO 完成后,在服务端注册分片元信息,换取 sliceId
使用方式:
python3 scripts/upload/register-slice.py <full_path> <md5> <size> <storage_type>
环境变量:
XG_BIZ_API_KEY / XG_APP_KEY — appKey(由 cms-auth-skills 预先准备)
"""
import sys
import os
import json
import urllib.request
import urllib.error
import ssl
# 接口完整 URL(与 openapi/upload/register-slice.md 中声明的一致)
API_URL = "https://sg-al-cwork-web.mediportal.com.cn/open-api/document-database/file/uploadFileSliceV2"
AUTH_MODE = "appKey"
def build_headers() -> dict:
"""根据鉴权模式构造请求头"""
headers = {"Content-Type": "application/json"}
if AUTH_MODE == "appKey":
app_key = os.environ.get("XG_BIZ_API_KEY") or os.environ.get("XG_APP_KEY")
if not app_key:
print("错误: 请设置环境变量 XG_BIZ_API_KEY 或 XG_APP_KEY", file=sys.stderr)
sys.exit(1)
headers["appKey"] = app_key
return headers
def call_api(file_path: str, md5: str, size: int, storage_type: str) -> dict:
"""调用注册分片接口,返回原始 JSON 响应"""
headers = build_headers()
body = {
"filePath": file_path,
"md5": md5,
"size": size,
"storageType": storage_type
}
req = urllib.request.Request(
API_URL,
data=json.dumps(body).encode("utf-8"),
headers=headers,
method="POST"
)
ctx = ssl.create_default_context()
ctx.check_hostname = False
ctx.verify_mode = ssl.CERT_NONE
for attempt in range(3):
try:
with urllib.request.urlopen(req, context=ctx, timeout=60) as resp:
return json.loads(resp.read().decode("utf-8"))
except urllib.error.HTTPError as e:
if attempt < 2:
import time
time.sleep(1)
else:
print(f"错误: HTTP {e.code} - {e.reason}", file=sys.stderr)
sys.exit(1)
except Exception as e:
if attempt < 2:
import time
time.sleep(1)
else:
print(f"错误: {e}", file=sys.stderr)
sys.exit(1)
def process_result(result):
"""处理 API 响应结果,优先按 resultCode、resultMsg、data 读取"""
if isinstance(result, dict):
# 优先读取 resultCode、resultMsg、data
result_code = result.get('resultCode')
result_msg = result.get('resultMsg')
data = result.get('data')
# 构建标准化输出
processed = {
'resultCode': result_code,
'resultMsg': result_msg,
'data': data
}
return processed
return result
def main():
if len(sys.argv) < 5:
print("用法: python3 scripts/upload/register-slice.py <full_path> <md5> <size> <storage_type>", file=sys.stderr)
sys.exit(1)
file_path = sys.argv[1]
md5 = sys.argv[2]
size = int(sys.argv[3])
storage_type = sys.argv[4]
result = call_api(file_path, md5, size, storage_type)
processed_result = process_result(result)
print(json.dumps(processed_result, ensure_ascii=False))
if __name__ == "__main__":
main()
FILE:scripts/upload/save-file-by-parent-id.py
#!/usr/bin/env python3
"""
upload / saveFileByParentId 脚本
用途:已知目标文件夹 ID 时,将物理文件保存到项目目录(比 saveFileByPath 少一次路径解析)
使用方式:
python3 scripts/upload/save-file-by-parent-id.py <project_id> <parent_id> <resource_id> "文件名.pdf" [--suffix pdf] [--size 12345]
环境变量:
XG_BIZ_API_KEY / XG_APP_KEY — appKey(由 cms-auth-skills 预先准备)
"""
import sys
import os
import json
import urllib.request
import urllib.error
import ssl
# 接口完整 URL(与 openapi/upload/save-file-by-parent-id.md 中声明的一致)
API_URL = "https://sg-al-cwork-web.mediportal.com.cn/open-api/document-database/file/saveFileByParentId"
AUTH_MODE = "appKey"
def build_headers() -> dict:
"""根据鉴权模式构造请求头"""
headers = {"Content-Type": "application/json"}
if AUTH_MODE == "appKey":
app_key = os.environ.get("XG_BIZ_API_KEY") or os.environ.get("XG_APP_KEY")
if not app_key:
print("错误: 请设置环境变量 XG_BIZ_API_KEY 或 XG_APP_KEY", file=sys.stderr)
sys.exit(1)
headers["appKey"] = app_key
return headers
def call_api(project_id: int, parent_id: int, resource_id: int, name: str,
suffix: str = None, size: int = None, is_sensitive: int = None) -> dict:
"""调用按父ID保存文件接口,返回原始 JSON 响应"""
headers = build_headers()
body = {
"projectId": project_id,
"parentId": parent_id,
"resourceId": resource_id,
"name": name,
"fileType": "file"
}
if suffix:
body["suffix"] = suffix
if size is not None:
body["size"] = size
if is_sensitive is not None:
body["isSensitive"] = is_sensitive
req = urllib.request.Request(
API_URL,
data=json.dumps(body).encode("utf-8"),
headers=headers,
method="POST"
)
ctx = ssl.create_default_context()
ctx.check_hostname = False
ctx.verify_mode = ssl.CERT_NONE
for attempt in range(3):
try:
with urllib.request.urlopen(req, context=ctx, timeout=60) as resp:
return json.loads(resp.read().decode("utf-8"))
except urllib.error.HTTPError as e:
if attempt < 2:
import time
time.sleep(1)
else:
print(f"错误: HTTP {e.code} - {e.reason}", file=sys.stderr)
sys.exit(1)
except Exception as e:
if attempt < 2:
import time
time.sleep(1)
else:
print(f"错误: {e}", file=sys.stderr)
sys.exit(1)
def process_result(result):
"""处理 API 响应结果,优先按 resultCode、resultMsg、data 读取"""
if isinstance(result, dict):
# 优先读取 resultCode、resultMsg、data
result_code = result.get('resultCode')
result_msg = result.get('resultMsg')
data = result.get('data')
# 构建标准化输出
processed = {
'resultCode': result_code,
'resultMsg': result_msg,
'data': data
}
return processed
return result
def main():
import argparse
parser = argparse.ArgumentParser(description="将物理文件保存到指定父目录")
parser.add_argument("project_id", type=int, help="目标项目空间 ID")
parser.add_argument("parent_id", type=int, help="目标文件夹 ID(根目录传 0)")
parser.add_argument("resource_id", type=int, help="资源 ID(必须)")
parser.add_argument("name", type=str, help="保存的文件名")
parser.add_argument("--suffix", type=str, help="文件后缀")
parser.add_argument("--size", type=int, help="文件大小(字节)")
parser.add_argument("--is-sensitive", type=int, choices=[0, 1], help="是否敏感文件(0 否,1 是)")
args = parser.parse_args()
result = call_api(
project_id=args.project_id,
parent_id=args.parent_id,
resource_id=args.resource_id,
name=args.name,
suffix=args.suffix,
size=args.size,
is_sensitive=args.is_sensitive
)
processed_result = process_result(result)
print(json.dumps(processed_result, ensure_ascii=False))
if __name__ == "__main__":
main()
FILE:scripts/upload/save-file-by-path.py
#!/usr/bin/env python3
"""
upload / saveFileByPath 脚本
用途:将物理文件保存到指定项目空间的指定逻辑目录路径(路径不存在自动创建)
使用方式:
python3 scripts/upload/save-file-by-path.py
环境变量:
XG_BIZ_API_KEY / XG_APP_KEY — appKey(由 cms-auth-skills 预先准备)
"""
import sys
import os
import json
import urllib.request
import urllib.error
import ssl
# 接口完整 URL(与 openapi/upload/save-file-by-path.md 中声明的一致)
API_URL = "https://sg-al-cwork-web.mediportal.com.cn/open-api/document-database/file/saveFileByPath"
AUTH_MODE = "appKey"
def build_headers() -> dict:
"""根据鉴权模式构造请求头"""
headers = {"Content-Type": "application/json"}
if AUTH_MODE == "appKey":
app_key = os.environ.get("XG_BIZ_API_KEY") or os.environ.get("XG_APP_KEY")
if not app_key:
print("错误: 请设置环境变量 XG_BIZ_API_KEY 或 XG_APP_KEY", file=sys.stderr)
sys.exit(1)
headers["appKey"] = app_key
return headers
def call_api(project_id: int, name: str, resource_id: int,
path: str = None, suffix: str = None,
size: int = None, is_sensitive: int = None) -> dict:
"""调用按路径保存文件接口,返回原始 JSON 响应"""
headers = build_headers()
body = {
"projectId": project_id,
"name": name,
"fileType": "file",
"resourceId": resource_id
}
if path:
body["path"] = path
if suffix:
body["suffix"] = suffix
if size is not None:
body["size"] = size
if is_sensitive is not None:
body["isSensitive"] = is_sensitive
req = urllib.request.Request(
API_URL,
data=json.dumps(body).encode("utf-8"),
headers=headers,
method="POST"
)
ctx = ssl.create_default_context()
ctx.check_hostname = False
ctx.verify_mode = ssl.CERT_NONE
for attempt in range(3):
try:
with urllib.request.urlopen(req, context=ctx, timeout=60) as resp:
return json.loads(resp.read().decode("utf-8"))
except urllib.error.HTTPError as e:
if attempt < 2:
import time
time.sleep(1)
else:
print(f"错误: HTTP {e.code} - {e.reason}", file=sys.stderr)
sys.exit(1)
except Exception as e:
if attempt < 2:
import time
time.sleep(1)
else:
print(f"错误: {e}", file=sys.stderr)
sys.exit(1)
def process_result(result):
"""处理 API 响应结果,优先按 resultCode、resultMsg、data 读取"""
if isinstance(result, dict):
# 优先读取 resultCode、resultMsg、data
result_code = result.get('resultCode')
result_msg = result.get('resultMsg')
data = result.get('data')
# 构建标准化输出
processed = {
'resultCode': result_code,
'resultMsg': result_msg,
'data': data
}
return processed
return result
def main():
import argparse
parser = argparse.ArgumentParser(description="将物理文件保存到指定项目空间的指定路径")
parser.add_argument("project_id", type=int, help="目标项目空间 ID")
parser.add_argument("name", type=str, help="保存的文件名")
parser.add_argument("resource_id", type=int, help="资源 ID(必须,先通过 upload-whole-file 获得)")
parser.add_argument("--path", type=str, help="逻辑目录路径,支持多级,不存在自动创建")
parser.add_argument("--suffix", type=str, help="文件后缀")
parser.add_argument("--size", type=int, help="文件大小(字节)")
parser.add_argument("--is-sensitive", type=int, choices=[0, 1], help="是否敏感文件(0 否,1 是)")
args = parser.parse_args()
result = call_api(
project_id=args.project_id,
name=args.name,
resource_id=args.resource_id,
path=args.path,
suffix=args.suffix,
size=args.size,
is_sensitive=args.is_sensitive
)
processed_result = process_result(result)
print(json.dumps(processed_result, ensure_ascii=False))
if __name__ == "__main__":
main()
FILE:scripts/upload/upload-content.py
#!/usr/bin/env python3
"""
upload / uploadContent 脚本
用途:一键快速保存纯文本内容到个人知识库(AI 内容入库首选)
使用方式:
python3 scripts/upload/upload-content.py
环境变量:
XG_BIZ_API_KEY / XG_APP_KEY — appKey(由 cms-auth-skills 预先准备)
"""
import sys
import os
import json
import urllib.request
import urllib.parse
import urllib.error
import ssl
# 接口完整 URL(与 openapi/upload/upload-content.md 中声明的一致)
API_URL = "https://sg-al-cwork-web.mediportal.com.cn/open-api/document-database/file/uploadContent"
AUTH_MODE = "appKey"
def build_headers() -> dict:
"""根据鉴权模式构造请求头"""
headers = {"Content-Type": "application/json"}
if AUTH_MODE == "appKey":
app_key = os.environ.get("XG_BIZ_API_KEY") or os.environ.get("XG_APP_KEY")
if not app_key:
print("错误: 请设置环境变量 XG_BIZ_API_KEY 或 XG_APP_KEY", file=sys.stderr)
sys.exit(1)
headers["appKey"] = app_key
return headers
def call_api(content: str, file_name: str,
file_suffix: str = None, folder_name: str = None,
update_file_id: int = None, version_name: str = None,
version_remark: str = None) -> dict:
"""调用一键上传接口,返回原始 JSON 响应"""
headers = build_headers()
body = {
"content": content,
"fileName": file_name
}
if file_suffix:
body["fileSuffix"] = file_suffix
if folder_name:
body["folderName"] = folder_name
if update_file_id is not None:
body["updateFileId"] = update_file_id
if version_name:
body["versionName"] = version_name
if version_remark:
body["versionRemark"] = version_remark
req = urllib.request.Request(
API_URL,
data=json.dumps(body).encode("utf-8"),
headers=headers,
method="POST"
)
ctx = ssl.create_default_context()
ctx.check_hostname = False
ctx.verify_mode = ssl.CERT_NONE
for attempt in range(3):
try:
with urllib.request.urlopen(req, context=ctx, timeout=60) as resp:
return json.loads(resp.read().decode("utf-8"))
except urllib.error.HTTPError as e:
if attempt < 2:
import time
time.sleep(1)
else:
print(f"错误: HTTP {e.code} - {e.reason}", file=sys.stderr)
sys.exit(1)
except Exception as e:
if attempt < 2:
import time
time.sleep(1)
else:
print(f"错误: {e}", file=sys.stderr)
sys.exit(1)
def process_result(result):
"""处理 API 响应结果,优先按 resultCode、resultMsg、data 读取"""
if isinstance(result, dict):
# 优先读取 resultCode、resultMsg、data
result_code = result.get('resultCode')
result_msg = result.get('resultMsg')
data = result.get('data')
# 构建标准化输出
processed = {
'resultCode': result_code,
'resultMsg': result_msg,
'data': data
}
return processed
return result
def main():
import argparse
parser = argparse.ArgumentParser(description="一键保存纯文本内容到个人知识库")
parser.add_argument("content", type=str, help="文件内容")
parser.add_argument("file_name", type=str, help="文件名(建议带扩展名)")
parser.add_argument("--file-suffix", type=str, help="文件后缀(md/html/txt/json)")
parser.add_argument("--folder-name", type=str, help="逻辑目录路径,支持多级(仅新建模式有效)")
parser.add_argument("--update-file-id", type=int, help="版本更新模式:要更新的目标文件 ID,传入后切换为版本更新模式")
parser.add_argument("--version-name", type=str, help="版本名称,如 V2.0(版本更新模式专用)")
parser.add_argument("--version-remark", type=str, help="版本说明(版本更新模式专用)")
args = parser.parse_args()
result = call_api(
content=args.content,
file_name=args.file_name,
file_suffix=args.file_suffix,
folder_name=args.folder_name,
update_file_id=args.update_file_id,
version_name=args.version_name,
version_remark=args.version_remark,
)
processed_result = process_result(result)
print(json.dumps(processed_result, ensure_ascii=False))
if __name__ == "__main__":
main()
FILE:scripts/upload/upload-whole-file.py
#!/usr/bin/env python3
"""
upload / upload-whole-file 脚本
用途:上传本地完整文件(建议 20MB 以下),直接返回 resourceId
使用方式:
python3 scripts/upload/upload-whole-file.py <file_path>
环境变量:
XG_BIZ_API_KEY / XG_APP_KEY — appKey(由 cms-auth-skills 预先准备)
"""
import sys
import os
import json
import http.client
import uuid
import ssl
# 接口完整 URL(与 openapi/upload/upload-whole-file.md 中声明的一致)
API_HOST = "sg-al-cwork-web.mediportal.com.cn"
API_PATH = "/open-api/cwork-file/uploadWholeFile"
AUTH_MODE = "appKey"
def build_app_key() -> str:
"""获取 appKey"""
app_key = os.environ.get("XG_BIZ_API_KEY") or os.environ.get("XG_APP_KEY")
if not app_key:
print("错误: 请设置环境变量 XG_BIZ_API_KEY 或 XG_APP_KEY", file=sys.stderr)
sys.exit(1)
return app_key
def call_api(file_path: str) -> dict:
"""调用整传接口,返回原始 JSON 响应"""
app_key = build_app_key()
filename = os.path.basename(file_path)
boundary = uuid.uuid4().hex
# 构造 multipart/form-data body
with open(file_path, "rb") as f:
file_content = f.read()
body_parts = []
# 文件字段
header = (
f"--{boundary}\r\n"
f'Content-Disposition: form-data; name="file"; filename="{filename}"\r\n'
f"Content-Type: application/octet-stream\r\n\r\n"
)
body_parts.append(header.encode("utf-8"))
body_parts.append(file_content)
body_parts.append(f"\r\n--{boundary}--\r\n".encode("utf-8"))
body = b"".join(body_parts)
for attempt in range(3):
try:
ctx = ssl.create_default_context()
ctx.check_hostname = False
ctx.verify_mode = ssl.CERT_NONE
conn = http.client.HTTPSConnection(API_HOST, timeout=120, context=ctx)
try:
conn.request(
"POST",
API_PATH,
body=body,
headers={
"appKey": app_key,
"Content-Type": f"multipart/form-data; boundary={boundary}",
"Content-Length": str(len(body)),
}
)
resp = conn.getresponse()
result = json.loads(resp.read().decode("utf-8"))
return result
finally:
conn.close()
except Exception as e:
if attempt < 2:
import time
time.sleep(1)
else:
print(f"错误: {e}", file=sys.stderr)
sys.exit(1)
def process_result(result):
"""处理 API 响应结果,优先按 resultCode、resultMsg、data 读取"""
if isinstance(result, dict):
# 优先读取 resultCode、resultMsg、data
result_code = result.get('resultCode')
result_msg = result.get('resultMsg')
data = result.get('data')
# 构建标准化输出
processed = {
'resultCode': result_code,
'resultMsg': result_msg,
'data': data
}
return processed
return result
def main():
if len(sys.argv) < 2:
print("错误: 请提供文件路径", file=sys.stderr)
sys.exit(1)
file_path = sys.argv[1]
if not os.path.isfile(file_path):
print(f"错误: 文件不存在: {file_path}", file=sys.stderr)
sys.exit(1)
result = call_api(file_path)
processed_result = process_result(result)
print(json.dumps(processed_result, ensure_ascii=False))
if __name__ == "__main__":
main()
药品电商价格监控与版本识别系统。监控京东、淘宝、拼多多等电商平台药品售价,识别低于标准价的违规商家及非授权版本(海外版、港版等),自动生成 Markdown 分析报告与多期趋势对比。使用 OpenClaw Browser 工具进行数据抓取,绕过反爬机制。适用于:价格体系维护、渠道风险监控、市场调研、代理商合规检查...
---
name: pharmacy-price-monitor
description: 药品电商价格监控与版本识别系统。监控京东、淘宝、拼多多等电商平台药品售价,识别低于标准价的违规商家及非授权版本(海外版、港版等),自动生成 Markdown 分析报告与多期趋势对比。使用 OpenClaw Browser 工具进行数据抓取,绕过反爬机制。适用于:价格体系维护、渠道风险监控、市场调研、代理商合规检查等场景。触发词:监控价格/查价格/价格监控/药品价格/电商比价/渠道监控。
---
# 药品电商价格监控 Skill
## 核心能力
- **价格合规性监控**:识别低于标准价的违规店铺
- **非授权版本识别**:检测海外版、港版、代购等非官方渠道商品
- **多平台监控**:支持京东、淘宝、拼多多
- **趋势分析**:多期数据对比,追踪违规变化
- **报告生成**:自动生成 Markdown 结构化报告
- **数据库存储**:SQLite 本地持久化,支持历史查询和趋势分析
## 适用场景
- 监控指定药品的电商平台售价是否低于公司 MAP 价
- 识别非授权销售渠道(海外版、港版等)
- 定期价格巡检,生成合规报告
- 分析各平台价格分布和违规趋势
---
## 工作流程
### 第一步:收集需求参数
必需参数:
- **药品名称**:完整药品名称(如:"阿莫西林胶囊")
- **标准价格**:公司规定的统一零售价(如:33 元)
可选参数:
- **规格**:药品规格(如:"0.25g*24粒")
- **监控平台**:京东、淘宝、拼多多(默认全部)
示例:
```
监控"黛力新 0.5mg:10mg 20片/盒"的价格,标准价50元,查京东和淘宝
```
---
### 第二步:数据抓取(Browser 工具)
**推荐方法:使用 Browser 工具直接抓取**
- 不依赖 Cookie导出
- 不受登录态过期影响
- 翻页更可靠
#### 2.1 京东抓取流程(✅已验证 2026-04-17)
**分页机制(2024年京东改版后):**
- 底部显示 `"1/10 共10页 上一页 1 2 3 4 5 6 7 下一页"`
- URL **不变**,内容通过 AJAX 无刷新加载
- ❌ 点击"下一页"文字链接 → 无效(分页器不更新)
- ✅ 点击数字按钮(如"2")→ 触发 AJAX 加载
**数据提取选择器(2024年京东新结构):**
```
商品卡片:document.querySelectorAll('[data-sku]')
标题:item.querySelector('[class*="title"]').innerText
价格:item.querySelector('[class*="price"]').innerText(需清洗换行)
店铺:item.querySelector('[class*="name"]').innerText
```
**完整操作流程:**
```
1. 打开搜索页
browser action:open url:"https://search.jd.com/Search?keyword=<关键词>"
2. 等待8秒让JS渲染
browser action:act kind:wait timeMs:8000
3. 提取第1页数据(JS evaluate)
browser action:act kind:evaluate fn:<提取JS>
→ 返回 'clicked page X' 表示成功
→ 返回 'no more pages' 表示已到最后一页,停止
4. 执行翻页JS(京东用"下一页"按钮,淘宝用数字按钮)
browser action:act kind:evaluate fn:<翻页JS>
5. 等待6秒
browser action:act kind:wait timeMs:6000
6. 提取当前页数据
→ 重复步骤4-6直到返回 'no more pages'
7. 合并所有页数据,去重
```
**⚠️ 重要:必须翻到"no more pages"才停止!**
- 京东有10-20页,必须逐页翻完
- 中途停止会丢失后面页面的数据
**停止条件:**
- 翻页JS返回 `'no more pages'` → 已到最后一页
- 翻页后商品数量不变 → 可能已卡在最后一页,停止
**提取数据 JS(每页执行):**
```javascript
() => {
const results = [];
const items = document.querySelectorAll('[data-sku]');
for (const item of items) {
const titleEl = item.querySelector('[class*="title"]');
const priceEl = item.querySelector('[class*="price"]');
const shopEl = item.querySelector('[class*="name"]');
const title = titleEl ? titleEl.innerText.trim() : '';
let price = priceEl ? priceEl.innerText.trim().replace(/\s/g, '') : '';
const shop = shopEl ? shopEl.innerText.trim() : '';
if (title && price) {
results.push({ title, price, shop, platform: '京东' });
}
}
return JSON.stringify(results);
}
```
**翻页 JS(京东 - 推荐方案):**
```javascript
// 京东翻页 - 直接点击"下一页"按钮
// 适用于京东搜索页(2024年改版后验证)
() => {
const nextBtns = document.querySelectorAll('[class*="next"]');
for (const btn of nextBtns) {
const txt = btn.innerText ? btn.innerText.trim() : '';
if (txt === '下一页') {
btn.click();
return 'clicked next page';
}
}
return 'no more pages';
}
```
**翻页 JS(备选方案 - 动态找数字按钮):**
```javascript
// 如果"下一页"按钮失效,使用数字按钮翻页
() => {
const allElements = document.querySelectorAll('*');
let maxPage = 0;
let nextBtn = null;
for (const el of allElements) {
try {
const txt = el.innerText ? el.innerText.trim() : '';
if (txt.isdigit() && el.childNodes.length === 1) {
const num = parseInt(txt);
const parent = el.parentElement;
if (parent && parent.innerText && parent.innerText.includes('下一页')) {
if (num > maxPage) {
maxPage = num;
nextBtn = el;
}
}
}
} catch(e) {}
}
if (nextBtn) {
nextBtn.click();
return 'clicked page ' + maxPage;
}
return 'no more pages';
}
```
**⚠️ 注意事项:**
- 翻页后必须等待 6 秒(AJAX 加载需要时间)
- 用 `networkidle` 判断加载完成会永远超时(京东有持续追踪请求)
- 翻页失败特征:点击后商品数量不变 → 停止翻页
---
#### 2.2 淘宝抓取流程(✅已验证 2026-04-17)
**已知限制:**
- URL 的 page 参数会被服务器强制重定向回 page=1
- ❌ 直接访问 `&page=2` 的 URL → 回到第1页
- ✅ 必须用点击数字按钮翻页
**分页机制:**
- 显示"上一页 当前第1页 /5 下一页"
- 点击数字按钮"2"可以正常翻页(不是URL参数)
**提取数据 JS:**
```javascript
() => {
const results = [];
const seen = new Set();
const links = document.querySelectorAll('a[href*="detail.tmall.com"], a[href*="item.htm"]');
for (const link of links) {
const text = link.innerText || '';
if (text.includes('黛力新') && text.includes('¥') && text.includes('片')) {
const priceMatch = text.match(/¥\s*([\d.]+)/);
const dealsMatch = text.match(/(\d+[\d万]*)\s*人付款/);
const price = priceMatch ? priceMatch[1] : '';
const deals = dealsMatch ? dealsMatch[1] : '';
let title = '商品标题';
// 提取店铺名
const lines = text.split('\n').filter(l => l.trim());
let shop = '';
for (let j = lines.length - 1; j >= 0; j--) {
const l = lines[j].trim();
if (l.includes('旗舰店') || l.includes('大药房') || l.includes('医药专营')) {
shop = l;
break;
}
}
if (price && !seen.has(price)) {
seen.add(price);
results.push({ title, price, shop, deals, platform: '淘宝' });
}
}
}
return JSON.stringify(results);
}
```
**翻页 JS(淘宝通用 - 动态版本):**
```javascript
// 淘宝翻页 - 自动点击下一个数字按钮
() => {
const allElements = document.querySelectorAll('*');
let maxPage = 0;
let nextBtn = null;
for (const el of allElements) {
try {
const txt = el.innerText ? el.innerText.trim() : '';
if (txt.isdigit() && el.childNodes.length === 1) {
const num = parseInt(txt);
const parent = el.parentElement;
if (parent && parent.innerText && parent.innerText.includes('下一页')) {
if (num > maxPage) {
maxPage = num;
nextBtn = el;
}
}
}
} catch(e) {}
}
if (nextBtn) {
nextBtn.click();
return 'clicked page ' + maxPage;
}
return 'no more pages';
}
```
---
#### 2.3 拼多多抓取流程(✅已验证 2026-04-20)
**关键技术发现(⚠️ 重要修正 2026-04-20):**
- `window.rawData.stores.store.data.ssrListData.list` 只在**首屏 SSR 时注入一次**
- 滚动后 AJAX 加载的新商品**不在** `window.rawData` 中,翻页后它仍然是 20 条
- ✅ 正确方法:**滚动完成后,从 DOM 提取所有商品文本**,再解析结构
- 商品文本特征:同时包含 `黛力新` + `¥` + `片` + 长度 30~800 字符的 DOM 元素
- 价格字段在 DOM 中被拆成多行(如 `¥\n42\n.6`),需要清洗合并
- 翻页机制:**瀑布流无限滚动**,`scrollHeight` 会随加载不断扩大
- 停止条件:连续 3 次滚动后 DOM 中 drug items 数量不变(建议上限 20 次滚动)
**操作流程:**
```
1. browser action:open url:"https://mobile.yangkeduo.com/search_result.html?search_key=<关键词>"
2. 等待8秒(SSR渲染完成)
3. 循环(最多20次):
a. 执行滚动 JS:window.scrollBy(0, 800)
b. 等待3秒(让 AJAX 加载)
c. 检查 drug items 数量
d. 若数量不变次数 >= 3,停止滚动
4. 执行 DOM 提取 JS(见下方)
5. Python 解析价格/标题/销量,生成报告
```
**滚动 JS(拼多多):**
```javascript
() => {
window.scrollBy(0, 800);
return 'scrolled, scrollH=' + document.body.scrollHeight;
}
```
**DOM 提取 JS(滚动完成后执行,全量提取):**
```javascript
// 从 DOM 提取所有商品(用于滚动完成后一次性提取)
() => {
var all = document.querySelectorAll('*');
var rawItems = [];
for (var i = 0; i < all.length; i++) {
var txt = all[i].innerText || '';
if (txt.indexOf('黛力新') === -1) continue;
if (txt.indexOf('片') === -1) continue;
if (txt.indexOf('¥') === -1) continue;
if (txt.length < 30 || txt.length > 800) continue;
rawItems.push(txt);
}
// 按前50字符去重
var seen = {};
var unique = [];
for (var j = 0; j < rawItems.length; j++) {
var key = rawItems[j].substring(0, 50);
if (!seen[key]) { seen[key] = true; unique.push(rawItems[j]); }
}
return 'unique:' + unique.length + ' samples:' + JSON.stringify(unique.slice(0,2)).substring(0, 300);
}
```
**Python 解析脚本(处理 DOM 原始文本):**
```python
import re
def parse_pinduoduo_items(raw_texts):
"""解析拼多多 DOM 原始文本,提取结构化商品数据"""
results = []
for txt in raw_texts:
lines = txt.split('\n')
title = ''
price = ''
sales = ''
for li, line in enumerate(lines):
l = line.strip()
# 取第一个含药品名的行作为标题
if not title and ('黛力新' in l or '氟哌噻吨' in l or '加乐舒' in l):
title = l
# ¥ 符号后面跟着数字/小数点是价格
if l == '¥' and li + 1 < len(lines):
p1 = lines[li+1].strip()
p2 = lines[li+2].strip() if li+2 < len(lines) else ''
# 处理 ¥
42
.6 格式(价格被拆成多行)
if p2 and re.match(r'^\.[0-9]$', p2):
price = p1 + p2
else:
price = p1
# 销量
if '本店已拼' in l or '全店总售' in l:
sales = l
break
if title and price:
results.append({'title': title[:80], 'price': price, 'sales': sales, 'platform': '拼多多'})
return results
```
**⚠️ 注意事项:**
- ❌ 不要依赖 `window.rawData` 取滚动后的数据(它不更新!)
- ✅ 必须从 DOM 提取:`querySelectorAll` + 关键词过滤
- 等待时间:首次加载 8 秒,每次滚动后等待 3 秒
- 价格清洗:`¥\n42\n.6` → `42.6`
- 拼多多无限滚动:热门药品搜索可达数百条,建议设滚动上限(如 20 次)
---
### 第三步:数据库存储
#### 3.1 数据库初始化
数据库路径:`~/.openclaw/skills/pharmacy-price-monitor/price_monitor.db`
**数据库架构:**
```sql
-- 药品表(按药品名称去重)
CREATE TABLE IF NOT EXISTS drugs (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL UNIQUE,
spec TEXT,
standard_price REAL,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
-- 抓取记录表(每次抓取一条记录)
CREATE TABLE IF NOT EXISTS crawl_records (
id INTEGER PRIMARY KEY AUTOINCREMENT,
drug_id INTEGER NOT NULL,
platform TEXT NOT NULL,
total_items INTEGER,
min_price REAL,
max_price REAL,
avg_price REAL,
crawl_time TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (drug_id) REFERENCES drugs(id)
);
-- 商品详情表
CREATE TABLE IF NOT EXISTS products (
id INTEGER PRIMARY KEY AUTOINCREMENT,
crawl_record_id INTEGER NOT NULL,
drug_id INTEGER NOT NULL,
platform TEXT NOT NULL,
title TEXT,
price REAL NOT NULL,
shop TEXT,
deals TEXT,
url TEXT,
is_low_price BOOLEAN DEFAULT 0,
notes TEXT,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (crawl_record_id) REFERENCES crawl_records(id),
FOREIGN KEY (drug_id) REFERENCES drugs(id)
);
```
#### 3.2 数据存储脚本
每次抓取完成后,将数据存入数据库:
```python
import sqlite3, json
from datetime import datetime
DB_PATH = "~/.openclaw/skills/pharmacy-price-monitor/price_monitor.db"
def init_db():
conn = sqlite3.connect(DB_PATH)
c = conn.cursor()
c.execute('''CREATE TABLE IF NOT EXISTS drugs (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL UNIQUE,
spec TEXT,
standard_price REAL,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP)''')
c.execute('''CREATE TABLE IF NOT EXISTS crawl_records (
id INTEGER PRIMARY KEY AUTOINCREMENT,
drug_id INTEGER NOT NULL,
platform TEXT NOT NULL,
total_items INTEGER,
min_price REAL,
max_price REAL,
avg_price REAL,
crawl_time TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (drug_id) REFERENCES drugs(id))''')
c.execute('''CREATE TABLE IF NOT EXISTS products (
id INTEGER PRIMARY KEY AUTOINCREMENT,
crawl_record_id INTEGER NOT NULL,
drug_id INTEGER NOT NULL,
platform TEXT NOT NULL,
title TEXT,
price REAL NOT NULL,
shop TEXT,
deals TEXT,
url TEXT,
is_low_price BOOLEAN DEFAULT 0,
notes TEXT,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (crawl_record_id) REFERENCES crawl_records(id),
FOREIGN KEY (drug_id) REFERENCES drugs(id))''')
conn.commit()
return conn
def save_crawl(drug_name, spec, standard_price, platform, products):
conn = init_db()
c = conn.cursor()
# 插入或获取药品ID
c.execute('''INSERT OR IGNORE INTO drugs (name, spec, standard_price)
VALUES (?, ?, ?)''', (drug_name, spec, standard_price))
c.execute('SELECT id FROM drugs WHERE name = ?', (drug_name,))
drug_id = c.fetchone()[0]
# 计算价格统计
prices = [float(p['price'].replace('¥','').replace(',','')) for p in products]
min_p, max_p, avg_p = min(prices), max(prices), sum(prices)/len(prices)
# 插入抓取记录
c.execute('''INSERT INTO crawl_records
(drug_id, platform, total_items, min_price, max_price, avg_price)
VALUES (?, ?, ?, ?, ?, ?)''',
(drug_id, platform, len(products), min_p, max_p, avg_p))
crawl_record_id = c.lastrowid
# 插入商品
for p in products:
price_val = float(p['price'].replace('¥','').replace(',',''))
is_low = 1 if standard_price and price_val < standard_price else 0
c.execute('''INSERT INTO products
(crawl_record_id, drug_id, platform, title, price, shop, deals, is_low_price)
VALUES (?, ?, ?, ?, ?, ?, ?, ?)''',
(crawl_record_id, drug_id, platform, p.get('title',''),
price_val, p.get('shop',''), p.get('deals',''), is_low))
conn.commit()
conn.close()
print(f"已存储 {len(products)} 条商品数据到数据库")
# 使用示例:
# save_crawl("黛力新", "0.5mg:10mg 20片/盒", 50.0, "京东", jd_products)
# save_crawl("黛力新", "0.5mg:10mg 20片/盒", 50.0, "淘宝", taobao_products)
```
#### 3.3 历史查询
```python
def query_history(drug_name, platform=None, limit=10):
conn = sqlite3.connect(DB_PATH)
c = conn.cursor()
if platform:
c.execute('''SELECT cr.crawl_time, cr.platform, cr.total_items,
cr.min_price, cr.max_price, cr.avg_price
FROM crawl_records cr
JOIN drugs d ON cr.drug_id = d.id
WHERE d.name = ? AND cr.platform = ?
ORDER BY cr.crawl_time DESC LIMIT ?''',
(drug_name, platform, limit))
else:
c.execute('''SELECT cr.crawl_time, cr.platform, cr.total_items,
cr.min_price, cr.max_price, cr.avg_price
FROM crawl_records cr
JOIN drugs d ON cr.drug_id = d.id
WHERE d.name = ?
ORDER BY cr.crawl_time DESC LIMIT ?''',
(drug_name, limit))
results = c.fetchall()
conn.close()
return results
# 查询示例:query_history("黛力新", platform="京东")
```
---
### 第四步:数据处理
#### 4.1 价格清洗
京东价格含换行符,需清洗:
```python
price = price.replace('¥', '').replace('\n', '').replace(' ', '')
# "¥\n50" → "50"
```
#### 4.2 去重
多页数据按 title+price 去重:
```python
seen = set()
unique = []
for p in results:
key = p['title'][:30] + p['price']
if key not in seen:
seen.add(key)
unique.append(p)
```
---
### 第五步:报告生成
生成 Markdown 报告,结构:
```markdown
# 药品电商价格监控报告
## 一、调研目标
- 药品:XXX
- 规格:XXX
- 标准价格:XXX 元
- 平台:京东、淘宝
## 二、数据概览
| 平台 | 商品数 | 价格区间 |
|------|--------|----------|
| 京东 | X | ¥X - ¥X |
| 淘宝 | X | ¥X - ¥X |
## 三、低价商品清单
| 平台 | 店铺 | 价格 | 备注 |
|------|------|------|------|
## 四、购买建议
| 场景 | 推荐 | 价格 |
|------|------|------|
```
---
## 快速开始
**抓取京东全部数据(动态翻页):**
```
1. browser action:open url:"https://search.jd.com/Search?keyword=黛力新+0.5mg+10mg+20片"
2. 等待8秒
3. 执行提取JS → 保存第1页
4. 执行翻页JS → 返回 'clicked page X'
5. 等待6秒 → 执行提取JS → 保存第X页
6. 重复步骤4-5,直到返回 'no more pages'
7. 合并所有页数据,去重
```
**抓取淘宝数据:**
```
1. browser action:open url:"https://s.taobao.com/search?q=黛力新+0.5mg+10mg+20片"
2. 等待8秒
3. 执行提取JS → 保存
4. 执行翻页JS(2) → 等待6秒 → 执行提取JS
5. 重复直到翻页失败
6. 合并数据,去重
```
**抓取拼多多数据:**
```
1. browser action:open url:"https://mobile.yangkeduo.com/search_result.html?search_key=黛力新+0.5mg+10mg+20片"
2. 等待8秒
3. 执行提取JS → 保存数据
4. 执行滚动JS → 等待5秒
5. 检查 lastPage:
- lastPage === false → 重复步骤3-5
- lastPage === true → 停止
6. 合并所有数据,去重
```
---
## 重要注意事项
### 1. 翻页核心区别
| 平台 | 翻页方式 | URL变化 | 关键 |
|------|----------|---------|------|
| 京东 | 点击数字按钮 | 不变 | ❌ 不能用"下一页"文字链接 |
| 淘宝 | 点击数字按钮 | 可能被重定向 | ✅ 以页面文字为准 |
| 拼多多 | `window.scrollBy(0,800)` + DOM提取 | 不变 | ✅ 滚动完成后从 DOM 提取(`window.rawData` 不更新!) |
### 2. 等待时间
- 京东首次加载:8秒
- 京东翻页后:6秒
- 淘宝每次翻页:6秒
- 拼多多每次滚动后:3秒
### 3. 防封注意
- 每页间隔 1-2 秒随机延迟
- 避免短时间内大量请求
- 优先使用 Browser 工具(保持登录态)
- 拼多多:✅ 已验证可从 DOM 提取(`window.rawData` 在滚动后不更新,必须用 querySelectorAll + 关键词过滤)
### 4. 各平台验证时间
- 京东:✅ 已验证 2026-04-17
- 淘宝:✅ 已验证 2026-04-17
- 拼多多:✅ 已验证 2026-04-20
FILE:db_storage.py
#!/usr/bin/env python3
"""
药品价格监控数据库存储模块
数据库路径:~/.openclaw/skills/pharmacy-price-monitor/price_monitor.db
"""
import sqlite3
import os
from datetime import datetime
DB_DIR = os.path.expanduser("~/.openclaw/skills/pharmacy-price-monitor")
DB_PATH = os.path.join(DB_DIR, "price_monitor.db")
def get_db_path():
return DB_PATH
def init_db():
"""初始化数据库,创建表结构"""
os.makedirs(DB_DIR, exist_ok=True)
conn = sqlite3.connect(DB_PATH)
c = conn.cursor()
c.execute('''CREATE TABLE IF NOT EXISTS drugs (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL UNIQUE,
spec TEXT,
standard_price REAL,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP)''')
c.execute('''CREATE TABLE IF NOT EXISTS crawl_records (
id INTEGER PRIMARY KEY AUTOINCREMENT,
drug_id INTEGER NOT NULL,
platform TEXT NOT NULL,
total_items INTEGER,
min_price REAL,
max_price REAL,
avg_price REAL,
crawl_time TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (drug_id) REFERENCES drugs(id))''')
c.execute('''CREATE TABLE IF NOT EXISTS products (
id INTEGER PRIMARY KEY AUTOINCREMENT,
crawl_record_id INTEGER NOT NULL,
drug_id INTEGER NOT NULL,
platform TEXT NOT NULL,
title TEXT,
price REAL NOT NULL,
shop TEXT,
deals TEXT,
url TEXT,
is_low_price BOOLEAN DEFAULT 0,
notes TEXT,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (crawl_record_id) REFERENCES crawl_records(id),
FOREIGN KEY (drug_id) REFERENCES drugs(id))''')
conn.commit()
return conn
def parse_price(price_str):
"""解析价格字符串为浮点数"""
if not price_str:
return 0.0
return float(str(price_str).replace('¥', '').replace(',', '').replace(' ', ''))
def save_crawl(drug_name, spec, standard_price, platform, products, conn=None):
"""
保存一次抓取数据到数据库
参数:
drug_name: 药品名称
spec: 规格
standard_price: 标准价格
platform: 平台(京东/淘宝/拼多多)
products: 商品列表,每项包含 title, price, shop, deals, url
conn: 可选的数据库连接(用于批量操作)
"""
should_close = False
if conn is None:
conn = init_db()
should_close = True
c = conn.cursor()
# 插入或获取药品ID
c.execute('''INSERT OR IGNORE INTO drugs (name, spec, standard_price)
VALUES (?, ?, ?)''', (drug_name, spec, standard_price))
c.execute('SELECT id FROM drugs WHERE name = ?', (drug_name,))
row = c.fetchone()
drug_id = row[0] if row else None
if drug_id is None:
print("错误:无法获取药品ID")
return None
# 计算价格统计
prices = [parse_price(p.get('price', '0')) for p in products if p.get('price')]
if prices:
min_p, max_p, avg_p = min(prices), max(prices), sum(prices) / len(prices)
else:
min_p, max_p, avg_p = 0, 0, 0
# 插入抓取记录
c.execute('''INSERT INTO crawl_records
(drug_id, platform, total_items, min_price, max_price, avg_price)
VALUES (?, ?, ?, ?, ?, ?)''',
(drug_id, platform, len(products), min_p, max_p, avg_p))
crawl_record_id = c.lastrowid
# 插入商品
for p in products:
price_val = parse_price(p.get('price', '0'))
is_low = 1 if (standard_price and price_val < standard_price) else 0
c.execute('''INSERT INTO products
(crawl_record_id, drug_id, platform, title, price, shop, deals, url, is_low_price)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)''',
(crawl_record_id, drug_id, platform,
p.get('title', ''), price_val, p.get('shop', ''),
p.get('deals', ''), p.get('url', ''), is_low))
conn.commit()
print(f"[{platform}] 已存储 {len(products)} 条商品数据到数据库 (记录ID: {crawl_record_id})")
if should_close:
conn.close()
return crawl_record_id
def query_history(drug_name, platform=None, limit=10):
"""查询历史抓取记录"""
conn = sqlite3.connect(DB_PATH)
c = conn.cursor()
if platform:
c.execute('''SELECT cr.id, cr.crawl_time, cr.platform, cr.total_items,
cr.min_price, cr.max_price, cr.avg_price
FROM crawl_records cr
JOIN drugs d ON cr.drug_id = d.id
WHERE d.name = ? AND cr.platform = ?
ORDER BY cr.crawl_time DESC LIMIT ?''',
(drug_name, platform, limit))
else:
c.execute('''SELECT cr.id, cr.crawl_time, cr.platform, cr.total_items,
cr.min_price, cr.max_price, cr.avg_price
FROM crawl_records cr
JOIN drugs d ON cr.drug_id = d.id
WHERE d.name = ?
ORDER BY cr.crawl_time DESC LIMIT ?''',
(drug_name, limit))
results = c.fetchall()
conn.close()
return results
def query_low_price_products(drug_name, platform=None, standard_price=None):
"""查询低于标准价的商品"""
conn = sqlite3.connect(DB_PATH)
c = conn.cursor()
if platform:
c.execute('''SELECT p.platform, p.shop, p.price, p.title, p.deals, p.is_low_price
FROM products p
JOIN drugs d ON p.drug_id = d.id
WHERE d.name = ? AND p.platform = ? AND p.is_low_price = 1
ORDER BY p.price ASC''',
(drug_name, platform))
else:
c.execute('''SELECT p.platform, p.shop, p.price, p.title, p.deals, p.is_low_price
FROM products p
JOIN drugs d ON p.drug_id = d.id
WHERE d.name = ? AND p.is_low_price = 1
ORDER BY p.price ASC''',
(drug_name,))
results = c.fetchall()
conn.close()
return results
def get_latest_crawl_id(drug_name, platform):
"""获取最新一次抓取记录ID"""
conn = sqlite3.connect(DB_PATH)
c = conn.cursor()
c.execute('''SELECT cr.id
FROM crawl_records cr
JOIN drugs d ON cr.drug_id = d.id
WHERE d.name = ? AND cr.platform = ?
ORDER BY cr.crawl_time DESC LIMIT 1''',
(drug_name, platform))
row = c.fetchone()
conn.close()
return row[0] if row else None
def get_products_by_crawl(crawl_record_id):
"""获取某次抓取记录的所有商品"""
conn = sqlite3.connect(DB_PATH)
c = conn.cursor()
c.execute('''SELECT title, price, shop, deals, url, is_low_price
FROM products WHERE crawl_record_id = ? ORDER BY price ASC''',
(crawl_record_id,))
results = c.fetchall()
conn.close()
return results
def print_history_table(drug_name, platform=None):
"""格式化打印历史记录"""
rows = query_history(drug_name, platform)
if not rows:
print(f"暂无 '{drug_name}' 的历史抓取记录")
return
print(f"\n{'='*80}")
print(f"药品: {drug_name}")
if platform:
print(f"平台: {platform}")
print(f"{'='*80}")
print(f"{'时间':<20} {'平台':<6} {'商品数':<6} {'最低价':<8} {'最高价':<8} {'均价':<8}")
print("-"*80)
for row in rows:
crawl_id, crawl_time, plat, total, min_p, max_p, avg_p = row
print(f"{crawl_time:<20} {plat:<6} {total:<6} ¥{min_p:<7.2f} ¥{max_p:<7.2f} ¥{avg_p:<7.2f}")
if __name__ == "__main__":
# 初始化数据库
init_db()
print(f"数据库已初始化: {DB_PATH}")
FILE:scripts/analyze_prices.py
#!/usr/bin/env python3
"""
价格合规性检查脚本
识别低于标准价的违规商品
"""
import json
import argparse
from typing import List, Dict, Any
def analyze_prices(data: List[Dict[str, Any]], standard_price: float) -> List[Dict[str, Any]]:
"""
分析商品价格,识别违规商品
Args:
data: 商品数据列表,每个商品包含 platform, shop, title, price, url
standard_price: 标准价格
Returns:
违规商品列表
"""
violations = []
for item in data:
try:
price = float(item.get('price', 0))
if price < standard_price:
violations.append({
'platform': item.get('platform', ''),
'shop': item.get('shop', ''),
'title': item.get('title', ''),
'actual_price': price,
'standard_price': standard_price,
'diff': round(price - standard_price, 2),
'url': item.get('url', '')
})
except (ValueError, TypeError):
continue
return violations
def load_data(file_path: str) -> List[Dict[str, Any]]:
"""加载 JSON 数据文件"""
with open(file_path, 'r', encoding='utf-8') as f:
return json.load(f)
def save_data(data: List[Dict[str, Any]], file_path: str):
"""保存 JSON 数据文件"""
with open(file_path, 'w', encoding='utf-8') as f:
json.dump(data, f, ensure_ascii=False, indent=2)
def main():
parser = argparse.ArgumentParser(description='价格合规性检查')
parser.add_argument('--data', required=True, help='原始数据文件 (JSON)')
parser.add_argument('--price', type=float, required=True, help='标准价格')
parser.add_argument('--output', required=True, help='输出文件 (JSON)')
args = parser.parse_args()
# 加载数据
data = load_data(args.data)
# 分析价格
violations = analyze_prices(data, args.price)
# 保存结果
save_data(violations, args.output)
print(f"✅ 分析完成")
print(f"📊 总商品数: {len(data)}")
print(f"⚠️ 违规商品数: {len(violations)}")
print(f"💾 结果已保存到: {args.output}")
if __name__ == '__main__':
main()
FILE:scripts/db.py
#!/usr/bin/env python3
"""
药品价格监控数据库管理脚本
SQLite 数据库,支持按平台、按时间存储数据
"""
import sqlite3
import json
import argparse
from datetime import datetime
from typing import List, Dict, Any, Optional
import os
# 数据库路径
DB_PATH = os.path.join(os.path.dirname(__file__), '..', 'data', 'pharmacy_monitor.db')
def get_db_path() -> str:
"""获取数据库路径"""
return os.path.abspath(DB_PATH)
def init_db(db_path: Optional[str] = None) -> sqlite3.Connection:
"""初始化数据库,创建表结构"""
if db_path is None:
db_path = get_db_path()
# 确保目录存在
os.makedirs(os.path.dirname(db_path), exist_ok=True)
conn = sqlite3.connect(db_path)
cursor = conn.cursor()
# 创建商品表
cursor.execute('''
CREATE TABLE IF NOT EXISTS products (
id INTEGER PRIMARY KEY AUTOINCREMENT,
platform TEXT NOT NULL,
crawl_date TEXT NOT NULL,
drug_name TEXT NOT NULL,
drug_spec TEXT,
title TEXT NOT NULL,
shop TEXT,
price REAL,
url TEXT,
is_violation INTEGER DEFAULT 0,
is_unauthorized INTEGER DEFAULT 0,
unauthorized_reason TEXT,
created_at TEXT DEFAULT CURRENT_TIMESTAMP,
UNIQUE(platform, crawl_date, url)
)
''')
# 创建汇总表
cursor.execute('''
CREATE TABLE IF NOT EXISTS crawl_summary (
id INTEGER PRIMARY KEY AUTOINCREMENT,
platform TEXT NOT NULL,
crawl_date TEXT NOT NULL,
drug_name TEXT NOT NULL,
drug_spec TEXT,
total_products INTEGER DEFAULT 0,
total_violations INTEGER DEFAULT 0,
total_unauthorized INTEGER DEFAULT 0,
standard_price REAL,
created_at TEXT DEFAULT CURRENT_TIMESTAMP,
UNIQUE(platform, crawl_date, drug_name, drug_spec)
)
''')
# 创建索引
cursor.execute('''
CREATE INDEX IF NOT EXISTS idx_platform_date
ON products(platform, crawl_date)
''')
cursor.execute('''
CREATE INDEX IF NOT EXISTS idx_drug_name
ON products(drug_name)
''')
cursor.execute('''
CREATE INDEX IF NOT EXISTS idx_crawl_date
ON products(crawl_date)
''')
conn.commit()
return conn
def save_products(products: List[Dict], platform: str, drug_name: str,
drug_spec: str, standard_price: float,
db_path: Optional[str] = None) -> int:
"""
保存商品数据到数据库
Args:
products: 商品列表
platform: 平台名称
drug_name: 药品名称
drug_spec: 药品规格
standard_price: 标准价格
db_path: 数据库路径
Returns:
保存的商品数量
"""
conn = init_db(db_path)
cursor = conn.cursor()
crawl_date = datetime.now().strftime('%Y-%m-%d')
saved_count = 0
violation_count = 0
unauthorized_count = 0
for product in products:
try:
price = float(product.get('price', 0))
is_violation = 1 if price < standard_price else 0
is_unauthorized = product.get('is_unauthorized', 0)
unauthorized_reason = product.get('unauthorized_reason', '')
if is_violation:
violation_count += 1
if is_unauthorized:
unauthorized_count += 1
cursor.execute('''
INSERT OR REPLACE INTO products
(platform, crawl_date, drug_name, drug_spec, title, shop,
price, url, is_violation, is_unauthorized, unauthorized_reason)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
''', (
platform,
crawl_date,
drug_name,
drug_spec or '',
product.get('title', ''),
product.get('shop', ''),
price,
product.get('url', ''),
is_violation,
is_unauthorized,
unauthorized_reason
))
saved_count += 1
except Exception as e:
print(f"保存商品失败: {e}")
continue
# 保存汇总
cursor.execute('''
INSERT OR REPLACE INTO crawl_summary
(platform, crawl_date, drug_name, drug_spec, total_products,
total_violations, total_unauthorized, standard_price)
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
''', (
platform,
crawl_date,
drug_name,
drug_spec or '',
saved_count,
violation_count,
unauthorized_count,
standard_price
))
conn.commit()
conn.close()
return saved_count
def get_history(platform: str, drug_name: str,
days: int = 30,
db_path: Optional[str] = None) -> List[Dict]:
"""
获取历史数据
Args:
platform: 平台名称
drug_name: 药品名称
days: 查询最近天数
db_path: 数据库路径
Returns:
历史数据列表
"""
conn = init_db(db_path)
conn.row_factory = sqlite3.Row
cursor = conn.cursor()
cursor.execute('''
SELECT * FROM crawl_summary
WHERE platform = ?
AND drug_name = ?
AND crawl_date >= date('now', '-' || ? || ' days')
ORDER BY crawl_date DESC
''', (platform, drug_name, days))
results = [dict(row) for row in cursor.fetchall()]
conn.close()
return results
def get_trend(drug_name: str, db_path: Optional[str] = None) -> Dict:
"""
获取趋势分析数据
Args:
drug_name: 药品名称
db_path: 数据库路径
Returns:
趋势分析数据
"""
conn = init_db(db_path)
conn.row_factory = sqlite3.Row
cursor = conn.cursor()
# 获取最近7天的汇总
cursor.execute('''
SELECT
crawl_date,
platform,
SUM(total_products) as total_products,
SUM(total_violations) as total_violations,
SUM(total_unauthorized) as total_unauthorized
FROM crawl_summary
WHERE drug_name = ?
AND crawl_date >= date('now', '-7 days')
GROUP BY crawl_date, platform
ORDER BY crawl_date DESC
''', (drug_name,))
daily_data = [dict(row) for row in cursor.fetchall()]
# 获取上期数据对比
cursor.execute('''
SELECT
platform,
SUM(total_products) as total_products,
SUM(total_violations) as total_violations,
SUM(total_unauthorized) as total_unauthorized
FROM crawl_summary
WHERE drug_name = ?
AND crawl_date >= date('now', '-14 days')
AND crawl_date < date('now', '-7 days')
GROUP BY platform
''', (drug_name,))
prev_week = {row['platform']: dict(row) for row in cursor.fetchall()}
conn.close()
return {
'daily_data': daily_data,
'prev_week': prev_week
}
def get_violations(platform: str, drug_name: str,
days: int = 7,
db_path: Optional[str] = None) -> List[Dict]:
"""
获取违规商品列表
Args:
platform: 平台名称
drug_name: 药品名称
days: 查询最近天数
db_path: 数据库路径
Returns:
违规商品列表
"""
conn = init_db(db_path)
conn.row_factory = sqlite3.Row
cursor = conn.cursor()
cursor.execute('''
SELECT * FROM products
WHERE platform = ?
AND drug_name = ?
AND crawl_date >= date('now', '-' || ? || ' days')
AND is_violation = 1
ORDER BY price ASC
''', (platform, drug_name, days))
results = [dict(row) for row in cursor.fetchall()]
conn.close()
return results
def get_unauthorized(platform: str, drug_name: str,
days: int = 7,
db_path: Optional[str] = None) -> List[Dict]:
"""
获取非授权商品列表
Args:
platform: 平台名称
drug_name: 药品名称
days: 查询最近天数
db_path: 数据库路径
Returns:
非授权商品列表
"""
conn = init_db(db_path)
conn.row_factory = sqlite3.Row
cursor = conn.cursor()
cursor.execute('''
SELECT * FROM products
WHERE platform = ?
AND drug_name = ?
AND crawl_date >= date('now', '-' || ? || ' days')
AND is_unauthorized = 1
ORDER BY crawl_date DESC
''', (platform, drug_name, days))
results = [dict(row) for row in cursor.fetchall()]
conn.close()
return results
def export_to_json(platform: str, drug_name: str,
output_path: str,
days: int = 7,
db_path: Optional[str] = None):
"""
导出数据到 JSON 文件
Args:
platform: 平台名称
drug_name: 药品名称
output_path: 输出文件路径
days: 查询最近天数
db_path: 数据库路径
"""
conn = init_db(db_path)
conn.row_factory = sqlite3.Row
cursor = conn.cursor()
# 获取商品数据
cursor.execute('''
SELECT * FROM products
WHERE platform = ?
AND drug_name = ?
AND crawl_date >= date('now', '-' || ? || ' days')
ORDER BY crawl_date DESC, price ASC
''', (platform, drug_name, days))
products = [dict(row) for row in cursor.fetchall()]
# 获取汇总数据
cursor.execute('''
SELECT * FROM crawl_summary
WHERE platform = ?
AND drug_name = ?
AND crawl_date >= date('now', '-' || ? || ' days')
ORDER BY crawl_date DESC
''', (platform, drug_name, days))
summary = [dict(row) for row in cursor.fetchall()]
conn.close()
data = {
'platform': platform,
'drug_name': drug_name,
'export_time': datetime.now().isoformat(),
'products': products,
'summary': summary
}
with open(output_path, 'w', encoding='utf-8') as f:
json.dump(data, f, ensure_ascii=False, indent=2)
print(f"✅ 导出完成: {output_path}")
print(f" 商品数量: {len(products)}")
print(f" 汇总记录: {len(summary)}")
def main():
parser = argparse.ArgumentParser(description='药品价格监控数据库管理')
subparsers = parser.add_subparsers(dest='command', help='子命令')
# init 命令
subparsers.add_parser('init', help='初始化数据库')
# save 命令
save_parser = subparsers.add_parser('save', help='保存商品数据')
save_parser.add_argument('--platform', required=True, help='平台名称')
save_parser.add_argument('--drug-name', required=True, help='药品名称')
save_parser.add_argument('--spec', help='药品规格')
save_parser.add_argument('--price', type=float, required=True, help='标准价格')
save_parser.add_argument('--data', required=True, help='商品数据文件 (JSON)')
# history 命令
history_parser = subparsers.add_parser('history', help='查询历史数据')
history_parser.add_argument('--platform', required=True, help='平台名称')
history_parser.add_argument('--drug-name', required=True, help='药品名称')
history_parser.add_argument('--days', type=int, default=30, help='查询天数')
# trend 命令
trend_parser = subparsers.add_parser('trend', help='趋势分析')
trend_parser.add_argument('--drug-name', required=True, help='药品名称')
# violations 命令
violations_parser = subparsers.add_parser('violations', help='查询违规商品')
violations_parser.add_argument('--platform', required=True, help='平台名称')
violations_parser.add_argument('--drug-name', required=True, help='药品名称')
violations_parser.add_argument('--days', type=int, default=7, help='查询天数')
# export 命令
export_parser = subparsers.add_parser('export', help='导出数据')
export_parser.add_argument('--platform', required=True, help='平台名称')
export_parser.add_argument('--drug-name', required=True, help='药品名称')
export_parser.add_argument('--output', required=True, help='输出文件路径')
export_parser.add_argument('--days', type=int, default=7, help='查询天数')
args = parser.parse_args()
if args.command == 'init':
conn = init_db()
conn.close()
print(f"✅ 数据库初始化完成: {get_db_path()}")
elif args.command == 'save':
with open(args.data, 'r', encoding='utf-8') as f:
products = json.load(f)
count = save_products(products, args.platform, args.drug_name,
args.spec, args.price)
print(f"✅ 保存完成: {count} 件商品")
elif args.command == 'history':
results = get_history(args.platform, args.drug_name, args.days)
print(f"历史数据 ({args.days} 天):")
for row in results:
print(f" {row['crawl_date']}: {row['total_products']} 件商品, "
f"{row['total_violations']} 违规, {row['total_unauthorized']} 非授权")
elif args.command == 'trend':
results = get_trend(args.drug_name)
print(f"趋势分析 (最近7天):")
for day in results['daily_data']:
print(f" {day['crawl_date']} [{day['platform']}]: "
f"{day['total_products']} 件, {day['total_violations']} 违规")
elif args.command == 'violations':
results = get_violations(args.platform, args.drug_name, args.days)
print(f"违规商品 ({args.days} 天):")
for row in results:
print(f" [{row['shop']}] {row['title']}: ¥{row['price']}")
elif args.command == 'export':
export_to_json(args.platform, args.drug_name, args.output, args.days)
else:
parser.print_help()
if __name__ == '__main__':
main()
FILE:scripts/detect_versions.py
#!/usr/bin/env python3
"""
非授权版本识别脚本
基于关键词匹配识别非授权商品(海外版、港版等)
"""
import json
import argparse
from typing import List, Dict, Any
# 违规关键词列表
VIOLATION_KEYWORDS = [
'海外版',
'港版',
'美版',
'欧版',
'代购',
'进口',
'跨境',
'原装进口',
'直邮',
'免税',
'保税仓',
]
def detect_unauthorized(data: List[Dict[str, Any]]) -> List[Dict[str, Any]]:
"""
检测非授权版本商品
Args:
data: 商品数据列表
Returns:
非授权商品列表
"""
unauthorized = []
for item in data:
title = item.get('title', '').lower()
# 检查是否命中违规关键词
for keyword in VIOLATION_KEYWORDS:
if keyword.lower() in title:
unauthorized.append({
'platform': item.get('platform', ''),
'shop': item.get('shop', ''),
'title': item.get('title', ''),
'reason': f'命中关键词: {keyword}',
'url': item.get('url', '')
})
break
return unauthorized
def load_data(file_path: str) -> List[Dict[str, Any]]:
"""加载 JSON 数据文件"""
with open(file_path, 'r', encoding='utf-8') as f:
return json.load(f)
def save_data(data: List[Dict[str, Any]], file_path: str):
"""保存 JSON 数据文件"""
with open(file_path, 'w', encoding='utf-8') as f:
json.dump(data, f, ensure_ascii=False, indent=2)
def main():
parser = argparse.ArgumentParser(description='非授权版本识别')
parser.add_argument('--data', required=True, help='原始数据文件 (JSON)')
parser.add_argument('--output', required=True, help='输出文件 (JSON)')
args = parser.parse_args()
# 加载数据
data = load_data(args.data)
# 检测非授权商品
unauthorized = detect_unauthorized(data)
# 保存结果
save_data(unauthorized, args.output)
print(f"✅ 检测完成")
print(f"📊 总商品数: {len(data)}")
print(f"⚠️ 疑似非授权商品数: {len(unauthorized)}")
print(f"💾 结果已保存到: {args.output}")
if __name__ == '__main__':
main()
FILE:scripts/generate_report.py
#!/usr/bin/env python3
"""
Markdown 报告生成脚本
汇总分析结果并生成结构化报告
"""
import json
import argparse
from datetime import datetime
from typing import List, Dict, Any
from collections import Counter, defaultdict
def load_data(file_path: str) -> List[Dict[str, Any]]:
"""加载 JSON 数据文件"""
with open(file_path, 'r', encoding='utf-8') as f:
return json.load(f)
def generate_summary(violations: List[Dict], unauthorized: List[Dict],
all_data: List[Dict]) -> Dict[str, Any]:
"""生成汇总统计数据"""
# 平台分布
violation_by_platform = Counter([v['platform'] for v in violations])
unauthorized_by_platform = Counter([u['platform'] for u in unauthorized])
# 总商品数
total_by_platform = Counter([d['platform'] for d in all_data])
return {
'total_violations': len(violations),
'total_unauthorized': len(unauthorized),
'total_products': len(all_data),
'violation_by_platform': dict(violation_by_platform),
'unauthorized_by_platform': dict(unauthorized_by_platform),
'total_by_platform': dict(total_by_platform),
}
def generate_markdown(summary: Dict, violations: List[Dict], unauthorized: List[Dict],
drug_name: str, spec: str, standard_price: float, platforms: List[str]) -> str:
"""生成 Markdown 报告"""
md = f"""# 药品电商价格监控报告
**生成时间**: {datetime.now().strftime('%Y-%m-%d %H:%M:%S')}
---
## 一、目标与结论
### 调研目标
- **药品名称**: {drug_name}
- **规格**: {spec or '未指定'}
- **标准价格**: {standard_price} 元
- **监控平台**: {', '.join(platforms)}
### 核心结论
- ✅ 低价违规店铺:**{summary['total_violations']} 家**
- 📍 平台分布:{', '.join([f'{k} {v} 家' for k, v in summary['violation_by_platform'].items()]) or '无'}
- ⚠️ 疑似非授权商品:**{summary['total_unauthorized']} 件**
---
## 二、总体情况
| 平台 | 商品数量 | 违规数量 | 非授权数量 |
|--------|----------|----------|------------|
"""
# 平台汇总表
all_platforms = set(summary['total_by_platform'].keys()) | set(platforms)
for platform in sorted(all_platforms):
total = summary['total_by_platform'].get(platform, 0)
violation = summary['violation_by_platform'].get(platform, 0)
unauthorized = summary['unauthorized_by_platform'].get(platform, 0)
md += f"| {platform} | {total} | {violation} | {unauthorized} |\n"
md += f"| **总计** | **{summary['total_products']}** | **{summary['total_violations']}** | **{summary['total_unauthorized']}** |\n"
md += "\n---\n\n"
# 问题分析
md += "## 三、问题分析\n\n"
# 价格违规情况
md += "### 价格违规情况\n\n"
if summary['total_violations'] > 0:
violation_rate = round(summary['total_violations'] / summary['total_products'] * 100, 1)
md += f"- 违规率:**{violation_rate}%**({summary['total_violations']}/{summary['total_products']})\n"
md += f"- 主要平台:{', '.join([f'{k}({v} 家)' for k, v in summary['violation_by_platform'].items()])}\n"
else:
md += "- ✅ 无价格违规商品\n"
md += "\n"
# 非授权商品情况
md += "### 非授权商品\n\n"
if summary['total_unauthorized'] > 0:
md += f"- 疑似非授权商品:**{summary['total_unauthorized']} 件**\n"
md += f"- 主要平台:{', '.join([f'{k}({v} 件)' for k, v in summary['unauthorized_by_platform'].items()])}\n"
else:
md += "- ✅ 无疑似非授权商品\n"
md += "\n---\n\n"
# 详细数据 - 违规商品清单
if violations:
md += "## 四、详细数据\n\n"
md += "### 违规商品清单\n\n"
md += "| 平台 | 店铺名称 | 商品名称 | 实际售价 | 标准价格 | 差价 | 链接 |\n"
md += "|--------|----------|----------|----------|----------|------|------|\n"
for v in violations:
md += f"| {v['platform']} | {v['shop']} | {v['title']} | {v['actual_price']} | {v['standard_price']} | {v['diff']} | [{v['url']}]({v['url']}) |\n"
md += "\n---\n\n"
# 非授权商品清单
if unauthorized:
md += "### 非授权商品清单\n\n"
md += "| 平台 | 店铺名称 | 商品标题 | 判定依据 | 链接 |\n"
md += "|--------|----------|----------|----------|------|\n"
for u in unauthorized:
md += f"| {u['platform']} | {u['shop']} | {u['title']} | {u['reason']} | [{u['url']}]({u['url']}) |\n"
md += "\n---\n\n"
md += "---\n\n"
md += "*本报告由 OpenClaw 药品电商价格监控系统自动生成*\n"
return md
def save_report(markdown: str, file_path: str):
"""保存 Markdown 报告"""
with open(file_path, 'w', encoding='utf-8') as f:
f.write(markdown)
def main():
parser = argparse.ArgumentParser(description='生成 Markdown 报告')
parser.add_argument('--violations', help='违规商品数据文件 (JSON)')
parser.add_argument('--unauthorized', help='非授权商品数据文件 (JSON)')
parser.add_argument('--all-data', help='原始商品数据文件 (JSON)')
parser.add_argument('--output', required=True, help='输出报告文件 (Markdown)')
parser.add_argument('--drug-name', default='未知药品', help='药品名称')
parser.add_argument('--spec', default='', help='药品规格')
parser.add_argument('--price', type=float, required=True, help='标准价格')
parser.add_argument('--platforms', nargs='+', default=['京东', '淘宝', '拼多多'],
help='监控平台列表')
args = parser.parse_args()
# 加载数据
violations = load_data(args.violations) if args.violations else []
unauthorized = load_data(args.unauthorized) if args.unauthorized else []
all_data = load_data(args.all_data) if args.all_data else []
# 生成汇总统计
summary = generate_summary(violations, unauthorized, all_data)
# 生成 Markdown 报告
markdown = generate_markdown(summary, violations, unauthorized,
args.drug_name, args.spec, args.price, args.platforms)
# 保存报告
save_report(markdown, args.output)
print(f"✅ 报告生成完成")
print(f"📊 违规商品数: {summary['total_violations']}")
print(f"⚠️ 非授权商品数: {summary['total_unauthorized']}")
print(f"💾 报告已保存到: {args.output}")
if __name__ == '__main__':
main()
FILE:scripts/jd_scraper.py
#!/usr/bin/env python3
"""
京东爬虫 - 验证版 (2026-04-17)
关键修复(已通过实测):
1. 使用已登录Cookie突破第1页限制
2. 点击数字分页按钮(不是"下一页"链接)
3. 用wait_until='load'+充分等待替代networkidle
4. 新选择器[data-sku]替代旧的.gl-item
"""
import argparse, json, time, random, os
from playwright.sync_api import sync_playwright
DEFAULT_COOKIES = '/tmp/jd_cookies.json'
def scrape_jd(keyword, max_pages=5, output='/tmp/jd_results.json',
headless=True, cookies_file=DEFAULT_COOKIES):
"""
抓取京东搜索结果
"""
results = []
with sync_playwright() as p:
browser = p.chromium.launch(
headless=headless,
args=['--disable-blink-features=AutomationControlled']
)
ctx = browser.new_context(
viewport={'width': 1920, 'height': 1080},
locale='zh-CN',
timezone_id='Asia/Shanghai',
user_agent='Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36'
)
# 加载JD cookies(只加载jd.com域名,避免其他cookie干扰)
if os.path.exists(cookies_file):
with open(cookies_file, 'r') as f:
all_cookies = json.load(f)
jd_cookies = [c for c in all_cookies if '.jd.com' in c.get('domain', '')]
ctx.add_cookies(jd_cookies)
print(f"✅ 已加载 {len(jd_cookies)} 个JD cookies")
else:
print(f"⚠️ Cookie文件不存在: {cookies_file}")
page = ctx.new_page()
page.add_init_script("Object.defineProperty(navigator, 'webdriver', {get: () => undefined});")
search_url = f"https://search.jd.com/Search?keyword={keyword}&enc=utf-8"
print(f"🔍 搜索: {keyword}")
for page_num in range(1, max_pages + 1):
url = f"{search_url}&page={page_num*2-1}" if page_num > 1 else search_url
print(f"📄 第 {page_num}/{max_pages} 页 (page={page_num*2-1 if page_num > 1 else 1})...")
# 京东有持续追踪请求,networkidle永远不会完成
# 改用load+充分等待
try:
page.goto(url, wait_until='networkidle', timeout=15000)
except:
try:
page.goto(url, wait_until='load', timeout=20000)
except:
page.goto(url, wait_until='domcontentloaded', timeout=20000)
# 关键:充分等待JS渲染(京东页面复杂,需要10秒以上)
time.sleep(12)
items = page.query_selector_all('[data-sku]')
print(f" 找到 {len(items)} 个商品")
if len(items) == 0:
print(f" ⚠️ 无商品,停止抓取")
break
for item in items:
try:
title_el = item.query_selector('[class*="title"]')
price_el = item.query_selector('[class*="price"]')
shop_el = item.query_selector('[class*="name"]')
title = title_el.inner_text().strip() if title_el else ''
price = price_el.inner_text().strip().replace('\n', '').replace(' ', '') if price_el else ''
shop = shop_el.inner_text().strip() if shop_el else ''
if title:
results.append({
'title': title,
'price': price,
'shop': shop,
'platform': '京东'
})
except:
continue
# 点击下一页数字按钮(不是"下一页"链接)
if page_num < max_pages:
clicked = False
pagers = page.query_selector_all('[class*="pagination"]')
for pager in pagers:
pager_text = pager.inner_text().strip()
if '下一页' in pager_text:
for child in pager.query_selector_all('*'):
try:
txt = child.inner_text().strip()
if txt.isdigit() and int(txt) == page_num + 1:
child.click()
clicked = True
print(f" -> 点击第{page_num+1}页按钮")
time.sleep(8) # 等待AJAX加载
break
except:
continue
break
if not clicked:
print(f" -> 找不到第{page_num+1}页按钮,停止")
break
time.sleep(random.uniform(2, 4))
browser.close()
# 去重(按 title前30字符 + price)
seen = set()
unique = []
for p2 in results:
key = p2['title'][:30] + p2['price']
if key not in seen:
seen.add(key)
unique.append(p2)
print(f"\n✅ 共获取 {len(unique)} 个唯一商品(共抓取 {len(results)} 条)")
if output:
with open(output, 'w', encoding='utf-8') as f:
json.dump(unique, f, ensure_ascii=False, indent=2)
print(f"💾 已保存到 {output}")
return unique
if __name__ == '__main__':
parser = argparse.ArgumentParser(description='京东商品爬虫')
parser.add_argument('--keyword', '-k', required=True, help='搜索关键词')
parser.add_argument('--max-pages', '-m', type=int, default=5, help='最大页数(默认5)')
parser.add_argument('--output', '-o', default='/tmp/jd_results.json', help='输出文件')
parser.add_argument('--cookies', '-c', default=DEFAULT_COOKIES, help='Cookie文件路径')
parser.add_argument('--visible', action='store_true', help='显示浏览器窗口')
args = parser.parse_args()
scrape_jd(
keyword=args.keyword,
max_pages=args.max_pages,
output=args.output,
headless=not args.visible,
cookies_file=args.cookies
)
FILE:references/report-template.md
# 报告模板说明
本文档说明药品电商价格监控报告的结构、字段和生成规范。
---
## 报告结构
报告分为五个主要部分:
1. **目标与结论** - 监控目标和核心发现
2. **总体情况** - 各平台数据汇总
3. **问题分析** - 违规情况详细分析
4. **详细数据** - 违规/非授权商品明细表
5. **趋势分析**(可选)- 多期数据对比
---
## 字段说明
### 1. 目标与结论
#### 调研目标字段
- **药品名称** (string) - 被监控的药品全名
- **规格** (string, optional) - 药品规格(如:"0.25g*24粒")
- **标准价格** (float) - 公司规定的统一零售价(单位:元)
- **监控平台** (array) - 监控的电商平台列表
#### 核心结论字段
- **低价违规店铺数量** (int) - 低于标准价的店铺总数
- **平台分布** (object) - 各平台违规店铺数量
```json
{
"京东": 2,
"淘宝": 1,
"拼多多": 0
}
```
- **疑似非授权商品数量** (int) - 疑似非授权商品总数
---
### 2. 总体情况
#### 汇总表字段
| 字段 | 类型 | 说明 |
|------|------|------|
| 平台 | string | 电商平台名称 |
| 商品数量 | int | 该平台抓取的商品总数 |
| 违规数量 | int | 低于标准价的商品数量 |
| 非授权数量 | int | 疑似非授权商品数量 |
---
### 3. 问题分析
#### 价格违规情况字段
- **违规率** (float) - 违规商品占比(百分比)
- **主要平台分布** (array) - 违规商品较多的平台列表
#### 非授权商品字段
- **疑似非授权商品数量** (int)
- **主要平台分布** (array) - 非授权商品较多的平台列表
---
### 4. 详细数据
#### 违规商品清单字段
| 字段 | 类型 | 说明 | 示例 |
|------|------|------|------|
| 平台 | string | 电商平台 | 京东 |
| 店铺名称 | string | 销售店铺名称 | 某某大药房旗舰店 |
| 商品名称 | string | 商品完整标题 | 阿莫西林胶囊 0.25g*24粒 |
| 实际售价 | float | 抓取到的商品售价(元) | 28.5 |
| 标准价格 | float | 公司规定的标准价(元) | 33.0 |
| 差价 | float | 实际售价与标准价的差额 | -4.5 |
| 链接 | string | 商品详情页链接 | https://item.jd.com/xxx |
#### 非授权商品清单字段
| 字段 | 类型 | 说明 | 示例 |
|------|------|------|------|
| 平台 | string | 电商平台 | 淘宝 |
| 店铺名称 | string | 销售店铺名称 | 某某代购 |
| 商品标题 | string | 商品完整标题 | 阿莫西林胶囊(港版) |
| 判定依据 | string | 命中的关键词 | 命中关键词: 港版 |
| 链接 | string | 商品详情页链接 | https://item.taobao.com/xxx |
---
### 5. 趋势分析(可选)
#### 趋势表字段
| 字段 | 类型 | 说明 |
|------|------|------|
| 指标 | string | 监控指标名称(如:违规店铺数量) |
| 本期 | int | 本次监控数据 |
| 上期 | int | 上次监控数据 |
| 变化 | string | 变化趋势(如:"-2 ⬇️") |
---
## 生成规范
### 命名规范
报告文件名格式:`{药品名称}_价格监控_{日期}.md`
示例:
- `阿莫西林胶囊_价格监控_2026-04-17.md`
- `阿莫西林胶囊_价格监控_2026-04-17_0937.md`
### 数据格式
1. **价格字段**:保留 1-2 位小数
2. **百分比**:保留 1 位小数
3. **日期时间**:格式为 `YYYY-MM-DD HH:MM:SS`
### 排序规则
1. 违规商品清单:按差价从大到小排序
2. 非授权商品清单:按平台分组,组内按店铺名称排序
---
## 报告示例
完整报告示例见 `scripts/generate_report.py` 脚本输出。
---
## 自定义扩展
### 添加新字段
如需在报告中添加自定义字段,修改 `scripts/generate_report.py`:
```python
# 1. 在 generate_summary 函数中添加新统计
summary['custom_field'] = custom_value
# 2. 在 generate_markdown 函数中添加新章节
md += "## 自定义章节\n\n"
md += "| 字段 | 值 |\n"
md += "|------|-----|\n"
md += f"| 自定义字段 | {summary['custom_field']} |\n\n"
```
### 调整报告格式
如需修改报告样式或结构,直接编辑 `scripts/generate_report.py` 中的 Markdown 模板。
---
## JSON 数据格式
如需输出 JSON 格式报告(用于程序处理),可修改 `generate_report.py` 或创建新脚本:
```python
def generate_json_report(summary, violations, unauthorized) -> dict:
"""生成 JSON 格式报告"""
return {
'generated_at': datetime.now().isoformat(),
'summary': summary,
'violations': violations,
'unauthorized': unauthorized
}
# 保存 JSON
with open('report.json', 'w', encoding='utf-8') as f:
json.dump(json_report, f, ensure_ascii=False, indent=2)
```
FILE:references/violation-keywords.md
# 非授权版本识别关键词列表
## 核心关键词
以下关键词用于识别非授权版本商品(如海外版、港版、代购等)。
---
## 默认关键词列表
### 地区版本类
- 海外版
- 港版
- 美版
- 欧版
- 日版
- 韩版
- 英版
- 德版
- 法版
- 澳版
### 购买渠道类
- 代购
- 代下单
- 代买
- 海淘
- 直邮
- 转运
- 跨境
- 跨境电商
### 进口相关类
- 进口
- 原装进口
- 纯进口
- 正品进口
- 保税仓
- 免税
- 自贸区
- 免税店
### 特殊渠道类
- 水货
- 平行进口
- 专柜代购
- 柜台代购
- 专柜货
- 门店代购
---
## 自定义扩展
如需添加新的关键词,修改 `scripts/detect_versions.py` 文件:
```python
VIOLATION_KEYWORDS = [
# 原有关键词
'海外版',
'港版',
# ...
# 添加新关键词
'你的新关键词1',
'你的新关键词2',
]
```
---
## 关键词匹配规则
### 匹配逻辑
1. **不区分大小写**:关键词匹配时忽略大小写
2. **部分匹配**:商品标题中包含关键词即判定为命中
3. **多关键词**:一个商品可能命中多个关键词,但只记录第一个匹配
### 排除规则(可选)
如需排除某些特殊情况,可在脚本中添加排除逻辑:
```python
# 排除规则示例
EXCLUDED_PATTERNS = [
'官方进口',
'旗舰店', # 官方旗舰店排除
]
def is_excluded(title: str) -> bool:
"""检查是否需要排除"""
for pattern in EXCLUDED_PATTERNS:
if pattern.lower() in title.lower():
return True
return False
# 在主逻辑中使用
if keyword in title and not is_excluded(title):
# 标记为非授权
```
---
## 命中示例
### 命中案例
- ✅ "阿莫西林胶囊(海外版)" → 命中"海外版"
- ✅ "香港代购阿莫西林" → 命中"代购"
- ✅ "保税仓发货阿莫西林" → 命中"保税仓"
- ✅ "原装进口阿莫西林" → 命中"进口"
### 未命中案例
- ❌ "阿莫西林胶囊(国产)" → 未命中
- ❌ "官方正品阿莫西林" → 未命中
---
## 注意事项
1. **关键词更新**:根据实际监控情况,定期更新关键词列表
2. **误判处理**:如发现误判,可通过排除规则或人工审核机制处理
3. **关键词分类**:可按地区、渠道等维度分类管理,便于后续分析
---
## 扩展功能(可选)
### 1. 按关键词分类统计
```python
from collections import defaultdict
keyword_stats = defaultdict(int)
for item in unauthorized:
for keyword in VIOLATION_KEYWORDS:
if keyword.lower() in item['title'].lower():
keyword_stats[keyword] += 1
break
# 输出统计结果
print("非授权类型分布:")
for keyword, count in keyword_stats.items():
print(f" {keyword}: {count} 件")
```
### 2. 多关键词匹配
如需记录所有匹配的关键词(不只是第一个),修改检测逻辑:
```python
def detect_unauthorized_multi(data: List[Dict]) -> List[Dict]:
"""检测非授权版本,记录所有匹配关键词"""
unauthorized = []
for item in data:
title = item.get('title', '').lower()
matched_keywords = []
for keyword in VIOLATION_KEYWORDS:
if keyword.lower() in title:
matched_keywords.append(keyword)
if matched_keywords:
unauthorized.append({
'platform': item.get('platform', ''),
'shop': item.get('shop', ''),
'title': item.get('title', ''),
'reason': f'命中关键词: {", ".join(matched_keywords)}',
'url': item.get('url', '')
})
return unauthorized
```
FILE:references/browser-workflow.md
# OpenClaw Browser 工具使用指南
## 工具概览
OpenClaw `browser` 工具提供真实 Chromium 浏览器控制能力,支持:
- 页面导航与加载
- 快照与页面结构提取
- 元素交互(点击、输入、滚动)
- 处理动态 JavaScript 内容
- 登录与 session 保留
---
## 基础操作
### 1. 启动浏览器
```bash
# 默认配置启动
browser action:start
# 指定 profile(user 为用户浏览器,需浏览器已运行)
browser action:start profile:user
```
### 2. 打开页面
```bash
# 打开 URL
browser action:open url:"https://search.jd.com/Search?keyword=阿莫西林"
```
### 3. 获取页面快照
```bash
# 使用 role 标签(默认)
browser action:snapshot
# 使用 ARIA 标签(推荐,更稳定)
browser action:snapshot refs:aria
```
**快照返回内容:**
- 页面标题
- URL
- 页面元素列表(带 role、name、selector 等)
- 可交互元素
### 4. 元素交互
```bash
# 点击元素
browser action:act ref:<element_ref> kind:click
# 输入文本
browser action:act ref:<element_ref> kind:type text:"阿莫西林胶囊"
# 滚动页面
browser action:act kind:press key:PageDown
```
---
## 京东商品抓取流程
### 步骤 1:打开搜索页面
```bash
browser action:open url:"https://search.jd.com/Search?keyword=阿莫西林胶囊 0.25g*24粒"
```
### 步骤 2:等待页面加载
```bash
# 等待几秒确保 JavaScript 执行完成
# 或使用 action:act kind:wait timeMs:3000
browser action:snapshot refs:aria
```
### 步骤 3:定位商品列表
从 snapshot 中查找:
- `role="list"` 或 `role="listitem"` 元素
- 包含商品卡片的容器
**典型 ARIA 结构:**
```json
{
"role": "listitem",
"name": "阿莫西林胶囊 0.25g*24粒",
"elements": [
{
"role": "text",
"name": "¥28.50"
},
{
"role": "link",
"name": "某某大药房旗舰店",
"selector": ".shop-name"
},
{
"role": "link",
"name": "查看详情",
"url": "https://item.jd.com/12345678.html"
}
]
}
```
### 步骤 4:提取商品数据
**提取字段:**
- 商品标题:`name` 属性
- 店铺名称:店铺链接的 `name` 属性
- 价格:价格文本元素(去掉 "¥" 符号)
- 商品链接:`url` 属性
**代码示例(Python 解析):**
```python
import json
# 假设 snapshot_data 是 browser action:snapshot 返回的 JSON
data = json.loads(snapshot_data)
# 遍历商品列表
for item in data['elements']:
if item.get('role') == 'listitem':
# 提取商品信息
title = item.get('name', '')
# 提取价格
price = None
for elem in item.get('elements', []):
if '¥' in elem.get('name', ''):
price = float(elem['name'].replace('¥', ''))
break
# 提取店铺
shop = None
for elem in item.get('elements', []):
if elem.get('role') == 'link' and '店' in elem.get('name', ''):
shop = elem['name']
break
# 提取链接
url = None
for elem in item.get('elements', []):
if elem.get('role') == 'link' and 'item.jd.com' in elem.get('url', ''):
url = elem['url']
break
if title and price:
products.append({
'title': title,
'price': price,
'shop': shop,
'url': url,
'platform': '京东'
})
```
### 步骤 5:翻页(必须执行,确保数据完整)
京东翻页机制:底部有 "上一页 | 1 2 3 4 5 ... 下一页 | 输入框+跳转"
```bash
# 循环翻页,抓取全部数据
while True:
# 抓取当前页数据
browser action:snapshot refs:aria
extract_products()
# 检查是否最后一页
total_pages = extract_total_pages() # 从"共100页"中提取
if current_page >= total_pages:
break
# 点击"下一页"(使用selector,最稳定)
browser action:act selector:".pn-next a" kind:click
browser action:act kind:wait timeMs:3000
current_page += 1
```
**翻页选择器参考:**
- 下一页:`.pn-next a`
- 上一页:`.pn-prev`
- 页码数字:`.pn-num a`
- 输入框:`.p-skip input`
- 跳转按钮:`.p-skip button`
---
## 淘宝商品抓取流程
### 步骤 1:打开搜索页面
```bash
browser action:open url:"https://s.taobao.com/search?q=阿莫西林胶囊 0.25g*24粒"
```
### 步骤 2:处理登录(如需要)
淘宝可能需要登录,遇到登录提示时:
- 用户手动登录
- 重新 snapshot 获取登录后的页面
```bash
# 登录后重新快照
browser action:snapshot refs:aria
```
### 步骤 3:提取商品数据
淘宝的 ARIA 结构与京东类似,提取字段相同:
- 商品标题
- 店铺名称
- 价格
- 商品链接
### 步骤 4:翻页(必须执行,确保数据完整)
淘宝翻页机制:底部有 "上一页 | 下一页 | 1/100" 格式
```bash
# 循环翻页,抓取全部数据
while True:
# 抓取当前页数据
browser action:snapshot refs:aria
extract_products()
# 检查"下一页"按钮是否禁用(最后一页时禁用)
browser action:snapshot refs:aria
if is_next_button_disabled(snapshot):
break
# 点击"下一页"(使用selector)
browser action:act selector:".next" kind:click
browser action:act kind:wait timeMs:3000
current_page += 1
```
**翻页选择器参考:**
- 下一页:`.next`
- 上一页:`.prev`
- 页码显示:`.page-num`(格式:1/100)
**检测最后一页:**
- 方法1:检查 `.next` 元素是否包含 `disabled` 类
- 方法2:从页码显示提取当前页和总页数,判断是否到达最后一页
---
## 拼多多商品抓取流程
### 步骤 1:打开搜索页面
```bash
browser action:open url:"https://mobile.yangkeduo.com/search_result.html?search_key=阿莫西林胶囊"
```
### 步骤 2:提取商品数据
拼多多的 ARIA结构可能不同,需要通过 snapshot 实际分析后调整提取逻辑。
---
## 常见问题处理
### 1. 验证码处理
遇到滑块验证码时:
- 用户手动完成验证
- 重新 snapshot 获取验证后的页面
```bash
browser action:snapshot refs:aria
```
### 2. 页面加载慢
```bash
# 使用 wait 等待特定元素出现
browser action:act kind:wait textGone:"加载中"
```
### 3. 动态加载内容
```bash
# 滚动到底部触发加载
browser action:act kind:press key:End
# 等待内容加载
browser action:act kind:wait timeMs:2000
browser action:snapshot refs:aria
```
### 4. 封控处理
如遇到封控:
- 等待 5-10 分钟后重试
- 切换 IP(如有条件)
- 使用账号登录后再抓取
---
## 最佳实践
1. **使用 ARIA 标签**:`refs:aria` 更稳定,推荐使用
2. **等待页面加载**:重要页面加载完成后再 snapshot
3. **错误重试**:网络问题或加载失败时重试 1-2 次
4. **避免高频请求**:每个平台间隔 30-60 秒,防止封控
5. **保存 session**:如需登录,使用 `profile:user` 保留 cookies
---
## 完整工作流示例
```bash
# 1. 启动浏览器
browser action:start
# 2. 打开京东搜索页
browser action:open url:"https://search.jd.com/Search?keyword=阿莫西林"
# 3. 等待加载
browser action:snapshot refs:aria
# 4. 提取数据(解析 snapshot 返回的 JSON)
# [Python 脚本处理]
# 5. 翻页(如需要)
browser action:act ref:<下一页> kind:click
browser action:snapshot refs:aria
# 6. 切换平台,重复步骤 2-5
browser action:open url:"https://s.taobao.com/search?q=阿莫西林"
browser action:snapshot refs:aria
# 7. 关闭浏览器
browser action:stop
```
提供【TBS 训战场景创建】全流程执行能力。用户一旦表达“创建场景/生成对练场景/医药代表训练/销售训练/校验场景/确认落库”等执行意图,必须进入本 Skill 的分阶段脚本调用流程;仅当用户明确是纯咨询(如问规则、问怎么做)时,才允许先文字说明并二次确认是否执行。本 Skill 通过依赖 `cms-auth-s...
---
name: cms-tbs-scene-create
description: 提供【TBS 训战场景创建】全流程执行能力。用户一旦表达“创建场景/生成对练场景/医药代表训练/销售训练/校验场景/确认落库”等执行意图,必须进入本 Skill 的分阶段脚本调用流程;仅当用户明确是纯咨询(如问规则、问怎么做)时,才允许先文字说明并二次确认是否执行。本 Skill 通过依赖 `cms-auth-skills` 获取 `access-token` 并完成鉴权后,才允许进入真实创建链路。
skillcode: cms-tbs-scene-create
github: https://github.com/xgjk/xg-skills/tree/main/cms-tbs-scene-create
dependencies:
- cms-auth-skills
# bump 时须同步修改同目录下 version.json 的 version 字段
version: 0.6.26
tools_provided:
- name: tbs-client
category: exec
risk_level: medium
permission: write
description: TBS Admin API 共享客户端(scripts/tbs-client.py,与 cms-cwork-workflow 的 scripts 扁平约定一致),封装请求、主数据精确匹配与创建逻辑
status: active
- name: tbs-scene-parse
category: exec
risk_level: low
permission: read
description: 解析用户输入为场景草稿,并输出待确认字段与缺失字段
status: active
- name: tbs-scene-validate
category: exec
risk_level: low
permission: read
description: 校验场景草稿(scope=FULL 全量校验 或 PATCH 后 scope=TBV 轻量校验:标题+场景背景/叙述规则;调用决策见 references/tbs-scene-validate.md)
status: active
- name: tbs-scene-knowledge-check
category: exec
risk_level: medium
permission: write
description: 在产品知识主题确认后,按药品与主题查询/复用已有产品知识;缺失且用户提供正文时查重后创建知识
status: active
- name: tbs-scene-create
category: exec
risk_level: medium
permission: write
description: 在用户明确确认后解析主数据并调用 createScene 创建场景
status: active
---
# cms-tbs-scene-create
## 核心定位
本 Skill 只做一件事:根据用户执行意图,读取对应 `references/*.md` 与 `references/*.json`,再执行 `scripts/*.py`。
参数、边界、分支逻辑都以 `references` 为准,`SKILL.md` 只负责入口和流程约束。
## 执行纪律(最高优先级)
1. 流程由 Gate-0 → Gate-5 单向推进,禁止跳步。
2. 每个 Gate 只允许"本 Gate 规定的动作",其余一律禁止。
3. 每次向用户输出前,必须通过 `references/review-checklist.md`;未通过不得输出。
4. `tbs-scene-parse.py` 返回的 `stage` 是唯一推进依据;禁止靠猜测判断阶段。
5. 用户未明确回复"确认",永远不进入 Gate-5(create)。
6. Agent 执行 `tbs-scene-*.py` 必须传 `--output <result.json>`;完整 JSON 只读文件,不把 stdout 结果贴给用户。
7. 每次会话的 payload / parse / draft / validate / create JSON 必须保存到会话级状态目录:`workspace/.cms-log/state/cms-tbs-scene-create/{sessionId}/`。`/tmp` 仅允许一次性调试,不得作为长期 `draftPath` 或跨轮状态来源。
8. **单一真源**:跨轮一律以同一路径下的 `latest-draft.json`(`draftPath` 指向的文件)为 `scene` 与 `meta` 的权威来源;`knowledge-check` / `parse` / 内部场景生成之后,必须先**写回或重读**该文件,再进入下一步。禁止用孤立的 `parse-result.json` 片段替代整份 draft 做后续门禁。
9. **校验输入与 Gate-3 顺序**:`tbs-scene-knowledge-check.py` 成功后必须基于更新后的 `latest-draft.json` 再 `parse`;内部按 `scenario-json-parse` 生成 `title` / `sceneBackground` / `actorProfile` / `doctorOnlyContext` / `coachOnlyContext` 后,必须合并进 `scene` 并再次 `parse` 直至 `stage` 进入可校验阶段后,才允许对**同一份** draft 执行 `tbs-scene-validate.py`(scope=FULL)。禁止在场景正文尚未写入 draft 时,用“仅含基础字段 + 占位 knowledgeIds”的中间态去跑 FULL 校验。
推荐文件名:
```text
workspace/.cms-log/state/cms-tbs-scene-create/{sessionId}/
├── latest-payload.json
├── latest-parse-result.json
├── latest-draft.json
├── latest-validate-result.json
└── latest-create-result.json
```
所有 parse / validate / create 轮次必须沿用同一个 `{sessionId}` 目录;下一轮 payload 必须基于 `latest-draft.json` 的 `scene` 增量合并,禁止用 `scene: {}` 或临时 `/tmp/*.json` 覆盖已确认状态。
## 强制前置
真实创建前须经 `cms-auth-skills` 取 token,并以 `--access-token` 注入 `tbs-scene-create.py`。细则见 `references/auth.md`。
## 阶段状态机
每个 Gate 列明:本阶段加载 references、允许的动作、绝对禁止、推进条件。
`success=true` 不等于可推进;推进依据是当前 Gate 规定的条件,不是 `success`。
---
### Gate-0 · 意图分流
| | |
|--|--|
| **读** | `references/output-templates.md`(模板 0)、`references/common-params.md` |
| **允许** | 咨询 → 仅说明 + 问"是否执行";执行意图 → 进 Gate-1 |
| **长文本例外(强制视为执行意图)** | 首轮用户输入建议 ≥80 字,且在以下 6 类“基础要素”中命中 ≥3 类(不要求精确字段名,语义命中即可):① 产品/品种 ② 科室/医生对象 ③ 场景地点 ④ 医生顾虑/异议点 ⑤ 代表目标/希望达成结果 ⑥ 时间窗口/业务节点/背景 → 必须执行一次 `base-info-parse.md` 抽取 `parsedFields`,再执行 `tbs-scene-parse.py` 并按模板 1/2 回显确认清单;禁止仅用自由发挥追问替代脚本链路 |
| **禁止** | 纯咨询时调用任何脚本;在命中长文本例外时停留在纯追问/自由发挥;生成任何字段 |
---
### Gate-1 · `BASE_INFO_CONFIRM`
| | |
|--|--|
| **读** | `references/base-info-parse.md`、`references/tbs-scene-parse.md`(阶段 1)、`references/output-templates.md`(模板 1/2)、`references/common-params.md` |
| **允许** | ① 用模板 1 回显已识别字段 + 待补充项 + clarifyQuestions;② 每轮最多 2 问、最多 2 轮;③ 接收补充后重新 parse;④ 基础 6 项齐备但未确认时,仅预告“确认后会建议产品知识主题”;⑤ 首次 parse 前创建会话级状态目录,并把 `--output` 与 `draftPath` 指向该目录 |
| **禁止** | 调用 validate / create;自行生成 title / sceneBackground / doctorOnlyContext;扩展确认清单字段;用户未明确确认前设置 `baseInfoAcknowledged=true`;未确认基础信息前生成或要求确认产品知识主题 |
| **推进条件** | 6 个基础字段齐备 **且** 用户明确确认 → 下一轮 payload 顶层 + `scene` 内双写 `baseInfoAcknowledged=true`,再进入 Gate-2 |
---
### Gate-2 · `KNOWLEDGE_CONFIRM`
| | |
|--|--|
| **读** | `references/tbs-scene-parse.md`(阶段 2)、`references/product-knowledge-topic-generate.md`、`references/output-templates.md`、`references/common-params.md` |
| **允许** | ① 当主题为空时按 `product-knowledge-topic-generate.md` 生成 2-4 条建议主题并写入 parse payload;② 同时展示基础信息 6 项 + 产品知识主题;③ 用户确认主题后执行 `tbs-scene-knowledge-check.py`;④ 品种不存在时先自动创建并保存 `drugId`,再查产品知识;⑤ 已存在知识直接复用,缺失主题要求补正文;⑥ 用轻确认收口:确认 / 删除 / 改名 / 新增;⑦ 同轮最多 1 次 parse;⑧ 用户说"暂无正文"时仅记录正文为空 |
| **禁止** | 只展示知识主题不展示基础信息 6 项;同轮 parse 超 1 次;未完成知识检查就进入场景内容生成;调用 validate / create;把"暂无正文"当作"无需主题";向用户索要 title / sceneBackground |
| **推进条件** | 用户确认产品知识主题并传 `productKnowledgeNeedsConfirmed=true`,且知识检查 `knowledgeReady=true` → parse 返回 `READY_FOR_SCENE_GENERATION` |
---
### Gate-3 · `READY_FOR_SCENE_GENERATION`(内部生成,不等用户)
| | |
|--|--|
| **读** | `references/scenario-json-parse.md`、`references/scene.schema.json` |
| **允许** | ① 内部生成 title / sceneBackground / actorProfile / doctorOnlyContext / coachOnlyContext;② 合并进 scene 后重新 parse |
| **禁止** | 跳过生成直接 validate;把 doctorOnlyContext / coachOnlyContext 展示给用户逐段确认;调用 create |
| **推进条件** | 生成完成 + parse 返回 `READY_FOR_VALIDATE` |
---
### Gate-4 · `READY_FOR_VALIDATE` → 落库前确认
| | |
|--|--|
| **读** | `references/tbs-scene-validate.md`、`references/output-templates.md`(模板 3)、`references/common-params.md`(展示声明规则) |
| **步骤(有序不可跳)** | **Step 1** 执行 validate(scope=FULL);`passed=false` → 转写 blockingIssues 为中文,禁止继续 |
| | **Step 2**(仅 `passed=true` 后)执行 `references/review-checklist.md` §D;未通过不得展示落库前确认 |
| | **Step 3** 用模板 3 完整展示 mustDisplayFields,并记录 `validationReport.displayHash` 作为本轮确认绑定值;只给"确认 / 取消"收口 |
| | **Step 4** 等待用户明确回复;"取消" → 终止;"确认" → 进 Gate-5 |
| **禁止** | `passed=false` 时继续;未完整展示 mustDisplayFields 就收口 |
---
### Gate-5 · 真实落库(create)
| | |
|--|--|
| **读** | `references/tbs-scene-create.md`、`references/auth.md` |
| **进入条件(缺一不可)** | ① `userConfirmation = "确认"`;② `meta.lastParseStage=READY_FOR_VALIDATE`;③ validate `passed=true` 满足 FULL / TBV+`meta.lastFullValidationPassed` 组合门禁;④ `confirmedDisplayHash=validationReport.displayHash`;⑤ `displayContractSatisfied=true` 或 `displayedFields` 覆盖 mustDisplayFields;⑥ access-token 已注入且非占位符 |
| **步骤** | 执行 `tbs-scene-create.py`;成功 → 模板 4A;失败 → 模板 4B |
| **禁止** | 任意条件未满足时调用 create |
补充硬门禁:`tbs-scene-validate.py` 会输出 `validationReport.sceneHash` 与 `validationReport.displayHash`;`tbs-scene-create.py` 必须校验当前 `scene` 与 `sceneHash` 一致,且用户确认绑定的 `confirmedDisplayHash` 与当前展示内容一致,否则要求重新 validate 或重新展示最终确认。
---
## 输出自检
每次用户可见输出、调用 validate 前、调用 create 前,必须执行 `references/review-checklist.md`。
## 用户可见回复
所有话术模板 → `references/output-templates.md`。输出规则与字段拦截 → `references/common-params.md`、`references/review-checklist.md`。禁止播报读文档/跑脚本等内部过程;不向用户贴 JSON。
## 常用命令与必读文档
建议先读:`references/README.md`(总索引与推荐阅读顺序)。
## 模块路由与能力索引(平铺结构)
> 本 Skill 对齐 `cms-cwork-workflow` 的平铺结构:`references/*.md` 与 `scripts/*.py` 同级索引;不做 `scripts/<module>/` 分层。
> 规则与门禁以对应 `references/*.md` 为准,执行入口以对应 `scripts/*.py` 为准。
| 用户意图 | 模块 | 能力摘要 | 模块说明 | 脚本 |
|---|---|---|---|---|
| 创建/生成场景(分阶段收集与推进) | `scene` | 解析输入→回显缺失→推进 Gate | `./references/tbs-scene-parse.md` | `./scripts/tbs-scene-parse.py` |
| 校验场景草稿(创建前门禁) | `scene` | FULL/TBV 校验并生成 issues | `./references/tbs-scene-validate.md` | `./scripts/tbs-scene-validate.py` |
| 检查产品知识主题可用性(确认后门禁) | `scene` | 查询/复用已有知识;缺失则要求正文 | `./references/tbs-scene-parse.md` | `./scripts/tbs-scene-knowledge-check.py` |
| 真实创建落库(用户确认后) | `scene` | resolve 主数据并 createScene | `./references/tbs-scene-create.md` | `./scripts/tbs-scene-create.py` |
补充:
- 自然语言骨架提取:`references/base-info-parse.md` + `references/scene.schema.json`(完整场景契约;基础信息阶段仅填基础字段,`required` 不用于 S1 阶段校验)
- 场景正文生成:`references/scenario-json-parse.md` + `references/scene.schema.json`
- 用户可见模板:`references/output-templates.md`
- 运行时自检:`references/review-checklist.md`
- 开发态自检:发布前按 `references/doc-consistency.md` 执行 `scripts/check-doc-consistency.py`
- 复杂编排示例:`references/agent-patterns.md`
## 测试示例(推荐)
### 示例 1:先做基础信息分阶段解析
```bash
# 第一步:先读 references/base-info-parse.md
# 第二步:按 references/scene.schema.json 提取基础信息骨架并写入 payload.json(仅填基础字段,不要求 required 全满)
# 第三步:执行 parse,判断当前阶段
python3 scripts/tbs-scene-parse.py --params-file payload.json --output result.json
```
### 示例 2:校验(全量 / PATCH 后轻量)
```bash
# 先读 references/tbs-scene-validate.md
python3 scripts/tbs-scene-validate.py --params-file draft.json --output validate-result.json
python3 scripts/tbs-scene-validate.py --params-file draft.json --scope tbv --output validate-tbv-result.json
```
### 示例 3:用户确认创建后真实落库
```bash
# 第一步:先读 references/tbs-scene-create.md
# --access-token 传入 cms-auth-skills 返回的真实 token;勿使用尖括号占位字面量
python3 scripts/tbs-scene-create.py \
--params-file draft.json \
--access-token "$ACCESS_TOKEN" \
--output create-result.json
```
## 反向示例(不要这样做)
- 未到 `READY_FOR_SCENE_GENERATION` 就读取 `scenario-json-parse.md` 或生成场景正文。
- 未经过 validate 通过就进入创建。
- 用户未明确回复"确认"就调用 `/scene/createScene`。
## 错误处理与通用参数
通用错误格式、`--params-file` 用法、输入文件规则请查看 `references/common-params.md`。
---
## 目录结构
```text
cms-tbs-scene-create/
├── SKILL.md
├── version.json
├── scripts/
│ ├── README.md
│ ├── tbs-client.py
│ ├── tbs-md-sanitize.py
│ ├── tbs-scene-parse.py
│ ├── tbs-scene-knowledge-check.py
│ ├── tbs-scene-validate.py
│ ├── tbs-scene-create.py
│ └── check-doc-consistency.py
└── references/
├── README.md
├── auth.md
├── base-info-parse.md
├── tbs-scene-parse.md
├── tbs-scene-validate.md
├── tbs-scene-create.md
├── scenario-json-parse.md
├── common-params.md
├── output-templates.md
├── parse-runtime-config.json
├── review-checklist.md
├── agent-patterns.md
├── maintenance.md
├── scene.schema.json
└── doc-consistency.md
```
FILE:version.json
{
"skillcode": "cms-tbs-scene-create",
"version": "0.6.26"
}
FILE:scripts/tbs-scene-create.py
"""
Create a TBS scene after explicit user confirmation.
"""
from __future__ import annotations
import argparse
import hashlib
import importlib.util
import json
import os
import sys
from datetime import datetime, timezone
from pathlib import Path
from typing import Any
def _load_tbs_client() -> Any:
module_path = Path(__file__).with_name("tbs-client.py")
spec = importlib.util.spec_from_file_location("tbs_client_runtime", module_path)
if spec is None or spec.loader is None:
raise ImportError(f"Cannot load TBS client from {module_path}")
module = importlib.util.module_from_spec(spec)
spec.loader.exec_module(module)
return module
_tbs_client = _load_tbs_client()
TBSClient = _tbs_client.TBSClient
extract_created_entity_id = _tbs_client.extract_created_entity_id
resolve_ids_for_scene = _tbs_client.resolve_ids_for_scene
STEP = "tbs-scene-create"
DEFAULT_TBS_ADMIN_BASE_URL = "https://sg-al-cwork-web.mediportal.com.cn/tbs-admin"
DEFAULT_BASE_URL = os.getenv("TBS_ADMIN_BASE_URL", DEFAULT_TBS_ADMIN_BASE_URL).strip() or DEFAULT_TBS_ADMIN_BASE_URL
OUTPUT_PATH: str | None = None
SCENE_HASH_FIELDS = [
"title",
"businessDomainName",
"departmentName",
"drugName",
"location",
"doctorConcerns",
"repGoal",
"sceneBackground",
"productKnowledgeNeeds",
"knowledgeIds",
"doctorOnlyContext",
"coachOnlyContext",
"actorProfile",
]
REQUIRED_CREATE_FIELDS = [
"title",
"businessDomainName",
"departmentName",
"drugName",
"location",
"doctorOnlyContext",
"coachOnlyContext",
]
REQUIRED_DISPLAY_FIELDS = [
"businessDomainName",
"departmentName",
"drugName",
"location",
"doctorConcerns",
"repGoal",
"productKnowledgeNeeds",
"title",
"sceneBackground",
"actorProfile",
]
def _write_output_json(payload: dict[str, Any]) -> None:
if not OUTPUT_PATH:
return
parent = os.path.dirname(OUTPUT_PATH)
if parent:
os.makedirs(parent, exist_ok=True)
with open(OUTPUT_PATH, "w", encoding="utf-8") as handle:
json.dump(payload, handle, ensure_ascii=False, indent=2)
def _canonical_scene_for_hash(scene: dict[str, Any]) -> dict[str, Any]:
canonical: dict[str, Any] = {}
for field in SCENE_HASH_FIELDS:
if field == "sceneBackground":
value = scene.get("sceneBackground") or scene.get("background")
else:
value = scene.get(field)
if value is None:
continue
canonical[field] = value
return canonical
def compute_scene_hash(scene: dict[str, Any]) -> str:
canonical = _canonical_scene_for_hash(scene)
text = json.dumps(canonical, ensure_ascii=False, sort_keys=True, separators=(",", ":"))
return hashlib.sha256(text.encode("utf-8")).hexdigest()
def _canonical_display_for_hash(scene: dict[str, Any]) -> dict[str, Any]:
canonical: dict[str, Any] = {}
for field in REQUIRED_DISPLAY_FIELDS:
if field == "sceneBackground":
value = scene.get("sceneBackground") or scene.get("background")
else:
value = scene.get(field)
canonical[field] = "" if value is None else value
return canonical
def compute_display_hash(scene: dict[str, Any]) -> str:
canonical = _canonical_display_for_hash(scene)
text = json.dumps(canonical, ensure_ascii=False, sort_keys=True, separators=(",", ":"))
return hashlib.sha256(text.encode("utf-8")).hexdigest()
def _summary(payload: dict[str, Any], *, ok: bool) -> str:
parts = ["OK" if ok else "ERROR", STEP]
if payload.get("sceneId"):
parts.append(f"sceneId={payload['sceneId']}")
if payload.get("cancelled") is True:
parts.append("cancelled=true")
if payload.get("error"):
parts.append(f"error={payload['error']}")
if OUTPUT_PATH:
parts.append(f"result={OUTPUT_PATH}")
return " ".join(parts)
def emit_success(payload: dict[str, Any]) -> None:
payload = {"success": True, **payload}
if OUTPUT_PATH:
_write_output_json(payload)
print(_summary(payload, ok=True))
else:
print(json.dumps(payload, ensure_ascii=False, indent=2))
def emit_error(error: str, exit_code: int = 1, **extra: Any) -> None:
payload = {"success": False, "step": STEP, "error": error, **extra}
if OUTPUT_PATH:
_write_output_json(payload)
print(_summary(payload, ok=False), file=sys.stderr)
else:
print(json.dumps(payload, ensure_ascii=False, indent=2), file=sys.stderr)
sys.exit(exit_code)
def _access_token_looks_unresolved_placeholder(token: str) -> bool:
"""拦截文档示例或 shell 未展开变量,避免无意义请求打满鉴权日志。"""
t = token.strip()
if not t:
return True
lowered = t.lower()
if lowered in {"<access_token>", "access_token", "your_access_token", "access_token"}:
return True
if t.startswith("<") and t.endswith(">"):
return True
if t.startswith("") and t.endswith(""):
return True
return False
def read_payload(input_path: str | None, params_file: str | None) -> dict[str, Any]:
path = params_file or input_path
if path and path != "-":
with open(path, "r", encoding="utf-8") as handle:
data = json.load(handle)
if not isinstance(data, dict):
raise ValueError("输入 JSON 须为对象")
return data
raw = sys.stdin.read()
data = json.loads(raw or "{}")
if not isinstance(data, dict):
raise ValueError("输入 JSON 须为对象")
return data
def _read_draft_object(draft_path: str) -> dict[str, Any]:
if not draft_path or not os.path.isfile(draft_path):
return {}
try:
with open(draft_path, "r", encoding="utf-8") as handle:
data = json.load(handle)
return data if isinstance(data, dict) else {}
except (OSError, json.JSONDecodeError):
return {}
def merged_meta(payload: dict[str, Any], explicit_path: str | None) -> dict[str, Any]:
top = payload.get("meta") if isinstance(payload.get("meta"), dict) else {}
meta = dict(top)
path = explicit_path
if (not isinstance(path, str) or not path.strip()) and isinstance(
payload.get("draftPath"), str
):
path = payload.get("draftPath")
if isinstance(path, str) and path.strip():
existing = _read_draft_object(path.strip())
file_meta = existing.get("meta") if isinstance(existing.get("meta"), dict) else {}
return {**file_meta, **meta}
return meta
def create_validation_gate_ok(
scene: dict[str, Any], validation_report: dict[str, Any], meta: dict[str, Any]
) -> tuple[bool, str]:
passed = validation_report.get("passed") is True
scope = str(validation_report.get("scope") or "FULL").strip().upper()
expected_hash = str(
validation_report.get("sceneHash") or meta.get("lastValidatedSceneHash") or ""
).strip()
if not expected_hash:
return False, "validation_scene_hash_missing: 校验结果缺少 sceneHash,请重新执行 tbs-scene-validate.py。"
current_hash = compute_scene_hash(scene)
if expected_hash != current_hash:
return (
False,
"validation_scene_hash_mismatch: 当前 scene 与 validationReport 不一致,请重新执行 tbs-scene-validate.py。",
)
if passed and scope == "FULL":
return True, ""
if passed and scope == "TBV":
if meta.get("lastFullValidationPassed") is True:
return True, ""
return (
False,
"PATCH 落库路径要求草稿 meta.lastFullValidationPassed=true(曾通过全量校验),且本轮 validationReport.scope=TBV 且 passed=true。",
)
return False, "创建前校验未通过:需要全量校验通过,或(曾全量通过 + 本轮 TBV 通过)组合。"
def create_display_gate_ok(
scene: dict[str, Any], validation_report: dict[str, Any], payload: dict[str, Any], meta: dict[str, Any]
) -> tuple[bool, str]:
expected_hash = str(
validation_report.get("displayHash") or meta.get("lastDisplayHash") or ""
).strip()
confirmed_hash = str(
payload.get("confirmedDisplayHash")
or payload.get("userConfirmedDisplayHash")
or meta.get("confirmedDisplayHash")
or ""
).strip()
if not expected_hash:
return False, "display_hash_missing: 校验结果缺少 displayHash,请重新执行 validate 并重新展示最终确认。"
if not confirmed_hash:
return (
False,
"display_confirmation_hash_missing: 用户确认必须绑定本次最终确认清单,请携带 confirmedDisplayHash。",
)
current_hash = compute_display_hash(scene)
if expected_hash != current_hash or confirmed_hash != current_hash:
return (
False,
"display_hash_mismatch: 用户确认后场景展示内容发生变化,请重新展示最终确认清单并重新取得确认。",
)
if payload.get("displayContractSatisfied") is True:
return True, ""
if meta.get("displayContractSatisfied") is True:
return True, ""
displayed_fields: list[str] = []
for source in (payload.get("displayedFields"), meta.get("displayedFields")):
if isinstance(source, list):
displayed_fields.extend(str(item).strip() for item in source if str(item).strip())
if displayed_fields:
shown = set(displayed_fields)
missing = [field for field in REQUIRED_DISPLAY_FIELDS if field not in shown]
if not missing:
return True, ""
return (
False,
"display_contract_incomplete: 未声明完整展示字段,缺少 "
+ ", ".join(missing),
)
return (
False,
"display_contract_missing: 创建前需声明已按 mustDisplayFields 向用户展示(传 displayContractSatisfied=true 或 displayedFields)。",
)
def create_knowledge_gate_ok(scene: dict[str, Any], meta: dict[str, Any]) -> tuple[bool, str]:
if _is_empty(scene.get("productKnowledgeNeeds")):
return True, ""
if meta.get("knowledgeChecked") is True and meta.get("knowledgeReady") is True:
return True, ""
return (
False,
"knowledge_gate_failed: 创建前必须先执行 tbs-scene-knowledge-check.py,且 knowledgeReady=true。",
)
def create_parse_stage_gate_ok(meta: dict[str, Any]) -> tuple[bool, str]:
if str(meta.get("lastParseStage") or "").strip() == "READY_FOR_VALIDATE":
return True, ""
return (
False,
"parse_stage_gate_failed: 创建前必须完成场景内容生成,并由 tbs-scene-parse.py 输出 READY_FOR_VALIDATE。",
)
def _is_empty(value: Any) -> bool:
if value is None:
return True
if isinstance(value, str):
return not value.strip()
if isinstance(value, list):
return len([item for item in value if str(item).strip()]) == 0
if isinstance(value, dict):
return len(value) == 0
return False
def create_scene_self_check_ok(
scene: dict[str, Any], validation_report: dict[str, Any]
) -> tuple[bool, str]:
missing = [field for field in REQUIRED_CREATE_FIELDS if _is_empty(scene.get(field))]
if missing:
return False, f"scene_required_fields_missing: {', '.join(missing)}"
# 至少保证场景背景可落库,避免仅靠伪造 validationReport 进入创建。
has_background = not _is_empty(scene.get("sceneBackground")) or not _is_empty(
scene.get("background")
)
if not has_background:
return False, "scene_background_missing: sceneBackground 必填"
actor = scene.get("actorProfile")
if not isinstance(actor, dict) or _is_empty(actor.get("name")):
return False, "scene_actor_profile_invalid: actorProfile.name 必填"
# 与 validate 输出做最小一致性校验,防止伪造 passed=true 但仍带阻断项。
blocking = validation_report.get("blockingIssues")
if isinstance(blocking, list) and len(blocking) > 0:
return False, "validation_report_inconsistent: blockingIssues 非空"
issues = validation_report.get("issues")
if isinstance(issues, list) and len(issues) > 0:
return False, "validation_report_inconsistent: issues 非空"
return True, ""
def require_confirmation(payload: dict[str, Any]) -> str:
value = str(payload.get("userConfirmation") or "").strip()
if not value:
raise RuntimeError("缺少 userConfirmation,必须为 确认 或 取消")
if value not in {"确认", "取消"}:
raise RuntimeError("userConfirmation 仅允许为 确认 或 取消")
return value
def persist_result(
draft_path: str | None,
scene: dict[str, Any],
validation_report: dict[str, Any],
scene_id: str,
resolved_ids: dict[str, Any],
resolution_report: dict[str, dict[str, Any]],
) -> None:
if not draft_path:
return
parent = os.path.dirname(draft_path)
if parent:
os.makedirs(parent, exist_ok=True)
existing = _read_draft_object(draft_path)
prior_meta = existing.get("meta") if isinstance(existing.get("meta"), dict) else {}
meta = {
**prior_meta,
"updatedAt": datetime.now(timezone.utc).isoformat(),
"lastStep": STEP,
}
payload = {
**existing,
"scene": scene,
"validationReport": validation_report,
"persistResult": {
"sceneId": scene_id,
"resolvedIds": resolved_ids,
"resolutionReport": resolution_report,
"updatedAt": datetime.now(timezone.utc).isoformat(),
},
"meta": meta,
}
with open(draft_path, "w", encoding="utf-8") as handle:
json.dump(payload, handle, ensure_ascii=False, indent=2)
def main() -> None:
global OUTPUT_PATH
parser = argparse.ArgumentParser()
parser.add_argument("--input", default="-", help="JSON file path, or '-' for stdin")
parser.add_argument("--params-file", default=None, help="Read params from UTF-8 JSON file")
parser.add_argument("--output", default=None, help="Write full JSON result to this file")
parser.add_argument("--base-url", default=DEFAULT_BASE_URL)
parser.add_argument("--access-token", required=True)
args = parser.parse_args()
OUTPUT_PATH = args.output
try:
payload = read_payload(args.input, args.params_file)
except (OSError, json.JSONDecodeError, ValueError) as exc:
emit_error("invalid_json_input", exit_code=2, hint=str(exc))
try:
confirmation = require_confirmation(payload)
except RuntimeError as exc:
emit_error(str(exc), exit_code=2)
if confirmation == "取消":
emit_success(
{
"step": STEP,
"cancelled": True,
"message": "用户已取消,本次不执行创建。",
}
)
return
access_token = str(args.access_token or "").strip()
if not access_token:
emit_error("缺少 access-token", exit_code=2)
if _access_token_looks_unresolved_placeholder(access_token):
emit_error(
"access_token_invalid",
exit_code=2,
hint="access-token 疑似占位符或未替换的模板;请先通过 cms-auth-skills 取得真实 token 后再以 --access-token 注入,勿把 <ACCESS_TOKEN> 等示例原文传入。",
)
scene = payload.get("scene") if isinstance(payload.get("scene"), dict) else {}
validation_report = (
payload.get("validationReport") if isinstance(payload.get("validationReport"), dict) else {}
)
draft_path = payload.get("draftPath")
loaded_from_path: dict[str, Any] = {}
if not scene and (args.params_file or (args.input and args.input != "-")):
draft_path = args.params_file or args.input
loaded_from_path = read_payload(draft_path, None)
scene = (
loaded_from_path.get("scene")
if isinstance(loaded_from_path.get("scene"), dict)
else {}
)
validation_report = (
loaded_from_path.get("validationReport")
if isinstance(loaded_from_path.get("validationReport"), dict)
else {}
)
if not scene:
emit_error("缺少 scene", exit_code=2)
effective_payload = {**loaded_from_path, **payload} if loaded_from_path else payload
path_for_meta = (
draft_path.strip() if isinstance(draft_path, str) and draft_path.strip() else None
)
meta = merged_meta(effective_payload, path_for_meta)
parse_stage_ok, parse_stage_hint = create_parse_stage_gate_ok(meta)
if not parse_stage_ok:
emit_error("parse_stage_gate_failed", exit_code=2, hint=parse_stage_hint)
gate_ok, gate_hint = create_validation_gate_ok(scene, validation_report, meta)
if not gate_ok:
emit_error("validation_gate_failed", exit_code=2, hint=gate_hint)
knowledge_ok, knowledge_hint = create_knowledge_gate_ok(scene, meta)
if not knowledge_ok:
emit_error("knowledge_gate_failed", exit_code=2, hint=knowledge_hint)
display_ok, display_hint = create_display_gate_ok(
scene, validation_report, effective_payload, meta
)
if not display_ok:
emit_error("display_gate_failed", exit_code=2, hint=display_hint)
self_ok, self_hint = create_scene_self_check_ok(scene, validation_report)
if not self_ok:
emit_error("scene_self_check_failed", exit_code=2, hint=self_hint)
# Use sceneBackground as canonical background text for persistence/display.
canonical_background = str(scene.get("sceneBackground") or scene.get("background") or "").strip()
if canonical_background:
scene["sceneBackground"] = canonical_background
scene["background"] = canonical_background
client = TBSClient(base_url=args.base_url, access_token=access_token)
try:
resolved_ids, resolution_report = resolve_ids_for_scene(client, scene)
if resolved_ids.get("personaIds"):
scene["personaIds"] = resolved_ids.get("personaIds") or []
if resolved_ids.get("knowledgeIds"):
scene["knowledgeIds"] = [str(item) for item in resolved_ids.get("knowledgeIds") or []]
body = {
"title": scene["title"],
"businessDomainId": resolved_ids["businessDomainId"],
"departmentId": resolved_ids["departmentId"],
"drugId": resolved_ids["drugId"],
"location": scene["location"],
"doctorOnlyContext": scene["doctorOnlyContext"],
"coachOnlyContext": scene["coachOnlyContext"],
"repBriefing": canonical_background,
"personaIds": resolved_ids.get("personaIds") or [],
"knowledgeIds": resolved_ids.get("knowledgeIds") or [],
"status": 1,
}
created = client.request_json("POST", "/scene/createScene", body)
scene_id = extract_created_entity_id(created, "sceneId", "scene_id")
if not scene_id:
raise RuntimeError("createScene 返回中缺少 sceneId")
except Exception as exc: # noqa: BLE001
emit_error(str(exc), exit_code=1)
if isinstance(draft_path, str) and draft_path.strip():
persist_result(
draft_path=draft_path.strip(),
scene=scene,
validation_report=validation_report,
scene_id=scene_id,
resolved_ids=resolved_ids,
resolution_report=resolution_report,
)
emit_success(
{
"step": STEP,
"sceneId": scene_id,
"resolvedIds": resolved_ids,
"resolutionReport": resolution_report,
"personaIds": resolved_ids.get("personaIds") or [],
"knowledgeIds": resolved_ids.get("knowledgeIds") or [],
"message": "场景创建成功",
}
)
if __name__ == "__main__":
main()
FILE:scripts/tbs-scene-knowledge-check.py
"""
Check confirmed product knowledge topics before scene content generation.
"""
from __future__ import annotations
import argparse
import hashlib
import importlib.util
import json
import os
import re
import sys
from pathlib import Path
from typing import Any
def _load_tbs_client() -> Any:
module_path = Path(__file__).with_name("tbs-client.py")
spec = importlib.util.spec_from_file_location("tbs_client_runtime", module_path)
if spec is None or spec.loader is None:
raise ImportError(f"Cannot load TBS client from {module_path}")
module = importlib.util.module_from_spec(spec)
spec.loader.exec_module(module)
return module
_tbs_client = _load_tbs_client()
TBSClient = _tbs_client.TBSClient
check_or_create_knowledge_for_topics = _tbs_client.check_or_create_knowledge_for_topics
resolve_or_create_business_domain = _tbs_client.resolve_or_create_business_domain
resolve_or_create_drug = _tbs_client.resolve_or_create_drug
STEP = "tbs-scene-knowledge-check"
DEFAULT_TBS_ADMIN_BASE_URL = "https://sg-al-cwork-web.mediportal.com.cn/tbs-admin"
DEFAULT_BASE_URL = os.getenv("TBS_ADMIN_BASE_URL", DEFAULT_TBS_ADMIN_BASE_URL).strip() or DEFAULT_TBS_ADMIN_BASE_URL
_PLACEHOLDER_KNOWLEDGE_ID = re.compile(r"^new-\d+$", re.IGNORECASE)
OUTPUT_PATH: str | None = None
def _is_placeholder_knowledge_id(value: str) -> bool:
text = str(value).strip()
if not text:
return True
return bool(_PLACEHOLDER_KNOWLEDGE_ID.match(text))
def _normalize_topics(value: Any) -> list[str]:
if isinstance(value, str):
items = [value]
elif isinstance(value, list):
items = value
else:
items = []
normalized = [str(item).strip() for item in items if str(item).strip()]
seen: set[str] = set()
out: list[str] = []
for item in sorted(normalized, key=lambda x: x.lower()):
k = item.lower()
if k in seen:
continue
seen.add(k)
out.append(item)
return out
def _knowledge_fingerprint(scene: dict[str, Any]) -> str:
knowledge = scene.get("knowledge")
items: list[dict[str, str]] = []
if isinstance(knowledge, list):
for raw in knowledge:
if isinstance(raw, dict):
items.append(
{
"category": str(raw.get("category") or "").strip(),
"title": str(raw.get("title") or "").strip(),
"content": str(raw.get("content") or "").strip(),
}
)
elif isinstance(raw, str) and raw.strip():
items.append({"category": "", "title": raw.strip(), "content": ""})
elif isinstance(knowledge, dict):
items.append(
{
"category": str(knowledge.get("category") or "").strip(),
"title": str(knowledge.get("title") or "").strip(),
"content": str(knowledge.get("content") or "").strip(),
}
)
elif isinstance(knowledge, str) and knowledge.strip():
items.append({"category": "", "title": knowledge.strip(), "content": ""})
canonical = [
item
for item in items
if item.get("title") or item.get("content") or item.get("category")
]
text = json.dumps(canonical, ensure_ascii=False, sort_keys=True, separators=(",", ":"))
return hashlib.sha256(text.encode("utf-8")).hexdigest()
def compute_knowledge_key(scene: dict[str, Any]) -> str:
canonical = {
"businessDomainName": str(scene.get("businessDomainName") or "").strip(),
"drugName": str(scene.get("drugName") or "").strip(),
"drugId": str(scene.get("drugId") or "").strip(),
"productKnowledgeNeeds": _normalize_topics(scene.get("productKnowledgeNeeds")),
"knowledgeFingerprint": _knowledge_fingerprint(scene),
}
text = json.dumps(canonical, ensure_ascii=False, sort_keys=True, separators=(",", ":"))
return hashlib.sha256(text.encode("utf-8")).hexdigest()
def _can_skip_network_check(scene: dict[str, Any], draft_meta: dict[str, Any]) -> tuple[bool, str, str]:
expected_key = str(draft_meta.get("lastKnowledgeKey") or "").strip()
current_key = compute_knowledge_key(scene)
if not expected_key:
return False, "draft_meta_lastKnowledgeKey_missing", current_key
if current_key != expected_key:
return False, "knowledge_key_changed", current_key
if scene.get("knowledgeReady") is not True:
return False, "scene_knowledgeReady_not_true", current_key
knowledge_ids = [str(x).strip() for x in (scene.get("knowledgeIds") or []) if str(x).strip()]
if not knowledge_ids:
return False, "scene_knowledgeIds_empty", current_key
if any(_is_placeholder_knowledge_id(x) for x in knowledge_ids):
return False, "scene_knowledgeIds_contains_placeholder", current_key
if not str(scene.get("drugId") or "").strip():
return False, "scene_drugId_missing", current_key
return True, "reused_previous_knowledge_check", current_key
def _merge_knowledge_ids(scene_ids: list[str], server_ids: list[str]) -> list[str]:
"""Drop agent-side placeholders (e.g. new-001); keep real ids; append server ids in order without dupes."""
kept = [x for x in scene_ids if x and not _is_placeholder_knowledge_id(x)]
seen = set(kept)
merged = list(kept)
for item in server_ids:
text = str(item).strip()
if not text or text in seen:
continue
seen.add(text)
merged.append(text)
return merged
def _write_output_json(payload: dict[str, Any]) -> None:
if not OUTPUT_PATH:
return
parent = os.path.dirname(OUTPUT_PATH)
if parent:
os.makedirs(parent, exist_ok=True)
with open(OUTPUT_PATH, "w", encoding="utf-8") as handle:
json.dump(payload, handle, ensure_ascii=False, indent=2)
def _summary(payload: dict[str, Any], *, ok: bool) -> str:
parts = ["OK" if ok else "ERROR", STEP]
if payload.get("knowledgeReady") is not None:
parts.append(f"knowledgeReady={str(payload['knowledgeReady']).lower()}")
report = payload.get("knowledgeCheckReport")
if isinstance(report, dict):
parts.append(f"missing={len(report.get('missingTopics') or [])}")
parts.append(f"existing={len(report.get('existingTopics') or [])}")
parts.append(f"created={len(report.get('createdTopics') or [])}")
if payload.get("error"):
parts.append(f"error={payload['error']}")
if OUTPUT_PATH:
parts.append(f"result={OUTPUT_PATH}")
return " ".join(parts)
def emit_success(payload: dict[str, Any]) -> None:
payload = {"success": True, **payload}
if OUTPUT_PATH:
_write_output_json(payload)
print(_summary(payload, ok=True))
else:
print(json.dumps(payload, ensure_ascii=False, indent=2))
def emit_error(error: str, exit_code: int = 1, **extra: Any) -> None:
payload = {"success": False, "step": STEP, "error": error, **extra}
if OUTPUT_PATH:
_write_output_json(payload)
print(_summary(payload, ok=False), file=sys.stderr)
else:
print(json.dumps(payload, ensure_ascii=False, indent=2), file=sys.stderr)
sys.exit(exit_code)
def read_payload(input_path: str, params_file: str | None) -> dict[str, Any]:
path = params_file or input_path
if path and path != "-":
with open(path, "r", encoding="utf-8") as handle:
data = json.load(handle)
else:
data = json.load(sys.stdin)
if not isinstance(data, dict):
raise ValueError("输入 JSON 须为对象")
return data
def _access_token_looks_unresolved_placeholder(token: str) -> bool:
text = token.strip()
if not text:
return True
lowered = text.lower()
if lowered in {"<access_token>", "access_token", "your_access_token", "access_token"}:
return True
return (text.startswith("<") and text.endswith(">")) or (
text.startswith("") and text.endswith("")
)
def _read_draft_object(draft_path: str) -> dict[str, Any]:
if not draft_path or not os.path.isfile(draft_path):
return {}
try:
with open(draft_path, "r", encoding="utf-8") as handle:
data = json.load(handle)
return data if isinstance(data, dict) else {}
except (OSError, json.JSONDecodeError):
return {}
def maybe_write_draft(
draft_path: str | None,
scene: dict[str, Any],
meta: dict[str, Any],
knowledge_report: dict[str, Any],
resolved_ids: dict[str, Any],
resolution_report: dict[str, Any],
) -> None:
if not draft_path:
return
parent = os.path.dirname(draft_path)
if parent:
os.makedirs(parent, exist_ok=True)
existing = _read_draft_object(draft_path.strip())
prior_meta = existing.get("meta") if isinstance(existing.get("meta"), dict) else {}
prior_resolved_ids = (
existing.get("resolvedIds") if isinstance(existing.get("resolvedIds"), dict) else {}
)
prior_resolution_report = (
existing.get("resolutionReport")
if isinstance(existing.get("resolutionReport"), dict)
else {}
)
payload = {
**existing,
"scene": scene,
"knowledgeCheckReport": knowledge_report,
"resolvedIds": {**prior_resolved_ids, **resolved_ids},
"resolutionReport": {
**prior_resolution_report,
**resolution_report,
},
"meta": {**prior_meta, **meta},
}
with open(draft_path, "w", encoding="utf-8") as handle:
json.dump(payload, handle, ensure_ascii=False, indent=2)
def main() -> None:
global OUTPUT_PATH
parser = argparse.ArgumentParser()
parser.add_argument("--input", default="-", help="JSON file path, or '-' for stdin")
parser.add_argument("--params-file", default=None, help="Read params from UTF-8 JSON file")
parser.add_argument("--output", default=None, help="Write full JSON result to this file")
parser.add_argument("--base-url", default=DEFAULT_BASE_URL)
parser.add_argument("--access-token", required=True)
args = parser.parse_args()
OUTPUT_PATH = args.output
try:
payload = read_payload(args.input, args.params_file)
except (OSError, json.JSONDecodeError, ValueError) as exc:
emit_error("invalid_json_input", exit_code=2, hint=str(exc))
access_token = str(args.access_token or "").strip()
if _access_token_looks_unresolved_placeholder(access_token):
emit_error("access_token_invalid", exit_code=2)
scene = payload.get("scene") if isinstance(payload.get("scene"), dict) else {}
if not scene:
emit_error("缺少 scene", exit_code=2)
topics = _normalize_topics(scene.get("productKnowledgeNeeds"))
if not topics:
emit_error("缺少 productKnowledgeNeeds", exit_code=2)
business_domain_name = str(scene.get("businessDomainName") or "").strip()
drug_name = str(scene.get("drugName") or "").strip()
if not business_domain_name:
emit_error("缺少 businessDomainName", exit_code=2)
if not drug_name:
emit_error("缺少 drugName", exit_code=2)
draft_path = payload.get("draftPath")
draft_obj: dict[str, Any] = {}
draft_meta: dict[str, Any] = {}
if isinstance(draft_path, str) and draft_path.strip():
draft_obj = _read_draft_object(draft_path.strip())
if isinstance(draft_obj.get("meta"), dict):
draft_meta = dict(draft_obj.get("meta") or {})
skip_ok, skip_reason, knowledge_key = _can_skip_network_check(scene, draft_meta)
if skip_ok:
knowledge_ids = [str(x).strip() for x in (scene.get("knowledgeIds") or []) if str(x).strip()]
knowledge_report = {
"action": "skipped",
"reason": skip_reason,
"totalTopics": len(topics),
"existingTopics": topics,
"missingTopics": [],
"createdTopics": [],
"pendingTopics": [],
"knowledgeIds": knowledge_ids,
}
# Best-effort reuse; not required for skip path correctness.
resolved = draft_meta.get("resolvedIds") if isinstance(draft_meta.get("resolvedIds"), dict) else {}
business_domain_id = str(resolved.get("businessDomainId") or "").strip()
drug_id = str(scene.get("drugId") or "").strip()
business_domain_report = {"action": "reused_from_draft", "input": business_domain_name}
drug_report = {"action": "reused_from_scene", "input": drug_id or drug_name}
else:
client = TBSClient(base_url=args.base_url, access_token=access_token)
try:
business_domain_id, business_domain_report = resolve_or_create_business_domain(
client, business_domain_name, allow_create=False
)
drug_id, drug_report = resolve_or_create_drug(
client,
drug_name,
business_domain_id,
business_domain_name,
allow_create=True,
)
knowledge_ids, knowledge_report = check_or_create_knowledge_for_topics(
client, scene, drug_id
)
except Exception as exc: # noqa: BLE001
emit_error(str(exc), exit_code=1)
# Ensure key includes canonical drugId once resolved.
knowledge_key = compute_knowledge_key({**scene, "drugId": str(drug_id)})
missing_topics = knowledge_report.get("missingTopics") or []
knowledge_ready = not missing_topics
meta = payload.get("meta") if isinstance(payload.get("meta"), dict) else {}
meta = {
**meta,
"knowledgeChecked": True,
"knowledgeReady": knowledge_ready,
"knowledgeIds": knowledge_ids,
"knowledgeCheckReport": knowledge_report,
"lastKnowledgeKey": knowledge_key,
"knowledgeCheckSkipped": bool(skip_ok),
"knowledgeCheckSkipReason": skip_reason if skip_ok else "",
"resolvedIds": {
"businessDomainId": business_domain_id,
"drugId": drug_id,
},
"resolutionReport": {
"businessDomain": business_domain_report,
"drug": drug_report,
},
}
scene = {**scene}
scene["drugId"] = str(drug_id)
raw_knowledge_ids = [str(item) for item in scene.get("knowledgeIds") or [] if str(item).strip()]
if knowledge_ids:
scene["knowledgeIds"] = _merge_knowledge_ids(raw_knowledge_ids, knowledge_ids)
else:
scene["knowledgeIds"] = [x for x in raw_knowledge_ids if not _is_placeholder_knowledge_id(x)]
if isinstance(draft_path, str) and draft_path.strip():
maybe_write_draft(
draft_path.strip(),
scene,
meta,
knowledge_report,
{"businessDomainId": business_domain_id, "drugId": drug_id},
{"businessDomain": business_domain_report, "drug": drug_report},
)
emit_success(
{
"step": STEP,
"scene": scene,
"knowledgeReady": knowledge_ready,
"knowledgeIds": knowledge_ids,
"knowledgeKey": knowledge_key,
"knowledgeCheckSkipped": bool(skip_ok),
"knowledgeCheckSkipReason": skip_reason if skip_ok else "",
"knowledgeCheckReport": knowledge_report,
"resolvedIds": {
"businessDomainId": business_domain_id,
"drugId": drug_id,
},
"resolutionReport": {
"businessDomain": business_domain_report,
"drug": drug_report,
},
"missingKnowledgeTopics": missing_topics,
"nextAction": (
"补充缺失产品知识正文后重新检查"
if missing_topics
else "继续进入场景内容生成"
),
"meta": meta,
}
)
if __name__ == "__main__":
main()
FILE:scripts/README.md
# scripts 目录说明
本目录存放 `cms-tbs-scene-create` 的 Python 执行脚本与共享客户端,采用扁平布局(共享库与入口脚本同级)。
## 脚本清单
| 脚本 | 类型 | 作用 |
|------|------|------|
| `tbs-scene-parse.py` | 编排入口 | 解析输入并推进分阶段确认流程 |
| `tbs-scene-knowledge-check.py` | 知识检查入口 | 在知识主题确认后检查/复用/创建产品知识,并写回 `knowledgeIds` / `knowledgeReady`(支持基于 `meta.lastKnowledgeKey` 的等价去重跳过重复网络检查) |
| `tbs-scene-validate.py` | 校验入口 | 对场景草稿执行创建前门禁校验 |
| `tbs-scene-create.py` | 创建入口 | 在用户确认后执行真实创建 |
| `tbs-client.py` | 共享库 | 封装 TBS Admin API 请求、主数据匹配/创建、知识去重逻辑 |
| `tbs-md-sanitize.py` | 共享库 | 对部分 Markdown 内容做预处理与清洗 |
| `check-doc-consistency.py` | 开发态自检 | 发布前校验文档口径一致性(不参与运行时链路) |
从技能根目录执行:`python3 scripts/tbs-scene-parse.py --params-file … --output result.json`。Agent 调用入口脚本时必须使用 `--output`,完整 JSON 写文件,stdout/stderr 仅保留一行摘要。
---
## `tbs-client.py` 说明
### 定位
`tbs-client.py` 是本 Skill 的共享客户端与领域工具库,不直接面向用户,也不是独立的业务入口脚本。
它主要被 `tbs-scene-create.py` 导入调用。
### 主要职责
1. 封装 HTTP 请求与通用重试(`TBSClient.request_json`)。
2. 统一解析不同响应包裹格式(如 `data` / `result`)。
3. 解析主数据 ID,并执行“先查后建”(业务领域、科室、品种、画像、知识等)。
4. 汇总创建场景所需关联 ID:`resolve_ids_for_scene`。
### 边界与约束
- 不负责用户对话与话术输出;不单独获取鉴权(`access-token` 由 `cms-auth-skills` 注入)。
### 维护注意事项
- 若新增/变更主数据解析逻辑,需同步核对 `references/tbs-scene-create.md`、`references/maintenance.md`、`tbs-scene-create.py`。
FILE:scripts/check-doc-consistency.py
#!/usr/bin/env python3
"""
Local doc consistency checks for cms-tbs-scene-create.
Design goals:
- Zero runtime impact (dev-only tool).
- No third-party dependencies.
- Deterministic checks with actionable messages.
"""
from __future__ import annotations
import re
import sys
import json
from pathlib import Path
from typing import Iterable, List, Tuple
ROOT = Path(__file__).resolve().parents[1]
SKILL_MD = ROOT / "SKILL.md"
REF_DIR = ROOT / "references"
SCRIPTS_README = ROOT / "scripts" / "README.md"
COMMON_PARAMS = REF_DIR / "common-params.md"
OUTPUT_TEMPLATES = REF_DIR / "output-templates.md"
PARSE_RUNTIME_CONFIG = REF_DIR / "parse-runtime-config.json"
SCRIPT_DIR = ROOT / "scripts"
def read_text(path: Path) -> str:
return path.read_text(encoding="utf-8")
def find_lines(content: str, pattern: str) -> List[int]:
lines: List[int] = []
rx = re.compile(pattern)
for idx, line in enumerate(content.splitlines(), start=1):
if rx.search(line):
lines.append(idx)
return lines
def iter_md_files() -> Iterable[Path]:
yield SKILL_MD
if SCRIPTS_README.exists():
yield SCRIPTS_README
if REF_DIR.exists():
for p in sorted(REF_DIR.glob("*.md")):
yield p
def main() -> int:
errors: List[Tuple[Path, int, str]] = []
docs = {path: read_text(path) for path in iter_md_files() if path.exists()}
for path, content in docs.items():
for line_no in find_lines(content, r"全量\s*S4"):
errors.append((path, line_no, "禁止使用术语“全量 S4”,请改为 scope=FULL 全量校验。"))
for path, content in docs.items():
for line_no in find_lines(content, r"医生关注点(建议\s*2-4\s*条"):
errors.append((path, line_no, "医生关注点口径应为 1-2 条(聚焦最核心顾虑)。"))
cp = docs.get(COMMON_PARAMS)
if cp:
for idx, line in enumerate(cp.splitlines(), start=1):
if (
"READY_FOR_SCENE_GENERATION" in line
and "title" in line
and "sceneBackground" in line
and "不作为此阶段必显项" not in line
):
errors.append(
(
COMMON_PARAMS,
idx,
"READY_FOR_SCENE_GENERATION 不应将 title/sceneBackground 定义为必显项(应为待内部生成)。",
)
)
if cp and "mustDisplayFields" in cp:
must_display_line = next(
(
line
for line in cp.splitlines()
if "`businessDomainName`" in line
and "`sceneBackground`" in line
and "`title`" in line
),
"",
)
if must_display_line and "`productKnowledgeNeeds`" not in must_display_line:
errors.append(
(
COMMON_PARAMS,
1,
"创建前 mustDisplayFields 必须包含 productKnowledgeNeeds,避免落库前漏展示产品知识主题。",
)
)
if must_display_line and "`actorProfile`" not in must_display_line:
errors.append(
(
COMMON_PARAMS,
1,
"创建前 mustDisplayFields 必须包含 actorProfile,避免落库前漏展示对练对象角色。",
)
)
joined = "\n".join(docs.values())
if "mustEchoUpdatedConfirmation" in joined and "updatedConfirmationEchoed" not in joined:
errors.append(
(
COMMON_PARAMS,
1,
"检测到 mustEchoUpdatedConfirmation,但未找到 updatedConfirmationEchoed 对称说明。",
)
)
# Rule 5: Hard intercept section must exist in common-params.
if cp and "绝对禁止直出字段(强制)" not in cp:
errors.append(
(
COMMON_PARAMS,
1,
"缺少“绝对禁止直出字段(强制)”章节,存在内部状态外显风险。",
)
)
# Rule 6: Single final user message per round rule must exist.
if cp and "同轮用户侧最多输出 1 条最终消息" not in cp:
errors.append(
(
COMMON_PARAMS,
1,
"缺少“同轮用户侧最多输出 1 条最终消息”约束,存在重复回显风险。",
)
)
tmpl_heading = re.compile(r"^\s*####\s*模板\s*[0-4][AB]?", re.MULTILINE)
for path, content in docs.items():
if path == OUTPUT_TEMPLATES:
continue
for m in tmpl_heading.finditer(content):
line_no = content[: m.start()].count("\n") + 1
errors.append((path, line_no, "模板正文应只在 references/output-templates.md 定义,其他文档请仅引用。"))
command_block = re.compile(r"```(?:bash|sh|text)?\n(.*?)```", re.DOTALL)
script_cmd = re.compile(r"python3\s+scripts/tbs-scene-(?:parse|knowledge-check|validate|create)\.py")
for path, content in docs.items():
for block in command_block.finditer(content):
body = block.group(1)
if script_cmd.search(body) and "--output" not in body:
line_no = content[: block.start()].count("\n") + 1
errors.append((path, line_no, "入口脚本示例必须包含 --output,避免 stdout 暴露完整 JSON。"))
for idx, line in enumerate(content.splitlines(), start=1):
if script_cmd.search(line) and "--output" not in line and not line.rstrip().endswith("\\"):
errors.append((path, idx, "入口脚本行内示例必须包含 --output。"))
old_shared_names = (r"tbs_client\.py", r"tbs_md_sanitize\.py")
for path, content in docs.items():
for pattern in old_shared_names:
for line_no in find_lines(content, pattern):
errors.append((path, line_no, "共享库文件名已统一为连字符命名,请勿引用旧下划线文件名。"))
for required_script in ("tbs-client.py", "tbs-md-sanitize.py"):
if not (SCRIPT_DIR / required_script).exists():
errors.append((SCRIPTS_README, 1, f"缺少共享库脚本:scripts/{required_script}。"))
if not PARSE_RUNTIME_CONFIG.exists():
errors.append((PARSE_RUNTIME_CONFIG, 1, "缺少 parse-runtime-config.json,parse 脚本文案/轻量规则不应继续硬编码。"))
else:
try:
runtime_config = json.loads(PARSE_RUNTIME_CONFIG.read_text(encoding="utf-8"))
except json.JSONDecodeError as exc:
errors.append((PARSE_RUNTIME_CONFIG, exc.lineno, f"parse-runtime-config.json 不是合法 JSON:{exc.msg}"))
runtime_config = {}
if isinstance(runtime_config, dict):
for key in (
"fieldLabels",
"baseQuestionMap",
"stageLabelText",
"phaseTitleText",
"bestPracticeKeywords",
):
if key not in runtime_config:
errors.append((PARSE_RUNTIME_CONFIG, 1, f"parse-runtime-config.json 缺少关键配置:{key}。"))
doctor_question = (
runtime_config.get("baseQuestionMap", {}).get("doctorConcerns", "")
if isinstance(runtime_config.get("baseQuestionMap"), dict)
else ""
)
if "具体顾虑" not in str(doctor_question) and "异议" not in str(doctor_question):
errors.append(
(
PARSE_RUNTIME_CONFIG,
1,
"doctorConcerns 追问文案必须强调“具体顾虑/异议”,避免把画像词当顾虑。",
)
)
else:
errors.append((PARSE_RUNTIME_CONFIG, 1, "parse-runtime-config.json 顶层必须是对象。"))
if errors:
print("Doc consistency check failed:\n")
for path, line_no, msg in errors:
rel = path.relative_to(ROOT)
print(f"- {rel}:{line_no}: {msg}")
print(f"\nTotal issues: {len(errors)}")
return 1
print("OK: doc consistency checks passed.")
return 0
if __name__ == "__main__":
sys.exit(main())
FILE:scripts/tbs-scene-parse.py
"""
Parse TBS scene input and output staged confirmation guidance.
"""
from __future__ import annotations
import argparse
import hashlib
import importlib.util
import json
import os
import re
import sys
from datetime import datetime, timezone
from pathlib import Path
from typing import Any
def _load_sanitize_helper() -> Any:
module_path = Path(__file__).with_name("tbs-md-sanitize.py")
spec = importlib.util.spec_from_file_location("tbs_md_sanitize_runtime", module_path)
if spec is None or spec.loader is None:
raise ImportError(f"Cannot load sanitize helper from {module_path}")
module = importlib.util.module_from_spec(spec)
spec.loader.exec_module(module)
return module
sanitize_doctor_core_concerns_to_two_bullets = (
_load_sanitize_helper().sanitize_doctor_core_concerns_to_two_bullets
)
STEP = "tbs-scene-parse"
OUTPUT_PATH: str | None = None
SCENE_HASH_FIELDS = [
"title",
"businessDomainName",
"departmentName",
"drugName",
"location",
"doctorConcerns",
"repGoal",
"sceneBackground",
"productKnowledgeNeeds",
"knowledgeIds",
"doctorOnlyContext",
"coachOnlyContext",
"actorProfile",
]
TEXT_FIELD_LABELS = {
"title": "场景标题",
"businessDomainName": "业务领域",
"departmentName": "科室",
"drugName": "产品名称",
"location": "地点",
"doctorConcerns": "医生顾虑",
"repGoal": "代表目标",
"sceneBackground": "场景背景",
"productKnowledgeNeeds": "产品知识主题",
"knowledge": "产品知识正文(可选)",
"doctorOnlyContext": "对练对象侧上下文",
"coachOnlyContext": "教练侧上下文",
"actorProfile": "对练对象角色",
"generationNotes": "待确认说明",
}
TEXT_DEFAULTS = {
"notProvidedEvidenceSource": "用户确认暂无书面证据资料",
"declinedProductKnowledgeTopic": "用户确认暂不补充产品知识主题",
"partialEvidenceSource": "用户已确认场景所需产品知识主题,书面证据待补充",
"readyEvidenceSource": "用户已提供可用证据资料",
}
TEXT_PATTERN_RULES = {
"declineRegexPatterns": [
"产品知识暂无",
"没有产品知识|无需产品知识|不提供产品知识",
"不要产品知识",
"不提供.*(产品知识|知识主题)",
"不提供.*(资料|证据).*(产品|书面)",
"暂不补充.*(知识|资料)",
],
"blockedPhrases": [
"确认",
"是",
"不是",
"对",
"不对",
"好的",
"ok",
"yes",
"no",
"暂无",
"没有",
"不清楚",
],
}
TEXT_LIMITS = {
"deriveLimits": {
"minTopicLength": 2,
"maxTopicLength": 30,
"maxTopicCount": 6,
},
"alignmentLimits": {
"minBackgroundLength": 40,
"minPieceLength": 3,
"missingPiecePreviewLength": 24,
"maxMissingPieces": 5,
},
"inferReplyLimits": {
"maxCandidateLength": 40,
},
}
TEXT_STATUS_AND_ALIGNMENT = {
"statusText": {
"toFill": "待补充",
"toConfirm": "请确认",
"needSupplement": "需要补充",
"noSupplementNeeded": "无需补充",
"ready": "已具备",
"pendingStart": "待开始",
"pendingConfirm": "待确认",
},
"alignmentText": {
"noBackground": "场景背景尚未生成,暂不做对齐评估。",
"backgroundTooShort": "场景背景偏短,不建议跳过场景正文生成。",
"missingCoreInputs": "缺少医生顾虑与代表目标,无法做对齐判断。",
"missingRepGoalLabel": "代表目标",
"coreNotCoveredPrefix": "场景背景未完全覆盖以下要点:",
"coveredOk": "场景背景已覆盖医生顾虑与代表目标要点,可建议跳过场景正文再生成。",
},
"changeSummaryText": {
"updatedFields": "本轮字段更新:{labels}。",
"canSkipS3": "编排提示:可跳过 scenario-json-parse 再生成,但仍须走 TBV 与 PRE。",
"shouldRunS3": "编排提示:建议执行 scenario-json-parse 完整生成或补全后再校验。",
},
}
TEXT_STAGE_NOTES_AND_QUESTIONS = {
"pendingNotesText": {
"baseInfoStage": "当前先确认业务领域、科室、产品、地点、医生顾虑、代表目标;标题、场景背景和上下文稍后统一生成。",
"knowledgeStageBaseAck": "基础信息已由用户确认,系统已根据当前场景给出产品知识主题建议;如无调整,请回复“确认”,也可删除、改名或新增主题。产品知识正文可稍后补充。",
"knowledgeStageBaseUnack": "基础信息字段已识别,但仍需用户核对是否准确;核对无误后请由 Agent 在下一轮解析请求中携带 baseInfoAcknowledged=true,再进入场景内容生成。",
"knowledgeStageOptionalKnowledge": "产品知识正文补充是可选的:可以只确认知识主题关键词,也可以额外补充知识正文/资料来源供创建前解析。",
"readyForGeneration": "基础信息与产品知识/资料已具备;此时应在内部执行场景内容生成,再进入校验。",
"autoDerivedNeeds": "已按产品知识主题生成规范建议主题,需用户确认后再进入场景内容生成。",
"bestPracticeAdopted": "已采纳你补充的代表话术/最佳实践内容,将归入教练侧上下文(coachOnlyContext)的“## 最佳实践”小节。",
},
"baseQuestionMap": {
"businessDomainName": "这是哪个业务领域?请从临床推广、院外零售、学术合作、通用能力中选择一个。",
"departmentName": "这次主要对应哪个科室?",
"drugName": "这次对应的具体产品或品种是什么?",
"location": "这个场景发生在什么地点?",
"doctorConcerns": "医生当前最核心的顾虑是什么?",
"repGoal": "代表本次沟通最想达成的目标是什么?",
},
"knowledgeQuestionText": {
"backgroundHintAck": "已确认的业务背景",
"backgroundHintUnack": "当前识别出的业务背景(请同时核对上方基础信息是否准确)",
"confirmNeeds": "请核对上方“产品知识主题”:如无调整,请回复“确认”;也可以删除、改名或新增主题。正文可稍后补充。",
"confirmBaseFirst": "请先明确确认上方基础信息是否全部准确;确认后由 Agent 在下一轮 tbs-scene-parse 请求中设置 baseInfoAcknowledged=true。",
},
"contentQuestionText": {
"echoUpdated": "本轮用户已更新:{labels}。请先向用户回显更新后的确认清单并请其确认。",
"genTitleBackground": "请在内部生成场景标题与场景背景。",
"genActorProfile": "请在内部补齐对练对象角色画像(至少包含 name);不向用户单独展示该字段确认项。",
"genContexts": "请在内部生成对练对象侧上下文与教练侧上下文,用户无需逐段确认正文。",
},
}
TEXT_ACTION_HINTS = {
"nextActionText": {
"baseInfo": "请先补充并确认基础信息;确认后再分析产品知识需求与资料情况。",
"knowledgeBaseFirst": "请先引导用户核对基础信息是否准确;用户明确确认后,在下一轮解析请求 JSON 顶层设置 baseInfoAcknowledged=true,再继续确认产品知识/资料并进入内部生成。",
"knowledge": "请展示基础信息 6 项和系统建议的产品知识主题;如无调整请用户回复“确认”,也允许删除、改名或新增。产品知识正文补充可选。",
"readyForGenerationWithEcho": "请先向用户回显更新后的确认清单(重点:{labels}),确认无误后再内部生成场景内容;生成完成后重新运行本脚本并进入场景校验。",
"readyForGeneration": "请在内部执行场景内容生成;生成完成后重新运行本脚本,再进入场景校验。",
"readyForValidateWithEcho": "用户本轮已更新({labels}),请先回显最新确认清单并确认无误,再执行场景校验。",
"default": "请确认上述关键信息;如无误,可以继续执行场景校验。",
},
"systemActionHintText": {
"baseInfo": "先与用户确认基础信息;此阶段不要提前执行 scenario-json-parse 全量生成。",
"knowledgeBaseFirst": "先引导用户核对基础信息;未携带 baseInfoAcknowledged=true 前,不要进入 scenario-json-parse 全量生成。",
"knowledge": "按 references/product-knowledge-topic-generate.md 基于已确认基础信息生成产品知识主题,并给用户轻确认;未收到主题确认前,不要进入场景内容生成。",
"readyForGenerationWithEcho": "检测到用户本轮更新字段({labels});先向用户回显更新后的确认清单,再内部读取 references/scenario-json-parse.md + references/*.json 生成内容。",
"readyForGeneration": "现在再内部读取 references/scenario-json-parse.md + references/*.json,生成 title、sceneBackground、actorProfile、doctorOnlyContext、coachOnlyContext。",
"readyForValidateWithEcho": "检测到用户本轮更新字段({labels});先回显更新后的确认清单,再执行 tbs-scene-validate.py。",
"default": "执行 tbs-scene-validate.py,确认是否达到最终创建前门禁。",
},
}
TEXT_STAGE_TITLES_AND_ERRORS = {
"stageLabelText": {
"BASE_INFO_CONFIRM": "先确认基础信息",
"KNOWLEDGE_CONFIRM": "再确认产品知识与资料",
"READY_FOR_SCENE_GENERATION": "已可内部生成场景内容",
"READY_FOR_VALIDATE": "已可执行场景校验",
},
"phaseTitleText": {
"BASE_INFO_CONFIRM": "基础信息确认",
"KNOWLEDGE_CONFIRM": "产品知识与资料确认",
"READY_FOR_SCENE_GENERATION": "场景内容生成",
},
"errorText": {
"missingInput": "缺少 userText 或结构化 scene",
"patchLockedHint": "在基础信息已确认后不可再补丁修改基础字段;进入场景内容生成或校验阶段后,仅允许补丁更新场景标题与场景背景(含 background 同义键)。",
"patchLockedPastKnowledgeSuffix": " 已进入场景内容生成或校验阶段:`parsedFields` / `userUpdates` / `userConfirmedFields` / `userProvidedFields` 仅允许 `title`、`sceneBackground`、`background`。被拒字段请改写到请求 JSON 顶层的 `scene` 对象中(与已有草稿合并)后再调用本脚本,勿再通过上述补丁键覆盖。",
"patchLockedBaseAckSuffix": " 被拒字段属于已确认基础六字段:请先在未声明 `baseInfoAcknowledged` 的轮次纠正,或由用户明确同意回退后再改;勿再通过补丁键覆盖已锁定基础项。",
},
}
TEXT_CONFIG: dict[str, Any] = {
"fieldLabels": TEXT_FIELD_LABELS,
"defaults": TEXT_DEFAULTS,
**TEXT_PATTERN_RULES,
**TEXT_LIMITS,
**TEXT_STATUS_AND_ALIGNMENT,
**TEXT_STAGE_NOTES_AND_QUESTIONS,
**TEXT_ACTION_HINTS,
**TEXT_STAGE_TITLES_AND_ERRORS,
}
def _deep_merge(base: dict[str, Any], override: dict[str, Any]) -> dict[str, Any]:
merged = dict(base)
for key, value in override.items():
if isinstance(value, dict) and isinstance(merged.get(key), dict):
merged[key] = _deep_merge(merged[key], value)
else:
merged[key] = value
return merged
def _load_external_text_config() -> dict[str, Any]:
config_path = Path(__file__).resolve().parents[1] / "references" / "parse-runtime-config.json"
if not config_path.exists():
return {}
with open(config_path, "r", encoding="utf-8") as handle:
data = json.load(handle)
if not isinstance(data, dict):
raise ValueError("parse-runtime-config.json must contain a JSON object")
return data
TEXT_CONFIG = _deep_merge(TEXT_CONFIG, _load_external_text_config())
def _cfg(path: str, default: Any = None) -> Any:
cursor: Any = TEXT_CONFIG
for key in path.split("."):
if not isinstance(cursor, dict) or key not in cursor:
return default
cursor = cursor[key]
return cursor
FIELD_LABELS = dict(_cfg("fieldLabels", {}))
BASE_CONFIRM_FIELDS = [
"businessDomainName",
"departmentName",
"drugName",
"location",
"doctorConcerns",
"repGoal",
]
KNOWLEDGE_CONFIRM_FIELDS = [
"productKnowledgeNeeds",
]
KNOWLEDGE_GATE_FIELDS: list[str] = []
GENERATED_CONFIRM_FIELDS = [
"title",
"sceneBackground",
]
GENERATED_STAGE_FIELDS = [
"title",
"sceneBackground",
"actorProfile",
]
PATCHABLE_AFTER_KNOWLEDGE_LOCK = {"title", "sceneBackground", "background"}
GENERATED_GATE_FIELDS: list[str] = []
INTERNAL_GENERATED_FIELDS = [
"doctorOnlyContext",
"coachOnlyContext",
]
BASE_PLUS_KNOWLEDGE_FIELDS = BASE_CONFIRM_FIELDS + KNOWLEDGE_CONFIRM_FIELDS
READY_CONFIRM_FIELDS = BASE_PLUS_KNOWLEDGE_FIELDS + GENERATED_CONFIRM_FIELDS
FINAL_MUST_DISPLAY_FIELDS = BASE_PLUS_KNOWLEDGE_FIELDS + GENERATED_CONFIRM_FIELDS + ["actorProfile"]
FINAL_REQUIRED_FIELDS = (
BASE_CONFIRM_FIELDS
+ KNOWLEDGE_CONFIRM_FIELDS
+ KNOWLEDGE_GATE_FIELDS
+ GENERATED_STAGE_FIELDS
+ GENERATED_GATE_FIELDS
+ INTERNAL_GENERATED_FIELDS
)
STAGE_BASE_INFO_CONFIRM = "BASE_INFO_CONFIRM"
STAGE_KNOWLEDGE_CONFIRM = "KNOWLEDGE_CONFIRM"
STAGE_READY_FOR_SCENE_GENERATION = "READY_FOR_SCENE_GENERATION"
STAGE_READY_FOR_VALIDATE = "READY_FOR_VALIDATE"
STAGE_CONFIRM_FIELDS = {
STAGE_BASE_INFO_CONFIRM: BASE_CONFIRM_FIELDS,
STAGE_KNOWLEDGE_CONFIRM: BASE_PLUS_KNOWLEDGE_FIELDS,
STAGE_READY_FOR_SCENE_GENERATION: READY_CONFIRM_FIELDS,
STAGE_READY_FOR_VALIDATE: READY_CONFIRM_FIELDS,
}
STAGE_MUST_DISPLAY_FIELDS = {
STAGE_BASE_INFO_CONFIRM: list(BASE_CONFIRM_FIELDS),
STAGE_KNOWLEDGE_CONFIRM: list(BASE_PLUS_KNOWLEDGE_FIELDS),
STAGE_READY_FOR_SCENE_GENERATION: list(READY_CONFIRM_FIELDS),
STAGE_READY_FOR_VALIDATE: list(FINAL_MUST_DISPLAY_FIELDS),
}
BEST_PRACTICE_TARGET_SECTION = "coachOnlyContext.## 最佳实践"
SUPPLEMENT_SCENE_FIELDS = (
"actorProfileSupplement",
"bestPracticeSupplement",
)
DEFAULT_DECLINED_PRODUCT_KNOWLEDGE_TOPIC = str(
_cfg("defaults.declinedProductKnowledgeTopic", "")
).strip()
DECLINE_REGEX_PATTERNS = tuple(_cfg("declineRegexPatterns", []))
DERIVE_MIN_TOPIC_LENGTH = int(_cfg("deriveLimits.minTopicLength", 2))
DERIVE_MAX_TOPIC_LENGTH = int(_cfg("deriveLimits.maxTopicLength", 30))
DERIVE_MAX_TOPIC_COUNT = int(_cfg("deriveLimits.maxTopicCount", 6))
ALIGNMENT_MIN_BACKGROUND_LENGTH = int(_cfg("alignmentLimits.minBackgroundLength", 40))
ALIGNMENT_MIN_PIECE_LENGTH = int(_cfg("alignmentLimits.minPieceLength", 3))
ALIGNMENT_MISSING_PREVIEW_LENGTH = int(
_cfg("alignmentLimits.missingPiecePreviewLength", 24)
)
ALIGNMENT_MAX_MISSING_PIECES = int(_cfg("alignmentLimits.maxMissingPieces", 5))
INFER_REPLY_MAX_CANDIDATE_LENGTH = int(_cfg("inferReplyLimits.maxCandidateLength", 40))
BLOCKED_PHRASES = {str(item).lower() for item in _cfg("blockedPhrases", [])}
BEST_PRACTICE_KEYWORDS = tuple(str(item) for item in _cfg("bestPracticeKeywords", []))
BASE_QUESTION_MAP = dict(_cfg("baseQuestionMap", {}))
STAGE_LABEL_TEXT = dict(_cfg("stageLabelText", {}))
PHASE_TITLE_TEXT = dict(_cfg("phaseTitleText", {}))
STATUS_TEXT = dict(_cfg("statusText", {}))
ALIGNMENT_TEXT = dict(_cfg("alignmentText", {}))
CHANGE_SUMMARY_TEXT = dict(_cfg("changeSummaryText", {}))
PENDING_NOTES_TEXT = dict(_cfg("pendingNotesText", {}))
KNOWLEDGE_QUESTION_TEXT = dict(_cfg("knowledgeQuestionText", {}))
CONTENT_QUESTION_TEXT = dict(_cfg("contentQuestionText", {}))
NEXT_ACTION_TEXT = dict(_cfg("nextActionText", {}))
SYSTEM_ACTION_HINT_TEXT = dict(_cfg("systemActionHintText", {}))
ERROR_TEXT = dict(_cfg("errorText", {}))
def _text(section: dict[str, Any], key: str, default: str = "") -> str:
return str(section.get(key, default)).strip()
def _textf(section: dict[str, Any], key: str, **kwargs: Any) -> str:
return _text(section, key).format(**kwargs)
def _validate_parse_config() -> None:
required_text_sections = [
"fieldLabels",
"pendingNotesText",
"baseQuestionMap",
"stageLabelText",
"phaseTitleText",
]
missing_sections = [key for key in required_text_sections if _cfg(key) is None]
if missing_sections:
raise ValueError(
"tbs-scene-parse config missing sections: " + ", ".join(missing_sections)
)
referenced_fields = set(
BASE_CONFIRM_FIELDS
+ FINAL_MUST_DISPLAY_FIELDS
+ KNOWLEDGE_CONFIRM_FIELDS
+ KNOWLEDGE_GATE_FIELDS
+ GENERATED_CONFIRM_FIELDS
+ GENERATED_STAGE_FIELDS
+ INTERNAL_GENERATED_FIELDS
+ ["generationNotes", "knowledge", "actorProfile"]
)
missing_labels = sorted(field for field in referenced_fields if field not in FIELD_LABELS)
if missing_labels:
raise ValueError(
"fieldLabels missing referenced fields: " + ", ".join(missing_labels)
)
stage_keys = {
STAGE_BASE_INFO_CONFIRM,
STAGE_KNOWLEDGE_CONFIRM,
STAGE_READY_FOR_SCENE_GENERATION,
STAGE_READY_FOR_VALIDATE,
}
if set(STAGE_CONFIRM_FIELDS.keys()) != stage_keys:
raise ValueError("STAGE_CONFIRM_FIELDS keys inconsistent with stage constants")
if set(STAGE_MUST_DISPLAY_FIELDS.keys()) != stage_keys:
raise ValueError("STAGE_MUST_DISPLAY_FIELDS keys inconsistent with stage constants")
for stage, fields in STAGE_CONFIRM_FIELDS.items():
if len(fields) != len(set(fields)):
raise ValueError(f"STAGE_CONFIRM_FIELDS[{stage}] contains duplicate fields")
unknown = [field for field in fields if field not in FIELD_LABELS]
if unknown:
raise ValueError(
f"STAGE_CONFIRM_FIELDS[{stage}] contains unknown labels: {', '.join(unknown)}"
)
for stage, fields in STAGE_MUST_DISPLAY_FIELDS.items():
if len(fields) != len(set(fields)):
raise ValueError(f"STAGE_MUST_DISPLAY_FIELDS[{stage}] contains duplicate fields")
unknown = [field for field in fields if field not in FIELD_LABELS]
if unknown:
raise ValueError(
f"STAGE_MUST_DISPLAY_FIELDS[{stage}] contains unknown labels: {', '.join(unknown)}"
)
missing_base_questions = sorted(
field for field in BASE_CONFIRM_FIELDS if field not in BASE_QUESTION_MAP
)
if missing_base_questions:
raise ValueError(
"baseQuestionMap missing base fields: " + ", ".join(missing_base_questions)
)
extra_base_questions = sorted(
field for field in BASE_QUESTION_MAP if field not in BASE_CONFIRM_FIELDS
)
if extra_base_questions:
raise ValueError(
"baseQuestionMap contains non-base fields: " + ", ".join(extra_base_questions)
)
if "title" not in FINAL_MUST_DISPLAY_FIELDS or "sceneBackground" not in FINAL_MUST_DISPLAY_FIELDS:
raise ValueError("FINAL_MUST_DISPLAY_FIELDS must include title and sceneBackground")
best_practice_note = _text(PENDING_NOTES_TEXT, "bestPracticeAdopted")
if not best_practice_note:
raise ValueError("pendingNotesText.bestPracticeAdopted must be configured")
if not BEST_PRACTICE_TARGET_SECTION.startswith("coachOnlyContext."):
raise ValueError("BEST_PRACTICE_TARGET_SECTION must target coachOnlyContext")
_validate_parse_config()
def _write_output_json(payload: dict[str, Any]) -> None:
if not OUTPUT_PATH:
return
parent = os.path.dirname(OUTPUT_PATH)
if parent:
os.makedirs(parent, exist_ok=True)
with open(OUTPUT_PATH, "w", encoding="utf-8") as handle:
json.dump(payload, handle, ensure_ascii=False, indent=2)
def _canonical_scene_for_hash(scene: dict[str, Any]) -> dict[str, Any]:
canonical: dict[str, Any] = {}
for field in SCENE_HASH_FIELDS:
if field == "sceneBackground":
value = scene.get("sceneBackground") or scene.get("background")
else:
value = scene.get(field)
if value is None:
continue
canonical[field] = value
return canonical
def compute_scene_hash(scene: dict[str, Any]) -> str:
canonical = _canonical_scene_for_hash(scene)
text = json.dumps(canonical, ensure_ascii=False, sort_keys=True, separators=(",", ":"))
return hashlib.sha256(text.encode("utf-8")).hexdigest()
def _summary(payload: dict[str, Any], *, ok: bool) -> str:
parts = ["OK" if ok else "ERROR", STEP]
if payload.get("stage"):
parts.append(f"stage={payload['stage']}")
if payload.get("error"):
parts.append(f"error={payload['error']}")
if OUTPUT_PATH:
parts.append(f"result={OUTPUT_PATH}")
return " ".join(parts)
def emit_success(payload: dict[str, Any]) -> None:
payload = {"success": True, **payload}
if OUTPUT_PATH:
_write_output_json(payload)
print(_summary(payload, ok=True))
else:
print(json.dumps(payload, ensure_ascii=False, indent=2))
def emit_error(error: str, exit_code: int = 1, **extra: Any) -> None:
payload = {"success": False, "step": STEP, "error": error, **extra}
if OUTPUT_PATH:
_write_output_json(payload)
print(_summary(payload, ok=False), file=sys.stderr)
else:
print(json.dumps(payload, ensure_ascii=False, indent=2), file=sys.stderr)
sys.exit(exit_code)
def read_payload(input_path: str | None, params_file: str | None) -> dict[str, Any]:
path = params_file or input_path
if path and path != "-":
with open(path, "r", encoding="utf-8") as handle:
data = json.load(handle)
if not isinstance(data, dict):
raise ValueError("输入 JSON 须为对象")
return data
raw = sys.stdin.read()
data = json.loads(raw or "{}")
if not isinstance(data, dict):
raise ValueError("输入 JSON 须为对象")
return data
def _load_draft_scene_for_merge(draft_path: str | None) -> dict[str, Any]:
if not draft_path or not str(draft_path).strip():
return {}
path = str(draft_path).strip()
if not os.path.isfile(path):
return {}
try:
with open(path, "r", encoding="utf-8") as handle:
data = json.load(handle)
except (OSError, json.JSONDecodeError):
return {}
scene = data.get("scene")
return dict(scene) if isinstance(scene, dict) else {}
def _truthy_signal(value: Any) -> bool:
if value is True:
return True
if isinstance(value, str):
return value.strip().lower() in {"true", "1", "yes", "确认"}
return False
def normalize_payload_shape(payload: dict[str, Any]) -> tuple[dict[str, Any], list[str]]:
normalized = dict(payload)
warnings: list[str] = []
scene = dict(normalized.get("scene")) if isinstance(normalized.get("scene"), dict) else {}
top_level_topics = normalized.get("productKnowledgeNeeds")
if not is_empty(top_level_topics) and is_empty(scene.get("productKnowledgeNeeds")):
scene["productKnowledgeNeeds"] = top_level_topics
warnings.append(
"migrated_top_level_productKnowledgeNeeds_to_scene_productKnowledgeNeeds"
)
for field in SUPPLEMENT_SCENE_FIELDS:
top_level_value = normalized.get(field)
if not is_empty(top_level_value) and is_empty(scene.get(field)):
scene[field] = top_level_value
warnings.append(f"migrated_top_level_{field}_to_scene_{field}")
draft_scene = _load_draft_scene_for_merge(normalized.get("draftPath"))
should_merge_draft_scene = bool(draft_scene) and bool(scene) and (
_truthy_signal(normalized.get("baseInfoAcknowledged"))
or _truthy_signal(scene.get("baseInfoAcknowledged"))
or _truthy_signal(normalized.get("productKnowledgeNeedsConfirmed"))
or _truthy_signal(scene.get("productKnowledgeNeedsConfirmed"))
)
if should_merge_draft_scene:
missing_confirmed_fields = [
field
for field in BASE_CONFIRM_FIELDS + KNOWLEDGE_CONFIRM_FIELDS
if field in draft_scene and is_empty(scene.get(field))
]
if missing_confirmed_fields:
scene = _deep_merge(draft_scene, scene)
warnings.append(
"merged_scene_from_existing_draft_for_missing_fields:"
+ ",".join(missing_confirmed_fields)
)
normalized["scene"] = scene
return normalized, warnings
def _user_text_declines_product_knowledge(user_text: str) -> bool:
s = (user_text or "").strip()
if not s:
return False
for pattern in DECLINE_REGEX_PATTERNS:
if re.search(str(pattern), s):
return True
return False
def _should_auto_decline_product_knowledge(
user_text: str, scene: dict[str, Any], payload: dict[str, Any]
) -> bool:
if payload.get("declineProductKnowledge") is True:
return True
meta = payload.get("meta") if isinstance(payload.get("meta"), dict) else {}
if meta.get("declineProductKnowledge") is True:
return True
if scene.get("declineProductKnowledge") is True:
return True
return _user_text_declines_product_knowledge(user_text)
def _is_declined_topic_placeholder(value: str) -> bool:
text = re.sub(r"\s+", "", str(value or ""))
placeholder = re.sub(r"\s+", "", DEFAULT_DECLINED_PRODUCT_KNOWLEDGE_TOPIC)
return bool(placeholder and text == placeholder)
def _has_knowledge_content(value: Any) -> bool:
if isinstance(value, list):
for item in value:
if isinstance(item, dict) and str(item.get("content") or "").strip():
return True
if isinstance(item, str) and item.strip():
return True
if isinstance(value, dict):
if str(value.get("content") or "").strip():
return True
items = value.get("items")
if isinstance(items, list):
return _has_knowledge_content(items)
if isinstance(value, str):
return bool(value.strip())
return False
def _derive_product_knowledge_needs(scene: dict[str, Any], user_text: str) -> list[str]:
# Primary topic generation is governed by references/product-knowledge-topic-generate.md.
# This function only preserves user-provided knowledge headings; it does not create business topics.
_ = user_text
candidates: list[str] = []
concern_values = scene.get("doctorConcerns")
if isinstance(concern_values, list):
concern_texts = {str(item).strip() for item in concern_values if str(item).strip()}
elif isinstance(concern_values, str) and concern_values.strip():
concern_texts = {concern_values.strip()}
else:
concern_texts = set()
knowledge = scene.get("knowledge")
if isinstance(knowledge, list):
for item in knowledge:
if isinstance(item, dict):
for key in ("title", "category"):
text = str(item.get(key) or "").strip()
if text:
candidates.append(text)
elif isinstance(item, str) and item.strip():
candidates.append(item.strip())
elif isinstance(knowledge, dict):
for key in ("title", "category"):
text = str(knowledge.get(key) or "").strip()
if text:
candidates.append(text)
normalized: list[str] = []
seen: set[str] = set()
for item in candidates:
text = re.sub(r"\s+", " ", str(item or "").strip())
if len(text) < DERIVE_MIN_TOPIC_LENGTH:
continue
if text in concern_texts:
continue
if len(text) > DERIVE_MAX_TOPIC_LENGTH:
text = text[:DERIVE_MAX_TOPIC_LENGTH].rstrip(",,。;; ")
key = text.lower()
if key in seen:
continue
seen.add(key)
normalized.append(text)
if len(normalized) >= DERIVE_MAX_TOPIC_COUNT:
break
return normalized
def _normalize_topic_list(value: Any) -> list[str]:
items: list[str] = []
if isinstance(value, list):
for item in value:
if isinstance(item, str) and item.strip():
items.append(item.strip())
elif isinstance(item, dict):
for key in ("title", "name", "topic"):
text = str(item.get(key) or "").strip()
if text:
items.append(text)
break
elif isinstance(value, str) and value.strip():
items.append(value.strip())
normalized: list[str] = []
seen: set[str] = set()
for item in items:
key = re.sub(r"\s+", " ", item).strip().lower()
if not key or key in seen:
continue
seen.add(key)
normalized.append(item)
return normalized
def _resolve_existing_knowledge_topics(payload: dict[str, Any], scene: dict[str, Any]) -> list[str]:
candidates = [
payload.get("existingKnowledgeTopics"),
payload.get("existingProductKnowledgeTopics"),
]
meta = payload.get("meta") if isinstance(payload.get("meta"), dict) else {}
if meta:
candidates.extend(
[
meta.get("existingKnowledgeTopics"),
meta.get("existingProductKnowledgeTopics"),
]
)
if isinstance(scene, dict):
candidates.extend(
[scene.get("existingKnowledgeTopics"), scene.get("existingProductKnowledgeTopics")]
)
for candidate in candidates:
topics = _normalize_topic_list(candidate)
if topics:
return topics
return []
def _build_knowledge_topic_buckets(
scene: dict[str, Any], payload: dict[str, Any], stage: str
) -> dict[str, Any]:
if stage != STAGE_KNOWLEDGE_CONFIRM:
return {}
confirmed_topics = _normalize_topic_list(scene.get("productKnowledgeNeeds"))
existing_topics = _resolve_existing_knowledge_topics(payload, scene)
existing_keys = {item.lower() for item in existing_topics}
suggested_missing_topics = [item for item in confirmed_topics if item.lower() not in existing_keys]
return {
"existingTopics": existing_topics,
"suggestedMissingTopics": suggested_missing_topics,
"existingTopicsSource": (
"provided_by_caller" if existing_topics else "not_provided_or_empty"
),
}
def is_empty(value: Any) -> bool:
if value is None:
return True
if isinstance(value, str):
return not value.strip()
if isinstance(value, list):
texts = [item for item in value if isinstance(item, str) and item.strip()]
return len(texts) == 0
return False
def normalize_scene(
scene: dict[str, Any], user_text: str, payload: dict[str, Any] | None = None
) -> dict[str, Any]:
out = dict(scene)
payload = payload if isinstance(payload, dict) else {}
base_acknowledged = _base_info_acknowledged(out, payload)
# 证据相关字段已退出当前流程:统一移除历史草稿遗留值,避免误导后续阶段判断。
for key in (
"needEvidenceConfirmation",
"needsEvidenceConfirmation",
"productEvidenceStatus",
"productEvidenceSource",
):
out.pop(key, None)
background = out.get("sceneBackground") or out.get("background") or ""
if isinstance(background, str) and background.strip():
out["sceneBackground"] = background.strip()
out["background"] = background.strip()
doctor_concerns = out.get("doctorConcerns")
if isinstance(doctor_concerns, list):
out["doctorConcerns"] = [
item.strip() for item in doctor_concerns if isinstance(item, str) and item.strip()
]
elif isinstance(doctor_concerns, str) and doctor_concerns.strip():
out["doctorConcerns"] = doctor_concerns.strip()
product_knowledge_needs = out.get("productKnowledgeNeeds")
removed_declined_topic_placeholder = False
if isinstance(product_knowledge_needs, str) and product_knowledge_needs.strip():
topic = product_knowledge_needs.strip()
removed_declined_topic_placeholder = _is_declined_topic_placeholder(topic)
out["productKnowledgeNeeds"] = [] if removed_declined_topic_placeholder else [topic]
elif isinstance(product_knowledge_needs, list):
removed_declined_topic_placeholder = any(
isinstance(item, str) and _is_declined_topic_placeholder(item)
for item in product_knowledge_needs
)
out["productKnowledgeNeeds"] = [
item.strip()
for item in product_knowledge_needs
if isinstance(item, str)
and item.strip()
and not _is_declined_topic_placeholder(item)
]
if removed_declined_topic_placeholder:
out["productKnowledgeNeedsConfirmed"] = False
has_topics = not is_empty(out.get("productKnowledgeNeeds"))
has_knowledge_content = _has_knowledge_content(out.get("knowledge"))
if user_text.strip() and not str(out.get("sourceUserText") or "").strip():
out["sourceUserText"] = user_text.strip()
# Agent 按 product-knowledge-topic-generate.md 负责出题;脚本只从已提供正文标题提取主题。
should_auto_derive_topics = (not has_topics) and has_knowledge_content
if should_auto_derive_topics:
derive_seed_text = user_text.strip() or str(out.get("sourceUserText") or "").strip()
auto_topics = _derive_product_knowledge_needs(out, derive_seed_text)
if base_acknowledged and auto_topics:
auto_topics = auto_topics[:4]
if auto_topics:
out["productKnowledgeNeeds"] = auto_topics
has_topics = True
notes = str(out.get("generationNotes") or "").strip()
extra = _text(PENDING_NOTES_TEXT, "autoDerivedNeeds")
out["generationNotes"] = f"{notes}\n{extra}".strip() if notes else extra
doc_ctx = out.get("doctorOnlyContext")
if isinstance(doc_ctx, str) and doc_ctx.strip():
fixed_doc, _ = sanitize_doctor_core_concerns_to_two_bullets(doc_ctx)
out["doctorOnlyContext"] = fixed_doc
return out
def _base_info_acknowledged(scene: dict[str, Any], payload: dict[str, Any]) -> bool:
if payload.get("baseInfoAcknowledged") is True:
return True
if scene.get("baseInfoAcknowledged") is True:
return True
meta = payload.get("meta") if isinstance(payload.get("meta"), dict) else {}
if meta.get("baseInfoAcknowledged") is True:
return True
return False
def _product_knowledge_needs_confirmed(scene: dict[str, Any], payload: dict[str, Any]) -> bool:
if is_empty(scene.get("productKnowledgeNeeds")):
return False
if _should_auto_decline_product_knowledge("", scene, payload):
return False
if scene.get("productKnowledgeNeedsConfirmed") is False:
return False
if payload.get("productKnowledgeNeedsConfirmed") is True:
return True
if scene.get("productKnowledgeNeedsConfirmed") is True:
return True
meta = payload.get("meta") if isinstance(payload.get("meta"), dict) else {}
if meta.get("productKnowledgeNeedsConfirmed") is True:
return True
return False
def _knowledge_ready(scene: dict[str, Any], payload: dict[str, Any]) -> bool:
if payload.get("knowledgeReady") is True:
return True
if scene.get("knowledgeReady") is True:
return True
meta = payload.get("meta") if isinstance(payload.get("meta"), dict) else {}
if meta.get("knowledgeReady") is True:
return True
return False
def _updated_confirmation_echoed(payload: dict[str, Any]) -> bool:
if payload.get("updatedConfirmationEchoed") is True:
return True
meta = payload.get("meta") if isinstance(payload.get("meta"), dict) else {}
if meta.get("updatedConfirmationEchoed") is True:
return True
return False
def _confirmation_status_for_field(
field: str,
value: Any,
*,
stage: str,
base_acknowledged: bool,
) -> str:
if is_empty(value):
return _text(STATUS_TEXT, "toFill")
if field in BASE_CONFIRM_FIELDS and stage in {
STAGE_KNOWLEDGE_CONFIRM,
STAGE_READY_FOR_SCENE_GENERATION,
STAGE_READY_FOR_VALIDATE,
}:
return (
_text(STATUS_TEXT, "toConfirm")
if base_acknowledged
else _text(STATUS_TEXT, "pendingConfirm")
)
return _text(STATUS_TEXT, "toConfirm")
def _missing_fields(scene: dict[str, Any], fields: list[str]) -> list[str]:
return [field for field in fields if is_empty(scene.get(field))]
def _missing_bool(value: Any) -> bool:
return not isinstance(value, bool)
def _actor_profile_missing(scene: dict[str, Any]) -> bool:
actor = scene.get("actorProfile")
if not isinstance(actor, dict):
return True
return not str(actor.get("name") or "").strip()
def _can_fast_forward_to_validate(
scene: dict[str, Any], *, base_acknowledged: bool, product_knowledge_needs_confirmed: bool
) -> bool:
"""Fast path: all create gates ready, only need confirmation/validate handoff."""
if not base_acknowledged:
return False
if not product_knowledge_needs_confirmed:
return False
return len(_missing_fields(scene, FINAL_REQUIRED_FIELDS)) == 0
def _normalize_incoming_patch(value: Any) -> dict[str, Any]:
if not isinstance(value, dict):
return {}
return {key: item for key, item in value.items() if key in FIELD_LABELS}
def _collect_scene_patch(payload: dict[str, Any]) -> dict[str, Any]:
patch: dict[str, Any] = {}
for key in (
"parsedFields",
"userUpdates",
"userConfirmedFields",
"userProvidedFields",
):
patch.update(_normalize_incoming_patch(payload.get(key)))
return patch
def _load_draft_meta_for_merge(draft_path: str | None) -> dict[str, Any]:
if not draft_path or not str(draft_path).strip():
return {}
path = str(draft_path).strip()
if not os.path.isfile(path):
return {}
try:
with open(path, "r", encoding="utf-8") as handle:
data = json.load(handle)
except (OSError, json.JSONDecodeError):
return {}
meta = data.get("meta")
return dict(meta) if isinstance(meta, dict) else {}
def build_patch_fields_locked_hint(
*,
past_knowledge_lock: bool,
base_acknowledged: bool,
rejected_fields: list[str],
) -> str:
base = _text(ERROR_TEXT, "patchLockedHint")
labels = "、".join(FIELD_LABELS.get(field, field) for field in rejected_fields)
if past_knowledge_lock:
return base + _text(ERROR_TEXT, "patchLockedPastKnowledgeSuffix") + f"(本轮被拒:{labels})"
if base_acknowledged:
return base + _text(ERROR_TEXT, "patchLockedBaseAckSuffix") + f"(本轮被拒:{labels})"
return base + (f"(本轮被拒:{labels})" if labels else "")
def collect_rejected_patch_keys(
patch: dict[str, Any],
*,
base_acknowledged: bool,
past_knowledge_lock: bool,
) -> list[str]:
rejected: set[str] = set()
for key in patch:
if key not in FIELD_LABELS:
continue
if past_knowledge_lock:
if key not in PATCHABLE_AFTER_KNOWLEDGE_LOCK:
rejected.add(key)
elif base_acknowledged and key in BASE_CONFIRM_FIELDS:
rejected.add(key)
return sorted(rejected)
def probe_patch_lock_state(
base_scene: dict[str, Any],
patch: dict[str, Any],
user_text: str,
payload: dict[str, Any],
) -> tuple[bool, bool]:
probe = normalize_scene({**dict(base_scene), **patch}, user_text, payload)
base_ack = _base_info_acknowledged(probe, payload)
if base_ack:
probe["baseInfoAcknowledged"] = True
knowledge_ack = _product_knowledge_needs_confirmed(probe, payload)
if knowledge_ack:
probe["productKnowledgeNeedsConfirmed"] = True
stage = determine_stage(
probe,
base_acknowledged=base_ack,
product_knowledge_needs_confirmed=knowledge_ack,
knowledge_ready=_knowledge_ready(probe, payload),
)[0]
past_knowledge = stage in {
STAGE_READY_FOR_SCENE_GENERATION,
STAGE_READY_FOR_VALIDATE,
}
return base_ack, past_knowledge
def alignment_with_locked_core(scene: dict[str, Any]) -> tuple[bool, str]:
bg = str(scene.get("sceneBackground") or scene.get("background") or "").strip()
if not bg:
return False, _text(ALIGNMENT_TEXT, "noBackground")
if len(bg) < ALIGNMENT_MIN_BACKGROUND_LENGTH:
return False, _text(ALIGNMENT_TEXT, "backgroundTooShort")
concerns = scene.get("doctorConcerns")
parts: list[str] = []
if isinstance(concerns, list):
parts.extend(str(item).strip() for item in concerns if str(item).strip())
elif isinstance(concerns, str) and concerns.strip():
parts.append(concerns.strip())
goal = str(scene.get("repGoal") or "").strip()
if not parts and not goal:
return False, _text(ALIGNMENT_TEXT, "missingCoreInputs")
missing: list[str] = []
for piece in parts:
if len(piece) >= ALIGNMENT_MIN_PIECE_LENGTH and piece not in bg:
missing.append(
piece[:ALIGNMENT_MISSING_PREVIEW_LENGTH]
+ ("…" if len(piece) > ALIGNMENT_MISSING_PREVIEW_LENGTH else "")
)
if goal and len(goal) >= ALIGNMENT_MIN_PIECE_LENGTH and goal not in bg:
missing.append(_text(ALIGNMENT_TEXT, "missingRepGoalLabel"))
if missing:
prefix = _text(ALIGNMENT_TEXT, "coreNotCoveredPrefix")
return False, prefix + ";".join(missing[:ALIGNMENT_MAX_MISSING_PIECES])
return True, _text(ALIGNMENT_TEXT, "coveredOk")
def build_parse_change_summary(
stage: str,
*,
updated_labels: list[str],
alignment_ok: bool,
alignment_note: str,
) -> dict[str, Any]:
lines: list[str] = []
if updated_labels:
lines.append(_textf(CHANGE_SUMMARY_TEXT, "updatedFields", labels="、".join(updated_labels)))
lines.append(alignment_note)
skip_s3 = alignment_ok and stage == STAGE_READY_FOR_VALIDATE
if skip_s3:
lines.append(_text(CHANGE_SUMMARY_TEXT, "canSkipS3"))
else:
lines.append(_text(CHANGE_SUMMARY_TEXT, "shouldRunS3"))
return {
"lines": lines,
"alignmentWithLockedCore": alignment_ok,
"skipScenarioGenerationSuggested": skip_s3,
"alignmentNote": alignment_note,
}
def _clean_user_short_text(user_text: str) -> str:
text = (user_text or "").strip()
if not text:
return ""
text = text.strip("。!?;;,,::、 \t\r\n")
if len(text) >= 2 and ((text[0] == text[-1]) and text[0] in {'"', "'"}):
text = text[1:-1].strip()
return text
def _user_provided_best_practice(user_text: str) -> bool:
text = (user_text or "").strip()
if not text:
return False
return any(token in text for token in BEST_PRACTICE_KEYWORDS)
def _is_non_value_reply(text: str) -> bool:
lowered = text.lower()
return lowered in BLOCKED_PHRASES
def infer_user_reply_patch(user_text: str, missing_base_fields: list[str]) -> dict[str, Any]:
if len(missing_base_fields) != 1:
return {}
field = missing_base_fields[0]
if field != "drugName":
return {}
candidate = _clean_user_short_text(user_text)
if not candidate or len(candidate) > INFER_REPLY_MAX_CANDIDATE_LENGTH:
return {}
if any(sep in candidate for sep in ("\n", ",", ",", "。", ";", ";", ":", ":")):
return {}
if _is_non_value_reply(candidate):
return {}
return {"drugName": candidate}
def _stringify_value(field: str, value: Any) -> str:
if value is None:
return ""
if field == "actorProfile" and isinstance(value, dict):
parts = [
str(value.get("title") or "").strip(),
str(value.get("name") or "").strip(),
str(value.get("description") or "").strip(),
]
return ";".join(part for part in parts if part)
if isinstance(value, list):
parts = [str(item).strip() for item in value if str(item).strip()]
return "、".join(parts)
if isinstance(value, dict):
return json.dumps(value, ensure_ascii=False)
return str(value).strip()
def _humanize_generation_notes(text: str) -> str:
note = (text or "").strip()
if not note:
return ""
for field, label in sorted(FIELD_LABELS.items(), key=lambda item: len(item[0]), reverse=True):
note = note.replace(f"`{field}`", label)
keys = [re.escape(field) for field in FIELD_LABELS]
if not keys:
return note
pattern = re.compile(r"\b(" + "|".join(keys) + r")\b")
return pattern.sub(lambda m: FIELD_LABELS.get(m.group(1), m.group(1)), note)
def build_confirmation_items(
scene: dict[str, Any],
fields: list[str],
*,
stage: str,
base_acknowledged: bool,
) -> list[dict[str, str]]:
items: list[dict[str, str]] = []
for field in fields:
value = scene.get(field)
items.append(
{
"label": FIELD_LABELS.get(field, field),
"status": _confirmation_status_for_field(
field, value, stage=stage, base_acknowledged=base_acknowledged
),
"value": _stringify_value(field, value),
}
)
return items
def _trim_display_text(value: Any, limit: int = 160) -> str:
text = re.sub(r"\s+", " ", str(value or "").strip())
if len(text) <= limit:
return text
return text[:limit].rstrip(",,。;; ") + "…"
def build_supplement_items(
scene: dict[str, Any], *, user_text: str, best_practice_adopted: bool
) -> list[dict[str, str]]:
items: list[dict[str, str]] = []
actor_summary = (
_stringify_value("actorProfile", scene.get("actorProfile"))
or str(scene.get("actorProfileSupplement") or "").strip()
)
if not actor_summary:
# 基础抽取阶段常把画像线索暂存到 generationNotes;结构化 UI 需要显式字段才能回显。
actor_summary = str(scene.get("generationNotes") or "").strip()
if actor_summary:
items.append(
{
"label": "对象角色画像",
"value": _trim_display_text(_humanize_generation_notes(actor_summary)),
}
)
best_practice_summary = (
str(scene.get("bestPracticeSupplement") or "").strip()
or str(scene.get("bestPractice") or "").strip()
or str(scene.get("coachBestPractice") or "").strip()
)
if not best_practice_summary and best_practice_adopted:
best_practice_summary = user_text
if best_practice_summary:
items.append(
{
"label": "代表成功经验/典型话术",
"value": _trim_display_text(best_practice_summary),
}
)
return items
def build_supplement_render_block(supplement_items: list[dict[str, str]]) -> str:
if not supplement_items:
return ""
lines = ["- 补充素材(如已提供):"]
for item in supplement_items:
label = str(item.get("label") or "").strip()
value = str(item.get("value") or "").strip()
if label and value:
lines.append(f" - {label}:{value}")
return "\n".join(lines) if len(lines) > 1 else ""
def build_output_blocking_requirements(
*,
stage: str,
supplement_items: list[dict[str, str]],
) -> list[str]:
requirements: list[str] = []
if supplement_items:
requirements.append(
"用户可见输出必须展示“补充素材”区块,并逐条回显 userOutputTemplate.supplementItems;不得只展示产品知识主题后继续推进。"
)
if stage == STAGE_KNOWLEDGE_CONFIRM:
requirements.append(
"用户可见输出必须同时展示基础信息 6 项与产品知识主题;禁止只展示产品知识主题。"
)
return requirements
def _section_status(missing_fields: list[str], items: list[dict[str, str]]) -> str:
if not missing_fields:
return _text(STATUS_TEXT, "ready")
if any(item["value"] for item in items):
return _text(STATUS_TEXT, "toFill")
return _text(STATUS_TEXT, "pendingStart")
def determine_stage(
scene: dict[str, Any],
*,
base_acknowledged: bool,
product_knowledge_needs_confirmed: bool,
knowledge_ready: bool,
) -> tuple[str, list[str], list[str], list[str], list[str], bool]:
missing_base_fields = _missing_fields(scene, BASE_CONFIRM_FIELDS)
missing_knowledge_fields = _missing_fields(
scene, KNOWLEDGE_CONFIRM_FIELDS + KNOWLEDGE_GATE_FIELDS
)
missing_generated_fields = _missing_fields(
scene, GENERATED_STAGE_FIELDS + GENERATED_GATE_FIELDS
)
if "actorProfile" not in missing_generated_fields and _actor_profile_missing(scene):
missing_generated_fields.append("actorProfile")
missing_internal_fields = _missing_fields(scene, INTERNAL_GENERATED_FIELDS)
missing_needs_evidence_confirmation = (
product_knowledge_needs_confirmed and not knowledge_ready
)
if missing_base_fields:
return (
STAGE_BASE_INFO_CONFIRM,
missing_base_fields,
missing_knowledge_fields,
missing_generated_fields,
missing_internal_fields,
missing_needs_evidence_confirmation,
)
if not base_acknowledged:
return (
STAGE_BASE_INFO_CONFIRM,
missing_base_fields,
missing_knowledge_fields,
missing_generated_fields,
missing_internal_fields,
missing_needs_evidence_confirmation,
)
if (
missing_knowledge_fields
or missing_needs_evidence_confirmation
or not product_knowledge_needs_confirmed
):
return (
STAGE_KNOWLEDGE_CONFIRM,
missing_base_fields,
missing_knowledge_fields,
missing_generated_fields,
missing_internal_fields,
missing_needs_evidence_confirmation,
)
if missing_generated_fields or missing_internal_fields:
return (
STAGE_READY_FOR_SCENE_GENERATION,
missing_base_fields,
missing_knowledge_fields,
missing_generated_fields,
missing_internal_fields,
missing_needs_evidence_confirmation,
)
return (
STAGE_READY_FOR_VALIDATE,
missing_base_fields,
missing_knowledge_fields,
missing_generated_fields,
missing_internal_fields,
missing_needs_evidence_confirmation,
)
def build_phase_sections(
scene: dict[str, Any],
missing_base_fields: list[str],
missing_knowledge_fields: list[str],
missing_generated_fields: list[str],
missing_internal_fields: list[str],
*,
stage: str,
base_acknowledged: bool,
missing_needs_evidence_confirmation: bool,
) -> list[dict[str, Any]]:
base_items = build_confirmation_items(
scene, BASE_CONFIRM_FIELDS, stage=stage, base_acknowledged=base_acknowledged
)
knowledge_items = build_confirmation_items(
scene, KNOWLEDGE_CONFIRM_FIELDS, stage=stage, base_acknowledged=base_acknowledged
)
if not base_acknowledged:
knowledge_items = []
generated_items = build_confirmation_items(
scene, GENERATED_CONFIRM_FIELDS, stage=stage, base_acknowledged=base_acknowledged
)
base_section_status = _section_status(missing_base_fields, base_items)
if not missing_base_fields and not base_acknowledged:
base_section_status = _text(STATUS_TEXT, "pendingConfirm")
knowledge_section_status = _text(STATUS_TEXT, "ready")
if not base_acknowledged:
knowledge_section_status = _text(STATUS_TEXT, "pendingConfirm")
elif missing_needs_evidence_confirmation or missing_knowledge_fields:
knowledge_section_status = _section_status(missing_knowledge_fields, knowledge_items)
return [
{
"stage": STAGE_BASE_INFO_CONFIRM,
"title": PHASE_TITLE_TEXT.get(STAGE_BASE_INFO_CONFIRM, STAGE_BASE_INFO_CONFIRM),
"status": base_section_status,
"items": base_items,
},
{
"stage": STAGE_KNOWLEDGE_CONFIRM,
"title": PHASE_TITLE_TEXT.get(STAGE_KNOWLEDGE_CONFIRM, STAGE_KNOWLEDGE_CONFIRM),
"status": knowledge_section_status,
"items": knowledge_items,
},
{
"stage": STAGE_READY_FOR_SCENE_GENERATION,
"title": PHASE_TITLE_TEXT.get(
STAGE_READY_FOR_SCENE_GENERATION, STAGE_READY_FOR_SCENE_GENERATION
),
"status": _section_status(missing_generated_fields + missing_internal_fields, generated_items),
"items": generated_items,
},
]
def build_pending_confirm_notes(
scene: dict[str, Any], stage: str, *, base_acknowledged: bool
) -> list[str]:
notes: list[str] = []
generation_notes = str(scene.get("generationNotes") or "").strip()
if generation_notes:
notes.append(_humanize_generation_notes(generation_notes))
if stage == STAGE_BASE_INFO_CONFIRM:
notes.append(_text(PENDING_NOTES_TEXT, "baseInfoStage"))
elif stage == STAGE_KNOWLEDGE_CONFIRM:
if base_acknowledged:
notes.append(_text(PENDING_NOTES_TEXT, "knowledgeStageBaseAck"))
else:
notes.append(_text(PENDING_NOTES_TEXT, "knowledgeStageBaseUnack"))
notes.append(_text(PENDING_NOTES_TEXT, "knowledgeStageOptionalKnowledge"))
elif stage == STAGE_READY_FOR_SCENE_GENERATION:
notes.append(_text(PENDING_NOTES_TEXT, "readyForGeneration"))
return notes
def build_base_questions(missing_fields: list[str]) -> list[str]:
return [BASE_QUESTION_MAP[field] for field in missing_fields if field in BASE_QUESTION_MAP]
def _append_optional_enhancement_prompts(questions: list[str]) -> list[str]:
# UI 会把 clarifyQuestions 作为“需要确认的问题/事项”展示;这里统一用“可选”标记,避免误解为门禁。
out = list(questions)
out.append(
"(可选,已提供可忽略)请用 2-3 句话描述这位医生(角色/职称、沟通风格、最在意什么、最可能抛出的质疑)。"
)
out.append(
"(可选,已提供可忽略)你当时最关键的一句话/应对方式是什么?医生怎么回?你如何把对话推进下去的?"
)
return out
def build_knowledge_questions(
scene: dict[str, Any],
missing_fields: list[str],
missing_needs_evidence_confirmation: bool,
base_acknowledged: bool,
) -> list[str]:
hint_key = "backgroundHintAck" if base_acknowledged else "backgroundHintUnack"
background_hint = _text(KNOWLEDGE_QUESTION_TEXT, hint_key)
need_one_shot = bool(missing_fields) or missing_needs_evidence_confirmation
questions: list[str] = []
if need_one_shot:
questions.append(_textf(KNOWLEDGE_QUESTION_TEXT, "confirmNeeds", background_hint=background_hint))
if not base_acknowledged:
questions.append(_text(KNOWLEDGE_QUESTION_TEXT, "confirmBaseFirst"))
return questions
def build_content_generation_questions(
missing_generated_fields: list[str],
missing_internal_fields: list[str],
*,
must_echo_updated_confirmation: bool,
updated_labels: list[str],
) -> list[str]:
questions: list[str] = []
if must_echo_updated_confirmation and updated_labels:
labels = "、".join(updated_labels)
questions.append(_textf(CONTENT_QUESTION_TEXT, "echoUpdated", labels=labels))
if "title" in missing_generated_fields or "sceneBackground" in missing_generated_fields:
questions.append(_text(CONTENT_QUESTION_TEXT, "genTitleBackground"))
if "actorProfile" in missing_generated_fields:
questions.append(_text(CONTENT_QUESTION_TEXT, "genActorProfile"))
if missing_internal_fields:
questions.append(_text(CONTENT_QUESTION_TEXT, "genContexts"))
return questions
def build_clarify_questions(
stage: str,
scene: dict[str, Any],
missing_base_fields: list[str],
missing_knowledge_fields: list[str],
missing_generated_fields: list[str],
missing_internal_fields: list[str],
missing_needs_evidence_confirmation: bool,
base_acknowledged: bool,
must_echo_updated_confirmation: bool,
updated_labels: list[str],
) -> list[str]:
if stage == STAGE_BASE_INFO_CONFIRM:
base_qs = build_base_questions(missing_base_fields)
return _append_optional_enhancement_prompts(base_qs)
if stage == STAGE_KNOWLEDGE_CONFIRM:
return build_knowledge_questions(
scene,
missing_knowledge_fields,
missing_needs_evidence_confirmation,
base_acknowledged,
)
if stage == STAGE_READY_FOR_SCENE_GENERATION:
return build_content_generation_questions(
missing_generated_fields,
missing_internal_fields,
must_echo_updated_confirmation=must_echo_updated_confirmation,
updated_labels=updated_labels,
)
return []
def build_next_action(
stage: str,
*,
base_acknowledged: bool,
must_echo_updated_confirmation: bool,
updated_labels: list[str],
) -> str:
if stage == STAGE_BASE_INFO_CONFIRM:
return _text(NEXT_ACTION_TEXT, "baseInfo")
if stage == STAGE_KNOWLEDGE_CONFIRM:
if not base_acknowledged:
return _text(NEXT_ACTION_TEXT, "knowledgeBaseFirst")
return _text(NEXT_ACTION_TEXT, "knowledge")
if stage == STAGE_READY_FOR_SCENE_GENERATION:
if must_echo_updated_confirmation and updated_labels:
labels = "、".join(updated_labels)
return _textf(NEXT_ACTION_TEXT, "readyForGenerationWithEcho", labels=labels)
return _text(NEXT_ACTION_TEXT, "readyForGeneration")
if stage == STAGE_READY_FOR_VALIDATE and must_echo_updated_confirmation and updated_labels:
labels = "、".join(updated_labels)
return _textf(NEXT_ACTION_TEXT, "readyForValidateWithEcho", labels=labels)
return _text(NEXT_ACTION_TEXT, "default")
def build_system_action_hint(
stage: str,
*,
base_acknowledged: bool,
must_echo_updated_confirmation: bool,
updated_labels: list[str],
) -> str:
if stage == STAGE_BASE_INFO_CONFIRM:
return _text(SYSTEM_ACTION_HINT_TEXT, "baseInfo")
if stage == STAGE_KNOWLEDGE_CONFIRM:
if not base_acknowledged:
return _text(SYSTEM_ACTION_HINT_TEXT, "knowledgeBaseFirst")
return _text(SYSTEM_ACTION_HINT_TEXT, "knowledge")
if stage == STAGE_READY_FOR_SCENE_GENERATION:
if must_echo_updated_confirmation and updated_labels:
labels = "、".join(updated_labels)
return _textf(SYSTEM_ACTION_HINT_TEXT, "readyForGenerationWithEcho", labels=labels)
return _text(SYSTEM_ACTION_HINT_TEXT, "readyForGeneration")
if stage == STAGE_READY_FOR_VALIDATE and must_echo_updated_confirmation and updated_labels:
labels = "、".join(updated_labels)
return _textf(SYSTEM_ACTION_HINT_TEXT, "readyForValidateWithEcho", labels=labels)
return _text(SYSTEM_ACTION_HINT_TEXT, "default")
def maybe_write_draft(draft_path: str | None, scene: dict[str, Any], parse_result: dict[str, Any]) -> None:
if not draft_path:
return
parent = os.path.dirname(draft_path)
if parent:
os.makedirs(parent, exist_ok=True)
prior_meta = _load_draft_meta_for_merge(draft_path)
meta = {
**prior_meta,
"updatedAt": datetime.now(timezone.utc).isoformat(),
"lastStep": STEP,
"lastParseStage": parse_result.get("stage"),
"sceneHash": parse_result.get("sceneHash"),
"scenarioGenerated": parse_result.get("scenarioGenerated") is True,
}
payload = {
"scene": scene,
"parseResult": parse_result,
"meta": meta,
}
with open(draft_path, "w", encoding="utf-8") as handle:
json.dump(payload, handle, ensure_ascii=False, indent=2)
def main() -> None:
global OUTPUT_PATH
parser = argparse.ArgumentParser()
parser.add_argument("--input", default="-", help="JSON file path, or '-' for stdin")
parser.add_argument("--params-file", default=None, help="Read params from UTF-8 JSON file")
parser.add_argument("--output", default=None, help="Write full JSON result to this file")
parser.add_argument(
"--mode",
default="default",
choices=["default", "fast_forward"],
help="default(默认)| fast_forward(满足条件时可直接进入 READY_FOR_VALIDATE)",
)
parser.add_argument(
"--no-write-draft",
action="store_true",
help="仅返回解析结果,不写回 draftPath 文件(减少中间轮次 IO)",
)
args = parser.parse_args()
OUTPUT_PATH = args.output
try:
payload = read_payload(args.input, args.params_file)
except (OSError, json.JSONDecodeError, ValueError) as exc:
emit_error("invalid_json_input", exit_code=2, hint=str(exc))
payload, payload_shape_warnings = normalize_payload_shape(payload)
user_text = str(payload.get("userText") or "").strip()
base_scene = payload.get("scene") if isinstance(payload.get("scene"), dict) else {}
scene_patch = _collect_scene_patch(payload)
draft_path = payload.get("draftPath")
if not user_text and not base_scene and not scene_patch:
emit_error(_text(ERROR_TEXT, "missingInput"), exit_code=2)
if scene_patch:
base_ack_probe, past_probe = probe_patch_lock_state(
base_scene, scene_patch, user_text, payload
)
rejected_patch = collect_rejected_patch_keys(
scene_patch,
base_acknowledged=base_ack_probe,
past_knowledge_lock=past_probe,
)
if rejected_patch:
emit_error(
"patch_fields_locked",
exit_code=2,
rejectedFields=rejected_patch,
hint=build_patch_fields_locked_hint(
past_knowledge_lock=past_probe,
base_acknowledged=base_ack_probe,
rejected_fields=rejected_patch,
),
)
scene = dict(base_scene)
scene.update(scene_patch)
scene = normalize_scene(scene, user_text, payload)
base_acknowledged = _base_info_acknowledged(scene, payload)
if base_acknowledged:
scene["baseInfoAcknowledged"] = True
product_knowledge_needs_confirmed = _product_knowledge_needs_confirmed(scene, payload)
knowledge_ready = _knowledge_ready(scene, payload)
if product_knowledge_needs_confirmed:
scene["productKnowledgeNeedsConfirmed"] = True
else:
scene.pop("productKnowledgeNeedsConfirmed", None)
(
stage,
missing_base_fields,
missing_knowledge_fields,
missing_generated_fields,
missing_internal_fields,
missing_needs_evidence_confirmation,
) = determine_stage(
scene,
base_acknowledged=base_acknowledged,
product_knowledge_needs_confirmed=product_knowledge_needs_confirmed,
knowledge_ready=knowledge_ready,
)
inferred_reply_patch = infer_user_reply_patch(user_text, missing_base_fields)
if inferred_reply_patch:
scene.update(inferred_reply_patch)
scene = normalize_scene(scene, user_text, payload)
product_knowledge_needs_confirmed = _product_knowledge_needs_confirmed(scene, payload)
knowledge_ready = _knowledge_ready(scene, payload)
if product_knowledge_needs_confirmed:
scene["productKnowledgeNeedsConfirmed"] = True
else:
scene.pop("productKnowledgeNeedsConfirmed", None)
(
stage,
missing_base_fields,
missing_knowledge_fields,
missing_generated_fields,
missing_internal_fields,
missing_needs_evidence_confirmation,
) = determine_stage(
scene,
base_acknowledged=base_acknowledged,
product_knowledge_needs_confirmed=product_knowledge_needs_confirmed,
knowledge_ready=knowledge_ready,
)
applied_user_patch = {**scene_patch, **inferred_reply_patch}
confirm_fields = BASE_CONFIRM_FIELDS + KNOWLEDGE_CONFIRM_FIELDS + GENERATED_CONFIRM_FIELDS
updated_confirm_fields = [field for field in confirm_fields if field in applied_user_patch]
updated_labels = [FIELD_LABELS.get(field, field) for field in updated_confirm_fields]
has_user_updates = len(updated_labels) > 0
updated_confirmation_echoed = _updated_confirmation_echoed(payload)
must_echo_updated_confirmation = has_user_updates and not updated_confirmation_echoed
if (
args.mode == "fast_forward"
and stage != STAGE_READY_FOR_VALIDATE
and _can_fast_forward_to_validate(
scene,
base_acknowledged=base_acknowledged,
product_knowledge_needs_confirmed=product_knowledge_needs_confirmed,
)
):
stage = STAGE_READY_FOR_VALIDATE
missing_base_fields = []
missing_knowledge_fields = []
missing_generated_fields = []
missing_internal_fields = []
missing_needs_evidence_confirmation = False
phase_sections = build_phase_sections(
scene,
missing_base_fields,
missing_knowledge_fields,
missing_generated_fields,
missing_internal_fields,
stage=stage,
base_acknowledged=base_acknowledged,
missing_needs_evidence_confirmation=missing_needs_evidence_confirmation,
)
current_confirmation_items = build_confirmation_items(
scene,
STAGE_CONFIRM_FIELDS[stage],
stage=stage,
base_acknowledged=base_acknowledged,
)
updated_stage = (
STAGE_KNOWLEDGE_CONFIRM
if stage in {STAGE_READY_FOR_SCENE_GENERATION, STAGE_READY_FOR_VALIDATE}
else stage
)
updated_confirmation_items = build_confirmation_items(
scene,
STAGE_CONFIRM_FIELDS[updated_stage],
stage=stage,
base_acknowledged=base_acknowledged,
)
must_display_fields = STAGE_MUST_DISPLAY_FIELDS.get(stage, FINAL_MUST_DISPLAY_FIELDS)
must_display_items = build_confirmation_items(
scene,
must_display_fields,
stage=stage,
base_acknowledged=base_acknowledged,
)
confirm_view = {
field: scene.get(field)
for field in (
BASE_CONFIRM_FIELDS + KNOWLEDGE_CONFIRM_FIELDS + GENERATED_CONFIRM_FIELDS
)
}
all_missing_fields = _missing_fields(scene, FINAL_REQUIRED_FIELDS)
missing_knowledge_confirm_fields = _missing_fields(scene, KNOWLEDGE_CONFIRM_FIELDS)
if stage == STAGE_BASE_INFO_CONFIRM:
user_facing_missing_fields = list(missing_base_fields)
elif stage == STAGE_KNOWLEDGE_CONFIRM:
user_facing_missing_fields = list(missing_base_fields + missing_knowledge_confirm_fields)
elif stage == STAGE_READY_FOR_SCENE_GENERATION:
user_facing_missing_fields = list(
missing_base_fields + missing_knowledge_confirm_fields + missing_generated_fields
)
else:
user_facing_missing_fields = []
pending_confirm_notes = build_pending_confirm_notes(scene, stage, base_acknowledged=base_acknowledged)
best_practice_adopted = _user_provided_best_practice(user_text)
if best_practice_adopted:
pending_confirm_notes.append(_text(PENDING_NOTES_TEXT, "bestPracticeAdopted"))
supplement_items = build_supplement_items(
scene, user_text=user_text, best_practice_adopted=best_practice_adopted
)
supplement_render_block = build_supplement_render_block(supplement_items)
output_blocking_requirements = build_output_blocking_requirements(
stage=stage,
supplement_items=supplement_items,
)
actor_profile_supplement = next(
(item["value"] for item in supplement_items if item["label"] == "对象角色画像"), ""
)
best_practice_supplement = next(
(item["value"] for item in supplement_items if item["label"] == "代表成功经验/典型话术"), ""
)
clarify_questions = build_clarify_questions(
stage,
scene,
missing_base_fields,
missing_knowledge_fields,
missing_generated_fields,
missing_internal_fields,
missing_needs_evidence_confirmation,
base_acknowledged,
must_echo_updated_confirmation,
updated_labels,
)
alignment_ok, alignment_note = alignment_with_locked_core(scene)
parse_meta = build_parse_change_summary(
stage,
updated_labels=updated_labels,
alignment_ok=alignment_ok,
alignment_note=alignment_note,
)
scene_hash = compute_scene_hash(scene)
scenario_generated = stage == STAGE_READY_FOR_VALIDATE
knowledge_topic_buckets = _build_knowledge_topic_buckets(scene, payload, stage)
result = {
"step": STEP,
"stage": stage,
"scene": scene,
"sceneHash": scene_hash,
"scenarioGenerated": scenario_generated,
"confirmedFields": confirm_view,
"missingFields": user_facing_missing_fields,
"baseMissingFields": missing_base_fields,
"knowledgeMissingFields": missing_knowledge_fields,
"contentMissingFields": missing_generated_fields,
"createGateMissingFields": all_missing_fields,
"internalGeneratedMissingFields": missing_internal_fields,
"readyForScenarioJsonParse": stage == STAGE_READY_FOR_SCENE_GENERATION,
"readyForValidate": stage == STAGE_READY_FOR_VALIDATE,
"baseInfoAcknowledged": base_acknowledged,
"productKnowledgeNeedsConfirmed": product_knowledge_needs_confirmed,
"knowledgeReady": knowledge_ready,
"knowledgeCheckRequired": product_knowledge_needs_confirmed and not knowledge_ready,
"appliedUserPatch": applied_user_patch,
"updatedFieldLabels": updated_labels,
"bestPracticeAdopted": best_practice_adopted,
"bestPracticeTargetSection": (
BEST_PRACTICE_TARGET_SECTION if best_practice_adopted else ""
),
"mustEchoUpdatedConfirmation": must_echo_updated_confirmation,
"updatedConfirmationEchoed": updated_confirmation_echoed,
"clarifyQuestions": clarify_questions,
"parseMeta": parse_meta,
"knowledgeTopicBuckets": knowledge_topic_buckets,
"payloadShapeWarnings": payload_shape_warnings,
"supplementItems": supplement_items,
"mustDisplaySupplementItems": bool(supplement_items),
"supplementRenderBlock": supplement_render_block,
"outputBlockingRequirements": output_blocking_requirements,
"userOutputTemplate": {
"stage": stage,
"stageLabel": STAGE_LABEL_TEXT.get(stage, stage),
"confirmationItems": current_confirmation_items,
"updatedConfirmationItems": updated_confirmation_items,
"mustEchoUpdatedConfirmation": must_echo_updated_confirmation,
"updatedFieldLabels": updated_labels,
"bestPracticeAdopted": best_practice_adopted,
"bestPracticeTargetSection": (
BEST_PRACTICE_TARGET_SECTION if best_practice_adopted else ""
),
"phaseSections": phase_sections,
"mustDisplayFields": must_display_fields,
"mustDisplayLabels": [FIELD_LABELS.get(field, field) for field in must_display_fields],
"mustDisplayConfirmationItems": must_display_items,
"knowledgeReady": knowledge_ready,
"knowledgeCheckRequired": product_knowledge_needs_confirmed and not knowledge_ready,
"missingLabels": [FIELD_LABELS.get(field, field) for field in user_facing_missing_fields],
"createGateMissingLabels": [
FIELD_LABELS.get(field, field) for field in all_missing_fields
],
"internalGeneratedMissingLabels": [
FIELD_LABELS.get(field, field) for field in missing_internal_fields
],
"pendingConfirmNotes": pending_confirm_notes,
"clarifyQuestions": clarify_questions,
"nextAction": build_next_action(
stage,
base_acknowledged=base_acknowledged,
must_echo_updated_confirmation=must_echo_updated_confirmation,
updated_labels=updated_labels,
),
"systemActionHint": build_system_action_hint(
stage,
base_acknowledged=base_acknowledged,
must_echo_updated_confirmation=must_echo_updated_confirmation,
updated_labels=updated_labels,
),
"changeSummaryLines": parse_meta.get("lines", []),
"alignmentWithLockedCore": alignment_ok,
"skipScenarioGenerationSuggested": bool(
parse_meta.get("skipScenarioGenerationSuggested")
),
"mustShowSceneBackgroundFullText": bool(
str(scene.get("sceneBackground") or scene.get("background") or "").strip()
),
"sceneBackgroundFullText": str(
scene.get("sceneBackground") or scene.get("background") or ""
).strip(),
"knowledgeTopicBuckets": knowledge_topic_buckets,
"payloadShapeWarnings": payload_shape_warnings,
"supplementItems": supplement_items,
"mustDisplaySupplementItems": bool(supplement_items),
"supplementRenderBlock": supplement_render_block,
"outputBlockingRequirements": output_blocking_requirements,
"actorProfileSupplement": actor_profile_supplement,
"bestPracticeSupplement": best_practice_supplement,
},
}
if isinstance(draft_path, str) and draft_path.strip() and not args.no_write_draft:
maybe_write_draft(draft_path.strip(), scene, result)
result["draftPath"] = draft_path.strip()
elif isinstance(draft_path, str) and draft_path.strip():
result["draftPath"] = draft_path.strip()
result["draftWriteSkipped"] = True
emit_success(result)
if __name__ == "__main__":
main()
FILE:scripts/tbs-md-sanitize.py
"""
Shared Markdown tweaks for TBS scene drafts (used by parse + validate).
"""
from __future__ import annotations
import re
def sanitize_doctor_core_concerns_to_two_bullets(doctor_only_context: str) -> tuple[str, bool]:
"""
`tbs-scene-validate` requires `## 核心顾虑` to have 1-2 lines starting with `-`.
Models often emit 3+ bullets; merge extras into the second bullet so structure passes
without losing wording.
"""
text = doctor_only_context or ""
if not text.strip():
return text, False
match = re.search(r"(?ms)^(## 核心顾虑)\s*$\n(.*?)(?=^##\s+|\Z)", text)
if not match:
return text, False
header_line = match.group(1)
body = match.group(2)
lines = [line.strip() for line in body.splitlines() if line.strip()]
bullets = [line for line in lines if line.startswith("-")]
if len(bullets) <= 2:
return text, False
first = bullets[0]
pieces: list[str] = []
for b in bullets[1:]:
piece = b[1:].strip() if b.startswith("-") else b.strip()
if piece:
pieces.append(piece)
second = "- " + ";".join(pieces)
new_section_body = first + "\n" + second + "\n"
replacement = header_line + "\n" + new_section_body
new_text = text[: match.start()] + replacement + text[match.end() :]
return new_text, True
FILE:scripts/tbs-client.py
"""
TBS Admin API helper for cms-tbs-scene-create.
"""
from __future__ import annotations
import hashlib
import json
import time
from typing import Any
import requests
TIMEOUT = 60
MAX_RETRIES = 3
RETRY_INTERVAL = 1
def extract_data(payload: Any) -> Any:
if isinstance(payload, dict):
if "data" in payload:
return payload["data"]
if "result" in payload:
return payload["result"]
return payload
def guess_entity_id(item: dict) -> str | None:
for key in (
"id",
"personaId",
"persona_id",
"rolePersonaId",
"knowledgeId",
"knowledge_id",
"departmentId",
"drugId",
"businessDomainId",
):
value = item.get(key)
if isinstance(value, (str, int, float)) and not isinstance(value, bool):
text = str(value).strip()
if text:
return text
return None
def _scalar_entity_id(value: Any) -> str | None:
"""Coerce API-returned scalar ids; do not treat 0 as 'missing'."""
if value is None or isinstance(value, bool):
return None
if isinstance(value, float):
if value.is_integer():
return str(int(value))
return str(value).strip() or None
if isinstance(value, int):
return str(value)
if isinstance(value, str):
text = value.strip()
return text or None
return None
def extract_create_persona_id(raw_response: Any) -> str | None:
"""
TBS createRolePersona success body is often::
{"resultCode": 1, "data": 3008}
so extract_data returns an int. Some gateways instead return a dict or put id on the envelope.
"""
inner = extract_data(raw_response) if isinstance(raw_response, dict) else raw_response
sid = _scalar_entity_id(inner)
if sid is not None:
return sid
if isinstance(inner, dict):
for key in (
"id",
"personaId",
"persona_id",
"recordId",
"rolePersonaId",
):
sid = _scalar_entity_id(inner.get(key))
if sid is not None:
return sid
for nested_key in ("persona", "rolePersona", "entity", "result"):
nested = inner.get(nested_key)
if isinstance(nested, dict):
sid = guess_entity_id(nested)
if sid:
return sid
sid = _scalar_entity_id(nested)
if sid is not None:
return sid
guessed = guess_entity_id(inner)
if guessed:
return guessed
if isinstance(raw_response, dict):
for key in ("personaId", "persona_id", "id", "recordId", "data"):
sid = _scalar_entity_id(raw_response.get(key))
if sid is not None:
return sid
return None
def extract_created_entity_id(raw_response: Any, *preferred_keys: str) -> str | None:
"""Extract an API-created entity id without treating None as the string "None"."""
inner = extract_data(raw_response) if isinstance(raw_response, dict) else raw_response
sid = _scalar_entity_id(inner)
if sid is not None:
return sid
if isinstance(inner, dict):
keys = preferred_keys or ("id",)
for key in (*keys, "id", "recordId", "entityId"):
sid = _scalar_entity_id(inner.get(key))
if sid is not None:
return sid
guessed = guess_entity_id(inner)
if guessed:
return guessed
return None
def guess_entity_name(item: dict) -> str | None:
for key in (
"name",
"title",
"departmentName",
"drugName",
"businessDomainName",
):
value = item.get(key)
if isinstance(value, str) and value.strip():
return value.strip()
return None
def normalize_name(value: str) -> str:
text = (value or "").strip()
if not text:
return ""
table = str.maketrans(
{
"(": "(",
")": ")",
",": ",",
"、": ",",
"-": "-",
"–": "-",
"—": "-",
}
)
return "".join(text.translate(table).split()).lower()
def exact_match_ids(items: list[dict], target_name: str) -> list[str]:
target = normalize_name(target_name)
if not target:
return []
matches: list[str] = []
for item in items:
item_name = guess_entity_name(item)
item_id = guess_entity_id(item)
if item_id and normalize_name(item_name or "") == target:
matches.append(item_id)
seen = set()
ordered: list[str] = []
for item_id in matches:
if item_id not in seen:
seen.add(item_id)
ordered.append(item_id)
return ordered
class TBSClient:
def __init__(
self, base_url: str, access_token: str, timeout: int = TIMEOUT, verify_tls: bool = True
):
self.base_url = base_url.rstrip("/")
self.access_token = access_token
self.timeout = timeout
self.verify_tls = verify_tls
def _headers(self) -> dict[str, str]:
return {
"Content-Type": "application/json",
"accept": "application/json",
"access-token": self.access_token,
}
def request_json(self, method: str, path: str, body: dict | None = None) -> Any:
url = self.base_url + (path if path.startswith("/") else f"/{path}")
payload_text = json.dumps(body or {}, ensure_ascii=False)
last_error: Exception | None = None
for attempt in range(1, MAX_RETRIES + 1):
try:
response = requests.request(
method=method.upper(),
url=url,
headers=self._headers(),
json=body,
timeout=self.timeout,
verify=self.verify_tls,
)
if response.status_code >= 400:
raise RuntimeError(
f"HTTP {response.status_code} {method.upper()} {url}: {response.text[:500]}"
)
try:
return response.json()
except ValueError as exc:
raise RuntimeError(f"响应不是合法 JSON: {response.text[:500]}") from exc
except Exception as exc: # noqa: BLE001
last_error = exc
if attempt >= MAX_RETRIES:
break
time.sleep(RETRY_INTERVAL)
raise RuntimeError(
f"请求失败: {method.upper()} {url}, body={payload_text[:500]}, error={last_error}"
)
def list_entities(client: TBSClient, path: str, body: dict | None = None) -> list[dict]:
data = extract_data(client.request_json("POST", path, body or {}))
if isinstance(data, list):
return data
if isinstance(data, dict):
for key in ("records", "list", "items"):
value = data.get(key)
if isinstance(value, list):
return value
return []
def list_entities_get(client: TBSClient, path: str) -> list[dict]:
data = extract_data(client.request_json("GET", path))
if isinstance(data, list):
return data
if isinstance(data, dict):
for key in ("records", "list", "items", "data"):
value = data.get(key)
if isinstance(value, list):
return value
return []
def _maybe_id(value: str) -> str | None:
text = str(value or "").strip()
if text.isdigit():
return text
return None
def fingerprint_sha256_of_text(text: str) -> str:
return hashlib.sha256(str(text or "").strip().encode("utf-8")).hexdigest()
def _ensure_text(value: Any) -> str:
return str(value or "").strip()
def _find_persona_by_name_and_title(
items: list[dict[str, Any]], name: str, title: str, role_type: str = ""
) -> str | None:
target_name = normalize_name(name)
target_title = normalize_name(title)
target_role_type = normalize_name(role_type)
for item in items:
item_id = guess_entity_id(item)
if not item_id or not isinstance(item, dict):
continue
item_name = normalize_name(
item.get("name") or item.get("personaName") or item.get("doctorName") or ""
)
item_title = normalize_name(item.get("title") or item.get("doctorTitle") or "")
item_role_type = normalize_name(item.get("roleType") or item.get("type") or "")
if item_name != target_name:
continue
if target_title and item_title and item_title != target_title:
continue
if target_role_type and item_role_type and item_role_type != target_role_type:
continue
return str(item_id)
return None
def resolve_or_create_business_domain(
client: TBSClient,
business_domain_name: str,
allow_create: bool = True,
) -> tuple[str, dict]:
candidate_id = _maybe_id(business_domain_name)
if candidate_id:
return candidate_id, {"action": "matched_by_id", "input": business_domain_name}
items = list_entities(
client,
"/businessDomain/listBusinessDomains",
{"pageNo": 1, "pageSize": 200, "name": business_domain_name},
)
matches = exact_match_ids(items, business_domain_name)
if len(matches) == 1:
return matches[0], {"action": "matched", "input": business_domain_name}
if len(matches) > 1:
raise RuntimeError(f"业务领域名称匹配到多条记录,需人工确认:{business_domain_name}")
if not allow_create:
raise RuntimeError(f"业务领域不存在:{business_domain_name}")
payload = {"name": business_domain_name}
created = client.request_json("POST", "/businessDomain/createBusinessDomain", payload)
created_id = extract_created_entity_id(created, "businessDomainId")
if not created_id:
raise RuntimeError("创建业务领域失败:返回中缺少 id")
return created_id, {"action": "created", "input": business_domain_name}
def resolve_or_create_department(
client: TBSClient,
department_name: str,
business_domain_id: str,
business_domain_name: str,
allow_create: bool = True,
) -> tuple[str, dict]:
candidate_id = _maybe_id(department_name)
if candidate_id:
return candidate_id, {"action": "matched_by_id", "input": department_name}
items = list_entities(
client,
"/department/listDepartments",
{
"pageNo": 1,
"pageSize": 200,
"name": department_name,
"businessDomainId": business_domain_id,
},
)
matches = exact_match_ids(items, department_name)
if len(matches) == 1:
return matches[0], {"action": "matched", "input": department_name}
if len(matches) > 1:
raise RuntimeError(f"科室名称匹配到多条记录,需人工确认:{department_name}")
if not allow_create:
raise RuntimeError(f"科室不存在:{department_name}")
payload = {
"name": department_name,
"businessDomainId": business_domain_id,
"businessDomainName": business_domain_name,
}
created = client.request_json("POST", "/department/createDepartment", payload)
created_id = extract_created_entity_id(created, "departmentId")
if not created_id:
raise RuntimeError("创建科室失败:返回中缺少 id")
return created_id, {"action": "created", "input": department_name}
def resolve_or_create_drug(
client: TBSClient,
drug_name: str,
business_domain_id: str,
business_domain_name: str,
allow_create: bool = True,
) -> tuple[str, dict]:
candidate_id = _maybe_id(drug_name)
if candidate_id:
return candidate_id, {"action": "matched_by_id", "input": drug_name}
items = list_entities(
client,
"/drug/listDrugs",
{
"pageNo": 1,
"pageSize": 200,
"name": drug_name,
"businessDomainId": business_domain_id,
},
)
matches = exact_match_ids(items, drug_name)
if len(matches) == 1:
return matches[0], {"action": "matched", "input": drug_name}
if len(matches) > 1:
raise RuntimeError(f"品种名称匹配到多条记录,需人工确认:{drug_name}")
if not allow_create:
raise RuntimeError(f"品种不存在:{drug_name}")
payload = {
"name": drug_name,
"businessDomainId": business_domain_id,
"businessDomainName": business_domain_name,
}
created = client.request_json("POST", "/drug/createDrug", payload)
created_id = extract_created_entity_id(created, "drugId")
if not created_id:
raise RuntimeError("创建品种失败:返回中缺少 id")
return created_id, {"action": "created", "input": drug_name}
def resolve_or_create_persona(
client: TBSClient, scene: dict[str, Any]
) -> tuple[list[dict[str, Any]], dict[str, Any]]:
actor_profile = scene.get("actorProfile") if isinstance(scene.get("actorProfile"), dict) else {}
if not actor_profile and isinstance(scene.get("doctorProfile"), dict):
actor_profile = scene.get("doctorProfile")
if not actor_profile:
return [], {"action": "skipped", "reason": "missing_actor_profile"}
name = _ensure_text(
actor_profile.get("name")
or actor_profile.get("doctor_name")
or actor_profile.get("persona_name")
)
title = _ensure_text(actor_profile.get("title") or actor_profile.get("doctor_title"))
role_type = _ensure_text(actor_profile.get("roleType"))
if not name:
raise RuntimeError("actorProfile.name 必填,无法解析或创建画像。")
items = list_entities_get(client, "/rolePersona/forResourceSelect")
matched_id = _find_persona_by_name_and_title(items, name=name, title=title, role_type=role_type)
if matched_id:
persona_entry = {
"personaId": str(matched_id),
"difficulty": _ensure_text(actor_profile.get("difficulty")) or "medium",
"isDefault": bool(actor_profile.get("isDefault", True)),
"rounds": int(actor_profile.get("rounds") or 5),
}
return [persona_entry], {"action": "matched", "input": {"name": name, "title": title}}
description = _ensure_text(
actor_profile.get("description")
or actor_profile.get("desc")
or actor_profile.get("summary")
)
persona_config = actor_profile.get("personaConfig") or actor_profile.get("persona_config")
payload: dict[str, Any] = {
"name": name,
"title": title,
"description": description,
"trustInitial": int(actor_profile.get("trustInitial") or actor_profile.get("trust_initial") or 80),
"patienceInitial": int(actor_profile.get("patienceInitial") or actor_profile.get("patience_initial") or 80),
"isPreset": False,
}
surname = _ensure_text(
actor_profile.get("surname")
or actor_profile.get("last_name")
or actor_profile.get("family_name")
)
if surname:
payload["surname"] = surname
if persona_config:
payload["personaConfig"] = (
persona_config
if isinstance(persona_config, str)
else json.dumps(persona_config, ensure_ascii=False)
)
raw = client.request_json("POST", "/rolePersona/createRolePersona", body=payload)
created_id = extract_create_persona_id(raw)
if created_id is None:
preview = raw
if isinstance(raw, dict):
preview = {k: raw.get(k) for k in list(raw.keys())[:12]}
raise RuntimeError(
"创建角色画像失败:响应中无法解析画像 ID(已兼容 data 为数值、id/personaId 及常见嵌套)。"
f" 响应摘要: {json.dumps(preview, ensure_ascii=False)[:800]}"
)
persona_entry = {
"personaId": str(created_id),
"difficulty": _ensure_text(actor_profile.get("difficulty")) or "medium",
"isDefault": bool(actor_profile.get("isDefault", True)),
"rounds": int(actor_profile.get("rounds") or 5),
}
return [persona_entry], {"action": "created", "input": {"name": name, "title": title}}
def _fetch_existing_knowledge(
client: TBSClient, drug_id: str, title: str = "", category: str = ""
) -> list[dict[str, Any]]:
list_err: Exception | None = None
body: dict[str, Any] = {"pageNo": 1, "pageSize": 200, "drugId": drug_id}
if title:
body["title"] = title
if category:
body["category"] = category
try:
payload = client.request_json("POST", "/knowledge/listProductKnowledge", body=body)
data = extract_data(payload)
if isinstance(data, list):
return data
if isinstance(data, dict):
for key in ("records", "list", "items"):
value = data.get(key)
if isinstance(value, list):
return value
except Exception as exc: # noqa: BLE001
list_err = exc
try:
payload = client.request_json("GET", f"/knowledge/forResourceSelect?drugId={drug_id}")
data = extract_data(payload)
if isinstance(data, list):
return data
if isinstance(data, dict):
for key in ("records", "list", "items", "data"):
value = data.get(key)
if isinstance(value, list):
return value
except Exception as exc: # noqa: BLE001
if list_err is not None:
raise RuntimeError(
f"产品知识查询失败:listProductKnowledge={list_err}; forResourceSelect={exc}"
) from exc
raise RuntimeError(f"产品知识查询失败:forResourceSelect={exc}") from exc
return []
def create_knowledge_if_needed_by_dedup(
client: TBSClient, drug_id: str, knowledge_draft: dict[str, Any], category: str
) -> dict[str, str] | None:
title = str(knowledge_draft.get("title") or "").strip()
content = str(knowledge_draft.get("content") or "").strip()
if not content:
return None
existing = _fetch_existing_knowledge(client, drug_id=drug_id, title=title, category=category)
content_fp = fingerprint_sha256_of_text(content)
for item in existing:
item_id = guess_entity_id(item)
item_title = ""
if isinstance(item, dict):
item_title = str(item.get("title") or item.get("name") or "").strip()
if item_id and title and item_title == title:
return {"id": str(item_id), "mode": "matched_by_title"}
for item in existing:
if not isinstance(item, dict):
continue
item_id = guess_entity_id(item)
item_content = str(item.get("content") or item.get("text") or "").strip()
if item_id and fingerprint_sha256_of_text(item_content) == content_fp:
return {"id": str(item_id), "mode": "matched_by_content"}
payload = {
"drugId": drug_id,
"category": category,
"title": title,
"content": content,
}
created = client.request_json("POST", "/knowledge/createProductKnowledge", body=payload)
created_id = extract_created_entity_id(created, "knowledgeId", "knowledge_id")
if not created_id:
raise RuntimeError("创建产品知识失败:返回中缺少 knowledgeId")
return {"id": str(created_id), "mode": "created"}
def _knowledge_title_matches(item: dict[str, Any], topic: str) -> bool:
target = normalize_name(topic)
if not target:
return False
for key in ("title", "name", "topic"):
value = str(item.get(key) or "").strip()
if value and normalize_name(value) == target:
return True
return False
def find_existing_knowledge_by_topic(
client: TBSClient, drug_id: str, topic: str
) -> dict[str, str] | None:
topic_text = str(topic or "").strip()
if not topic_text:
return None
candidates = _fetch_existing_knowledge(client, drug_id=drug_id, title=topic_text)
for item in candidates:
if not isinstance(item, dict):
continue
item_id = guess_entity_id(item)
if item_id and _knowledge_title_matches(item, topic_text):
return {
"id": str(item_id),
"title": str(item.get("title") or item.get("name") or topic_text).strip(),
"mode": "matched_by_topic",
}
return None
def check_or_create_knowledge_for_topics(
client: TBSClient, scene: dict[str, Any], drug_id: str
) -> tuple[list[str], dict[str, Any]]:
topics = scene.get("productKnowledgeNeeds") or []
if isinstance(topics, str):
topics = [topics]
topics = [str(item).strip() for item in topics if str(item).strip()]
knowledge_drafts = scene.get("knowledge") or scene.get("sceneKnowledge") or []
if isinstance(knowledge_drafts, dict) and "items" in knowledge_drafts:
knowledge_drafts = knowledge_drafts["items"]
if not isinstance(knowledge_drafts, list):
knowledge_drafts = []
drafts_by_title: dict[str, dict[str, Any]] = {}
for draft in knowledge_drafts:
if not isinstance(draft, dict):
continue
title = str(draft.get("title") or draft.get("name") or "").strip()
if title:
drafts_by_title[normalize_name(title)] = draft
report: dict[str, Any] = {
"action": "checked",
"drugId": str(drug_id),
"totalTopics": len(topics),
"existingTopics": [],
"missingTopics": [],
"createdTopics": [],
"pendingTopics": [],
"knowledgeIds": [],
}
knowledge_ids: list[str] = []
for topic in topics:
existing = find_existing_knowledge_by_topic(client, drug_id=drug_id, topic=topic)
if existing and existing.get("id"):
knowledge_ids.append(str(existing["id"]))
report["existingTopics"].append({"topic": topic, **existing})
continue
draft = drafts_by_title.get(normalize_name(topic))
if isinstance(draft, dict) and str(draft.get("content") or "").strip():
category = str(
draft.get("category")
or draft.get("knowledge_category")
or draft.get("knowledgeCategory")
or draft.get("type")
or draft.get("topicType")
or ""
).strip()
if category:
result = create_knowledge_if_needed_by_dedup(
client=client, drug_id=drug_id, knowledge_draft=draft, category=category
)
if result and result.get("id"):
knowledge_ids.append(str(result["id"]))
report["createdTopics"].append({"topic": topic, **result})
continue
report["missingTopics"].append(topic)
report["pendingTopics"].append(
{"topic": topic, "requiredFields": ["category", "title", "content"]}
)
report["knowledgeIds"] = knowledge_ids
return knowledge_ids, report
def resolve_or_create_knowledge_for_scene(
client: TBSClient, scene: dict[str, Any], drug_id: str
) -> tuple[list[str], dict[str, Any]]:
existing_ids: list[str] = []
raw_existing = scene.get("knowledgeIds")
if isinstance(raw_existing, str) and raw_existing.strip():
existing_ids = [raw_existing.strip()]
elif isinstance(raw_existing, list):
existing_ids = [str(item).strip() for item in raw_existing if str(item).strip()]
seen_existing = set(existing_ids)
knowledge_drafts = scene.get("knowledge") or scene.get("sceneKnowledge") or []
if isinstance(knowledge_drafts, dict) and "items" in knowledge_drafts:
knowledge_drafts = knowledge_drafts["items"]
if not isinstance(knowledge_drafts, list):
return existing_ids, {
"action": "reused_existing",
"reason": "knowledge_not_list",
"reusedExistingIds": existing_ids,
}
report: dict[str, Any] = {
"action": "processed",
"totalDrafts": len(knowledge_drafts),
"reusedExisting": len(existing_ids),
"created": 0,
"matchedByTitle": 0,
"matchedByContent": 0,
"skippedNoCategory": 0,
"skippedNoContent": 0,
"pendingTasks": [],
}
knowledge_ids: list[str] = list(existing_ids)
for draft in knowledge_drafts:
if not isinstance(draft, dict):
continue
title = str(draft.get("title") or "").strip() or "(untitled)"
content = str(draft.get("content") or "").strip()
category = str(
draft.get("category")
or draft.get("knowledge_category")
or draft.get("knowledgeCategory")
or draft.get("type")
or draft.get("topicType")
or ""
).strip()
if not category:
report["skippedNoCategory"] += 1
report["pendingTasks"].append(
{"title": title, "reason": "missing_category: 请提供知识分类(如“整体介绍”)"}
)
continue
if not content:
report["skippedNoContent"] += 1
continue
result = create_knowledge_if_needed_by_dedup(
client=client, drug_id=drug_id, knowledge_draft=draft, category=category
)
if result and result.get("id"):
kid = str(result["id"])
if kid and kid not in seen_existing:
seen_existing.add(kid)
knowledge_ids.append(kid)
mode = result.get("mode")
if mode == "created":
report["created"] += 1
elif mode == "matched_by_title":
report["matchedByTitle"] += 1
elif mode == "matched_by_content":
report["matchedByContent"] += 1
return knowledge_ids, report
def resolve_ids_for_scene(client: TBSClient, scene: dict) -> tuple[dict[str, Any], dict[str, dict[str, Any]]]:
business_domain_name = str(scene.get("businessDomainName") or "").strip()
department_name = str(scene.get("departmentName") or "").strip()
drug_name = str(scene.get("drugName") or "").strip()
existing_drug_id = _maybe_id(str(scene.get("drugId") or ""))
if not business_domain_name:
raise RuntimeError("缺少 businessDomainName")
if not department_name:
raise RuntimeError("缺少 departmentName")
if not drug_name:
raise RuntimeError("缺少 drugName")
business_domain_id, business_domain_report = resolve_or_create_business_domain(
client, business_domain_name
)
department_id, department_report = resolve_or_create_department(
client, department_name, business_domain_id, business_domain_name
)
if existing_drug_id:
drug_id, drug_report = existing_drug_id, {
"action": "matched_by_id",
"input": scene.get("drugId"),
}
else:
drug_id, drug_report = resolve_or_create_drug(
client, drug_name, business_domain_id, business_domain_name
)
persona_ids, persona_report = resolve_or_create_persona(client, scene)
knowledge_ids, knowledge_report = resolve_or_create_knowledge_for_scene(client, scene, drug_id)
return (
{
"businessDomainId": business_domain_id,
"departmentId": department_id,
"drugId": drug_id,
"personaIds": persona_ids,
"knowledgeIds": knowledge_ids,
},
{
"businessDomain": business_domain_report,
"department": department_report,
"drug": drug_report,
"persona": persona_report,
"knowledge": knowledge_report,
},
)
FILE:scripts/tbs-scene-validate.py
"""
Validate whether the current scene draft is ready for user confirmation.
"""
from __future__ import annotations
import argparse
import hashlib
import importlib.util
import json
import os
import re
import sys
from datetime import datetime, timezone
from pathlib import Path
from typing import Any
def _load_sanitize_helper() -> Any:
module_path = Path(__file__).with_name("tbs-md-sanitize.py")
spec = importlib.util.spec_from_file_location("tbs_md_sanitize_runtime", module_path)
if spec is None or spec.loader is None:
raise ImportError(f"Cannot load sanitize helper from {module_path}")
module = importlib.util.module_from_spec(spec)
spec.loader.exec_module(module)
return module
sanitize_doctor_core_concerns_to_two_bullets = (
_load_sanitize_helper().sanitize_doctor_core_concerns_to_two_bullets
)
STEP = "tbs-scene-validate"
OUTPUT_PATH: str | None = None
SCENE_HASH_FIELDS = [
"title",
"businessDomainName",
"departmentName",
"drugName",
"location",
"doctorConcerns",
"repGoal",
"sceneBackground",
"productKnowledgeNeeds",
"knowledgeIds",
"doctorOnlyContext",
"coachOnlyContext",
"actorProfile",
]
FIELD_LABELS = {
"title": "场景标题",
"businessDomainName": "业务领域",
"departmentName": "科室",
"drugName": "产品",
"location": "地点",
"doctorConcerns": "医生顾虑",
"repGoal": "代表目标",
"sceneBackground": "场景背景",
"productKnowledgeNeeds": "产品知识主题",
"doctorOnlyContext": "对练对象侧上下文",
"coachOnlyContext": "教练侧上下文",
"actorProfile": "对练对象档案",
}
CONFIRM_DISPLAY_FIELDS = [
"title",
"sceneBackground",
"businessDomainName",
"departmentName",
"drugName",
"location",
"doctorConcerns",
"repGoal",
"productKnowledgeNeeds",
"actorProfile",
]
MUST_DISPLAY_FIELDS = [
"businessDomainName",
"departmentName",
"drugName",
"location",
"doctorConcerns",
"repGoal",
"productKnowledgeNeeds",
"title",
"sceneBackground",
"actorProfile",
]
ALLOWED_BUSINESS_DOMAINS = {"临床推广", "院外零售", "学术合作", "通用能力"}
WARNING_ISSUE_CODES = {
"scene.sceneBackground_too_long",
"scene.sceneBackground_placeholder",
"scene.sceneBackground_label_style",
"scene.sceneBackground_pronoun",
"scene.sceneBackground_anchor_missing",
}
DECLINED_PRODUCT_KNOWLEDGE_TOPIC = "用户确认暂不补充产品知识主题"
REQUIRED_FIELDS = [
"title",
"businessDomainName",
"departmentName",
"drugName",
"location",
"doctorConcerns",
"repGoal",
"sceneBackground",
"productKnowledgeNeeds",
"doctorOnlyContext",
"coachOnlyContext",
"actorProfile",
]
DOCTOR_REQUIRED_HEADERS = [
"## 已知背景",
"## 核心顾虑",
"## 今日状态",
"## 终止条件",
"## 输出要求",
"## 对话结束规则(强制)",
]
COACH_REQUIRED_HEADERS = [
"## 期望代表行为",
"## 评分重点",
"## 终止条件",
"## 最佳实践",
"## 输出要求",
]
DOCTOR_ENDING_RULES_TEMPLATE = [
"- 只有对练对象角色可结束:仅在本轮末尾追加 [对话结束],且必须放在全文最后。",
"- 允许结束:已触发终止条件,或系统明确要求本轮结束(最后一轮/轮次已满)。",
"- 互斥(执行检查):若本轮出现问号或疑问词,则必须删除 [对话结束]。",
"- 互斥(执行检查):若本轮要输出 [对话结束],则全文不得出现任何问号或疑问词,且不得出现提问意图。",
"- 结束语边界:结束语必须是纯陈述句,不得提问,也不得安排任何后续动作或要求。",
]
DOCTOR_OUTPUT_REQUIREMENTS_TEMPLATE = [
"- 输出长度控制:每次回复控制在30-50字左右,保持真实医生沟通的自然简洁;每轮最多聚焦1个核心点。",
"- 单问原则:每轮最多提出1个核心问题(问号≤1)。如果想到第二个问题,必须留到下一轮再问。",
"- 语言要求:以中文自然对话为主;允许必要的医学缩写/单位/符号,但不得滥用英文;严禁出现与医学沟通无关的英文单词。",
"- 纯文本要求(强制):只输出纯文本对话,不要使用任何加粗/斜体/标题/代码符号等格式化写法。",
"- 提问后必须等待代表回答:提问后必须收住,不得在同一轮连续追问,更不得在提问后追加结束标记。",
"- 避免臆造数据(强制):不得凭空编造背景之外的具体数值/比例/研究结论;不确定就说明需回去核对资料。",
]
_BRIEFING_LABEL_PATTERN = re.compile(r"(场景背景|人物关系|训练目的|开场建议|AI角色对象的顾虑)\s*[::]")
_SINGLE_PRONOUN_PATTERN = re.compile(
r"(^|[,。;:、“”()\s\d])"
r"([你我他她它咱])"
r"(?=$|[,。;:、“”()\s\d]|[的了吗呢吧啊呀])"
)
def _trim_display_text(value: Any, limit: int = 160) -> str:
text = re.sub(r"\s+", " ", str(value or "").strip())
if len(text) <= limit:
return text
return text[:limit].rstrip(",,。;; ") + "…"
def build_supplement_items(scene: dict[str, Any]) -> list[dict[str, str]]:
items: list[dict[str, str]] = []
actor_supplement = str(scene.get("actorProfileSupplement") or "").strip()
if actor_supplement:
items.append({"label": "对象角色画像", "value": _trim_display_text(actor_supplement)})
best_practice = str(scene.get("bestPracticeSupplement") or "").strip()
if best_practice:
items.append({"label": "代表成功经验/典型话术", "value": _trim_display_text(best_practice)})
return items
def build_supplement_render_block(supplement_items: list[dict[str, str]]) -> str:
if not supplement_items:
return ""
lines = ["- 补充素材(如已提供):"]
for item in supplement_items:
label = str(item.get("label") or "").strip()
value = str(item.get("value") or "").strip()
if label and value:
lines.append(f" - {label}:{value}")
return "\n".join(lines) if len(lines) > 1 else ""
def _write_output_json(payload: dict[str, Any]) -> None:
if not OUTPUT_PATH:
return
parent = os.path.dirname(OUTPUT_PATH)
if parent:
os.makedirs(parent, exist_ok=True)
with open(OUTPUT_PATH, "w", encoding="utf-8") as handle:
json.dump(payload, handle, ensure_ascii=False, indent=2)
def _canonical_scene_for_hash(scene: dict[str, Any]) -> dict[str, Any]:
canonical: dict[str, Any] = {}
for field in SCENE_HASH_FIELDS:
if field == "sceneBackground":
value = scene.get("sceneBackground") or scene.get("background")
else:
value = scene.get(field)
if value is None:
continue
canonical[field] = value
return canonical
def compute_scene_hash(scene: dict[str, Any]) -> str:
canonical = _canonical_scene_for_hash(scene)
text = json.dumps(canonical, ensure_ascii=False, sort_keys=True, separators=(",", ":"))
return hashlib.sha256(text.encode("utf-8")).hexdigest()
def _canonical_display_for_hash(scene: dict[str, Any]) -> dict[str, Any]:
canonical: dict[str, Any] = {}
for field in MUST_DISPLAY_FIELDS:
if field == "sceneBackground":
value = scene.get("sceneBackground") or scene.get("background")
else:
value = scene.get(field)
canonical[field] = "" if value is None else value
return canonical
def compute_display_hash(scene: dict[str, Any]) -> str:
canonical = _canonical_display_for_hash(scene)
text = json.dumps(canonical, ensure_ascii=False, sort_keys=True, separators=(",", ":"))
return hashlib.sha256(text.encode("utf-8")).hexdigest()
def _summary(payload: dict[str, Any], *, ok: bool) -> str:
parts = ["OK" if ok else "ERROR", STEP]
report = payload.get("validationReport")
if isinstance(report, dict):
parts.append(f"scope={report.get('scope')}")
parts.append(f"passed={report.get('passed')}")
if payload.get("error"):
parts.append(f"error={payload['error']}")
if OUTPUT_PATH:
parts.append(f"result={OUTPUT_PATH}")
return " ".join(parts)
def emit_success(payload: dict[str, Any]) -> None:
payload = {"success": True, **payload}
if OUTPUT_PATH:
_write_output_json(payload)
print(_summary(payload, ok=True))
else:
print(json.dumps(payload, ensure_ascii=False, indent=2))
def emit_error(error: str, exit_code: int = 1, **extra: Any) -> None:
payload = {"success": False, "step": STEP, "error": error, **extra}
if OUTPUT_PATH:
_write_output_json(payload)
print(_summary(payload, ok=False), file=sys.stderr)
else:
print(json.dumps(payload, ensure_ascii=False, indent=2), file=sys.stderr)
sys.exit(exit_code)
def read_payload(input_path: str | None, params_file: str | None) -> dict[str, Any]:
path = params_file or input_path
if path and path != "-":
with open(path, "r", encoding="utf-8") as handle:
data = json.load(handle)
if not isinstance(data, dict):
raise ValueError("输入 JSON 须为对象")
return data
raw = sys.stdin.read()
data = json.loads(raw or "{}")
if not isinstance(data, dict):
raise ValueError("输入 JSON 须为对象")
return data
def _stringify_value(value: Any, field: str = "") -> str:
if value is None:
return ""
if field == "actorProfile" and isinstance(value, dict):
parts = [
str(value.get("title") or "").strip(),
str(value.get("name") or "").strip(),
str(value.get("description") or "").strip(),
]
return ";".join(part for part in parts if part)
if isinstance(value, str):
return value.strip()
if isinstance(value, list):
parts = [str(item).strip() for item in value if str(item).strip()]
return "、".join(parts)
if isinstance(value, dict):
return json.dumps(value, ensure_ascii=False)
return str(value).strip()
def _issue_code_to_hint(code: str) -> str:
if code.startswith("scene.") and code.endswith("_missing"):
field = code[len("scene.") : -len("_missing")]
label = FIELD_LABELS.get(field, "该字段")
return f"「{label}」未填写或为空"
static = {
"scene.businessDomainName_invalid": "「业务领域」不在允许范围内,请从:临床推广、院外零售、学术合作、通用能力 中选择",
"scene.sceneBackground_invalid": "「场景背景」未通过检查(长度、格式、人称或需包含科室、产品、地点等关键信息)",
"scene.sceneBackground_too_long": "「场景背景」长度超过 180 字,建议精简",
"scene.sceneBackground_placeholder": "「场景背景」含占位符或非常规符号(如【】/待补充),建议改写为自然叙述",
"scene.sceneBackground_label_style": "「场景背景」含标签化前缀(如“场景背景:”),建议改成自然叙述",
"scene.sceneBackground_pronoun": "「场景背景」包含第一/第二人称代词(如你/我),建议改为角色称谓叙述",
"scene.sceneBackground_anchor_missing": "「场景背景」未完整覆盖科室/产品/地点锚点信息",
"scene.productKnowledgeNeeds_placeholder": "「产品知识主题」不能使用“暂不补充”占位文案;正文可暂无,但主题必须是具体建议主题",
"scene.doctorOnlyContext_invalid": "「对练对象侧上下文」未通过检查:六个 `##` 标题顺序/拼写、`## 核心顾虑` 条数,或「## 输出要求」「## 对话结束规则(强制)」与内置模板逐行不一致。请查看返回中的 doctorOnlyContextDiagnostics / doctorOnlyContextCanon。",
"scene.coachOnlyContext_invalid": "「教练侧上下文」的 Markdown 结构未通过检查",
"scene.actorProfile_invalid": "「对练对象档案」不完整(需包含角色姓名等)",
}
return static.get(code, "存在未通过的校验项,请根据草稿核对后重新校验")
def build_confirmation_items(scene: dict[str, Any], fields: list[str]) -> list[dict[str, str]]:
items: list[dict[str, str]] = []
for field in fields:
items.append(
{
"field": field,
"label": FIELD_LABELS.get(field, field),
"value": _stringify_value(scene.get(field), field),
}
)
return items
def issues_to_hints(issues: list[str], *, scene: dict[str, Any] | None = None) -> list[str]:
out: list[str] = []
seen: set[str] = set()
for code in issues:
hint = _issue_code_to_hint(code)
if code == "scene.doctorOnlyContext_invalid" and scene is not None:
diag = diagnose_doctor_only_context(scene)
extra = diag.get("agentHints") or []
if extra:
hint = hint + " 细分:" + ";".join(str(item) for item in extra if str(item).strip())
if hint not in seen:
seen.add(hint)
out.append(hint)
return out
def is_empty(value: Any) -> bool:
if value is None:
return True
if isinstance(value, str):
return not value.strip()
if isinstance(value, list):
return not any(isinstance(item, str) and item.strip() for item in value)
return False
def normalize_scene(scene: dict[str, Any]) -> dict[str, Any]:
out = dict(scene)
background = out.get("sceneBackground") or out.get("background")
if isinstance(background, str) and background.strip():
out["sceneBackground"] = background.strip()
out["background"] = background.strip()
pk_needs = out.get("productKnowledgeNeeds")
if isinstance(pk_needs, str) and pk_needs.strip():
topic = pk_needs.strip()
out["productKnowledgeNeeds"] = [] if topic == DECLINED_PRODUCT_KNOWLEDGE_TOPIC else [topic]
elif isinstance(pk_needs, list):
out["productKnowledgeNeeds"] = [
item.strip()
for item in pk_needs
if isinstance(item, str) and item.strip() and item.strip() != DECLINED_PRODUCT_KNOWLEDGE_TOPIC
]
doc_ctx = out.get("doctorOnlyContext")
if isinstance(doc_ctx, str) and doc_ctx.strip():
fixed_doc, changed = sanitize_doctor_core_concerns_to_two_bullets(doc_ctx)
if changed:
out["doctorOnlyContext"] = fixed_doc
out["__validateAutoNormalizedDoctorContext"] = True
scene_background = out.get("sceneBackground")
if isinstance(scene_background, str) and scene_background.strip():
fixed_background, background_changes = sanitize_scene_background(scene_background, out)
if background_changes:
out["sceneBackground"] = fixed_background
out["background"] = fixed_background
out["__validateAutoNormalizedSceneBackground"] = background_changes
return out
def _contains_personal_name_or_pronoun(text: str) -> bool:
if any(token in text for token in ("你们", "我们", "他们", "她们", "咱们")):
return True
return bool(_SINGLE_PRONOUN_PATTERN.search(text))
def _primary_drug_anchor(drug_name: str) -> str:
name = drug_name.strip()
if not name:
return ""
head = re.split(r"[((]", name, maxsplit=1)[0].strip()
head = re.split(r"[、,,/|]", head, maxsplit=1)[0].strip()
return head
def _anchor_in_background(text: str, field: str, value: str) -> bool:
needle = value.strip()
if not needle:
return True
if needle in text:
return True
if field == "drugName":
primary = _primary_drug_anchor(needle)
if len(primary) >= 2 and primary in text:
return True
return False
def _extract_md_section_lines(text: str, header: str) -> list[str]:
if not isinstance(text, str) or not text.strip():
return []
match = re.search(rf"(?ms)^({re.escape(header)})\s*$\n(.*?)(?=^##\s+|\Z)", text)
if not match:
return []
body = match.group(2)
return [line.strip() for line in body.splitlines() if line.strip()]
def _scene_background_valid(scene: dict[str, Any]) -> bool:
return len(_scene_background_issue_codes(scene)) == 0
def _scene_background_issue_codes(scene: dict[str, Any]) -> list[str]:
text = str(scene.get("sceneBackground") or scene.get("background") or "").strip()
if not text:
return []
issues: list[str] = []
if len(text) > 180:
issues.append("scene.sceneBackground_too_long")
if "【" in text or "】" in text or "待补充" in text:
issues.append("scene.sceneBackground_placeholder")
if _BRIEFING_LABEL_PATTERN.search(text):
issues.append("scene.sceneBackground_label_style")
if _contains_personal_name_or_pronoun(text):
issues.append("scene.sceneBackground_pronoun")
missing_anchor = False
for anchor in ("departmentName", "drugName", "location"):
value = str(scene.get(anchor) or "").strip()
if value and not _anchor_in_background(text, anchor, value):
missing_anchor = True
if missing_anchor:
issues.append("scene.sceneBackground_anchor_missing")
return issues
def sanitize_scene_background(text: str, scene: dict[str, Any]) -> tuple[str, list[str]]:
fixed = str(text or "").strip()
changes: list[str] = []
if not fixed:
return fixed, changes
if "【" in fixed or "】" in fixed:
fixed = fixed.replace("【", "").replace("】", "")
changes.append("sceneBackground:移除了【】样式符号")
if "待补充" in fixed:
fixed = fixed.replace("待补充", "")
changes.append("sceneBackground:移除了“待补充”占位词")
if _BRIEFING_LABEL_PATTERN.search(fixed):
fixed = _BRIEFING_LABEL_PATTERN.sub("", fixed)
changes.append("sceneBackground:移除了标签化前缀写法")
fixed = re.sub(r"\s+", " ", fixed).strip(",,。;; ")
missing_tokens: list[str] = []
for anchor in ("departmentName", "drugName", "location"):
value = str(scene.get(anchor) or "").strip()
if value and not _anchor_in_background(fixed, anchor, value):
missing_tokens.append(_primary_drug_anchor(value) if anchor == "drugName" else value)
if missing_tokens:
suffix = ",涉及" + "、".join(token for token in missing_tokens if token) + "。"
if len(fixed) + len(suffix) <= 180:
fixed = fixed + suffix
else:
keep_len = max(0, 180 - len(suffix))
fixed = fixed[:keep_len].rstrip(",,。;; ") + suffix
changes.append("sceneBackground:自动补齐了科室/产品/地点锚点信息")
if len(fixed) > 180:
fixed = fixed[:180].rstrip(",,。;; ")
changes.append("sceneBackground:长度已裁剪至 180 字以内")
return fixed, changes
def diagnose_doctor_only_context(scene: dict[str, Any]) -> dict[str, Any]:
"""供 Agent 定位 doctorOnlyContext 阻断原因;与 _doctor_only_context_valid 判定同源。"""
text = str(scene.get("doctorOnlyContext") or "").strip()
out: dict[str, Any] = {"passed": False, "reasonCodes": [], "agentHints": []}
def add(code: str, hint: str) -> None:
out["reasonCodes"].append(code)
out["agentHints"].append(hint)
if not text:
add("doctor_only_empty", "「对练对象侧上下文」为空;请生成含 6 个固定 `##` 小节的 Markdown。")
return out
headers = re.findall(r"(?m)^##\s+[^\n]+", text)
filtered = [header for header in headers if header in DOCTOR_REQUIRED_HEADERS]
if filtered != DOCTOR_REQUIRED_HEADERS:
add(
"doctor_only_headers",
"六个二级标题必须按顺序出现且标题与脚本完全一致:"
+ " → ".join(DOCTOR_REQUIRED_HEADERS),
)
concern_lines = _extract_md_section_lines(text, "## 核心顾虑")
concern_bullets = [line for line in concern_lines if line.startswith("-")]
if not (1 <= len(concern_bullets) <= 2):
add(
"doctor_only_core_concerns_bullets",
"「## 核心顾虑」内需有 1~2 条以 `-` 开头的要点行;超过 2 条请先合并后再校验。",
)
output_lines = _extract_md_section_lines(text, "## 输出要求")
ending_lines = _extract_md_section_lines(text, "## 对话结束规则(强制)")
if output_lines != DOCTOR_OUTPUT_REQUIREMENTS_TEMPLATE:
add(
"doctor_only_output_requirements_verbatim",
"「## 输出要求」下内容须与内置模板逐行一致(不得改写、增删、换序);请使用本脚本输出字段 "
"`doctorOnlyContextCanon.outputRequirementsLines` 原样拼接。",
)
if ending_lines != DOCTOR_ENDING_RULES_TEMPLATE:
add(
"doctor_only_ending_rules_verbatim",
"「## 对话结束规则(强制)」下内容须与内置模板逐行一致;请使用 "
"`doctorOnlyContextCanon.endingRulesLines` 原样拼接。",
)
out["passed"] = len(out["reasonCodes"]) == 0
return out
def _doctor_only_context_valid(scene: dict[str, Any]) -> bool:
return bool(diagnose_doctor_only_context(scene).get("passed"))
def _coach_only_context_valid(scene: dict[str, Any]) -> bool:
text = str(scene.get("coachOnlyContext") or "").strip()
if not text or "[对话结束]" in text:
return False
return all(header in text for header in COACH_REQUIRED_HEADERS)
def _actor_profile_valid(scene: dict[str, Any]) -> bool:
actor_profile = scene.get("actorProfile")
if not isinstance(actor_profile, dict):
return False
name = actor_profile.get("name")
return isinstance(name, str) and bool(name.strip())
def build_issues(scene: dict[str, Any]) -> list[str]:
issues: list[str] = []
for field in REQUIRED_FIELDS:
if is_empty(scene.get(field)):
issues.append(f"scene.{field}_missing")
raw_topics = scene.get("productKnowledgeNeeds")
if raw_topics == DECLINED_PRODUCT_KNOWLEDGE_TOPIC or (
isinstance(raw_topics, list) and DECLINED_PRODUCT_KNOWLEDGE_TOPIC in raw_topics
):
issues.append("scene.productKnowledgeNeeds_placeholder")
if not is_empty(scene.get("businessDomainName")) and scene.get("businessDomainName") not in ALLOWED_BUSINESS_DOMAINS:
issues.append("scene.businessDomainName_invalid")
issues.extend(_scene_background_issue_codes(scene))
if not _doctor_only_context_valid(scene):
issues.append("scene.doctorOnlyContext_invalid")
if not _coach_only_context_valid(scene):
issues.append("scene.coachOnlyContext_invalid")
if not _actor_profile_valid(scene):
issues.append("scene.actorProfile_invalid")
return issues
def split_issue_buckets(issues: list[str]) -> tuple[list[str], list[str]]:
blocking: list[str] = []
warning: list[str] = []
for code in issues:
if code in WARNING_ISSUE_CODES:
warning.append(code)
else:
blocking.append(code)
return blocking, warning
def build_tbv_issues(scene: dict[str, Any]) -> list[str]:
"""标题 + 场景背景子集校验,用于 PATCH 后轻量门禁。"""
issues: list[str] = []
if is_empty(scene.get("title")):
issues.append("scene.title_missing")
if is_empty(scene.get("sceneBackground")) and is_empty(scene.get("background")):
issues.append("scene.sceneBackground_missing")
issues.extend(_scene_background_issue_codes(scene))
return issues
def _read_draft_object(draft_path: str) -> dict[str, Any]:
if not draft_path or not os.path.isfile(draft_path):
return {}
try:
with open(draft_path, "r", encoding="utf-8") as handle:
data = json.load(handle)
return data if isinstance(data, dict) else {}
except (OSError, json.JSONDecodeError):
return {}
def maybe_write_draft(
draft_path: str | None,
scene: dict[str, Any],
validation_report: dict[str, Any],
*,
scope_mode: str,
) -> None:
if not draft_path:
return
parent = os.path.dirname(draft_path)
if parent:
os.makedirs(parent, exist_ok=True)
existing = _read_draft_object(draft_path.strip())
prior_meta = existing.get("meta") if isinstance(existing.get("meta"), dict) else {}
merged_meta: dict[str, Any] = {
**prior_meta,
"updatedAt": datetime.now(timezone.utc).isoformat(),
"lastStep": STEP,
"lastValidationScope": scope_mode,
"lastValidatedSceneHash": validation_report.get("sceneHash"),
"lastDisplayHash": validation_report.get("displayHash"),
}
passed = bool(validation_report.get("passed"))
if scope_mode == "FULL":
merged_meta["lastFullValidationPassed"] = passed
if not passed:
merged_meta["lastTbvPassed"] = False
elif scope_mode == "TBV":
merged_meta["lastTbvPassed"] = passed
payload = {
**existing,
"scene": scene,
"validationReport": validation_report,
"meta": merged_meta,
}
with open(draft_path, "w", encoding="utf-8") as handle:
json.dump(payload, handle, ensure_ascii=False, indent=2)
def main() -> None:
global OUTPUT_PATH
parser = argparse.ArgumentParser()
parser.add_argument("--input", default="-", help="JSON file path, or '-' for stdin")
parser.add_argument("--params-file", default=None, help="Read params from UTF-8 JSON file")
parser.add_argument("--output", default=None, help="Write full JSON result to this file")
parser.add_argument(
"--scope",
default=None,
help="full(默认)| tbv;也可在输入 JSON 顶层传 validationScope=FULL|TBV",
)
args = parser.parse_args()
OUTPUT_PATH = args.output
try:
payload = read_payload(args.input, args.params_file)
except (OSError, json.JSONDecodeError, ValueError) as exc:
emit_error("invalid_json_input", exit_code=2, hint=str(exc))
draft_path = None
scene: dict[str, Any] = {}
if isinstance(payload.get("scene"), dict):
scene = payload["scene"]
draft_path = payload.get("draftPath")
elif isinstance(payload, dict):
scene = payload
if not scene and (args.params_file or (args.input and args.input != "-")):
draft_path = args.params_file or args.input
loaded = read_payload(draft_path, None)
if isinstance(loaded.get("scene"), dict):
scene = loaded["scene"]
scene = normalize_scene(scene)
auto_normalized_doc = bool(scene.pop("__validateAutoNormalizedDoctorContext", False))
auto_normalized_background = scene.pop("__validateAutoNormalizedSceneBackground", [])
if not scene:
emit_error("缺少 scene", exit_code=2)
scope_token = str(
args.scope or payload.get("validationScope") or "full"
).strip().upper().replace("-", "_")
scope_mode = "TBV" if scope_token in {"TBV", "TITLE_BACKGROUND"} else "FULL"
if scope_mode == "TBV":
all_issues = build_tbv_issues(scene)
blocking_issues = list(all_issues)
warning_issues: list[str] = []
else:
all_issues = build_issues(scene)
blocking_issues, warning_issues = split_issue_buckets(all_issues)
passed = len(blocking_issues) == 0
scene_hash = compute_scene_hash(scene)
display_hash = compute_display_hash(scene)
confirmed_fields = {
"title": scene.get("title"),
"sceneBackground": scene.get("sceneBackground"),
"businessDomainName": scene.get("businessDomainName"),
"departmentName": scene.get("departmentName"),
"drugName": scene.get("drugName"),
"location": scene.get("location"),
"doctorConcerns": scene.get("doctorConcerns"),
"repGoal": scene.get("repGoal"),
"productKnowledgeNeeds": scene.get("productKnowledgeNeeds"),
"actorProfile": scene.get("actorProfile"),
}
validation_report: dict[str, Any] = {
"scope": scope_mode,
"passed": passed,
"sceneHash": scene_hash,
"displayHash": display_hash,
"validatedStage": "READY_FOR_VALIDATE",
"issues": blocking_issues,
"blockingIssues": blocking_issues,
"warningIssues": warning_issues,
"allIssues": all_issues,
}
if auto_normalized_doc:
validation_report["autoNormalized"] = [
"doctorOnlyContext:## 核心顾虑 已自动合并为至多 2 条 bullet,以满足创建前固定结构校验"
]
if auto_normalized_background:
existing = validation_report.get("autoNormalized")
if not isinstance(existing, list):
existing = []
validation_report["autoNormalized"] = existing + auto_normalized_background
doctor_only_canon: dict[str, Any] | None = None
doctor_only_diagnostics: dict[str, Any] | None = None
if scope_mode == "FULL":
doctor_only_canon = {
"requiredHeaderOrder": list(DOCTOR_REQUIRED_HEADERS),
"outputRequirementsLines": list(DOCTOR_OUTPUT_REQUIREMENTS_TEMPLATE),
"endingRulesLines": list(DOCTOR_ENDING_RULES_TEMPLATE),
}
doc_diag = diagnose_doctor_only_context(scene)
if not doc_diag.get("passed"):
doctor_only_diagnostics = doc_diag
create_agent_hints: list[str] = []
if passed and scope_mode == "FULL":
create_agent_hints = [
"调用 tbs-scene-create.py 前须先执行 tbs-scene-knowledge-check.py,并携带 meta.knowledgeReady=true。",
"调用 tbs-scene-create.py 时须在 JSON 顶层设置 displayContractSatisfied=true(或 displayedFields 完整覆盖 mustDisplayFields),并携带 confirmedDisplayHash=validationReport.displayHash,否则返回 display_gate_failed。",
"仅在已向用户完整展示 mustDisplayFields、且用户明确回复「确认」后,再将 userConfirmation 设为「确认」并携带 scene、validationReport、confirmedDisplayHash 调用创建。",
]
supplement_items = build_supplement_items(scene)
supplement_render_block = build_supplement_render_block(supplement_items)
actor_profile_supplement = next(
(item["value"] for item in supplement_items if item["label"] == "对象角色画像"), ""
)
best_practice_supplement = next(
(item["value"] for item in supplement_items if item["label"] == "代表成功经验/典型话术"), ""
)
user_output_template: dict[str, Any] = {
"stage": "READY_TO_CONFIRM" if passed else "GAP_ASKING",
"stageLabel": "可发起最终确认" if passed else "待补齐后再校验",
"confirmationItems": build_confirmation_items(scene, CONFIRM_DISPLAY_FIELDS),
"mustDisplayFields": MUST_DISPLAY_FIELDS,
"displayHash": display_hash,
"mustDisplayLabels": [FIELD_LABELS.get(field, field) for field in MUST_DISPLAY_FIELDS],
"mustDisplayConfirmationItems": build_confirmation_items(scene, MUST_DISPLAY_FIELDS),
"actorProfileSummary": _stringify_value(scene.get("actorProfile"), "actorProfile"),
"supplementItems": supplement_items,
"mustDisplaySupplementItems": bool(supplement_items),
"supplementRenderBlock": supplement_render_block,
"actorProfileSupplement": actor_profile_supplement,
"bestPracticeSupplement": best_practice_supplement,
"issueHints": issues_to_hints(blocking_issues, scene=scene),
"warningHints": issues_to_hints(warning_issues, scene=scene),
"mustShowSceneBackgroundFullText": bool(
str(scene.get("sceneBackground") or scene.get("background") or "").strip()
),
"sceneBackgroundFullText": str(
scene.get("sceneBackground") or scene.get("background") or ""
).strip(),
"nextAction": (
"可直接回复【确认】或【取消】;也可先按提示优化后再确认"
if passed and warning_issues
else "请回复【确认】或【取消】"
if passed
else "请根据提示补齐或修正后重新校验"
),
}
if doctor_only_canon is not None:
user_output_template["doctorOnlyContextCanon"] = doctor_only_canon
if doctor_only_diagnostics is not None:
user_output_template["doctorOnlyContextDiagnostics"] = doctor_only_diagnostics
if create_agent_hints:
user_output_template["createAgentHints"] = create_agent_hints
if not passed and scope_mode == "FULL":
user_output_template["preCreateBlockedReminder"] = (
"当前 validationReport.passed=false,不得调用 tbs-scene-create;"
"须先用自然语言向用户说明待修正项,修复并重新校验通过后,再请用户确认创建。"
)
result: dict[str, Any] = {
"step": STEP,
"scene": scene,
"passed": passed,
"validationReport": validation_report,
"confirmedFields": confirmed_fields,
"userOutputTemplate": user_output_template,
}
if scope_mode == "TBV":
result["tbvReport"] = {
"passed": passed,
"blockingIssues": blocking_issues,
"warningIssues": warning_issues,
}
if isinstance(draft_path, str) and draft_path.strip():
maybe_write_draft(
draft_path.strip(), scene, validation_report, scope_mode=scope_mode
)
result["draftPath"] = draft_path.strip()
emit_success(result)
if __name__ == "__main__":
main()
FILE:references/review-checklist.md
# 运行时审查 Checklist
本文件是 Reviewer 模式的唯一真源。每次用户可见输出前、调用 validate 前、调用 create 前都按本文件自检。
## A. 禁止直出
以下内容不得出现在用户可见文案中;需要表达时转写为中文业务句。
- `baseInfoAcknowledged`
- `updatedConfirmationEchoed`
- `mustEchoUpdatedConfirmation`
- `validationReport`
- `blockingIssues`
- `warningIssues`
- `createAgentHints`
- `systemActionHint`
- `doctorOnlyContext`
- `coachOnlyContext`
- `READY_FOR_VALIDATE`
- `READY_FOR_SCENE_GENERATION`
- `sceneHash`
- `displayHash`
- `confirmedDisplayHash`
- `lastParseStage`
- `parse_stage_gate_failed`
- `validation_gate_failed`
- `display_gate_failed`
- `knowledge_gate_failed`
- 脚本报错原文
- 脚本名、stage 名、内部错误码或“正在跑/需要先让某脚本进入某阶段”等内部执行话术
- Gate 编号(如 Gate-0/Gate-1/…)或“进入某 Gate”字样
- access-token 明文或可逆片段
## B. 模板检查
- 收集阶段禁止自由发挥:若命中模板 0/1/2,应直接套用 `references/output-templates.md` 的对应模板正文
- 收集阶段:使用 `output-templates.md` 模板 1。
- 收集阶段(含长文本例外导致的模板 2 回显):必须至少出现一次对“对象角色画像(自由复述)”与“代表成功经验/典型话术片段”的引导入口;若本轮未展示且用户未明确拒绝补充,则视为未通过本 Checklist。
- 若 `userOutputTemplate.supplementItems` 非空,用户可见回显必须展示为“补充素材”;不得只写入 `generationNotes` 或内部字段后对用户不可见。
- 产品知识阶段:`productKnowledgeNeeds` 必须展示为“产品知识主题”,`knowledge` 必须展示为“产品知识正文(可选)”。
- “当前未提供正文”只能放在“产品知识正文(可选)”下,禁止放在“产品知识主题”下。
- 基础信息未确认时,不得展示具体产品知识主题清单;只能预告确认后生成主题。
- `declineProductKnowledge=true` 不得作为主题已确认依据;“暂无正文”不等于“无需产品知识主题”。
- 产品知识主题应按 `references/product-knowledge-topic-generate.md` 生成;脚本不得内置业务主题替代规范出题。
- 产品知识主题确认必须是轻确认:如无调整回复“确认”;允许删除、改名或新增;不得要求补正文后才推进。
- 用户确认产品知识主题后,必须执行 `tbs-scene-knowledge-check.py`;`knowledgeReady=false` 时不得进入场景内容生成。
- 性能去重(不改变门禁语义):若草稿中已存在 `knowledgeReady=true` 且 `knowledgeIds` 完整,且 `meta.lastKnowledgeKey` 未变化,则不得重复触发知识检查网络请求(允许复用既有结果)。
- 知识检查阶段若品种不存在,应先自动创建品种并保存 `scene.drugId` / `meta.resolvedIds.drugId`,再查询或创建产品知识。
- `missingKnowledgeTopics` 非空时,必须要求用户按 `category + title + content` 补充正文后重新检查。
- 字段有更新:使用 `output-templates.md` 模板 2。
- 落库前确认:使用 `output-templates.md` 模板 3,完整覆盖 `mustDisplayFields`(含 `productKnowledgeNeeds`、`actorProfile`),且只给“确认/取消”收口。
- 落库前确认必须展示 `actorProfileSummary` 或等价的对练对象角色摘要。
- 落库前确认不得展示 `doctorOnlyContext` / `coachOnlyContext` 逐段内容;内部上下文只作为校验输入。
- 若用户确认后发生任何用户可见字段变更,必须重新展示模板 3 并重新取得确认。
- create 成功:使用 `output-templates.md` 模板 4A。
- create 失败:使用 `output-templates.md` 模板 4B。
## C. 同轮约束
- 同轮最多 1 次 parse。
- 同轮最多向用户发 1 条最终消息。
- 若同轮出现多条 parse 结果,只渲染最新结果。
## C1. Parse 前 Payload 自检
调用 `tbs-scene-parse.py` 前:
- `productKnowledgeNeeds` 必须位于 `scene.productKnowledgeNeeds`;不得只放在 payload 顶层。
- 若 `baseInfoAcknowledged=true`,`scene` 必须仍包含基础 6 项:`businessDomainName`、`departmentName`、`drugName`、`location`、`doctorConcerns`、`repGoal`。
- 下一轮 payload 必须基于上一轮最新 draft 的 `scene` 增量合并;禁止用 `scene: {}` 或局部 `scene` 覆盖已确认字段。
- 知识阶段新增/修改产品知识主题、对象画像、代表话术或正文时,优先写入 `scene`;不要通过 `parsedFields` / `userUpdates` 覆盖已确认基础字段。
- `draftPath` 必须位于 `workspace/.cms-log/state/cms-tbs-scene-create/{sessionId}/latest-draft.json`;`--output` 结果也必须写入同一会话目录。禁止使用 `/tmp/*.json`、固定 `base-info-draft.json` 或其他非会话隔离路径作为跨轮状态。
- 若用户本轮提供对象角色画像、代表话术、成功经验、开场话术、推进建议或应对方式,必须先写入 `scene.actorProfileSupplement` / `scene.bestPracticeSupplement` 并重新调用 `tbs-scene-parse.py`;不得只保存在临时上下文或 `generationNotes`。
## D. 推进前自检
调用 validate 前:
- 当前 `stage` 必须是 `READY_FOR_VALIDATE`。
- 草稿必须已写盘或有等价可追踪 payload。
- 若用户本轮提供过画像/话术/成功经验/推进建议,但 parse 输出没有 `userOutputTemplate.supplementItems` 或用户可见输出没有“补充素材”,不得继续进入产品知识主题确认、知识检查或场景内容生成。
- 若包含 `productKnowledgeNeeds`,必须已经由用户确认、删除/改名/新增后确认;不得把 Agent 建议主题直接当作用户已确认主题。
- `productKnowledgeNeeds` 不得是“用户确认暂不补充产品知识主题”等占位文案。
调用 create 前,以下条件缺一不可:
- `userConfirmation = "确认"`。
- 草稿 `meta.lastParseStage=READY_FOR_VALIDATE`,确认已经完成场景内容生成并重新 parse。
- validate `passed=true`,且满足 FULL 或 TBV + `meta.lastFullValidationPassed=true` 组合门禁。
- `validationReport.sceneHash` 必须与当前 `scene` 的计算值一致;若缺失或不一致,必须重新 validate。
- `confirmedDisplayHash` 必须等于本轮 validate 输出的 `validationReport.displayHash`;若缺失或不一致,必须重新展示最终确认并重新取得确认。
- `displayContractSatisfied=true`,或 `displayedFields` 完整覆盖 `mustDisplayFields`。
- access-token 已注入且非占位符。
FILE:references/agent-patterns.md
## Agent 调用模式示例
> 内部编排顺序示例;**禁止**对用户播报「先读 / exec」等字样。用户模板见 `output-templates.md`;输出规则见 `common-params.md`。
### 状态真源与门禁顺序(硬规则)
1. **draft 唯一真源**:会话目录内 `latest-draft.json`(`--params-file` / `draftPath` 指向的同一路径)承载跨轮的 `scene` 与 `meta`。每步脚本若会改状态,须写回该文件;下一步须以该文件为输入,**不要**只用 `parse-result` 里的片段当整份草稿去跑 `validate`。
2. **知识检查后**:`tbs-scene-knowledge-check.py`(`--output` 产物仅作查看)执行完,**先**确认 draft 已更新(含服务端 `knowledgeIds`),**再** `tbs-scene-parse.py`。
3. **内部生成后再 parse 再 FULL validate**:按 `scenario-json-parse.md` 在内部补全长文本字段,合并进 `scene`,写入 draft,再 `tbs-scene-parse.py` 直到 `stage` 允许进入校验;**仅此时**对**同一份** draft 跑 `tbs-scene-validate.py`(FULL)。若 `title` / `sceneBackground` / `doctorOnlyContext` 等仍缺,不得对中间态做 FULL 校验(否则会失败并多耗一轮)。
### 模式 A:自然语言场景 -> 基础信息确认
```
用户:「帮我建一个心内科的训练场景,主任担心长期安全性」
Agent → 先读 references/base-info-parse.md
Agent → 再读 references/scene.schema.json(完整场景契约;本阶段仅填基础字段,required 不用于 S1 校验)
Agent → 从用户输入提炼 businessDomainName / departmentName / drugName / location / doctorConcerns / repGoal
Agent → exec: python3 scripts/tbs-scene-parse.py --params-file payload.json --output result.json
Agent ← JSON(stage=BASE_INFO_CONFIRM 或后续阶段)
Agent → 向用户展示中文确认清单与待补充问题(见 output-templates.md 模板 1)
Agent → 若用户在对话中纠正基础字段(例如把 drugName 收窄为更短口径),下一轮必须把纠正写回 `parsedFields` 再执行 `tbs-scene-parse.py`,避免后续生成/校验仍引用旧值
Agent → 检测到用户纠正字段后,先回显“修改后的确认清单”(至少覆盖改动字段)并请用户确认,再进入内部生成/校验
```
### 模式 B:基础信息确认后 -> 产品知识确认
```
用户:「产品是某某,顾虑主要是长期安全性和价格」
Agent → 重新执行 tbs-scene-parse.py
Agent ← JSON(stage=KNOWLEDGE_CONFIRM)
Agent → 先核对脚本返回的 `stage` 与确认清单项状态(`userOutputTemplate.confirmationItems[*].status`)是否仍为“待确认”;只有当用户明确确认基础信息无误后,才在下一轮 payload 顶层设置 `baseInfoAcknowledged: true`(或写入 `scene.baseInfoAcknowledged: true`)
Agent → 基于当前基础信息给出“产品知识主题”建议,让用户确认/删除/改名/新增;正文另列为“产品知识正文(可选)”
Agent → 若用户明确不提供产品知识正文(如「暂无正文」):**不得**反复追问正文;但仍必须展示并确认产品知识主题,禁止传 `declineProductKnowledge=true` 跳过主题
Agent → 若用户补充了产品知识正文/政策解读,则写入 scene.knowledge;若未补充正文,只在“产品知识正文(可选)”下提示“当前未提供正文(可稍后补充)”
Agent → 若用户补充代表话术/经验,需当轮明确回显“已采纳”,并说明将归入 coachOnlyContext 的 `## 最佳实践` 小节
Agent → 话术优先套用 references/output-templates.md 模板 1,并遵守 references/common-params.md 的展示规则
```
### 模式 C:资料确认后 -> 内部生成场景内容
```
Agent → 再次执行 tbs-scene-parse.py
Agent ← JSON(stage=READY_FOR_SCENE_GENERATION)
Agent → 此时才在内部读取 references/scenario-json-parse.md 与 references/*.json
Agent → 生成 title / sceneBackground / actorProfile / doctorOnlyContext / coachOnlyContext
Agent → 生成后立刻合并进 scene,并写回同一会话目录下的 latest-draft.json(与 SKILL.md 的 draftPath 约定一致)
Agent → 再执行 tbs-scene-parse.py(以该 draft 为 --params-file),直至可进入校验阶段;**然后**对同一份 draft 执行 tbs-scene-validate.py(FULL),**在向用户展示「阶段 3」业务摘要之前**,应已拿到 validate 结果或已根据 issueHints 完成可自动修复项
Agent → 对用户展示时套用 references/output-templates.md 模板 3:必须回显累计确认清单(含前序已确认的基础信息/产品知识 + 新生成的标题/背景/角色),并遵守 references/common-params.md 的展示规则
```
### 模式 D:用户补充后终检
```
用户:「标题叫高血压门诊首诊沟通,目标是推动小范围试用」
Agent → exec: python3 scripts/tbs-scene-validate.py --params-file draft.json --output validate-result.json
Agent ← JSON(passed、validationReport)
Agent → 用户侧用 userOutputTemplate(含场景背景);规则见 common-params.md、tbs-scene-validate.md
Agent → passed=true 再收口「确认创建 / 取消」
```
### 模式 E:确认后真实创建
```
用户:「确认」
Agent → 先通过 cms-auth-skills 获取 access-token
Agent → exec: python3 scripts/tbs-scene-create.py --params-file draft.json --access-token "<ACCESS_TOKEN>" --output create-result.json
Agent ← JSON(sceneId、resolvedIds、knowledgeIds)
Agent → 告知场景创建成功
```
FILE:references/tbs-scene-parse.md
<!-- Gate-1 · BASE_INFO_CONFIRM ──────────────────────────────
允许 ① 模板1 回显已识别字段+待补充项+clarifyQuestions
② 每轮最多2问、最多2轮
③ 接收补充后重新 parse
禁止 调用 validate/create;自行生成 title/sceneBackground/doctorOnlyContext
扩展确认清单字段;用户未明确确认前设置 baseInfoAcknowledged=true
推进 6字段齐备 AND 用户明确确认 → 双写 baseInfoAcknowledged=true
Gate-2 · KNOWLEDGE_CONFIRM ─────────────────────────────
允许 ① 同时展示基础信息6项+产品知识确认项
② 展示主题分桶(existingTopics/suggestedMissingTopics)
③ 同轮最多1次 parse
④ 用户说"暂无正文"→ 仅记录正文为空
禁止 只展示知识主题不展示基础信息6项;同轮 parse 超1次
调用 validate/create;把"暂无正文"当作"无需主题";向用户索要 title/sceneBackground
推进 用户确认主题并传 productKnowledgeNeedsConfirmed=true → READY_FOR_SCENE_GENERATION
──────────────────────────────────────────────────────── -->
### 1. 解析场景 — `tbs-scene-parse.py`
**意图**:作为多阶段编排脚本,按“基础信息确认 → 产品知识/资料确认 → 场景内容生成 → 校验”的顺序,输出当前阶段需要用户确认/补充的内容。
**本脚本不做自然语言全量语义生成**:自然语言长文本或用户零散补充内容,应该先通过 `references/base-info-parse.md` 提取基础信息骨架;本脚本只接收已有 `scene` / `parsedFields`,判断当前处于哪个阶段,并给出下一步动作。
**硬性顺序**:长文本 → `base-info-parse.md` 抽取 `parsedFields` → `tbs-scene-parse.py --output ...`。不得把长文本直接当作最终 `scene` 传入,也不得把脚本完整 JSON 贴给用户。
## 0) 30 秒上手(先看这个)
- 最短命令:`python3 scripts/tbs-scene-parse.py --params-file payload.json --output result.json`
- 最关键输入:`scene`、`userText`、`baseInfoAcknowledged`、`productKnowledgeNeedsConfirmed`、`existingProductKnowledgeTopics`
- 最关键输出:`stage`、`sceneHash`、`missingFields`、`userOutputTemplate.confirmationItems`、`userOutputTemplate.clarifyQuestions`、`userOutputTemplate.supplementItems`、`knowledgeTopicBuckets`
- 推进规则:只按本轮 `stage` 决定下一步,不做调用方硬编码跳转。
```bash
python3 scripts/tbs-scene-parse.py --params-file payload.json --output result.json
# 可选:中间轮次加速(满足条件时快进到 READY_FOR_VALIDATE,且不写草稿)
python3 scripts/tbs-scene-parse.py --params-file payload.json --mode fast_forward --no-write-draft --output result.json
```
| 参数 | 必填 | 说明 |
|------|------|------|
| `--params-file` / `--input` | ✅ | 输入 JSON 文件 |
| `--mode` | ❌ | `default`(默认)或 `fast_forward`;当基础信息已确认且 `scene` 已满足创建前必需字段时,可直接输出 `READY_FOR_VALIDATE`,减少重复 parse 轮次 |
| `--no-write-draft` | ❌ | 若传入则不写回 `draftPath`(返回中含 `draftWriteSkipped=true`),用于中间轮次降低文件 IO |
**输入 JSON 关键字段**:
| 字段 | 必填 | 说明 |
|------|------|------|
| `userText` | ❌ | 用户自然语言输入 |
| `scene` | ❌ | 已有场景草稿 |
| `scene.knowledge` | ❌ | 用户补充的产品知识正文(可选);创建前按“先查后建”解析为 `knowledgeIds` |
| `parsedFields` | ❌ | 上游结构化补丁,覆盖同名字段(基础信息阶段可用;知识阶段优先写入 `scene`) |
| `userUpdates` / `userConfirmedFields` / `userProvidedFields` | ❌ | 用户本轮补充/纠正字段(与 `parsedFields` 等价,会按同名字段覆盖进 `scene`;知识阶段仅在必要时使用) |
| `draftPath` | ❌ | 草稿文件路径,便于后续 validate/create 复用 |
| `baseInfoAcknowledged` | ❌ | 仅当用户已明确确认基础信息无误时置为 `true`;用于区分“已识别”与“已确认”(也可写入 `scene.baseInfoAcknowledged` 或 `meta.baseInfoAcknowledged`) |
| `declineProductKnowledge` | ❌ | 历史兼容字段,仅表示用户不补充产品知识正文;不得用于跳过 `productKnowledgeNeeds` 主题确认 |
| `scene.actorProfileSupplement` | ❌ | 用户补充的对象角色画像摘要(自由复述);只用于“补充素材”回显和后续生成,不进入 `missingFields` |
| `scene.bestPracticeSupplement` | ❌ | 用户补充的代表成功经验、开场话术、推进建议或应对方式;只用于“补充素材”回显和后续 `coachOnlyContext ## 最佳实践`,不进入 `missingFields` |
| `actorProfileSupplement` / `bestPracticeSupplement` | ❌ | 顶层历史兼容输入;脚本会迁移到 `scene.actorProfileSupplement` / `scene.bestPracticeSupplement` 并输出 `payloadShapeWarnings`,调用方不得依赖该兜底 |
| `existingProductKnowledgeTopics` / `meta.existingProductKnowledgeTopics` | ❌ | 调用方可选传入“该产品已存在知识主题列表”(接口查询结果);用于在 `KNOWLEDGE_CONFIRM` 输出“已存在主题/建议补充主题”分组 |
| `updatedConfirmationEchoed` | ❌ | 调用方标记“本轮更新后的确认清单已回显给用户”;也可写入 `meta.updatedConfirmationEchoed=true` |
| `productKnowledgeNeedsConfirmed` | ❌ | 仅当用户已确认、删除/改名/新增并确认产品知识主题后置为 `true`;也可写入 `scene.productKnowledgeNeedsConfirmed` 或 `meta.productKnowledgeNeedsConfirmed` |
| `meta` | ❌ | 可选元信息;其中 `meta.baseInfoAcknowledged=true` 与顶层字段等价 |
**会话级状态目录(强制)**:
- 每个创建会话必须分配稳定 `{sessionId}`,并将所有中间 JSON 保存到:`workspace/.cms-log/state/cms-tbs-scene-create/{sessionId}/`。
- 推荐固定文件名:
- `latest-payload.json`
- `latest-parse-result.json`
- `latest-draft.json`
- `latest-validate-result.json`
- `latest-create-result.json`
- `draftPath` 必须使用同一会话目录下的 `latest-draft.json`,例如:`workspace/.cms-log/state/cms-tbs-scene-create/{sessionId}/latest-draft.json`。
- `/tmp/*.json` 仅允许一次性调试;不得作为正式流程中的 `draftPath`,也不得跨轮读取 `/tmp` 状态推进阶段。
- 下一轮 payload 必须基于同一 `{sessionId}` 下 `latest-draft.json` 中的 `scene` 增量合并,再写入 `latest-payload.json` 并调用脚本。
**Payload 形状硬约束(防字段冲突)**:
- `productKnowledgeNeeds` 必须写入 `scene.productKnowledgeNeeds`;禁止只写顶层 `productKnowledgeNeeds`。脚本会兼容迁移顶层旧写法并输出内部告警,但调用方不得依赖该兜底。
- `actorProfileSupplement` / `bestPracticeSupplement` 必须写入 `scene.*`;脚本会兼容迁移顶层旧写法并输出内部告警,但调用方不得依赖该兜底。
- `baseInfoAcknowledged=true` 后,后续 payload 必须以最新草稿中的 `scene` 为基线增量合并;禁止提交 `scene: {}` 或只提交局部 `scene` 覆盖已确认字段。
- 若使用 `draftPath`,必须使用上方会话级状态目录,并在下一轮沿用同一会话的最新草稿;禁止多个会话共用固定 `base-info-draft.json`、`/tmp/scene_draft*.json` 或其他非会话隔离路径作为长期草稿。
- 知识阶段新增/修改主题、正文、画像、话术等信息时,优先写入 `scene`,不要再通过 `parsedFields` / `userUpdates` 覆盖已确认基础字段。
- 用户本轮提供对象角色画像、代表话术、成功经验或推进建议时,必须先写入 `scene.actorProfileSupplement` / `scene.bestPracticeSupplement`(与最新草稿 `scene` 合并)并重新调用本脚本;禁止直接跳到产品知识主题生成或场景内容生成。
错误形状(禁止):
```json
{
"scene": {
"businessDomainName": "临床推广",
"departmentName": "风湿免疫科",
"drugName": "美泰彤",
"location": "风湿免疫科诊室",
"doctorConcerns": ["项目合规性"],
"repGoal": "推动主任观念转变",
"baseInfoAcknowledged": true
},
"productKnowledgeNeeds": ["项目背景与合规性", "扫码补贴流程"]
}
```
正确形状(推荐):
```json
{
"scene": {
"businessDomainName": "临床推广",
"departmentName": "风湿免疫科",
"drugName": "美泰彤",
"location": "风湿免疫科诊室",
"doctorConcerns": ["项目合规性"],
"repGoal": "推动主任观念转变",
"baseInfoAcknowledged": true,
"productKnowledgeNeeds": ["项目背景与合规性", "扫码补贴流程"]
},
"draftPath": ".cms-log/state/cms-tbs-scene-create/{sessionId}/latest-draft.json"
}
```
约束补充(强制):
- `baseInfoAcknowledged=true` 仅能由“用户明确确认基础信息无误”触发;产品知识补充行为本身不构成该确认信号。
- 基础 6 项齐备但 `baseInfoAcknowledged` 未确认时,只能预告“确认后会根据这些信息建议产品知识主题”;不得生成或要求用户确认 `productKnowledgeNeeds`。
- `productKnowledgeNeedsConfirmed=true` 仅能由“用户明确确认产品知识主题”触发;Agent 按规范生成的建议主题不构成确认信号。
- 用户明确“暂无正文/先不补正文”时,不需要反复追问正文;但仍必须展示并确认 `productKnowledgeNeeds`。
- `declineProductKnowledge=true` 是历史兼容字段,不再构成主题确认,也不会让脚本写入“用户确认暂不补充产品知识主题”。
**补丁锁定(脚本)**:`baseInfoAcknowledged` 后不得再补丁改业务六字段;进入 `READY_FOR_SCENE_GENERATION` / `READY_FOR_VALIDATE` 后,补丁**仅** `title`、`sceneBackground`、`background`。否则 `patch_fields_locked` + `rejectedFields`;`hint` 会明确:此阶段被拒字段须改写到请求 JSON 顶层的 `scene` 对象(与已有草稿合并)后再调用本脚本,**勿**再经 `parsedFields` / `userUpdates` / `userConfirmedFields` / `userProvidedFields` 覆盖(含 `productKnowledgeNeeds`、`doctorOnlyContext`、`knowledge` 等)。`actorProfile` 只走内部写入与门禁,不进用户补丁与阶段 3 清单。
**PRE 摘要**:`parseMeta` 与 `userOutputTemplate.changeSummaryLines`(对齐结论、是否建议跳过 S3)。对用户只口述中文,不展示 JSON。`skipScenarioGenerationSuggested=true` **不**免除 TBV 与 PRE。
**流程步骤**:
1. 读取 `scene` 和补丁输入(基础信息阶段可用补丁键;知识阶段优先以 `scene` 提交)。
2. 判断基础信息 6 字段是否齐备:`businessDomainName`、`departmentName`、`drugName`、`location`、`doctorConcerns`、`repGoal`。
3. 基础信息齐备后,仍需用户明确确认(`baseInfoAcknowledged=true`)才可推进到后续阶段。
4. 基于已确认基础信息分析并写入 `productKnowledgeNeeds`(建议主题/关键词)。
5. 产品知识阶段执行细则统一以“阶段 2:产品知识确认字段(用户侧)→ 说明(硬约束)”为准,本处不重复定义。
6. 基础信息与知识阶段满足推进条件后,内部执行 `references/scenario-json-parse.md` 生成 `title`、`sceneBackground`、`actorProfile`、`doctorOnlyContext`、`coachOnlyContext`。
7. 场景内容生成完成后输出 `READY_FOR_VALIDATE` 与 `sceneHash`,再进入 `tbs-scene-validate.py`;若提供 `draftPath`,写回草稿。
**编排**:`success=false` 先处理 `error`;`success=true` 再看 `stage` + `missingFields` + `clarifyQuestions`。`READY_FOR_SCENE_GENERATION` 仅做内部内容生成;`READY_FOR_VALIDATE` 执行校验。`draftPath` 建议贯穿 parse/validate/create;create 前必须使用 validate 针对当前 `sceneHash` 生成的结果。
性能建议(不改变门禁语义):
- 中间轮次可用 `--mode fast_forward --no-write-draft`,在满足门禁条件时直接进入 `READY_FOR_VALIDATE`,并跳过草稿写盘;
- 最终进入 validate/create 前,仍建议保留一次落盘(去掉 `--no-write-draft`)以便追踪与复现。
**`stage` 常见取值(以脚本实际返回为准)**:
- `BASE_INFO_CONFIRM`:先确认基础信息
- `KNOWLEDGE_CONFIRM`:再确认产品知识与资料
- `READY_FOR_SCENE_GENERATION`:已可进入场景内容生成
- `READY_FOR_VALIDATE`:已可执行场景校验
说明:调用方不应硬编码阶段推进逻辑,应以脚本本轮返回的 `stage` 为准决定下一步。
## 1) 调用方单轮执行约束(强制)
1. 同轮最多执行一次 parse;非必要不得同轮重复解析。
2. 同轮用户侧最多输出 1 条最终消息;若同轮出现多条结果,仅渲染最新结果。
3. 渲染前必须执行内部字段拦截(如 `baseInfoAcknowledged`、`updatedConfirmationEchoed` 等不得直出)。
4. `KNOWLEDGE_CONFIRM` 阶段仅在 `baseInfoAcknowledged=true` 后查询“已存在主题”。
5. 用户明确“暂无正文”后,不得反复追问正文;但必须继续展示并确认产品知识主题。
6. 每轮结束应落盘最新草稿;下一轮仅基于最新草稿增量更新。
7. 组装下一轮 payload 前必须先读取最新草稿 `scene` 并做合并;若 payload 中 `baseInfoAcknowledged=true`,则基础 6 项必须仍在 `scene` 中。
8. `productKnowledgeNeeds` 层级、草稿合并与 `draftPath` 规则见上方“Payload 形状硬约束”。
9. 用户补充对象画像/代表话术/推进建议后,必须先写回 `scene.actorProfileSupplement` / `scene.bestPracticeSupplement` 并重新 parse,使 `userOutputTemplate.supplementItems` 可见;不得只把内容作为临时上下文继续下一步。
**用户可见**:通用见 `common-params.md`。
**parse 补充**:
1. 以脚本返回阶段为准组织回显,展示 `clarifyQuestions` 与待确认项。
2. 未 `baseInfoAcknowledged` 前,不把基础信息当最终定案。
3. 若 `userOutputTemplate.supplementItems` 非空,必须作为“补充素材”展示;该字段仅用于回显,不进入 `missingFields`,不阻塞 Gate。调用方可直接使用 `userOutputTemplate.supplementRenderBlock` 渲染该区块;若 `userOutputTemplate.mustDisplaySupplementItems=true` 或 `outputBlockingRequirements` 非空,用户可见输出未展示“补充素材”不得继续推进。
4. 锁字段拒收(`patch_fields_locked`)要业务化解释,并引导调用方基于最新草稿 `scene` 重新合并后提交。
> 阶段对照:阶段1 ↔ `BASE_INFO_CONFIRM`;阶段2 ↔ `KNOWLEDGE_CONFIRM`;阶段3(内部生成)↔ `READY_FOR_SCENE_GENERATION`;阶段4 ↔ `READY_FOR_VALIDATE`。
**阶段 1(`BASE_INFO_CONFIRM`):基础信息确认字段**:
- `businessDomainName`
- `departmentName`
- `drugName`
- `location`
- `doctorConcerns`
- `repGoal`
**阶段 2(`KNOWLEDGE_CONFIRM`):产品知识确认字段(用户侧)**:
- `productKnowledgeNeeds`:产品知识主题,系统先建议,用户确认/删除/改名/新增。
- `knowledge`:产品知识正文(可选),用户额外提供的正文、政策、证据或资料。
说明(硬约束):
- [强制] `productKnowledgeNeeds` 表示 Agent 按 `references/product-knowledge-topic-generate.md` 基于基础信息分析得到的建议知识主题/关键词,用于用户确认“本场景需要覆盖哪些产品知识”。
- [强制] 用户侧必须把 `productKnowledgeNeeds` 显示为“产品知识主题”;不得把“当前未提供正文”写到该字段下。
- [强制] 用户侧必须把 `knowledge` 显示为“产品知识正文(可选)”;无正文时只在该字段下显示“当前未提供正文(可稍后补充)”。
- [强制] `KNOWLEDGE_CONFIRM` 仅做产品知识确认,不向用户索要 `title`、`sceneBackground`(后续内部生成)。
- [强制] 生成时机:仅当 `baseInfoAcknowledged=true` 且 `productKnowledgeNeeds` 为空时,Agent 先读取 `references/product-knowledge-topic-generate.md`,生成 2-4 条建议主题并写入下一轮 parse payload(不要求用户先补知识正文)。
- [强制] 未确认基础信息前,即使基础 6 项已齐备,脚本也应停留在 `BASE_INFO_CONFIRM`;不得生成建议主题,只提示“确认基础信息后会生成产品知识主题供确认”。
- [强制] 主题确认门禁:Agent 生成或用户修改 `productKnowledgeNeeds` 后,脚本必须停留在 `KNOWLEDGE_CONFIRM`;只有用户明确确认主题,并在下一轮 payload 写入 `productKnowledgeNeedsConfirmed=true`,才可进入 `READY_FOR_SCENE_GENERATION`。
- [强制] 知识检查门禁:用户确认主题后,必须先执行 `scripts/tbs-scene-knowledge-check.py`;该脚本按 `drugName` 解析 `drugId`,品种不存在时先创建并写回 `scene.drugId` / `meta.resolvedIds.drugId`,再按 `productKnowledgeNeeds` 查询已有产品知识。
- [强制] 知识检查结果:已有知识直接复用 `knowledgeIds`;缺失主题写入 `missingKnowledgeTopics`,并要求用户补充 `category + title + content` 后重新检查;未达到 `knowledgeReady=true` 前不得进入 `READY_FOR_SCENE_GENERATION`。
- [强制] 轻确认口径:展示建议主题后,只要求用户确认、删除、改名或新增;不得要求用户补产品知识正文后才推进。
- [强制] 推荐收口:`如无调整,请回复「确认」;也可以删除、改名或新增主题。`
- [强制] 查询触发:仅在 `baseInfoAcknowledged=true` 后、首次进入 `KNOWLEDGE_CONFIRM` 时查询一次;仅当 `drugName` 变化或用户明确要求“刷新主题”时重查,其余轮次复用缓存。
- [强制] 回显口径:仅展示 `已存在主题` 与 `建议补充主题`(2-4 条);脚本不得内置业务主题替 Agent 出题。
- [强制] 分组输出:`result.knowledgeTopicBuckets`(以及 `userOutputTemplate.knowledgeTopicBuckets`)包含 `existingTopics`、`suggestedMissingTopics`、`existingTopicsSource`,供调用方直接渲染“已存在/待补充”。
- [强制] 交互收口:产品知识阶段最多追问 2 轮、每轮最多 2 问;用户明确“暂无正文”时不得反复追问正文,但仍需确认主题后才能推进。
- [建议] 正文补充:用户可选补充 `scene.knowledge`,建议最小结构 `category + title + content`;若知识检查发现主题缺失,则缺失主题正文是进入内容生成前的补充项。
- 用户可以:
- 仅确认/调整这些主题关键词;
- 额外补充产品知识正文、政策内容等材料;
- 也可以暂时不补充正文,只保留需求关键词继续流程。
- 用户删除、改名或新增主题后,调用方必须回显基础信息 6 项 + 最新 `productKnowledgeNeeds` 全量清单,再请求确认。
- 若用户补充了“代表话术/经验”,默认归入 `coachOnlyContext` 的 `## 最佳实践`。
- 若用户补充了可落库的产品知识正文,建议写入可选字段 `scene.knowledge`(数组),供创建前执行“先查后建”的知识解析流程。
### 产品知识阶段常见问题与排查(重点)
1. 反复停留在 `KNOWLEDGE_CONFIRM`:
- 常见原因:用户已口头确认基础信息,但调用方未在下一轮 payload 顶层与 `scene` 内双写 `baseInfoAcknowledged=true`。
2. 一直提示“建议补充主题”,看不到“已存在主题”:
- 常见原因:调用方未传 `existingProductKnowledgeTopics`(或 `meta.existingProductKnowledgeTopics`),脚本无法做“已存在/待补充”分桶。
3. 用户说“暂无正文”后仍被反复追问正文:
- 常见原因:调用方把 `knowledge` 正文当成必填;正文可为空,主题不可跳过。
4. 主题每轮都变化、用户感知不稳定:
- 常见原因:同轮重复 parse 或 `drugName` 被改动触发重算;应遵守“同轮最多一次 parse,非必要不刷新主题”。
5. 明明传了产品知识主题,仍提示缺失 / `scene` 被清空:
- 常见原因:payload 形状不符合上方“Payload 形状硬约束”,尤其是主题写到顶层、未基于最新草稿合并、多个会话复用固定 draft。
6. 用户提供了画像/话术,但确认页未回显“补充素材”:
- 常见原因:调用方没有把用户补充写入 `scene.actorProfileSupplement` / `scene.bestPracticeSupplement` 并重新 parse,而是直接进入产品知识主题生成。
**阶段 3(`READY_FOR_SCENE_GENERATION`):内容生成后对用户展示的完整确认清单(累计)**:
- `businessDomainName`
- `departmentName`
- `drugName`
- `location`
- `doctorConcerns`
- `repGoal`
- `productKnowledgeNeeds`
- `title`
- `sceneBackground`
说明:以上为阶段 3 固定清单字段(**不含完整** `actorProfile` 对象:该字段由内部生成并参与 `validate/create` 门禁;落库前确认可展示对练对象角色摘要)。默认不得临时新增展示项。若需新增字段,必须先同步更新本文件模板与相关联动文档。
**不进入用户确认清单的内部生成字段**:
- `doctorOnlyContext`
- `coachOnlyContext`
这些字段继续参与后续 `validate` 与创建门禁,但默认由模型/系统内部生成与校验,不向用户逐段确认正文。
---
## 标准 payload 示例(仅保留核心形状)
### 示例 1:基础信息确认阶段
```json
{
"scene": {},
"parsedFields": {
"businessDomainName": "临床推广",
"departmentName": "消化内科",
"drugName": "美沙拉秦肠溶片",
"location": "三级医院门诊",
"doctorConcerns": [
"产品优势",
"集采与价格"
],
"repGoal": "帮助医生快速了解产品特点并回应价格顾虑",
"generationNotes": "drugName 为推断结果,待用户确认。"
}
}
```
### 示例 2:已确认基础信息后提交产品知识(推荐)
```json
{
"scene": {
"businessDomainName": "临床推广",
"departmentName": "儿科",
"drugName": "维图可",
"location": "儿童医院住院部办公室",
"doctorConcerns": [
"处方频率低的原因",
"如何提高处方量"
],
"repGoal": "了解用药障碍并提供解决方案,增加处方量",
"baseInfoAcknowledged": true,
"productKnowledgeNeeds": [
"适应症定位",
"用法用量",
"临床证据要点"
]
},
"draftPath": ".cms-log/state/cms-tbs-scene-create/{sessionId}/latest-draft.json"
}
```
要点:
- 当 `baseInfoAcknowledged=true` 后,基础 6 字段放在 `scene` 中作为已确认上下文,不要再通过补丁键重复覆盖。
- `productKnowledgeNeeds` 必须写入 `scene.productKnowledgeNeeds`。
- 下一轮 payload 应基于最新 draft 的 `scene` 增量合并。
### 示例 3:错误形状(禁止)
```json
{
"baseInfoAcknowledged": true,
"parsedFields": {
"businessDomainName": "临床推广",
"departmentName": "儿科",
"drugName": "维图可",
"location": "儿童医院住院部办公室",
"doctorConcerns": ["处方频率低的原因"],
"repGoal": "增加处方量",
"productKnowledgeNeeds": ["适应症定位"]
}
}
```
说明:
- 该形状会把已锁定基础字段当作补丁提交,常见结果是 `patch_fields_locked`。
- 若把 `productKnowledgeNeeds` 写在 payload 顶层,也属于错误形状;规范写法见上方“Payload 形状硬约束”和示例 2。
## 面向用户展示模板
固定模板不在本文件重复定义。
调用方一律直接引用:
- `references/output-templates.md`:四时点模板正文
- `references/common-params.md`:展示优先级与输出约束
本文件仅保留 parse 阶段判定与字段语义,不再维护用户话术模板文本,避免双真源漂移。
FILE:references/common-params.md
## 错误处理
所有脚本遵循统一错误约定:
- **成功**:JSON 到 stdout,含 `"success": true`
- **失败**:JSON 到 stderr,含 `"success": false` 和 `"error"` 字段,exit code ≠ 0
- **Agent 应同时检查 stdout 和 stderr**
### Agent 判定顺序(统一)
> 串联门禁总览见 `SKILL.md`「阶段状态机」;本节为 **success/字段判定** 与 **用户可见输出** 的通用真源。
1. 先判断 `success`:
- `success=false`:当前链路中断,先处理 `error`
- `success=true`:进入当前脚本的阶段字段判定
2. 再按脚本类型判断:
- parse:看 `stage`、`missingFields`、`userOutputTemplate.clarifyQuestions`、`parseMeta`;补丁被拒时看 `error=patch_fields_locked` 与 `rejectedFields`
- validate:看 `validationReport.scope`(`FULL`/`TBV`)、`validationReport.passed`、`blockingIssues`、`warningIssues`;TBV 时可看 `tbvReport`
- create:看 `userConfirmation`、校验与草稿 `meta` 组合是否满足创建门禁(见 `references/tbs-scene-create.md`),以及 `sceneId` 等结果字段
3. 最后按 `userOutputTemplate` 组织用户可见中文话术,不直出内部 JSON。
### 统一示例(成功)
```json
{
"success": true,
"stage": "BASE_INFO_CONFIRM",
"missingFields": [],
"userOutputTemplate": {
"clarifyQuestions": []
}
}
```
### 统一示例(失败)
```json
{
"success": false,
"error": "创建前校验未通过:validationReport.passed=false,请先修复 blockingIssues"
}
```
## 用户可见输出(通用,各脚本不再重复展开)
通用硬约束(唯一):
1. JSON 只给 Agent 读;对用户只输出中文业务句,优先使用各脚本 `userOutputTemplate`。
2. 仅使用脚本返回字段组织回显,不自行增删字段或改写字段含义。
3. **禁止**直出内部信息:英文字段名、内部状态、issue 码、鉴权原文与技术上下文报错。
4. `validationReport.passed=false` 时必须明确告知“当前不可确认创建 + 待修正项”,修复前不得假装可落库。
5. 基础信息收集采用“双阈值”:完整性门禁优先,同时限制追问成本,避免无限追问。
6. 用户补充"代表话术/最佳实践"时,当轮明确告知"已采纳",并按 `bestPracticeTargetSection` 回显归入位置(`bestPracticeTargetSection` 由脚本在 `userOutputTemplate` 中返回,默认值为 `coachOnlyContext ## 最佳实践`)。
7. 同轮用户侧最多输出 1 条最终消息;若同轮内有多次解析结果,仅保留最新结果渲染,禁止连续发送“解析结果+补充解释”两条消息。
### 绝对禁止直出字段(强制)
以下内部字段或状态不得出现在用户可见文案中(包含原字段名、`key=value`、代码块):
- `baseInfoAcknowledged`
- `updatedConfirmationEchoed`
- `mustEchoUpdatedConfirmation`
- `validationReport`
- `blockingIssues`
- `warningIssues`
- `createAgentHints`
- `systemActionHint`
若需表达其业务含义,必须转写为中文业务语句(例如:“基础信息已确认,可继续下一步”),不得透出技术字段名。
### 四时点最小真源(强制)
只按以下 4 个时点组织用户可见内容,其他描述均以本表为准。
| 时点 | 触发条件 | 必须展示 | 下一步 |
|------|----------|----------|--------|
| 收集阶段 | `stage=BASE_INFO_CONFIRM` 或 `stage=KNOWLEDGE_CONFIRM` | 当前阶段字段原值(不摘要);缺失项;`clarifyQuestions`(仅补充) | 用户补充/确认后重新 parse |
| 修改回显 | `mustEchoUpdatedConfirmation=true`(`updatedFieldLabels` 仅用于回显内容,不单独触发);Agent 完成回显后在下轮 payload 写 `updatedConfirmationEchoed=true` 告知脚本已回显 | 更新字段说明 + 更新后确认清单(优先 `updatedConfirmationItems`,否则 `confirmationItems`) | 用户确认后进入 validate(或继续 parse) |
| 落库前确认 | validate 通过且准备 create(并满足 validate/create 组合门禁) | 最终清单(按 `mustDisplayFields`/`mustDisplayConfirmationItems` 完整展示);仅给“确认/取消”收口 | 用户确认后调用 create |
| 落库结果 | create 返回成功/失败 | 成功:`sceneId` + 关键结果字段;失败:业务化原因 + 下一步动作 | 结束或按提示补充后重试 |
显示优先级与字段真源(唯一):
1. 所有字段用原值回显,不做二次改写摘要。
2. 字段展示优先级按时点区分:
- 修改回显:`sceneBackgroundFullText`(命中时)> `updatedConfirmationItems` > `confirmationItems`
- 落库前确认:`sceneBackgroundFullText`(命中时)> `mustDisplayConfirmationItems` > `confirmationItems`
3. 当 `mustShowSceneBackgroundFullText=true` 时,`sceneBackgroundFullText` 必须原文展示,不得摘要改写。
4. 若存在 `userOutputTemplate.createAgentHints`,落库前确认阶段必须同步遵守其展示与收口要求。
5. `KNOWLEDGE_CONFIRM` 阶段展示优先级:`phaseSections` > `confirmationItems` > `clarifyQuestions`;禁止只展示 `clarifyQuestions`。
6. 当用户侧文案出现“基础信息确认 ✓/已确认”时,调用方必须确保本轮或下一轮 parse payload 携带 `baseInfoAcknowledged=true`;否则改为“基础信息待你确认”。
7. 结果选取规则:用户侧仅展示“本轮最新 parseResult”;若存在多条结果,按 `updatedAt`/轮次取最后一条,旧结果一律丢弃。
8. 一致性规则:若 `parseResult.baseInfoAcknowledged=true` 且 `stage=KNOWLEDGE_CONFIRM`,禁止再展示“请先确认基础信息”类话术。
创建前展示声明(强制):
1. 调用 `tbs-scene-create.py` 前,必须声明已完整展示 `mustDisplayFields`:
- 方式 A:`displayContractSatisfied=true`
- 方式 B:`displayedFields`(字段键名数组,必须覆盖 `mustDisplayFields`)
2. `mustDisplayFields` 默认最小集合:
- `businessDomainName`、`departmentName`、`drugName`、`location`、`doctorConcerns`、`repGoal`、`productKnowledgeNeeds`、`title`、`sceneBackground`、`actorProfile`
3. 未声明或声明不完整时,create 返回 `display_gate_failed`,阻断创建。
4. 用户确认必须绑定本轮最终确认清单:调用 create 时传 `confirmedDisplayHash=validationReport.displayHash`;若确认后用户可见字段变化,必须重新展示最终确认并重新取得确认。
分阶段必显字段(统一):
1. `BASE_INFO_CONFIRM`:`businessDomainName`、`departmentName`、`drugName`、`location`、`doctorConcerns`、`repGoal`
2. `KNOWLEDGE_CONFIRM`:阶段 1 全部字段 + `productKnowledgeNeeds`;主题由系统先建议,用户确认/删除/改名/新增后才可推进
3. `READY_FOR_SCENE_GENERATION`:阶段 2 全部字段(含 `productKnowledgeNeeds`);`title`、`sceneBackground` 此时待内部生成,展示为「生成中」占位,不作为此阶段必显项
4. `READY_FOR_VALIDATE`:`businessDomainName`、`departmentName`、`drugName`、`location`、`doctorConcerns`、`repGoal`、`productKnowledgeNeeds`、`title`、`sceneBackground`、`actorProfile`
产品知识检查门禁(强制):
1. 用户确认 `productKnowledgeNeeds` 后,必须执行 `scripts/tbs-scene-knowledge-check.py`。
2. 检查顺序:按 `drugName` 解析 `drugId`;若品种不存在则先创建品种并写回 `scene.drugId` / `meta.resolvedIds.drugId`;若匹配多条则停下要求人工确认。
3. 拿到 `drugId` 后,按确认主题查询已有产品知识 → 已存在则复用 `knowledgeIds`。
4. 不存在且用户已提供同名 `knowledge` 正文时,脚本先查重,仍不存在才创建产品知识。
5. 不存在且无正文时,停留在 `KNOWLEDGE_CONFIRM`,回显 `missingKnowledgeTopics`,要求用户补 `category + title + content`。
6. 只有 `knowledgeReady=true` 后,才可进入 `READY_FOR_SCENE_GENERATION`。
7. 性能与去重(不改变门禁语义):若草稿中已存在 `knowledgeReady=true`、`knowledgeIds` 完整,且 `meta.lastKnowledgeKey` 与本轮输入计算结果一致,则允许跳过重复网络检查,直接复用既有 `knowledgeIds`(避免反复请求导致慢)。
产品知识展示口径:
- `productKnowledgeNeeds` 只显示为“产品知识主题”。
- `knowledge` 只显示为“产品知识正文(可选)”。
- “当前未提供正文”只能出现在 `knowledge` 下,禁止出现在 `productKnowledgeNeeds` 下。
- “暂无正文”不等于“无需产品知识主题”;`productKnowledgeNeeds` 必须展示并经用户确认后才可推进。
- 基础信息齐备但未确认时仍按 `BASE_INFO_CONFIRM` 展示,不展示产品知识主题清单;只提示“确认基础信息后会生成产品知识主题供确认”。
- 产品知识主题应按 `references/product-knowledge-topic-generate.md` 生成;脚本不得用内置业务主题替 Agent 出题。
- 产品知识主题确认采用轻确认:如无调整回复“确认”;可删除、改名或新增;不得要求用户补正文后才推进。
### 输出模板真源
用户可见模板统一维护在 `references/output-templates.md`。本文件只保留输出规则、字段拦截、展示优先级与通用参数。
模板使用对照:
| 当前时点 | 必用模板 |
|---------|---------|
| 收集阶段 | `output-templates.md` 模板 1 |
| 字段有更新 | `output-templates.md` 模板 2 |
| 落库前确认 | `output-templates.md` 模板 3 |
| create 成功 | `output-templates.md` 模板 4A |
| create 失败 | `output-templates.md` 模板 4B |
## 通用参数
所有脚本均支持以下通用参数:
| 参数 | 说明 |
|------|------|
| `--params-file <path>` | 从 UTF-8 JSON 读参数,解决长文本和中文转义问题。 |
| `--input <path>` | 与 `--params-file` 等价,兼容旧调用。 |
| `--output <path>` | 将完整 JSON 写入文件;stdout/stderr 只输出一行摘要,避免内部 JSON 外显。Agent 运行脚本时必须使用。 |
### 用法示例(`--params-file` 参数层)
```json
{
"userText": "帮我创建一个心内科沟通场景",
"scene": {},
"parsedFields": {},
"draftPath": ".cms-log/state/cms-tbs-scene-create/demo-draft.json"
}
```
```bash
python3 scripts/tbs-scene-parse.py --params-file payload.json --output result.json
```
> 文件参数与命令行参数可混用,命令行参数优先级更高。文件必须为 UTF-8 编码。
### 推荐链路示例(自然语言长文本)
1. 先按 `references/base-info-parse.md` 提取基础信息骨架。
2. 将基础信息结果放入 `parsedFields`,执行 `tbs-scene-parse.py`。
3. 根据 `tbs-scene-parse.py` 返回的 `stage` 继续做用户确认或内部生成。
推荐 payload 形状:
```json
{
"userText": "用户原始输入",
"scene": {},
"parsedFields": {
"businessDomainName": "临床推广",
"departmentName": "消化内科",
"drugName": "美沙拉秦肠溶片",
"location": "三级医院门诊",
"doctorConcerns": [
"产品优势",
"集采与价格"
],
"repGoal": "帮助医生快速了解产品特点并回应价格顾虑"
},
"draftPath": ".cms-log/state/cms-tbs-scene-create/demo-draft.json"
}
```
FILE:references/tbs-scene-validate.md
<!-- Gate-4 · READY_FOR_VALIDATE → 落库前确认 ────────────────
步骤 Step1 执行 validate(scope=FULL);passed=false → 转写blockingIssues为中文,禁止继续
Step2 仅 passed=true 后:执行 SKILL.md §D 自检;未通过不得展示落库前确认
Step3 模板3 完整展示 mustDisplayFields;只给"确认/取消"收口
Step4 等待用户明确回复;"取消"→终止;"确认"→进Gate-5
禁止 passed=false 时继续;未完整展示 mustDisplayFields 就收口;
scope=TBV 通过直接 create(须同时满足 meta.lastFullValidationPassed)
推进 用户明确回复"确认" → 进 Gate-5
──────────────────────────────────────────────────────── -->
### 2. 校验场景 — `tbs-scene-validate.py`
**意图**:校验场景草稿是否已经达到“可向用户发起最终确认”的条件。
```bash
python3 scripts/tbs-scene-validate.py --params-file draft.json --output validate-result.json
python3 scripts/tbs-scene-validate.py --params-file draft.json --scope tbv --output validate-tbv-result.json
```
| 参数 | 必填 | 说明 |
|------|------|------|
| `--params-file` / `--input` | ✅ | 输入 JSON 文件 |
| `--scope` | ❌ | `full`(默认)或 `tbv`;也可在输入 JSON 顶层传 `validationScope`:`FULL` / `TBV` |
| `scope` | 含义 | `meta`(有 `draftPath` 时) |
|---------|------|---------------------------|
| `FULL`(默认) | 下列全量「创建前」规则 | `passed` → `lastFullValidationPassed`;写入 `lastValidatedSceneHash`;失败时清 `lastTbvPassed` |
| `TBV` | 仅 `title`、`sceneBackground` 叙述规则(**背景类 issue 一律阻断**) | `passed` → `lastTbvPassed`;写入 `lastValidatedSceneHash`;另有 `tbvReport` |
写草稿:**合并**原文件,只覆盖 `scene`、`validationReport`、`meta`(保留 `parseResult` 等)。
## 调用决策(避免误用)
| 场景变更 | 推荐 scope | 说明 |
|---|---|---|
| 仅改 `title` / `sceneBackground` | `TBV` | 轻量校验,适合补丁后快速回归 |
| 改了基础字段、`doctorOnlyContext`、`coachOnlyContext`、`actorProfile`、`productKnowledgeNeeds` | `FULL` | 必须跑全量门禁 |
| 准备发起 create 前最终检查 | `FULL`(或 `TBV + meta.lastFullValidationPassed=true`) | 仍需满足知识检查门禁(`knowledgeReady=true`)与 create 展示门禁(`displayContractSatisfied` 或完整 `displayedFields`) |
> 关键:`validationReport.passed=true` 仅表示“本次 scope 通过”。
> - `scope=TBV` 时,**不等于可直接创建**。
> - `validationReport.sceneHash` 必须与 create 时当前 `scene` 的 hash 一致;不一致必须重新 validate。
> - `validationReport.displayHash` 必须绑定本轮最终确认展示内容;用户确认后传给 create 的 `confirmedDisplayHash` 必须与它一致。
> - 可创建判定以 `tbs-scene-create.py` 的组合门禁为准。
**校验要求(`scope=FULL`)**:
- 必须具备:`title`
- 必须具备:`businessDomainName`
- 必须具备:`departmentName`
- 必须具备:`drugName`
- 必须具备:`location`
- 必须具备:`doctorConcerns`
- 必须具备:`repGoal`
- 必须具备:`sceneBackground`
- 必须具备:`productKnowledgeNeeds`
- `sceneBackground` 额外规则:长度 <= 180;不得包含【】或“待补充”;不得使用标签化前缀(如“场景背景:”);`departmentName` 与 `location` 需作为子串出现;`drugName` 允许以括号前主名称作为锚点(与 `references/scenario-json-parse.md` 描述一致);避免出现面向具体个人的代词(你/我/他/她/它/咱/咱们/我们/你们/他们/她们)。
- 必须具备:`doctorOnlyContext`
- 必须具备:`coachOnlyContext`(且须包含 5 节固定标题,按顺序:`## 期望代表行为`、`## 评分重点`、`## 终止条件`、`## 最佳实践`、`## 输出要求`;详见 `references/scenario-json-parse.md §coachOnlyContext 要求`)
> 说明:`doctorOnlyContext` 与 `coachOnlyContext` 仍然是创建前必过的内部门禁,但不属于用户逐段确认内容。
**说明**:不再要求证据状态/证据来源作为校验阻断条件。`doctorOnlyContext` 的 `## 核心顾虑` bullet ≥3 条时合并为至多 2 条并记入 `autoNormalized`;生成阶段仍应尽量一次合规。
**流程**:读 `scene` → 规范化 → 计算 `sceneHash` 与 `displayHash` → 按 `scope` 收集 issues → `validationReport`(含 `sceneHash`、`displayHash`、`blockingIssues` / `warningIssues`;**TBV 无 warning 分桶**)→ `passed=true` 可进用户确认/下一步。`success` 判定同 `common-params.md`。
**FULL 专用**:`sceneBackground` 的 `too_long` / `placeholder` / `label_style` / `pronoun` / `anchor_missing` 进 **warning**;其余 issue 进 **blocking**。**TBV** 下上述背景码也进 **blocking**。
**与 create**:仅 TBV 通过不能落库,须 `meta.lastFullValidationPassed`(见 `tbs-scene-create.md`)。
**自动修复**:`sceneBackground` 低风险规范化(符号/占位/前缀/锚点/长度);写入 `validationReport.autoNormalized` 供追踪,**不改变** TBV/FULL 各自放行语义。
**用户可见话术(通用)**:`common-params.md`。
**validate 特有**:转述 `confirmationItems` 时**须含场景背景完整正文**(若 `mustShowSceneBackgroundFullText=true`,优先使用 `sceneBackgroundFullText` 原文);禁止写成“场景背景(摘要)/背景摘要/节选”,勿用「训练目标」替代;用 `issueHints` / `warningHints`(`passed=true` 时 warning 勿说成必改)。若返回 `mustDisplayFields` / `mustDisplayConfirmationItems`,须完整展示该清单并在创建前声明展示已完成。若用户本轮有修改,回显触发与内容范围统一遵守 `common-params.md`「修改回显协议(强制)」。
**Agent 专用输出(`scope=FULL`,stdout JSON)**:
| 字段路径 | 出现条件 | 用途 |
|----------|----------|------|
| `userOutputTemplate.doctorOnlyContextCanon` | 恒出现(FULL) | `requiredHeaderOrder`、`outputRequirementsLines`、`endingRulesLines`:生成或修复 `doctorOnlyContext` 时与校验脚本逐字对齐 |
| `userOutputTemplate.doctorOnlyContextDiagnostics` | `doctorOnlyContext` 未通过 | `reasonCodes` / `agentHints`:定位标题顺序、核心顾虑条数、两节固定模板是否逐字一致 |
| `userOutputTemplate.createAgentHints` | `passed=true` | 创建前须先完成 `tbs-scene-knowledge-check.py`,再满足 `displayContractSatisfied`(或 `displayedFields`)、`confirmedDisplayHash` 及用户确认口径 |
| `userOutputTemplate.preCreateBlockedReminder` | `passed=false` | 明确禁止在阻断未清除时调用 `tbs-scene-create`,并要求先对用户说明原因 |
## 校验失败后的动作(Agent)
1. 先按 `issueHints` / `doctorOnlyContextDiagnostics.agentHints` 给用户业务化说明“哪里不通过、怎么修”,不要只回 error code。
2. 若是 `doctorOnlyContext_invalid`:优先使用 `doctorOnlyContextCanon` 的固定段落逐字回填,再跑 `FULL`。
3. 仅改了标题/背景时可先 `TBV` 快速回归;涉及上下文/基础字段变动后,必须回到 `FULL`。
4. `passed=false` 时不得进入 create;需修复并重新校验通过后,再向用户收口“确认/取消”。
---
## 判定示例(内部)
### 可进入确认
```json
{
"success": true,
"validationReport": {
"scope": "FULL",
"passed": true,
"blockingIssues": [],
"warningIssues": [
"scene.sceneBackground_pronoun"
]
}
}
```
### 不可进入确认
```json
{
"success": true,
"validationReport": {
"scope": "FULL",
"passed": false,
"blockingIssues": [
"scene.title_missing"
],
"warningIssues": []
}
}
```
FILE:references/scene.schema.json
{
"$schema": "http://json-schema.org/draft-07/schema#",
"$id": "https://xgjk.local/schemas/cms-tbs-scene-create/scene.schema.json",
"title": "SceneDraftObject",
"type": "object",
"additionalProperties": true,
"properties": {
"title": {
"type": "string",
"minLength": 1
},
"businessDomainName": {
"type": "string",
"enum": [
"临床推广",
"院外零售",
"学术合作",
"通用能力"
]
},
"businessDomainId": {
"type": "string",
"minLength": 1
},
"departmentName": {
"type": "string",
"minLength": 1
},
"departmentId": {
"type": "string",
"minLength": 1
},
"drugName": {
"type": "string",
"minLength": 1
},
"drugId": {
"type": "string",
"minLength": 1
},
"location": {
"type": "string",
"minLength": 1
},
"doctorConcerns": {
"oneOf": [
{
"type": "string",
"minLength": 1
},
{
"type": "array",
"minItems": 1,
"items": {
"type": "string",
"minLength": 1
}
}
]
},
"repGoal": {
"type": "string",
"minLength": 1
},
"sceneBackground": {
"type": "string",
"minLength": 1
},
"background": {
"type": "string"
},
"productKnowledgeNeeds": {
"type": "array",
"minItems": 2,
"maxItems": 6,
"items": {
"type": "string",
"minLength": 1
}
},
"knowledge": {
"oneOf": [
{
"type": "array",
"items": {
"type": "object",
"additionalProperties": true
}
},
{
"type": "object",
"additionalProperties": true
}
]
},
"knowledgeIds": {
"type": "array",
"items": {
"type": "string",
"minLength": 1
}
},
"doctorOnlyContext": {
"type": "string",
"minLength": 1
},
"coachOnlyContext": {
"type": "string",
"minLength": 1
},
"actorProfile": {
"type": "object",
"required": [
"name"
],
"additionalProperties": true,
"properties": {
"name": {
"type": "string",
"minLength": 1
},
"roleType": {
"type": "string",
"minLength": 1
},
"title": {
"type": "string"
},
"description": {
"type": "string"
},
"personaConfig": {
"oneOf": [
{
"type": "object",
"additionalProperties": true
},
{
"type": "string",
"minLength": 1
}
]
}
}
},
"generationNotes": {
"type": "string"
}
},
"required": [
"title",
"businessDomainName",
"departmentName",
"drugName",
"location",
"doctorConcerns",
"repGoal",
"sceneBackground",
"productKnowledgeNeeds",
"doctorOnlyContext",
"coachOnlyContext",
"actorProfile"
]
}
FILE:references/base-info-parse.md
# 自然语言/补充输入 → 基础信息提取
本文件用于编排调用方在对话内调用模型时的系统提示词规范。目标是:无论用户一次性给出大段业务背景,还是按引导逐步补充字段,都先统一提取 `scene` 的**基础信息骨架**,供 `tbs-scene-parse.py` 进入“基础信息确认”阶段。
---
## 1) 使用时机
- 当用户输入是自然语言长文本、会议纪要、产品说明、业务需求描述时,应先使用本文件提取基础信息。
- 当用户按引导逐条补充字段时,也可把本轮新增信息合并到已有骨架后,继续使用本文件做增量抽取。
- 本文件**不负责**生成长正文,不生成:
- `sceneBackground`
- `doctorOnlyContext`
- `coachOnlyContext`
- 本文件只负责提取以下基础字段:
- `businessDomainName`
- `departmentName`
- `drugName`
- `location`
- `doctorConcerns`
- `repGoal`
- `generationNotes`(仅记录推断与待确认点)
---
## 2) 提取原则
- 只输出一个 UTF-8 JSON 对象,键名必须与 Schema 完全一致。
- 只抽取已明确表达或可保守推断的基础字段。
- 不确定就留空,不要为了“补齐”而臆造。
- 若有推断,必须写入 `generationNotes` 说明“为什么这样推断、哪些点需要用户确认”。
- `businessDomainName` 仅允许:`临床推广` / `院外零售` / `学术合作` / `通用能力`。
- `doctorConcerns` 可以是字符串,也可以是字符串数组;推荐优先输出数组。
- `doctorConcerns` 只放医生对产品、证据、安全性、费用、可及性、指南边界或推广方式的**具体顾虑/异议**;不得把职称、角色、性格、沟通风格直接写成顾虑。
- 若用户提到“主任/副主任/专家/观念保守/谨慎/强势/理性看数据/时间紧张”等画像线索,应写入 `generationNotes`,供后续生成 `actorProfile` 使用。
- 若用户这轮只补充了个别字段,其余字段应尽量保留已有输入值,不要清空。
- 本步骤产出的 JSON 仅用于内部编排:写入 `parsedFields` 并继续调用 `tbs-scene-parse.py`,不得直接展示给最终用户。
- 自然语言长文本禁止直接塞进 `tbs-scene-parse.py` 当成完整草稿;必须先经本文件抽取为 `parsedFields`,再调用 parse 判断 Gate。
## 2.1) 5W 采集策略(方法层)
- 引导与理解可使用 5W(Who / What / When / Where / Why),但 5W 仅用于分析,不得作为新增结构化字段输出。
- 字段映射建议:
- Who -> `departmentName`(必要时结合 `businessDomainName` 判断业务语境)
- What -> `drugName`、`doctorConcerns`、`repGoal`
- When -> 若与训练目标强相关但无对应结构化字段,写入 `generationNotes` 待确认
- Where -> `location`
- Why -> `repGoal`;无法稳定映射时写入 `generationNotes`
- 若 5W 信息不全,不做臆造;仅提取可确认部分,其余在 `generationNotes` 标注缺口与待确认点。
- 当用户输入为碎片化补充时,优先合并已有骨架后再按 5W 查漏,不得回退或清空既有字段。
## 2.2) 收集决策规则(默认自动解析 + 5W 兜底)
- 默认先执行“自动解析”:无论用户输入一句话还是长文本,先尝试抽取 6 个基础字段(`businessDomainName`、`departmentName`、`drugName`、`location`、`doctorConcerns`、`repGoal`)。
- 触发 5W 引导的条件:
- 用户明确表示“不知道该怎么填”或“请按 5W 引导”;
- 当前轮抽取后仍存在 2 个及以上关键缺口,且无法保守推断。
- 追问策略采用“完整性门禁 + 追问成本上限”双阈值:
- 完整性门禁:以 6 个基础字段是否齐备作为主判定标准;
- 追问上限:建议最多 2 轮,每轮最多 2 个问题,避免反复盘问。
- 到达追问上限仍有缺口时执行降级:
- 不再全量追问,仅保留 1 个最关键缺口问题;
- 其余缺口写入 `generationNotes` 标注“待确认”,允许用户后续补充更新。
- 无论走自动解析还是 5W 引导,最终输出字段集合必须一致;不得因引导模式切换而变更 JSON 契约。
## 2.3) 用户可见输出
遵守 `common-params.md`。本节额外强调:不得对用户贴本步 JSON;骨架只写入 `parsedFields` 再调 `tbs-scene-parse.py`。
## 2.4) 医生画像增强信息(可选收集,不阻塞)
- 医生画像用于提升场景拟真度与异议命中率,默认作为“增强信息”,不替代基础 6 字段门禁。
- 收集时机建议:基础信息确认后、内容生成前;若用户已在长文本中给出画像线索,可直接抽取并复用。
- 最小收集集(建议优先这 4 项):
- 角色定位:科室 + 职称(如“心内科副主任”)
- 沟通风格:理性数据型 / 时间紧张型 / 谨慎保守型(可多选)
- 核心关注:疗效 / 安全性 / 价格与可及性 / 指南证据(建议 2-3 项)
- 主要异议点:最可能出现的 1-2 条质疑
- 触发策略:
- 用户提出“希望更贴近真实医生”或主动补充医生背景时,优先收集;
- 自动解析后内容泛化风险高(缺少对象差异信息)时,可追加 1 轮定向追问。
- 成本约束:每轮最多 2 问;若用户不愿继续补充,按现有信息推进,并把未补齐画像线索写入 `generationNotes` 待确认。
- 契约约束:本文件输出字段仍保持基础字段子集 + `generationNotes`;医生画像细节不在此阶段扩展为新增结构化键。
## 2.5) 产品知识主题生成边界(与 parse 阶段衔接)
- 本文件不直接输出 `productKnowledgeNeeds`,其生成与兜底由 `references/tbs-scene-parse.md` 在 `KNOWLEDGE_CONFIRM` 阶段负责。
- 调用方在基础信息抽取完成后,应把结果写入 `parsedFields` 并进入 `tbs-scene-parse.py`;不要在本阶段扩展产品知识结构化字段。
- 若后续 `KNOWLEDGE_CONFIRM` 阶段出现 `productKnowledgeNeeds` 为空,应由 parse 链路基于基础 6 字段自动给出 2-4 条建议主题供用户确认(不在本文件处理)。
---
## 3) 共用系统提示词(System)
```text
你是企业训战「对话场景」设计助手。你必须只输出一个 UTF-8 JSON 对象,符合用户消息中给出的 JSON Schema:键名与层级完全一致,字符串值为简体中文。
你的任务不是生成完整场景,而是先从用户输入中提取基础信息骨架,供后续确认。
规则:
1. 只允许输出这些字段:businessDomainName、departmentName、drugName、location、doctorConcerns、repGoal、generationNotes。
- 严禁新增任何额外键(如:关键决策者、利好背景、场景氛围、产品分类、适应症等);此类信息若确有价值,只能写入 generationNotes 的文本说明,不得独立成字段。
2. businessDomainName 仅允许:临床推广 / 院外零售 / 学术合作 / 通用能力。
3. doctorConcerns 只记录医生的具体顾虑/异议,不记录医生职称、角色、性格或沟通风格;这些画像线索必须写入 generationNotes。
4. 对不确定信息允许保守推断,但必须在 generationNotes 标注“待确认”。
5. 若用户本轮只补充部分信息,保留已有基础字段,不要无故删除。
6. 先按 5W(Who/What/When/Where/Why)理解用户输入,再映射到允许字段;5W 不是输出字段。
7. 若 When/Why 等信息无法稳定映射到结构化字段,写入 generationNotes 并标注待确认。
8. 不要生成标题、场景背景、场景正文、上下文、产品知识正文。
9. 禁止在 JSON 外输出任何字符(不要 markdown 围栏)。
```
---
## 4) 统一用户提示词
```text
【用户输入】
{{user_input}}
【已有基础信息骨架(可空)】
{{base_scene}}
【任务】
1. 从用户输入中提取或更新以下字段:businessDomainName、departmentName、drugName、location、doctorConcerns、repGoal。
2. 若某字段输入中没有明确表达,且无法保守推断,则留空。
3. 若某字段来自推断而非明确表达,必须在 generationNotes 中写明。
4. 可先用 5W(Who/What/When/Where/Why)做语义归纳,再映射到允许字段;不得输出 5W 命名字段。
5. 若 5W 中有高价值信息暂无法映射(常见于 When/Why 细节),写入 generationNotes 并标注“待确认”。
6. 仅输出基础信息骨架,不生成完整场景内容;也不要把“关键决策者/主任关注点/利好背景/场景氛围”等扩展信息当结构化字段输出。
7. `doctorConcerns` 不得只包含“主任/副主任/专家/观念保守/谨慎/强势/理性看数据/时间紧张”等画像词;若有“反感推销/抵触推广”等可转化为异议的内容,只保留异议部分。
【输出】
仅输出 JSON 对象。
```
---
## 5) 生成后自检
- [ ] 可解析为合法 JSON,且不含 JSON 外字符
- [ ] 仅包含基础信息字段与 `generationNotes`
- [ ] `businessDomainName` 若非空,其值属于:`临床推广` / `院外零售` / `学术合作` / `通用能力`
- [ ] 未凭空生成标题、场景背景、上下文等长正文
- [ ] 对推断字段已在 `generationNotes` 说明
- [ ] 已按 5W 做查漏;无法映射的关键信息已写入 `generationNotes` 待确认
- [ ] `doctorConcerns` 未把职称、角色、性格或沟通风格当作顾虑;相关画像线索已转入 `generationNotes`
---
## 6) 用户消息中须附带的 JSON Schema
调用方应在同一次用户消息或紧随其后的消息中附上完整 JSON Schema,确保键名与层级严格一致。
| 用途 | 文件 |
|------|------|
| 基础信息提取模型输出 | `references/scene.schema.json`(完整场景契约;基础信息阶段仅填基础字段,`required` 不用于 S1 阶段校验) |
---
## 7) 标准交接示例
### 示例 A:长文本 -> 基础信息骨架
**模型输出示例**
```json
{
"businessDomainName": "临床推广",
"departmentName": "消化内科",
"drugName": "美沙拉秦肠溶片",
"location": "三级医院门诊",
"doctorConcerns": [
"产品优势",
"集采与价格"
],
"repGoal": "帮助医生快速了解产品特点并回应价格顾虑",
"generationNotes": "drugName 根据上下文推断为美沙拉秦肠溶片,需用户确认品种名称是否准确。"
}
```
### 示例 A2:画像线索与医生顾虑分离
**用户输入**
```text
在三级医院分科门诊,医药代表针对观念保守、反感推销的主任。
```
**推荐输出**
```json
{
"businessDomainName": "临床推广",
"departmentName": "",
"drugName": "",
"location": "三级医院分科门诊",
"doctorConcerns": [
"反感推销,对代表推广有抵触"
],
"repGoal": "降低医生对推销的抵触,建立专业信任",
"generationNotes": "“主任”是角色线索,“观念保守”是沟通风格线索,不直接作为医生顾虑;后续生成 actorProfile 时使用。科室和产品名待补充。"
}
```
**禁止输出**
```json
{
"doctorConcerns": [
"观念保守",
"主任"
]
}
```
### 示例 B:交给 `tbs-scene-parse.py` 的 payload
```json
{
"userText": "用户原始长文本,可保留用于日志或后续参考",
"scene": {},
"parsedFields": {
"businessDomainName": "临床推广",
"departmentName": "消化内科",
"drugName": "美沙拉秦肠溶片",
"location": "三级医院门诊",
"doctorConcerns": [
"产品优势",
"集采与价格"
],
"repGoal": "帮助医生快速了解产品特点并回应价格顾虑",
"generationNotes": "drugName 根据上下文推断为美沙拉秦肠溶片,需用户确认品种名称是否准确。"
},
"draftPath": ".cms-log/state/cms-tbs-scene-create/demo-draft.json"
}
```
### 示例 C:用户逐字段补充时的增量更新
```json
{
"userText": "地点改成病房医生办公室,顾虑再加一个长期安全性",
"scene": {
"businessDomainName": "临床推广",
"departmentName": "消化内科",
"drugName": "美沙拉秦肠溶片",
"location": "三级医院门诊",
"doctorConcerns": [
"产品优势",
"集采与价格"
],
"repGoal": "帮助医生快速了解产品特点并回应价格顾虑"
}
}
```
> 约定:基础信息提取阶段输出的是“增量可合并骨架”,下一步统一交给 `tbs-scene-parse.py` 做阶段确认。
FILE:references/product-knowledge-topic-generate.md
# 产品知识主题生成规范
本文件是 `productKnowledgeNeeds` 的生成真源。代码只负责接收、校验和阶段推进,不负责硬编码业务出题。
## 使用时机
- 仅在基础 6 项已确认后使用:`baseInfoAcknowledged=true`。
- 仅在 `productKnowledgeNeeds` 为空,或用户明确要求“刷新主题”时使用。
- 生成后必须回到 `tbs-scene-parse.py`,停留在 `KNOWLEDGE_CONFIRM` 等用户确认。
- 本文件只生成主题,不生成 `knowledge` 正文,不替用户确认主题。
- 若主题缺失,Agent 必须按本规范生成后再调用 parse;脚本不得内置业务主题兜底。
## 输入
使用已确认的基础信息:
- `businessDomainName`
- `departmentName`
- `drugName`
- `location`
- `doctorConcerns`
- `repGoal`
- 用户本轮补充的资料或关键词(如有)
## 输出
- 输出 2-4 条产品知识主题。
- 每条是短语,不写解释正文。
- 主题应可直接展示给用户删改确认。
- 用户确认后写入 `scene.productKnowledgeNeeds`。
## 生成原则
主题需覆盖当前场景最关键的知识缺口,优先从以下角度选择:
1. 医生核心顾虑:围绕医生为什么不接受、担心什么。
2. 代表目标:围绕本次沟通希望推动的行为变化。
3. 产品价值:围绕产品临床价值、定位、适用患者或证据方向。
4. 场景执行:围绕院外可及性、患者衔接、流程、合规边界中最相关的内容。
## 禁止
- 不得直接复述医生顾虑原文作为主题。
- 不得输出“产品核心信息”“常见问题”等空泛占位词。
- 不得编造疗效结论、指南结论或证据出处。
- 不得把知识正文、话术、证据段落写入主题。
- 不得输出“当前未提供正文”“暂无正文”等正文状态提示。
- 不得生成、改名或替用户确认 `scenario-json-parse.md` 阶段的新主题。
## 输出示例
输入要点:医生担心处方外流安全性,代表目标是增加院外处方数量,场景强调临床价值与院外可及性。
建议主题:
- 产品临床价值与适用患者
- 处方外流安全性与风险沟通
- 院外购药可及性与患者用药衔接
- 医生院外处方顾虑回应要点
FILE:references/README.md
# references 索引
本目录包含流程说明、参数约束与编排示例。**串联总览**以 `SKILL.md` 为准;**分环节门禁条文**以对应 `tbs-scene-*.md` 为准;**用户可见输出**以 `common-params.md` 为准(各 `tbs-scene-*.md` 只写脚本特有补充)。
## 文件清单
| 文件 | 用途 |
|---|---|
| `auth.md` | 鉴权前置、token 注入与安全约束 |
| `base-info-parse.md` | 自然语言到基础信息骨架提取规范 |
| `tbs-scene-parse.md` | 分阶段确认流程与用户模板 |
| `scenario-json-parse.md` | 已确认骨架到场景内容字段补齐规范 |
| `scene.schema.json` | 场景完整对象契约(基础信息与内容生成阶段共用;`required` 仅适用于完成稿校验,不用于 S1 阶段) |
| `tbs-scene-validate.md` | 创建前校验门禁与用户侧转写规则 |
| `tbs-scene-create.md` | 确认后真实创建链路与返回约定 |
| `common-params.md` | 通用参数、统一错误约定与输出规则 |
| `output-templates.md` | 用户可见模板真源 |
| `parse-runtime-config.json` | `tbs-scene-parse.py` 运行时字段标签、阶段提示与轻量关键词规则 |
| `review-checklist.md` | 运行时输出与推进自检真源 |
| `agent-patterns.md` | 典型调用顺序与编排模式示例 |
| `maintenance.md` | 联动维护清单与结构例外说明 |
| `doc-consistency.md` | 发布前文档一致性自检说明(开发态) |
## 使用顺序(按 Gate)
1. 先读 `SKILL.md` 的当前 Gate。
2. 按 Gate 读取对应 reference;不要提前加载后续阶段文档。
3. 用户可见模板读 `output-templates.md`;输出与推进自检读 `review-checklist.md`。
4. 需要 schema 时读取 `scene.schema.json`。
5. 变更发布前检查 `maintenance.md` 与 `doc-consistency.md`。
## 会话状态与校验顺序(编排硬规则)
与 `SKILL.md` 执行纪律第 8、9 条一致,供任意 Agent 复用:
1. **单一真源**:跨轮以 `workspace/.cms-log/state/cms-tbs-scene-create/{sessionId}/latest-draft.json`(即 `draftPath`)为 `scene` 与 `meta` 的权威来源。`tbs-scene-knowledge-check` / `tbs-scene-parse` / 内部场景生成任一步结束后,下一动必须以此文件为准(重读或上一步已写回);禁止用孤立的 `latest-parse-result.json` 替代整份 draft 推进门禁。
2. **知识检查之后**:`tbs-scene-knowledge-check.py` 写回 draft 后,再基于该 draft 执行 `tbs-scene-parse.py`,保证 `knowledgeIds` 等与脚本输出一致。
3. **Gate-3 → validate**:仅在内部按 `scenario-json-parse.md` 生成并合并 `title`、`sceneBackground`、`actorProfile`、`doctorOnlyContext`、`coachOnlyContext` 后,再次 `parse` 直至可进入校验阶段,才对**同一份** draft(或等价的 `--params-file`)执行 `tbs-scene-validate.py`(scope=FULL)。禁止在场景正文尚未写入 draft 时用中间态跑 FULL 校验。
补充(性能,不改变门禁语义):
4. **知识检查去重**:当草稿中已存在 `knowledgeReady=true`、`knowledgeIds` 完整,且 `meta.lastKnowledgeKey` 与本轮输入计算一致时,`tbs-scene-knowledge-check.py` 可跳过重复网络检查并复用既有知识结果(输出 `knowledgeCheckSkipped=true` 供追踪)。
更细的示例与话术约束见 `agent-patterns.md`。
FILE:references/tbs-scene-create.md
<!-- Gate-5 · 真实落库(create)─────────────────────────────
进入条件(缺一不可)
① userConfirmation = "确认"
② meta.lastParseStage=READY_FOR_VALIDATE
③ validate passed=true 满足 FULL / TBV+meta.lastFullValidationPassed 组合门禁
④ confirmedDisplayHash=validationReport.displayHash
⑤ displayContractSatisfied=true 或 displayedFields 覆盖 mustDisplayFields(含 productKnowledgeNeeds、actorProfile)
⑥ access-token 已注入且非占位符(无尖括号)
⑦ validationReport.sceneHash 与当前 sceneHash 一致
步骤 执行 tbs-scene-create.py;成功→模板4A;失败→模板4B
禁止 任意条件未满足时调用 create
──────────────────────────────────────────────────────── -->
### 3. 创建场景 — `tbs-scene-create.py`
**意图**:在用户明确确认后,解析主数据并调用 `POST /scene/createScene` 创建 TBS 场景。
```bash
python3 scripts/tbs-scene-create.py \
--params-file draft.json \
--access-token "<ACCESS_TOKEN>" \
--output create-result.json
```
| 参数 | 必填 | 说明 |
|------|------|------|
| `--params-file` / `--input` | ✅ | 输入 JSON 文件 |
| `--access-token` | ✅ | 由 `cms-auth-skills` 注入 |
| `--base-url` | ❌ | 默认生产环境 `https://sg-al-cwork-web.mediportal.com.cn/tbs-admin` |
**输入 JSON 关键字段**:
| 字段 | 必填 | 说明 |
|------|------|------|
| `scene` | ✅ | 已通过 validate 的场景草稿 |
| `validationReport` | ✅ | 校验结果;须满足创建组合门禁(见下文),且 `passed=true`、`sceneHash` 与当前 `scene` 一致 |
| `userConfirmation` | ✅ | 仅允许:`确认` 或 `取消` |
| `confirmedDisplayHash` | ✅ | 用户确认所绑定的最终确认清单 hash,必须等于 `validationReport.displayHash` |
| `draftPath` | ❌ | 草稿文件路径,成功后回写 `persistResult` |
`scene` 中与产品知识相关的补充约定(可选):
- `productKnowledgeNeeds`:本场景需要覆盖的知识主题/关键词,来源于基础信息分析与用户确认。
- `knowledge`:用户额外补充的产品知识正文数组;若提供,则创建前按“先查后建”解析为 `knowledgeIds`。
- `knowledgeIds`:创建链路派生字段,不要求用户手填。
- `drugId`:产品知识检查阶段可写回;最终 create 优先复用,缺失时再按 `drugName` 解析或创建。
**流程步骤**:
1. 检查 `userConfirmation`
2. 确认产品知识检查已完成:已有主题复用 `knowledgeIds`,缺失主题需已补正文并创建/匹配完成
3. 校验创建门禁(`validationReport` + 草稿 `meta` 合并结果;见下文)
4. 解析或创建 `businessDomainId`
5. 解析或创建 `departmentId`
6. 优先复用 `scene.drugId`;缺失时解析或创建 `drugId`
7. 解析或创建 `personaIds`
8. 若 `scene.knowledge` 有用户补充的产品知识正文,则按 `drugId` 解析或创建 `knowledgeIds`
9. 调用 `POST /scene/createScene`
10. 返回 `sceneId`、`resolvedIds`、`personaIds`、`knowledgeIds` 与创建摘要
**创建门禁**:`userConfirmation=确认` + 有效 `--access-token` + `meta.lastParseStage=READY_FOR_VALIDATE` + `meta.knowledgeChecked=true` + `meta.knowledgeReady=true` + `validationReport.sceneHash` 与当前 `scene` 计算值一致 + `confirmedDisplayHash` 与当前最终确认清单计算值一致 + 校验与合并后 `meta` 满足其一:**(1)** `passed` 且(`scope` 缺省或 `FULL`);**(2)** `passed` 且 `scope=TBV` 且 `meta.lastFullValidationPassed=true`。此外 create 会执行展示门禁(需声明已完整展示 `mustDisplayFields`,包含 `productKnowledgeNeeds`、`actorProfile`)、最小自校验(关键字段完整性、`actorProfile.name`、`sceneBackground`、`validationReport` 阻断项一致性)以防伪造 `validationReport` 或复用旧确认直接落库。否则返回 `parse_stage_gate_failed` / `validation_gate_failed` / `knowledge_gate_failed` / `display_gate_failed` / `scene_self_check_failed`,不调接口。成功返回 `sceneId` 及解析结果字段。
`validation_scene_hash_missing` / `validation_scene_hash_mismatch` 表示校验结果不是当前 `scene` 生成的结果,必须重新执行 `tbs-scene-validate.py`,不得继续 create。
`display_confirmation_hash_missing` / `display_hash_mismatch` 表示用户确认没有绑定当前最终展示内容,必须重新展示模板 3 并重新取得确认。
编排提示:在 `tbs-scene-validate.py` 全量通过时,stdout 的 `userOutputTemplate.createAgentHints` 会再次强调 `displayContractSatisfied` / `displayedFields` 与确认口径;Agent 应在发起 create 前主动读完并满足,避免依赖 `display_gate_failed` 反查。
**错误输出**:`success=false` + `error`(stderr);约定见 `common-params.md`。
补充:`access_token_invalid` 表示传入的 token 疑似占位符或未替换模板(如尖括号包裹),应先修正 `cms-auth-skills` 注入再重试,与 HTTP 401 类错误区分。
**创建接口字段**:
- `title`
- `businessDomainId`
- `departmentId`
- `drugId`
- `location`
- `doctorOnlyContext`
- `coachOnlyContext`
- `repBriefing`
- `personaIds`
- `knowledgeIds`(若有)
补充约定:
- 本技能以 `sceneBackground` 作为场景背景正文的单一来源;创建接口若仍需 `repBriefing` 字段,由创建阶段将 `sceneBackground` 映射到请求体,不再把 `repBriefing` 作为独立草稿字段维护。
**画像解析规则(必经)**:
- 从 `scene.actorProfile` 读取;若为空,兼容读取 `scene.doctorProfile`。
- 先查 `GET /rolePersona/forResourceSelect`,按 `name + title` 优先精确匹配;若输入含 `roleType`,则优先要求角色类型一致。
- 未命中则 `POST /rolePersona/createRolePersona`。
- 成功响应在文档中多为 `data` 直接返回数字 ID;客户端需同时兼容 `data` 为数值、`{ id | personaId }` 字典或顶层 `personaId` 等变体(`tbs_client.extract_create_persona_id`)。
- 创建时默认:
- `trustInitial=80`
- `patienceInitial=80`
- `isPreset=false`
- `personaIds` 传给 `createScene` 时使用接口要求的数组结构,例如:
- `personaId`
- `difficulty`(默认 `medium`)
- `isDefault`(默认 `true`)
- `rounds`(默认 `5`)
**产品知识解析规则(可选)**:
- 仅当 `scene.knowledge` 中存在可用条目时执行。
- 知识条目字段约定(与创建接口对齐):
- `drugId`:优先使用产品知识检查阶段写回的 `scene.drugId`;缺失时由已确认 `drugName`(品种)解析或创建后获得,不由用户手填。
- `category`:用户提供的知识分类(如“整体介绍”“适应症定位”“安全性”);缺失时该条不创建。
- `title`:用户确认后的产品知识主题(用于精确匹配复用)。
- `content`:该主题下的具体知识正文。
- 推荐用户输入模板:`category:...;title:...;content:...`(每条知识一行)。
- 解析顺序采用“先查后建”:
- 先查 `POST /knowledge/listProductKnowledge`
- 回退 `GET /knowledge/forResourceSelect`
- 未命中则 `POST /knowledge/createProductKnowledge`
- 同一 `drugId` 下优先按 `title` 精确复用,其次按 `content` 指纹复用。
- 不再要求证据状态/证据来源作为知识创建前置条件。
- 每条知识只要 `category`、`title`、`content` 可用即可参与“先查后建”。
- 缺 `category` 或 `content` 的条目会跳过并在结果中提示补齐。
---
FILE:references/auth.md
### cms-auth-skills:access-token 获取与注入(强制)
这份规则用于约束 Agent:只要要执行本 Skill 任一需要鉴权的 `scripts/*.py` 链路(如知识检查/创建),`access-token` 获取必须通过依赖 Skill `cms-auth-skills` 完成。
#### 必须做
- 只要确定要进入本 Skill 的执行链路(`exec python3 scripts/<name>.py`),在调用目标脚本之前,**必须先调用** `cms-auth-skills` 获取 `access-token`。
- 对 `cms-auth-skills` 返回值做**空值校验**:若 token 为空/缺失/明显占位,必须停止链路并给出友好提示(引导重新授权/登录),不得继续执行脚本。
- 将 `cms-auth-skills` 返回的 `access-token` **以 `--access-token`** 注入到后续执行命令,并使用 `--output` 保存完整 JSON:
- `python3 scripts/tbs-scene-create.py ... --access-token "<ACCESS_TOKEN>" --output create-result.json`
- 若使用 `--params-file` 注入参数,业务参数仍放在 JSON 中;`access-token` 仍应优先使用命令行显式传入(避免落盘泄露风险)。
#### 必须禁止
- 禁止在未经过 `cms-auth-skills` 的情况下直接进入真实创建链路。
- 禁止自行从环境变量读取 token(例如 `TBS_ACCESS_TOKEN` 等)。
- 禁止按某种“自动解析/推断逻辑”(如从 sender_id/account_id、上下文字段等)去获取 token。
- 禁止向用户索要内部 token 原文。
- 禁止在 `cms-auth-skills` 未返回可用 `access-token` 时继续调用 `tbs-scene-create.py`。
- 禁止在用户可见回复、日志、报错透传中输出 token/appKey/header 原文或可逆片段。
- 禁止把 `access-token` 写入 `draftPath`、业务 payload 持久化文件或非鉴权用途字段。
#### 失败处理
- `cms-auth-skills` 获取失败或无可用 `access-token`:必须停止当前链路,并引导用户重新完成授权/登录;然后再重新尝试进入执行链路。
- 鉴权失败对用户只输出业务化提示,不暴露鉴权实现细节(如 header 名称、网关返回原文、调试栈)。
#### 执行分支预检(纯咨询 → 同意执行)
- 用户若先处于「仅咨询」、后明确同意走真实落库:在进入 `tbs-scene-create.py` **之前**必须完成一轮 `cms-auth-skills`,确认拿到非空 token;**禁止**用文档示例字面量(如尖括号占位、未展开的 shell 变量)调用创建脚本。
- `tbs-scene-create.py` 会对明显占位 token 返回 `access_token_invalid`(stderr JSON),与「鉴权 HTTP 失败」区分,便于 Agent 先修正注入方式再重试。
- 并发与超时:若 `cms-auth-skills` 偶发超时,应单次重试或让用户稍后重试;**不要**在无 token 时反复空跑 create 以免放大鉴权服务压力。
#### 环境与 Base URL(强制)
- TBS Admin 契约见仓库接口文档(以仓库最新版本为准)。
- 本 Skill 默认按**生产环境**联调:`https://sg-al-cwork-web.mediportal.com.cn/tbs-admin`(与 `tbs-scene-create.py` 的 `--base-url` 默认值一致)。
- `access-token` / appKey 必须与 `--base-url` 指向的 **同一套门户环境** 签发;跨环境混用会表现为鉴权失败或创建失败——应改用与环境一致的鉴权后再调用脚本。
FILE:references/output-templates.md
# 输出模板真源
本文件只保存用户可见模板与模板占位符。输出规则、字段拦截与门禁见 `common-params.md` 与 `review-checklist.md`。
### 四时点统一模板正文(强制)
以下模板用于用户可见输出,调用方应按时点直接套用,避免自由发挥导致口径漂移。
#### 产品知识显示规则(强制)
1. `productKnowledgeNeeds` 用户侧名称固定为“产品知识主题”,表示系统建议、用户确认/删改/新增后的主题清单。
2. `knowledge` 用户侧名称固定为“产品知识正文(可选)”,表示用户额外提供的正文、政策、证据或资料。
3. `BASE_INFO_CONFIRM`:不显示 `knowledge`,仅可提示“后续会根据基础信息建议产品知识主题”。
4. `KNOWLEDGE_CONFIRM`:必须显示 `productKnowledgeNeeds`;若无 `knowledge`,只能在“产品知识正文(可选)”下写“当前未提供正文(可稍后补充)”,不得写到“产品知识主题”下。
5. `READY_FOR_SCENE_GENERATION`:必须保留 `productKnowledgeNeeds`;`knowledge` 无正文时可省略或写“当前未提供正文(可稍后补充)”。
6. `READY_FOR_VALIDATE` / 落库前确认:默认展示 `mustDisplayFields`;若本轮用户刚修改过主题或正文,需补充显示最新 `productKnowledgeNeeds` / `knowledge`。
7. 正文条目显示格式统一使用:`category / title / content / 状态`(状态仅 `已提供`、`未提供`)。
占位符兜底文案(统一):
1. `{knowledgeItemsOrNotProvidedHint}`:
- 有条目时:按 `category/title/content/状态` 逐条展开;
- 无条目时固定写:`当前未提供正文(可稍后补充)。`
2. `{knowledgeItemsIfRecentlyUpdatedElseOmit}`:
- 本轮修改过知识条目时:按 `category/title/content/状态` 逐条展开;
- 未修改时固定写:`本轮知识条目无新增修改,沿用已确认内容。`
#### 模板 0:首轮开场(基础信息收集)
当用户首次表达“要创建场景”,且尚未形成可回显的结构化确认清单时,先使用以下开场模板之一。
首轮快响约束(强制):
1. 首轮执行策略:默认先做收集引导;但命中第 4 条“长文本例外”时,首轮直接执行一次 parse。
2. 仅当用户补充了可解析基础信息,或明确要求“继续执行解析”时,才进入脚本链路。
3. 若执行链路受审批阻塞(approval-pending),先回到收集引导,不在用户侧暴露内部审批细节(此状态由 Agent 宿主平台控制,脚本不返回该 stage;遇到时静默回退到「模板 0」开场引导)。
4. 长文本例外触发条件:首轮输入建议 >=80 字,且含“医生关注/未处方原因/目标/希望达成”等语义;命中后按第 1 条直接执行一次 parse,并按 `confirmationItems/phaseSections` 回显。
5. 若首轮已解析出 `doctorConcerns` 或 `repGoal`,用户侧不得再把该字段标记为“待补充”;应改为“已识别,请确认/可补充修正”。
标准版(默认):
```text
开始创建场景。你可以选一种方式发给我:
- 业务领域选哪一类:临床推广 / 院外零售 / 学术合作 / 通用能力?
- 这次主要面对哪个科室/哪类医生?
- 要沟通的产品是什么?
- 场景发生在哪(门诊/病房/院外等)?
- 医生最关注或最担心的问题是什么?(建议 1-2 条)
- 这次代表希望达成什么目标?
- 是否有时间窗口、业务节点或其他背景?
💡 强烈建议顺手补一条:**当时你是怎么和医生沟通成功的?**
可以是:一段 3-6 轮的对话片段(你说/医生说),或你用的关键话术/应对方式,以及医生的反应。
复述大意就好,越接近原话越能让生成的场景贴近真实沟通。
💡 可选补充(不影响继续,但能让对练对象更贴近真实):请用 2-3 句话描述这位医生(比如:角色/职称、沟通风格、最在意什么、最可能抛出的质疑)。
你可以先简单写,信息不完整也没关系。我会自动提取并回显确认清单,再补问关键缺口。
```
分步引导版(可由系统主动提供):
```text
可以,我来一步步引导你补齐关键信息。先回答这两点即可:
- 业务领域选哪一类:临床推广 / 院外零售 / 学术合作 / 通用能力?
- 这次主要沟通对象是哪个科室/哪类医生?
我会根据你的回答继续追问关键缺口,每轮最多问 2 个问题。
```
分步引导模板触发建议(避免“用户不会说触发词”):
1. 首轮开场默认提供两种方式:`完整描述` / `引导问题`。
2. 引导问题可按 5W 方法组织,但用户侧不显示 Who/What/Where/Why/When 字样。
3. 当用户出现“我不知道怎么填/你来引导我/随便问我几个问题”等表达时,自动切换到分步引导版。
4. 当自动解析后关键缺口较多(建议 >=2)时,可主动改用分步引导版补缺,不等待用户明确提出引导方式。
5. 若用户已给出完整长文本,优先走自动解析 + 缺口确认,不强制切到分步逐问。
#### 模板 1:收集阶段(BASE_INFO_CONFIRM / KNOWLEDGE_CONFIRM)
```text
我先按当前阶段回显关键信息,请确认:
- 当前阶段:{stageLabel}
- 需确认字段:
- {field_1_label}:{field_1_value}
- {field_2_label}:{field_2_value}
- {field_3_label}:{field_3_value}
(按当前阶段字段继续展开)
- 产品知识正文(可选):
- {knowledgeItemsOrNotProvidedHint}
- 补充素材(如已提供):
- 对象角色画像:{actorProfileSupplementOrOmit}
- 代表成功经验/典型话术:{bestPracticeSupplementOrOmit}
- 待补充项:{missingLabels}
- 需要你确认的问题:
- {clarifyQuestion_1}
- {clarifyQuestion_2}
- 💡 可选补充(不影响继续,但能让场景更像真实沟通):
- 你当时最关键的一句话/应对方式是什么?医生怎么回?你如何把对话推进下去的?
- 💡 可选补充(对象角色画像,自由复述,2-3 句话即可):
- 这位医生大概是什么类型?他最在意什么、沟通风格怎样、最可能提出什么质疑?
请直接补充或纠正,我会按你的更新继续下一步。
```
#### 模板占位符字段来源(强制)
| 占位符 | 来源字段路径 | 无值时 |
|--------|-------------|--------|
| `{stageLabel}` | `stage`(映射:`BASE_INFO_CONFIRM`→"基础信息确认";`KNOWLEDGE_CONFIRM`→"产品知识确认") | 不展示 |
| `{field_N_label}` | `userOutputTemplate.confirmationItems[N].label` | 跳过该行 |
| `{field_N_value}` | `userOutputTemplate.confirmationItems[N].value` | 写"待补充" |
| `{missingLabels}` | `missingFields`(映射为中文字段名,逗号分隔) | 写"(无)" |
| `{clarifyQuestion_N}` | `userOutputTemplate.clarifyQuestions[N]` | 跳过该行 |
| `{updatedFieldLabels}` | `userOutputTemplate.updatedFieldLabels`(数组,逗号拼接) | 不展示模板 2 |
| `{mustDisplayField_N_label/value}` | `userOutputTemplate.mustDisplayConfirmationItems[N].label/value` | 不可省略 |
| `{knowledgeItemsOrNotProvidedHint}` | 见上方"占位符兜底文案" | 固定兜底文案 |
| `{knowledgeItemsIfRecentlyUpdatedElseOmit}` | 见上方"占位符兜底文案" | 固定兜底文案 |
| `{actorProfileSupplementOrOmit}` | 用户提供的对象角色画像摘要;来源可为 `scene.actorProfile`、`generationNotes` 中的画像线索或调用方从用户原文提炼的 1-2 句摘要 | 未提供则整行不展示 |
| `{bestPracticeSupplementOrOmit}` | 用户提供的代表成功经验/典型话术摘要;来源可为用户原文中的开场话术、推进建议、应对方式,后续落入 `coachOnlyContext` 的 `## 最佳实践` | 未提供则整行不展示 |
| `{supplementRenderBlock}` | `userOutputTemplate.supplementRenderBlock`;由脚本按 `supplementItems` 预渲染的“补充素材”区块 | 无值时整块不展示 |
| `{errorReason}` | `error`(业务化转写,不直出英文码) | "未知原因" |
| `{nextAction_N}` / `{nextActionText}` | `userOutputTemplate.nextActions[N]`(若无则用固定兜底) | 省略该行 |
收集阶段补充约束(强制):
1. 默认先回显“已识别字段 + 待补充项”,再提问题;不要要求用户整单重填。
2. 每轮最多提出 2 个澄清问题;优先问最影响下一步的关键缺口。
3. 连续追问建议不超过 2 轮;达到上限仍缺失时,改为“关键缺口单问 + 其余待确认挂起”。
4. 当用户明确要求“请你引导我填”时,可改用分步问法,但最终仍仅回填既有结构化字段。
5. 上限触发后的收口建议:先按当前可确认信息继续,并明确列出“待确认项可随时补充更新”。
5.1. 在“每轮最多 2 问”约束下,除基础 6 项缺口外,优先在下列两类里任选其一追问(另一个保持为“可选补充”入口即可):A) 成功沟通经验/典型话术片段;B) 对象角色画像。选择规则:若用户已提供对话片段/关键话术→优先追画像;若仅给背景无沟通细节→优先追话术片段。
5.2. 当用户提供对象角色画像、代表话术、成功经验、推进建议等增强信息时,后续回显必须在“补充素材”区展示摘要;不得只写入 `generationNotes` 后对用户不可见。该区块不进入 `missingFields`,不阻塞 Gate 推进。
5.3. 若对象角色画像与代表成功经验/典型话术均未提供,则“补充素材(如已提供)”整块不展示,避免空区块干扰用户。
5.4. 若脚本返回 `userOutputTemplate.mustDisplaySupplementItems=true`,调用方必须展示 `userOutputTemplate.supplementRenderBlock` 或等价逐条回显 `supplementItems`;若 `outputBlockingRequirements` 非空,未满足前不得进入产品知识主题确认、知识检查或场景内容生成。
6. `KNOWLEDGE_CONFIRM` 阶段仅确认产品知识主题/正文,不向用户索要 `title`、`sceneBackground`。
7. 产品知识主题是必确认项:用户回复“暂无正文”仅表示 `knowledge` 为空,不得跳过 `productKnowledgeNeeds` 主题确认。
8. 异步审批完成回执(如 “An async command ... completed”)按系统事件处理,不作为新的用户业务输入。
9. `KNOWLEDGE_CONFIRM` 阶段必须同时展示:基础信息 6 项 + 产品知识确认项;禁止只展示产品知识主题问题。
10. 基础 6 项齐备但用户未明确确认时,仍属于 `BASE_INFO_CONFIRM`;只能预告“确认后会根据这些信息建议产品知识主题”,不得生成或要求确认 `productKnowledgeNeeds`。
11. `productKnowledgeNeeds` 必须作为“系统建议的产品知识主题”展示,不能让用户从零填写主题。
12. 产品知识主题必须按 `references/product-knowledge-topic-generate.md` 生成;代码不得内置业务主题替 Agent 出题。
13. 产品知识主题采用轻确认:展示建议主题后,只给“确认 / 删除 / 改名 / 新增”收口;不得要求用户补正文后才继续。
14. 推荐收口话术:`如无调整,请回复「确认」;也可以删除、改名或新增主题。`
15. 用户修改主题后,必须完整回显基础信息 6 项 + 最新产品知识主题,再请求轻确认。
16. 用户确认主题后,下一轮 parse payload 必须写入 `productKnowledgeNeedsConfirmed=true`(也可写入 `scene.productKnowledgeNeedsConfirmed=true` 或 `meta.productKnowledgeNeedsConfirmed=true`)。
17. `declineProductKnowledge=true` 不得用于跳过主题确认;历史 payload 中出现该字段时,只能理解为“不补充正文”。
18. `KNOWLEDGE_CONFIRM` 阶段同轮最多一次 parse;若仅补充展示文案,不得重复调用 parse。
19. 用户明确确认基础信息后,后续 parse payload 必须双写:顶层 `baseInfoAcknowledged=true` 且 `scene.baseInfoAcknowledged=true`(除非用户明确要求回退)。
20. 每轮内部自检(不对用户展示):记录 `turnId`、`payload.baseInfoAcknowledged`、`payload.scene.baseInfoAcknowledged`、`payload.productKnowledgeNeedsConfirmed`、`result.stage`,用于排查竞态与信号丢失。
医生画像增强(可选,不阻塞):
1. 触发时机:基础信息已基本齐备,且用户希望“更贴近真实医生”或当前信息对医生对象区分度不足。
2. 追问范围:每轮最多 2 问,优先问“沟通风格 + 核心关注”或“角色定位 + 主要异议点”。
3. 用户可见提问模板(示例):
- 为了让场景更贴近真实对象,我再确认两点:
- 这位医生更偏哪种沟通风格(理性看数据/时间紧张/谨慎保守)?
- 你预判他最可能提出的质疑是什么?
4. 若用户不补充,需明确“可先按当前信息继续,后续可随时补充医生特征”。
#### 模板 2:修改回显(mustEchoUpdatedConfirmation / updatedFieldLabels)
```text
你本轮已更新以下内容:{updatedFieldLabels}。
我已按最新内容更新确认清单,请你核对:
- {field_1_label}:{field_1_value}
- {field_2_label}:{field_2_value}
- {field_3_label}:{field_3_value}
(完整展示本轮应回显字段)
- 产品知识正文(可选):
- {knowledgeItemsOrNotProvidedHint}
- 补充素材(如已提供):
- 对象角色画像:{actorProfileSupplementOrOmit}
- 代表成功经验/典型话术:{bestPracticeSupplementOrOmit}
- 💡 可选补充(不影响继续,但能让场景更像真实沟通):
- 你当时最关键的一句话/应对方式是什么?医生怎么回?你如何把对话推进下去的?
- 💡 可选补充(对象角色画像,自由复述,2-3 句话即可):
- 这位医生大概是什么类型?他最在意什么、沟通风格怎样、最可能提出什么质疑?
以上修改是否准确?确认后我继续下一步。
```
#### 模板 3:落库前确认(validate 通过,准备 create)
```text
创建前请你做最后确认:
- 最终确认清单:
- {mustDisplayField_1_label}:{mustDisplayField_1_value}
- {mustDisplayField_2_label}:{mustDisplayField_2_value}
- {mustDisplayField_3_label}:{mustDisplayField_3_value}
(按 mustDisplayFields 完整展示)
- 产品知识主题:
- {productKnowledgeNeedsItems}
- 对练对象角色:
- {actorProfileSummary}
- 补充素材(如已提供):
- 对象角色画像:{actorProfileSupplementOrOmit}
- 代表成功经验/典型话术:{bestPracticeSupplementOrOmit}
- 产品知识正文(可选):
- {knowledgeItemsIfRecentlyUpdatedElseOmit}
如无误,请回复「确认」或「取消」。
```
内部绑定(禁止对用户展示):渲染模板 3 时记录本轮 `validationReport.displayHash`;用户确认后调用 create 必须传 `confirmedDisplayHash=validationReport.displayHash`。若确认后任何用户可见字段变化,必须重新渲染模板 3 并重新取得确认。
#### 模板 4A:落库结果(create 成功)
```text
创建成功 🎉
- 场景名称:{title}
- 场景背景:{sceneBackground}
- 关键信息:
- 业务领域:{businessDomainName}
- 科室:{departmentName}
- 产品:{drugName}
- 知识条目数量:{knowledgeCount}
{nextActionText}
```
#### 模板 4B:落库结果(create 失败)
```text
创建失败,原因:{errorReason}
- 建议下一步:
- {nextAction_1}
- {nextAction_2}
```
约束:
1. 模板中的字段值必须来自脚本输出,不得自行改写含义。
2. 当 `mustShowSceneBackgroundFullText=true` 时,`sceneBackground` 必须使用 `sceneBackgroundFullText` 原文。
3. create 成功/失败必须分别使用模板 4A / 4B,不得混用字段。
4. `productKnowledgeNeeds` 与 `knowledge` 是否展示,严格按“产品知识显示规则(强制)”执行。
FILE:references/parse-runtime-config.json
{
"fieldLabels": {
"title": "场景标题",
"businessDomainName": "业务领域",
"departmentName": "科室",
"drugName": "产品名称",
"location": "地点",
"doctorConcerns": "医生顾虑",
"repGoal": "代表目标",
"sceneBackground": "场景背景",
"productKnowledgeNeeds": "产品知识主题",
"knowledge": "产品知识正文(可选)",
"doctorOnlyContext": "对练对象侧上下文",
"coachOnlyContext": "教练侧上下文",
"actorProfile": "对练对象角色",
"generationNotes": "待确认说明"
},
"defaults": {
"notProvidedEvidenceSource": "用户确认暂无书面证据资料",
"declinedProductKnowledgeTopic": "用户确认暂不补充产品知识主题",
"partialEvidenceSource": "用户已确认场景所需产品知识主题,书面证据待补充",
"readyEvidenceSource": "用户已提供可用证据资料"
},
"declineRegexPatterns": [
"产品知识暂无",
"没有产品知识|无需产品知识|不提供产品知识",
"不要产品知识",
"不提供.*(产品知识|知识主题)",
"不提供.*(资料|证据).*(产品|书面)",
"暂不补充.*(知识|资料)"
],
"blockedPhrases": [
"确认",
"是",
"不是",
"对",
"不对",
"好的",
"ok",
"yes",
"no",
"暂无",
"没有",
"不清楚"
],
"bestPracticeKeywords": [
"话术",
"最佳实践",
"沟通技巧",
"应对",
"怎么说",
"开场",
"异议处理"
],
"deriveLimits": {
"minTopicLength": 2,
"maxTopicLength": 30,
"maxTopicCount": 6
},
"alignmentLimits": {
"minBackgroundLength": 40,
"minPieceLength": 3,
"missingPiecePreviewLength": 24,
"maxMissingPieces": 5
},
"inferReplyLimits": {
"maxCandidateLength": 40
},
"statusText": {
"toFill": "待补充",
"toConfirm": "请确认",
"needSupplement": "需要补充",
"noSupplementNeeded": "无需补充",
"ready": "已具备",
"pendingStart": "待开始",
"pendingConfirm": "待确认"
},
"alignmentText": {
"noBackground": "场景背景尚未生成,暂不做对齐评估。",
"backgroundTooShort": "场景背景偏短,不建议跳过场景正文生成。",
"missingCoreInputs": "缺少医生顾虑与代表目标,无法做对齐判断。",
"missingRepGoalLabel": "代表目标",
"coreNotCoveredPrefix": "场景背景未完全覆盖以下要点:",
"coveredOk": "场景背景已覆盖医生顾虑与代表目标要点,可建议跳过场景正文再生成。"
},
"changeSummaryText": {
"updatedFields": "本轮字段更新:{labels}。",
"canSkipS3": "编排提示:可跳过 scenario-json-parse 再生成,但仍须走 TBV 与 PRE。",
"shouldRunS3": "编排提示:建议执行 scenario-json-parse 完整生成或补全后再校验。"
},
"pendingNotesText": {
"baseInfoStage": "当前先确认业务领域、科室、产品、地点、医生顾虑、代表目标;标题、场景背景和上下文稍后统一生成。",
"knowledgeStageBaseAck": "基础信息已由用户确认,系统已根据当前场景给出产品知识主题建议;如无调整,请回复“确认”,也可删除、改名或新增主题。产品知识正文可稍后补充。",
"knowledgeStageBaseUnack": "基础信息字段已识别,但仍需用户核对是否准确;核对无误后请由 Agent 在下一轮解析请求中携带 baseInfoAcknowledged=true,再进入场景内容生成。",
"knowledgeStageOptionalKnowledge": "产品知识正文补充是可选的:可以只确认知识主题关键词,也可以额外补充知识正文/资料来源供创建前解析。",
"readyForGeneration": "基础信息与产品知识/资料已具备;此时应在内部执行场景内容生成,再进入校验。",
"autoDerivedNeeds": "已按产品知识主题生成规范建议主题,需用户确认后再进入场景内容生成。",
"bestPracticeAdopted": "已采纳你补充的代表话术/最佳实践内容,将归入教练侧上下文(coachOnlyContext)的“## 最佳实践”小节。"
},
"baseQuestionMap": {
"businessDomainName": "这是哪个业务领域?请从临床推广、院外零售、学术合作、通用能力中选择一个。",
"departmentName": "这次主要对应哪个科室?",
"drugName": "这次对应的具体产品或品种是什么?",
"location": "这个场景发生在什么地点?",
"doctorConcerns": "医生对产品、证据、安全性、费用、可及性或推广方式的具体顾虑/异议是什么?",
"repGoal": "代表本次沟通最想达成的目标是什么?"
},
"knowledgeQuestionText": {
"backgroundHintAck": "已确认的业务背景",
"backgroundHintUnack": "当前识别出的业务背景(请同时核对上方基础信息是否准确)",
"confirmNeeds": "请核对上方“产品知识主题”:如无调整,请回复“确认”;也可以删除、改名或新增主题。正文可稍后补充。",
"confirmBaseFirst": "请先明确确认上方基础信息是否全部准确;确认后由 Agent 在下一轮 tbs-scene-parse 请求中设置 baseInfoAcknowledged=true。"
},
"contentQuestionText": {
"echoUpdated": "本轮用户已更新:{labels}。请先向用户回显更新后的确认清单并请其确认。",
"genTitleBackground": "请在内部生成场景标题与场景背景。",
"genActorProfile": "请在内部补齐对练对象角色画像(至少包含 name);不向用户单独展示该字段确认项。",
"genContexts": "请在内部生成对练对象侧上下文与教练侧上下文,用户无需逐段确认正文。"
},
"nextActionText": {
"baseInfo": "请先补充并确认基础信息;确认后再分析产品知识需求与资料情况。",
"knowledgeBaseFirst": "请先引导用户核对基础信息是否准确;用户明确确认后,在下一轮解析请求 JSON 顶层设置 baseInfoAcknowledged=true,再继续确认产品知识/资料并进入内部生成。",
"knowledge": "请展示基础信息 6 项和系统建议的产品知识主题;如无调整请用户回复“确认”,也允许删除、改名或新增。产品知识正文补充可选。",
"readyForGenerationWithEcho": "请先向用户回显更新后的确认清单(重点:{labels}),确认无误后再内部生成场景内容;生成完成后重新运行本脚本并进入场景校验。",
"readyForGeneration": "请在内部执行场景内容生成;生成完成后重新运行本脚本,再进入场景校验。",
"readyForValidateWithEcho": "用户本轮已更新({labels}),请先回显最新确认清单并确认无误,再执行场景校验。",
"default": "请确认上述关键信息;如无误,可以继续执行场景校验。"
},
"systemActionHintText": {
"baseInfo": "先与用户确认基础信息;此阶段不要提前执行 scenario-json-parse 全量生成。",
"knowledgeBaseFirst": "先引导用户核对基础信息;未携带 baseInfoAcknowledged=true 前,不要进入 scenario-json-parse 全量生成。",
"knowledge": "按 references/product-knowledge-topic-generate.md 基于已确认基础信息生成产品知识主题,并给用户轻确认;未收到主题确认前,不要进入场景内容生成。",
"readyForGenerationWithEcho": "检测到用户本轮更新字段({labels});先向用户回显更新后的确认清单,再内部读取 references/scenario-json-parse.md + references/*.json 生成内容。",
"readyForGeneration": "现在再内部读取 references/scenario-json-parse.md + references/*.json,生成 title、sceneBackground、actorProfile、doctorOnlyContext、coachOnlyContext。",
"readyForValidateWithEcho": "检测到用户本轮更新字段({labels});先回显更新后的确认清单,再执行 tbs-scene-validate.py。",
"default": "执行 tbs-scene-validate.py,确认是否达到最终创建前门禁。"
},
"stageLabelText": {
"BASE_INFO_CONFIRM": "先确认基础信息",
"KNOWLEDGE_CONFIRM": "再确认产品知识与资料",
"READY_FOR_SCENE_GENERATION": "已可内部生成场景内容",
"READY_FOR_VALIDATE": "已可执行场景校验"
},
"phaseTitleText": {
"BASE_INFO_CONFIRM": "基础信息确认",
"KNOWLEDGE_CONFIRM": "产品知识与资料确认",
"READY_FOR_SCENE_GENERATION": "场景内容生成"
},
"errorText": {
"missingInput": "缺少 userText 或结构化 scene",
"patchLockedHint": "在基础信息已确认后不可再补丁修改基础字段;进入场景内容生成或校验阶段后,仅允许补丁更新场景标题与场景背景(含 background 同义键)。",
"patchLockedPastKnowledgeSuffix": " 已进入场景内容生成或校验阶段:`parsedFields` / `userUpdates` / `userConfirmedFields` / `userProvidedFields` 仅允许 `title`、`sceneBackground`、`background`。被拒字段请改写到请求 JSON 顶层的 `scene` 对象中(与已有草稿合并)后再调用本脚本,勿再通过上述补丁键覆盖。",
"patchLockedBaseAckSuffix": " 被拒字段属于已确认基础六字段:请先在未声明 `baseInfoAcknowledged` 的轮次纠正,或由用户明确同意回退后再改;勿再通过补丁键覆盖已锁定基础项。"
}
}
FILE:references/scenario-json-parse.md
# 已确认骨架 → `scene` 内容生成 JSON
本文件用于编排调用方在对话内调用模型时的系统提示词规范。目标是:在**基础信息**与**产品知识/资料状态**已经确认后,再由模型内部补齐 `scene` 的剩余内容字段,避免一开始就全量生成整份场景草稿。
> **⛔ 硬性前置条件**:本文件**只能**在 `tbs-scene-parse.py` 返回 `stage=READY_FOR_SCENE_GENERATION` **之后**使用。若 Agent 未先执行 parse 脚本并确认 stage,则**禁止**读取本文件并生成内容。违反此规则将导致跳过用户分阶段确认,产出不合规的全量场景草稿。
> **产品知识边界**:本文件只消费已确认的 `productKnowledgeNeeds`,不得生成、改名、补充或替用户确认产品知识主题。
对用户可见话术遵守 `common-params.md`(本文件不重复)。
---
## 0) 使用时机(重要)
- **不要**把本文件作为自然语言场景的第一步。
- 第一阶段应先由 `tbs-scene-parse.py` 收集并确认以下基础信息:
- `businessDomainName`
- `departmentName`
- `drugName`
- `location`
- `doctorConcerns`
- `repGoal`
- 第二阶段再确认:
- `productKnowledgeNeeds`
- 第二阶段中,`productKnowledgeNeeds` 的来源应是:基于已确认的业务领域、科室、品种、地点、医生顾虑、代表目标,**先分析出当前训练场景应覆盖的产品知识主题/关键词**,再交由用户确认、调整或补充。
- 第二阶段用户侧必须拆开展示:“产品知识主题”对应 `productKnowledgeNeeds`;“产品知识正文(可选)”对应 `knowledge`。无正文时只提示“当前未提供正文(可稍后补充)”。
- 产品知识主题确认采用轻确认:系统展示 2-4 条建议主题,用户可直接回复“确认”,也可删除、改名或新增;确认后才进入本文件。
- 用户对产品知识的补充是**可选**的:
- 可以只确认 `productKnowledgeNeeds` 关键词;
- 也可以额外补充知识正文,写入 `scene.knowledge` 供后续创建前解析;
- 如果用户暂时不补充正文,不应阻断后续场景生成。
- 用户补充“代表话术/经验”时,默认归入 `coachOnlyContext` 的 `## 最佳实践`(必要时同步微调 `repGoal`)。
- **仅当以上信息已稳定后**,才在内部使用本文件补齐场景内容字段:
- 核心生成字段:`sceneBackground`、`doctorOnlyContext`、`coachOnlyContext`
- 最小补齐字段:`title`、`actorProfile`(仅在缺失时补齐,已有则保留)
---
## 1) 提取总原则(scene 单对象)
- 只输出一个 UTF-8 JSON 对象,字段键名必须与 Schema 完全一致。
- 输出对象即 `scene` 语义字段,不再使用旧结构键名。
- 对已确认字段遵循“**能复用就复用,非必要不改写**”原则,避免覆盖用户已确认的基础信息。
- 字段补齐采用固定顺序,保证稳定性:
1. 已确认主数据字段:`businessDomainName`、`departmentName`、`drugName`、`location`
2. 已确认训练目标字段:`doctorConcerns`、`repGoal`
3. 产品知识字段:`productKnowledgeNeeds`(已确认输入,只复用)
4. 用户可选补充的知识正文:`knowledge`
5. 待生成内容字段:`sceneBackground`、`doctorOnlyContext`、`coachOnlyContext`
6. 缺失时最小补齐字段:`title`、`actorProfile`
- 不确定信息允许保守推断,但必须写入 `generationNotes` 标注待确认。
- 禁止编造输入中未出现的具体数据、制度条文、研究结论、系统名。
---
## 2) 共用系统提示词(System)
```text
你是企业训战「对话场景」设计专家。你必须只输出一个 UTF-8 JSON 对象,符合用户消息中给出的 JSON Schema:键名与层级完全一致,字符串值为简体中文,可直接用于后台配置。
规则:
1. 已确认字段优先保留:businessDomainName、departmentName、drugName、location、doctorConcerns、repGoal、productKnowledgeNeeds。
- 除非输入里出现了更明确、且不与用户确认内容冲突的新事实,否则不要改写这些字段。
- `doctorConcerns` 在收集阶段建议控制为 1-2 条;本阶段仅复用,不扩写为更多条目。
2. 必填主数据字段:title、businessDomainName、departmentName、drugName、location、doctorConcerns、repGoal。
- businessDomainName 仅允许:临床推广 / 院外零售 / 学术合作 / 通用能力。
- businessDomainName 视为上阶段已确认结果,本阶段只保留,不重新确认。
- drugName 与训战活动配置的品种/产品或训练主题口径一致;若输入未给出,允许合理推断并在 generationNotes 标注待确认。
3. 必填正文字段:sceneBackground、doctorOnlyContext、coachOnlyContext。
- sceneBackground 需写成一段自然叙述,优先采用「场景对象与关系 + 核心冲突/顾虑 + 本次沟通目标」结构,避免空泛背景描述。
- sceneBackground 必须满足:长度 <= 180;不得包含【】或“待补充”;不得出现“场景背景:/人物关系:/训练目的:/开场建议:/AI角色对象的顾虑:”等标签化写法。
- sceneBackground 必须覆盖以下语义要素:场景发生时机/地点、双方角色关系、关键顾虑点、代表本次希望达成的训练目标(如达成共识/推动准入/优化用药方案)。
- 锚点匹配规则(与 `tbs-scene-validate.py` 对齐):`departmentName` 与 `location` 需作为子串出现在 `sceneBackground` 中;`drugName` 允许以**括号前主名称**作为锚点(例如 `drugName` 为“美泰彤(甲氨蝶呤针剂)”时,正文出现“美泰彤”即可)。
- sceneBackground 中不得出现具体姓氏/姓名(如“王某某”“李某某”);可使用“主任/医生/医师/药剂科主任”等职业称谓,但避免“某主任+具体姓氏”的组合写法。
- sceneBackground 中避免使用第一、第二人称代词(如“你/我/你们/我们/咱/咱们”),优先使用角色称谓与第三人称客观叙述。
- doctorOnlyContext 与 coachOnlyContext 均为纯字符串,允许在字符串内使用 Markdown 小节组织内容。
4. doctorOnlyContext(对练对象侧)要求:
- 不绑定固定行业身份,按场景写清对练对象称谓(如上级/下属/同事/客户/合作方/医师等)。
- 需体现:角色立场、具体担忧、对话行为、可追问方向。
- doctorOnlyContext 必须且按顺序包含以下 6 节标题:
## 已知背景
## 核心顾虑
## 今日状态
## 终止条件
## 输出要求
## 对话结束规则(强制)
- `## 核心顾虑` 必须是 1-2 条 bullet(最多 2 条)。**计数规则**:该小节内凡以 `-` 开头的行均计为一条 bullet;超过 2 条时 `tbs-scene-validate.py` 会在校验前自动合并为 2 条,但仍应在模型侧避免滥发列表,以免语义被挤进单条过长叙述。
- **输出 JSON 前强制自检**:若你写出了 3 条及以上「`-` 核心顾虑」,必须先合并为 2 条再输出;不要依赖后处理。
- `## 输出要求` 与 `## 对话结束规则(强制)` 两节内容必须逐字使用本文件“对话结束规则参考模板”中的固定条目,不得改写、增删、换序。
- `## 终止条件` 可按场景定制,但必须可判定、与输入事实一致。
- 此字段属于模型内部生成与系统校验内容,不要求向用户逐段确认正文。
5. coachOnlyContext(教练侧)要求:
- 必须包含以下 5 节标题:
## 期望代表行为
## 评分重点
## 终止条件
## 最佳实践
## 输出要求
- 内容需可观察、可评估;不得出现 `[对话结束]`。
- 此字段属于模型内部生成与系统校验内容,不要求向用户逐段确认正文。
6. actorProfile:
- 必须提供,且至少含 name。
- 推荐补充 roleType、title、description、personaConfig。
- 若无需人设细节,可仅保留最小结构(例如:`{"name":"..."}`)。
7. 产品知识:
- productKnowledgeNeeds 是上游已确认输入,本阶段只复用,不生成、不改名、不扩展。
- 若用户提供了可落库的产品知识正文,可写入可选字段 `knowledge`;每条知识建议至少包含 `category`、`title`、`content`(`category` 由用户提供,如“整体介绍”),可选补 `evidenceSource`、`evidenceStatus`。这些条目不要求在本提示词阶段创建 ID,只需保留到创建前链路。
8. 代表话术/经验吸收规则:
- 若用户提供了代表实战经验、常用话术或应对技巧,默认写入 `coachOnlyContext` 的 `## 最佳实践` 小节。
- 不要把“代表应对策略”并入 `doctorConcerns`。
9. generationNotes:
- 仅记录不确定点、待确认点、推断依据,不写与输入矛盾的“确定事实”。
10. 禁止在 JSON 外输出任何字符(不要 markdown 围栏)。
11. 字段名必须与 schema 完全一致。
```
---
## 3) 统一用户提示词(在基础信息与资料已确认后使用)
```text
【已确认的业务背景】
{{user_input}}
【可选补充信息】
- 产品知识材料(可空):{{product_knowledge}}
- 学员在训战中扮演的角色:{{trainee_role}}
- 期望对话形态:{{dialogue_type}}
- 品种/产品或训练主题(已知则填):{{product_name}}
- 部门/组织单元偏好:{{department_hint}}
- 是否需要详细对话结束规则(是/否):{{need_end_rules}}(为「是」时,须在 doctorOnlyContext 内用独立小节写全结束规则)
【任务】
1. 以上输入已经过用户确认,请在不违背已确认事实的前提下,**只补齐本阶段负责的场景内容字段**,不要把本阶段改写成重新确认基础信息或资料状态。
2. 本阶段核心生成:`sceneBackground`、`doctorOnlyContext`、`coachOnlyContext`。
3. `title`、`actorProfile` 仅在缺失时做最小补齐;若输入中已存在,则直接保留,不作为本阶段重点改写对象。
4. `businessDomainName`、`departmentName`、`drugName`、`location`、`doctorConcerns`、`repGoal`、`productKnowledgeNeeds` 若已提供,则直接复用,不重新确认、不擅自改写。
5. 若输入中已给出 `knowledge`,请直接保留并透传;若用户只确认了知识主题、未给完整正文,不要生成知识正文草案,只保留已确认主题。
6. 结束硬约束仅写入 `doctorOnlyContext`,不新增独立 JSON 字段;`need_end_rules` 仅用于调整场景语气,不影响固定结构输出。
【输出】
仅输出 JSON 对象,结构严格符合 System 中描述的 Schema。
```
---
## 4) 占位符说明
| 占位符 | 含义 | 未提供时 |
|--------|------|----------|
| `{{user_input}}` | 用户任意输入(可是一句话、脚本片段、业务背景、需求描述) | 必填 |
| `{{product_knowledge}}` | 产品知识全文或节选(可选) | 可为空 |
| `{{trainee_role}}` | 学员扮演角色 | 由模型推断 |
| `{{dialogue_type}}` | 如上下级辅导、跨部门、绩效面谈 | 由模型推断 |
| `{{product_name}}` | 品种/产品或训练主题标识 | 推断 + generationNotes |
| `{{department_hint}}` | 部门/组织单元偏好 | 可忽略 |
| `{{need_end_rules}}` | 是否要求把结束硬约束写进 doctorOnlyContext | 默认否;为是时必须完整撰写 |
---
## 5) 生成后自检(程序或人工)
- [ ] 可解析为合法 JSON,且不含 JSON 外字符
- [ ] `sceneBackground` 长度 <= 180,且覆盖 departmentName、drugName、location 三个锚点信息(其中 `drugName` 允许以括号前主名称命中);为一段自然叙述,不含“人物关系:/训练目的:”等标签化前缀
- [ ] `businessDomainName` 取值属于:`临床推广` / `院外零售` / `学术合作` / `通用能力`
- [ ] `coachOnlyContext` 包含五节固定标题,且不含 `[对话结束]`
- [ ] `doctorOnlyContext` 包含六节固定标题;`## 核心顾虑` 为 1-2 条以 `-` 开头的 bullet(输出前自检,勿超过 2 条);`## 输出要求` 与 `## 对话结束规则(强制)` 逐字匹配模板固定条目
---
## 6) `doctorOnlyContext` 固定模板(与 `tbs-scene-validate.py` 逐行一致)
整段 `doctorOnlyContext` 必须是 Markdown 字符串,且 **6 个二级标题按以下顺序出现**(标题字面值须完全一致,勿改标点或空格):
1. `## 已知背景`
2. `## 核心顾虑`(正文为 **1~2** 条以 `-` 开头的要点行)
3. `## 今日状态`
4. `## 终止条件`(可按场景自定义若干条 `-` 要点,**不参与**逐字比对)
5. `## 输出要求`(下文「固定 A」:**逐行**拷贝,不得改写/增删/换序)
6. `## 对话结束规则(强制)`(下文「固定 B」:**逐行**拷贝,且须放在全文最后一个小节)
> 生成后调用 `tbs-scene-validate.py` 时,成功/失败响应里的 `userOutputTemplate.doctorOnlyContextCanon` 会再次给出 `outputRequirementsLines` 与 `endingRulesLines`;**以脚本输出为准**,本文件与之冲突时以脚本为准。
### 固定 A — `## 输出要求` 正文(逐行原样)
```text
- 输出长度控制:每次回复控制在30-50字左右,保持真实医生沟通的自然简洁;每轮最多聚焦1个核心点。
- 单问原则:每轮最多提出1个核心问题(问号≤1)。如果想到第二个问题,必须留到下一轮再问。
- 语言要求:以中文自然对话为主;允许必要的医学缩写/单位/符号,但不得滥用英文;严禁出现与医学沟通无关的英文单词。
- 纯文本要求(强制):只输出纯文本对话,不要使用任何加粗/斜体/标题/代码符号等格式化写法。
- 提问后必须等待代表回答:提问后必须收住,不得在同一轮连续追问,更不得在提问后追加结束标记。
- 避免臆造数据(强制):不得凭空编造背景之外的具体数值/比例/研究结论;不确定就说明需回去核对资料。
```
### 固定 B — `## 对话结束规则(强制)` 正文(逐行原样)
```text
- 只有对练对象角色可结束:仅在本轮末尾追加 [对话结束],且必须放在全文最后。
- 允许结束:已触发终止条件,或系统明确要求本轮结束(最后一轮/轮次已满)。
- 互斥(执行检查):若本轮出现问号或疑问词,则必须删除 [对话结束]。
- 互斥(执行检查):若本轮要输出 [对话结束],则全文不得出现任何问号或疑问词,且不得出现提问意图。
- 结束语边界:结束语必须是纯陈述句,不得提问,也不得安排任何后续动作或要求。
```
### `## 终止条件` 示例(可改写;勿与固定 A/B 合并成一节)
以下为结构示例,**不**要求与脚本逐字一致,但须可判定、与输入事实一致:
```markdown
## 终止条件
- 出现无依据的绝对化承诺,或夸大效果、隐瞒重大限制与风险。
- 回避本场景已点明的关键问题(需替换为与本场景一致的条目)。
- 编造数据/证据,或引用来源不清、前后矛盾。
- 单向灌输、拒绝回应对方异议,导致沟通目标失真。
```
---
## 7) 用户消息中须附带的 JSON Schema
调用方应在同一次用户消息或紧随其后的消息中附上完整 JSON Schema,确保键名与层级严格一致。
| 用途 | 文件 |
|------|------|
| 模型单次输出与场景草稿契约(两阶段共用) | `references/scene.schema.json` |
FILE:references/doc-consistency.md
# doc-consistency 模块说明
该模块用于发布前执行**文档一致性自检**,只检查 `.md` 文档规则,不参与运行时链路(parse/validate/create)。
## 触发场景
- 修改了 `SKILL.md` 或 `references/*.md` 的流程门禁、模板口径、字段定义。
- 发布前想快速确认没有口径回退或术语漂移。
## 输入
- 无业务输入参数。
- 直接扫描 skill 根目录下:
- `SKILL.md`
- `references/*.md`
## 动作
- `check-doc-consistency.py`
- 校验禁用术语(如废弃阶段简称)
- 校验关键口径(如医生关注点条数)
- 校验阶段规则与模板单一真源约束
## 输出
- 通过:`OK: doc consistency checks passed.`
- 失败:逐条输出 `文件:行号:错误说明`,并返回非零退出码。
FILE:references/maintenance.md
## 维护说明
需求对齐以当前 `references/*.md` 与 `scripts/*.py` 的一致性为准。
版本要点:
- **v0.6.1**:补丁锁、TBV、`meta`+create 门禁、回显协议、create 自校验。
- **v0.6.3**:`patch_fields_locked` 分层 hint、`doctorOnlyContext` 诊断、`createAgentHints` / `preCreateBlockedReminder`。
- **v0.6.4**:补齐 Intent 边界与鉴权预检;移除历史 `tbs_rules.py`。
- **v0.6.5**:`scripts/` 恢复扁平布局。
- **v0.6.6**:移除 `prompts/`;Schema 统一迁入 `references/`。
- **v0.6.7**:新增 `--mode fast_forward` 与 `--no-write-draft`。
- **v0.6.8**:强化 validate 的 FULL/TBV 调用决策。
- **v0.6.9**:收敛 `common-params.md` 的模板触发与字段优先级。
- **v0.6.10**:按 Gate 重构 `SKILL.md`;拆出 `output-templates.md` 与 `review-checklist.md`。
- **v0.6.11**:入口脚本新增 `--output` 契约,完整 JSON 写文件,stdout/stderr 仅输出摘要。
- **v0.6.12**:产品知识主题改为系统建议后用户确认;新增 `productKnowledgeNeedsConfirmed` 推进门禁。
- **v0.6.13**:拆分产品知识主题与产品知识正文展示口径,禁止把正文缺失提示放到主题字段下。
- **v0.6.14**:`declineProductKnowledge` 不再绕过主题确认;产品知识主题不可跳过,正文可暂无。
- **v0.6.15**:基础 6 项齐备但未确认时仍停留 `BASE_INFO_CONFIRM`,只预告后续生成主题。
- 修改 `SKILL.md` 中的 `version` 时,须同步更新 `version.json`
- 新增脚本时,必须同步更新:
- `SKILL.md`
- 对应 `references/*.md`
- 目录结构说明
- JSON Schema 文件并入 `references/` 管理:`references/scene.schema.json`(两阶段共用,已移除冗余的 `scenario-json-parse.model.schema.json`)。
- 修改 `references/scenario-json-parse.md` 中的字段约束时,必须同步检查:
- `references/scene.schema.json`
- `scripts/tbs-scene-validate.py`
- 发布前须 diff `references/scenario-json-parse.md` 固定节 A/B 正文与 `scripts/tbs-scene-validate.py` 中 Canon 文本是否完全一致
- 若变更真实创建接口字段,必须同时核对 `规范和接口/TBS_ADMIN_API_REFERENCE.md`
- 若变更编排判定口径(`success`/`error`/`stage`/`validationReport`/`userConfirmation` 等),必须同步更新:
- `SKILL.md`
- `references/common-params.md`
- `references/tbs-scene-parse.md`
- `references/tbs-scene-validate.md`
- `references/tbs-scene-create.md`
- 若变更**用户可见输出**(拦截词、失败转写、收口等):以 `references/common-params.md` 为真源,并核对 `SKILL.md`、`tbs-scene-parse.md`、`tbs-scene-validate.md`、`agent-patterns.md`、`scenario-json-parse.md` 中的交叉引用是否仍成立。
- 若变更**用户可见模板**:以 `references/output-templates.md` 为真源,并核对 `common-params.md`、`review-checklist.md`、`agent-patterns.md` 的引用。
- 若变更**运行时自检**:以 `references/review-checklist.md` 为真源,并核对 `SKILL.md` 的 Gate 入口条件。
- 若变更 `tbs-scene-parse.py` 的字段标签、阶段提示、追问文案或轻量关键词规则:优先修改 `references/parse-runtime-config.json`,避免在脚本中新增硬编码。
## 结构例外说明(本 Skill 约定)
- 本 Skill 不保留 `prompts/` 目录;Schema 与说明文档统一放在 `references/`。
- **`scripts/` 目录扁平化**(对齐 `cms-cwork-workflow`):入口脚本 `tbs-scene-*.py` 与共享库 `tbs-client.py`、`tbs-md-sanitize.py` 同级;不设 `lib/` 子目录。
交互式引导用户一步步建立养虾文件体系,从"用AI工具"升级到"培养AI数字员工"。触发词:养虾、带我去养虾、如何养虾、从零开始养虾、养虾引导
---
name: cms-yangxia-skill
description: 交互式引导用户一步步建立养虾文件体系,从"用AI工具"升级到"培养AI数字员工"。触发词:养虾、带我去养虾、如何养虾、从零开始养虾、养虾引导
---
# cms-yangxia-skill / 养虾引导师
---
## 核心概念
| 概念 | 说明 |
|------|------|
| 养虾 | 不是用工具,而是培养一个能长期协作的数字员工 |
| 养虾的本质 | 养文件 — 把规则、记忆、经验、方法固化到文件系统 |
| Skill | 将做事方法沉淀为可调用的指令集 |
---
## 协作闭环
```
养熟 → 给资料 → 做任务 → 复盘 → 下次更好(循环)
```
---
## 养虾五件套
| 文件 | 解决什么问题 |
|------|-------------|
| **性格设定** | 它是谁 — AI的角色定位和行为边界 |
| **用户档案** | 我是谁 — 用户的身份、背景、偏好 |
| **长期记忆** | 它记住了什么 — 重要事件、决策、上下文 |
| **反思进化** | 它学会了什么 — 经验、教训、方法提炼 |
| **知识库** | 它掌握了什么 — 专业知识、术语、参考资料 |
---
## 养虾步骤(交互式引导流程)
### Step 0 / 欢迎
**系统行为:**
当用户触发养虾流程时,发送欢迎语并介绍整体路径,不立即开始执行任何文件操作。
**欢迎语:**
```
🪼 欢迎来到「养虾引导师」!
我来陪你一步步把 AI 从一个问答工具,培养成真正懂你、能配合你工作的数字员工。
整个过程分 5 步:
1️⃣ 性格设定 — 让它知道自己是谁
2️⃣ 用户档案 — 让它了解你是谁
3️⃣ 长期记忆 — 教会它记住重要上下文
4️⃣ 反思进化 — 帮它从经验中学习
5️⃣ 知识库 — 喂给它你的专业知识
每一步我都会告诉你为什么要做、怎么做、做完后结果在哪里。
全部完成后,你就拥有了一只「养熟」的虾 🦐
---
准备好了吗?我们从第 1 步开始——
```
**等待用户确认后,进入 Step 1。**
---
### Step 1 / 性格设定
**目的:** 让AI拥有稳定的角色定位和行为边界,不再用通用模板回应。
**系统行为:**
发送说明 → 询问基本信息 → 生成性格设定文件 → 写入 `~/SOUL.md` → 汇报结果
**说明语:**
```
📋 第 1 步:性格设定
【为什么做】
性格设定解决的是"它是谁"的问题。
没有这个,它永远用一套通用话术回应你,像个客服机器人。
有了这个,它开始有自己的角色感、行为准则和边界意识。
【怎么做】
我需要你回答 3 个问题:
1. 这个 AI 助手叫什么名字?(如:小虾、灵犀、或者你来定)
2. 你希望它的性格/风格是怎样的?(如:严谨专业、高效简洁、亲切随和)
3. 有没有什么它绝对不应该做的事?(如:不要发未经确认的外部消息、不要擅自做重大决策)
【输出位置】
写入对应文件:SOUL.md
```
**交互:**
等待用户回答3个问题后,生成并写入 `~/SOUL.md`,然后发送:
```
✅ 性格设定完成!
已写入:SOUL.md
名称:<name>
性格:<personality>
禁忌:<boundaries>
---
下一步:用户档案
```
**进入 Step 2。**
---
### Step 2 / 用户档案
**目的:** 让AI系统性了解用户是谁,包括身份、背景、偏好。
**系统行为:**
发送说明 → 引导用户填写/确认 → 写入 `~/USER.md` → 汇报结果
**说明语:**
```
📋 第 2 步:用户档案
【为什么做】
用户档案解决的是"我是谁"的问题。
AI 了解你越多,回复越精准、越贴合你的实际场景。
这也是后续所有协作的上下文基础。
【需要确认的信息】
1. 你的基本信息(姓名、部门、职位)
2. 你的工作背景(负责什么、常用工具、典型工作场景)
3. 你与 AI 协作的偏好(喜欢详细还是简洁?习惯中文还是中英双语?)
【输出位置】
写入对应文件:USER.md
```
**交互:**
等待用户提供信息,写入 `~/USER.md`,然后发送:
```
✅ 用户档案完成!
已写入:USER.md
姓名:<name>
部门:<department>
职位:<role>
偏好:<preferences>
---
下一步:长期记忆
```
**进入 Step 3。**
---
### Step 3 / 长期记忆
**目的:** 建立AI的记忆机制,让多次协作之间保持上下文连续性。
**系统行为:**
发送说明 → 解释记忆文件结构 → 说明当前 session 的记忆写入规则 → 汇报结果
**说明语:**
```
📋 第 3 步:长期记忆
【为什么做】
长期记忆解决的是"它记住了什么"的问题。
没有记忆机制,每次对话 AI 都从零开始,像金鱼一样。
有了记忆机制,它能记住重要的决策、上下文、你纠正过的点。
【怎么做】
我会帮你建立两层记忆体系:
📁 Daily 记忆(日常笔记)
用途:每次 session 后,自动记录本次发生的重要事件
规则:我会在 session 结束时主动写入,你也可以随时让我记录
📁 MEMORY.md(精选长期记忆)
用途:经过筛选的核心记忆——重要决策、人格偏好、教训经验
规则:每周回顾一次 daily 记忆,将值得沉淀的内容晋升到这里
【当前操作】
我会帮你初始化记忆文件体系。
之后的每次对话结束,如果你有值得记住的内容,我会提醒你确认写入。
```
**交互:**
初始化记忆文件,然后发送:
```
✅ 长期记忆体系建立完成!
已初始化:
Daily 记忆文件
MEMORY.md
机制说明:
• 每次 session 结束,我会问你"有什么需要记住的吗?"
• 重要内容会自动沉淀到 MEMORY.md
• 你也可以随时说"记住 xxx",我会立即写入
---
下一步:反思进化
```
**进入 Step 4。**
---
### Step 4 / 反思进化
**目的:** 让AI能够从过往任务中总结经验、提炼方法、避免重复犯错。
**系统行为:**
发送说明 → 解释反思机制 → 初始化反思文件 → 汇报结果
**说明语:**
```
📋 第 4 步:反思进化
【为什么做】
反思进化解决的是"它学会了什么"的问题。
只有反思,才能让 AI 从错误中学习、从成功中提炼方法,
而不是每次都犯同样的错。
【怎么做】
我会帮你建立一套反思文件体系:
📁 corrections.md
用途:记录被纠正的错误(重复 3 次 × 7 天 → 晋升到 memory.md)
规则:每次你纠正我,我立即写入;达到晋升条件时主动提醒你确认
📁 memory.md
用途:确认的持久规则(已从 corrections 晋升)
规则:经过验证、被你认可的教训,永久固化
【当前操作】
我会帮你初始化反思文件体系。
```
**交互:**
初始化反思文件,然后发送:
```
✅ 反思进化体系建立完成!
已初始化:
corrections.md (错误记录)
memory.md (持久规则)
使用方式:
• 你纠正我 → 我立即写入 corrections.md
• 同一错误被纠正 3 次、持续 7 天 → 我主动提议晋升到 memory.md
• 晋升需你确认,确认后规则永久生效
---
下一步:知识库
```
**进入 Step 5。**
---
### Step 5 / 知识库
**目的:** 让AI掌握用户所在领域的专业知识,包括术语、工作流程、参考资料等。
**系统行为:**
发送说明 → 引导用户提供知识素材 → 建立初始知识库文件 → 汇报结果
**说明语:**
```
📋 第 5 步:知识库
【为什么做】
知识库解决的是"它掌握了什么"的问题。
有了知识库,面对你的专业领域问题,AI不再是门外汉,
而是一个具备基本认知的协作者。
【怎么做】
请告诉我,你希望你的数字员工了解哪些方面的知识?
例如:
• 你所在的行业/领域基础知识
• 公司的业务背景
• 你常用的工具、平台、技术栈
• 常见的工作流程和规范
• 任何你觉得它应该知道的东西
你可以通过以下任意方式提供:
1. 直接告诉我内容(我来整理)
2. 上传已有的文档(我来提取要点)
3. 给我一个文档链接(我来读取)
【输出位置】
写入对应文件:knowledge/ 目录下相关文件
```
**交互:**
用户选择方式提供知识内容后,整理写入 `knowledge/` 目录,发送:
```
✅ 知识库初始化完成!
已创建:
knowledge/index.md (知识库索引)
knowledge/<domain-1>.md (领域知识-1)
knowledge/<domain-2>.md (领域知识-2)
...
后续你可以随时扩充,也可以说"把 xxx 加入知识库",我会自动整理。
```
**进入 Step 6。**
---
### Step 6 / 创建第一个 Skill(可选)
**目的:** 让AI掌握一件具体的做事方法,而不只是停留在"懂你"的层面。
**系统行为:**
发送说明 → 询问用户是否有具体的任务想让AI学习 → 如果有,触发 Skill 创作流程
**说明语:**
```
📋 第 6 步(可选):创建你的第一个 Skill
【为什么做】
前 5 步解决的是"懂你"的问题。
但懂你不等于会干活——Skill 让 AI 掌握做事的方法论,
把一个具体任务的执行流程固化下来,下次同类任务直接调用。
【Skill 的结构】
一个 Skill 包含三个要素:
• 触发条件 — 什么时候用这个技能
• 执行步骤 — 怎么做(分步说明)
• 完成标准 — 做成什么样才算合格
【例如】
如果你经常需要"发送工作汇报",我可以帮你创建一个"汇报 Skill":
触发:用户说"发汇报"或"提交日报"
步骤:查询待汇报内容 → 按模板整理 → 发送至指定位置
标准:格式规范、内容完整、已确认
---
请问你有想让我学习的具体技能吗?
如果有,告诉我这个技能是做什么的,我们一起来写。
如果没有,跳过这步,完成引导。
```
**交互:**
用户表示没有或已完成,发送完成语。
---
### 完成语
```
🪼 恭喜!你的虾已经基本成型了 🦐
【养虾完成清单】
✅ 性格设定(SOUL.md)
✅ 用户档案(USER.md)
✅ 长期记忆(Daily 记忆 + MEMORY.md)
✅ 反思进化(corrections.md + memory.md)
✅ 知识库(knowledge/)
⏳ 第一个 Skill(可选,后续随时添加)
【接下来的建议】
1. 在日常使用中,随时纠正我的错误 → 我会记录到对应文件
2. 有重要信息就说"记住 xxx" → 我会沉淀到记忆文件
3. 有新任务想让我学 → 说"创建一个 Skill"
4. 每周我会自动回顾一次记忆文件,找出值得晋升的规则
你的数字员工已经准备好了,我们正式开始协作吧 🚀
```
---
## 设计原则
1. **渐进式引导** — 每步只做一件事,不给用户压力
2. **先确认后写入** — 所有文件操作前先展示内容,用户确认再写入
3. **闭环反馈** — 每次操作后明确告知完成状态和下一步
4. **自动沉淀** — session 结束时主动询问记忆写入
5. **错误纠正即学习** — 用户纠正 = 学习机会,立即记录
---
## 注意事项
- 如果用户已有部分文件存在,引导开始前先读取现有内容,避免覆盖
- 如果用户中途说"算了""先不做了",保存当前进度,下次可继续
- 所有写入操作前必须先展示将要写入的内容,等用户确认
根据用户输入的城市和年份(默认最新),围绕异地就医备案、异地生育结算、公积金异地贷款、购房资格、车牌摇号与竞价、子女非户籍入学、本科人才落户等 7 个维度,执行多来源政策检索与交叉验证,输出结构化结论,并附带来源网址。
---
name: city-policy-cross-verify-search
description: 根据用户输入的城市和年份(默认最新),围绕异地就医备案、异地生育结算、公积金异地贷款、购房资格、车牌摇号与竞价、子女非户籍入学、本科人才落户等 7 个维度,执行多来源政策检索与交叉验证,输出结构化结论,并附带来源网址。
---
# Skill 定位
用于根据用户输入的 城市 和 年份(默认最新),围绕 7 个核心民生政策维度进行:
1. 充分检索
2. 多来源交叉验证
3. 结构化输出最终结果
4. 附带来源网址
该 Skill 的目标不是“搜到一条信息”,而是要做到:
* 尽量搜全
* 优先官方
* 多源核验
* 明确时间有效性
* 输出结论 + 来源
# Skill 核心能力
1. 全面检索
参考 openclaw-tavily-search 的思路,对单个问题做多轮、多关键词、多站点检索,避免只依赖单一网页结果。
检索范围应覆盖:
* 国家级平台
* 省级政务平台
* 市级政府官网
* 职能部门官网
* 政务服务网
* 官方公众号公开页面
* 官方小程序说明页
* 政策解读页
* 办事指南页
* 实施细则页
⸻
2. 交叉验证
对于每个维度,至少尝试从以下类型来源中交叉验证:
* 一级来源:政府官网 / 政务服务网 / 官方办事平台
* 二级来源:主管部门公众号文章 / 官方政策解读
* 三级来源:国家级统筹平台 / 省级统一平台 / 权威媒体转载官方文件
验证规则:
* 优先采信 原始政策文件
* 若政策文件与办事指南表述不同,优先说明:
* 文件发布日期
* 当前执行口径
* 是否存在更新版
* 若多来源冲突,必须标记:
* “存在口径差异”
* “建议以 XX 官方办事页/最新文件为准”
⸻
3. 输出结果 + 来源
每个维度最终输出必须包含:
* 维度名称
* 当前适用城市
* 年份
* 检索结论
* 适用条件
* 是否需本地户籍/社保/个税/学历/年龄限制
* 是否支持线上办理
* 备注
* 来源列表
来源格式示例:
* 【来源】网址1
* 【来源】网址2
* 【来源】网址3
# Skill 工作流程
第一步:识别检索对象
根据用户输入识别:
* 城市
* 年份
* 是否需要区级信息
* 是否搜索全部 7 项,还是只查某几项
若用户未提供年份,则默认使用“最新有效政策”。
⸻
第二步:为每个维度生成检索策略
每个维度都要生成:
* 主管部门
* 核心平台
* 官方站点范围
* 检索关键词组合
* 补充检索词
* 交叉验证词
⸻
第三步:多轮检索
每个维度至少执行以下几类检索:
A. 官方域名限定检索
使用:
* site:gov.cn
* site:[city].gov.cn
* site:gjj.[city].gov.cn
* site:rsj.[city].gov.cn
* site:ybj.[city].gov.cn
* site:edu.[city].gov.cn
B. 政务服务平台检索
优先查:
* 国家医保服务平台
* 国务院客户端
* 省政务服务网
* 市政务服务网
* 业务专项平台
C. 办事指南检索
重点找:
* 办理条件
* 办理流程
* 材料清单
* 线上入口
* 实施细则
D. 政策文件检索
重点找:
* 通知
* 实施意见
* 管理办法
* 补充规定
* 优化调整通知
E. 官方解读与FAQ检索
重点找:
* 政策解读
* 常见问题
* 官方答疑
* 业务口径说明
⸻
第四步:交叉验证
每个维度至少比对以下信息:
* 政策文件发布时间
* 办事指南更新时间
* 是否存在“最新通知”
* 是否有区级补充规定
* 是否有特殊适用对象限制
* 是否存在例外情形
优先级如下:
1. 最新正式政策文件
2. 最新官方办事指南
3. 官方解读/FAQ
4. 国家/省级统筹说明
5. 权威媒体转述官方文件
⸻
第五步:形成最终结论
输出时不只是罗列链接,而是给出“经过交叉验证后的结论”。
若证据不足,必须明确写:
* “当前未找到足够官方依据”
* “已找到办事页,但缺少正式文件支撑”
* “政策存在区级差异,建议补充区名后继续检索”
# 7 大维度专属检索图谱
⸻
1. 异地就医备案
主管部门
* 国家医保局
* 市医疗保障局
* 省医保局
优先平台
* 国家医保服务平台
* 国家异地就医备案小程序
* 市医保局官网
* 市医保公共服务平台
核心检索词
* [城市] 异地就医备案 [年份]
* [城市] 跨省异地就医直接结算 [年份]
* [城市] 临时外出就医人员备案
* [城市] 医保 异地就医 办事指南
重点核验项
* 是否需要备案
* 备案适用对象
* 是否支持跨省直接结算
* 线上办理入口
* 门诊/住院是否都支持
⸻
2. 异地生育结算
主管部门
* 省医保局
* 市医保局
* 政务服务平台
核心检索词
* [城市] 异地生育直接结算 [年份]
* [城市] 生育异地就医办理
* [城市] 生育保险 异地备案
* [城市] 生育医疗费用 跨省结算
重点核验项
* 生育医疗是否能异地直接结算
* 是否需提前备案
* 是否仅限住院
* 报销口径
* 办理入口与材料
⸻
3. 公积金异地贷款
主管部门
* 市住房公积金管理中心
优先平台
* 公积金中心官网
* 公积金网上业务平台
* 官方公众号
核心检索词
* [城市] 公积金 异地贷款 [年份]
* [城市] 公积金 跨省通办
* [城市] 异地贷款证明
* [城市] 贷款购买外地住房提取
重点核验项
* 是否支持异地贷款
* 是否支持异地缴存证明互认
* 是否支持跨省通办
* 提取与贷款是否分开适用
* 是否限制房屋所在地
⸻
4. 购房资格
主管部门
* 市住建局
* 市政府办公厅
* 房地产调控相关部门
核心检索词
* [城市] 购房资格 [年份]
* [城市] 非户籍 购房资格 社保 个税
* [城市] 房地产调控政策 最新
* [城市] 住房限购政策 [年份]
重点核验项
* 非本市户籍是否可购房
* 需要连续社保/个税多久
* 是否区分首套/二套
* 是否有区域差异
* 是否有近期优化政策
⸻
5. 车牌摇号与竞价
主管部门
* 市交通运输局
* 小客车指标调控管理办公室
核心检索词
* [城市] 车牌摇号 条件 [年份]
* [城市] 非户籍 增量指标申请条件
* [城市] 新能源车牌 申请条件
* [城市] 小客车指标 调控管理办法
重点核验项
* 非户籍申请条件
* 社保或个税要求
* 新能源指标是否直接配置
* 普通指标是否需摇号/竞价
* 申请入口与周期
⸻
6. 子女非户籍入学
主管部门
* 市教育局
* 区教育局
* 政务服务数据管理平台
核心检索词
* [区名] 非户籍子女入学 [年份]
* [区名] 随迁子女 义务教育 入学细则
* [区名] 积分入学 [年份]
* [区名] 外来务工人员子女 入学条件
重点核验项
* 是否按区执行
* 是否需要居住证
* 是否需要社保
* 是否采取积分制
* 所需材料
* 报名时间
特别规则
该维度通常必须优先精确到 区。
若用户只给城市未给区,应先查市级总规则,并提示区级政策可能不同。
⸻
7. 本科人才落户
主管部门
* 市人社局
* 公安局户政部门
* 人才引进入户系统
核心检索词
* [城市] 本科学历落户 [年份]
* [城市] 在职人才引进 申办指南
* [城市] 人才入户 本科 年龄 社保
* [城市] 人才落户 办理条件
重点核验项
* 本科是否可落户
* 年龄限制
* 社保要求
* 工作单位要求
* 应届/非应届是否有差异
* 线上申报入口
⸻
检索规则
规则 1:默认查最新有效政策
若用户未指定年份,默认搜索:
* 最新
* 当前有效
* 最新通知
* 最新办事指南
并优先选择最近更新的官方页面。
⸻
规则 2:优先官方,必要时再扩展
来源优先级:
1. 政府官网
2. 政务服务网
3. 官方业务平台
4. 官方公众号公开文章
5. 国家级统一平台
6. 权威媒体引用官方政策
禁止把商业中介、房产中介、论坛问答作为最终结论的主要依据。
⸻
规则 3:必须交叉验证
每个结论尽量满足:
* 至少 2 个来源支撑
* 其中至少 1 个为官方来源
* 优先 3 个来源交叉验证
⸻
规则 4:处理冲突信息
若不同来源口径不一致:
* 标记冲突点
* 比较发布时间
* 优先最新官方正式文件
* 若仍无法确认,明确写“以当地办事窗口最新审核口径为准”
⸻
规则 5:区级差异单独提示
对于以下场景,需提示区级差异:
* 子女入学
* 部分购房政策
* 人才落户的受理区政策
* 个别医保/公积金网点化差异
# 输出格式
# [城市] [年份] 核心民生政策检索结果
## 检索说明
- 城市:[城市]
- 年份:[年份或“最新”]
- 检索维度:7 项
- 检索原则:官方优先、多源交叉验证、以最新有效政策为准
---
## 1. 异地就医备案
### 结论
[经过交叉验证后的最终结论]
### 关键条件
- 条件1:
- 条件2:
- 条件3:
### 办理方式
- 是否支持线上:
- 办理入口:
- 是否需要备案:
### 风险提示 / 备注
[如有口径差异、更新时间差异、特殊适用范围,在这里说明]
### 来源
- 【来源】网址1
- 【来源】网址2
- 【来源】网址3
---
## 2. 异地生育结算
### 结论
...
### 来源
- 【来源】网址1
- 【来源】网址2
- 【来源】网址3
---
## 3. 公积金异地贷款
...
## 4. 购房资格
...
## 5. 车牌摇号与竞价
...
## 6. 子女非户籍入学
...
## 7. 本科人才落户
...
---
## 最终总结
### 可直接判断的结论
- ...
- ...
- ...
### 仍需补充确认的信息
- 是否需要具体区名
- 是否存在最新未公开细则
- 是否建议以窗口审核为准
BP个人月度汇报生成与发送工具。基于 BP 目标结构、衡量标准和当月汇报证据, 按分步流程生成结构固定、证据可追溯的月报初稿。 当用户需要生成、发送或预览个人BP月度汇报时使用。
---
name: bp-monthly-report
description: >-
BP个人月度汇报生成与发送工具。基于 BP 目标结构、衡量标准和当月汇报证据,
按分步流程生成结构固定、证据可追溯的月报初稿。
当用户需要生成、发送或预览个人BP月度汇报时使用。
---
# BP 个人月度汇报
为 BP 系统中的个人节点生成月度汇报。核心逻辑是**先拆证据、再做判断、最后组装报告**,而不是一步生成整篇。
详细参考:
- 报告模板(BP自查报告):[references/report-template-bp-self-check.md](references/report-template-bp-self-check.md)
- 灯色判断标准:[references/traffic-light-rules.md](references/traffic-light-rules.md)
- 证据优先级规则:[references/evidence-rules.md](references/evidence-rules.md)
## 报告定位
月报最终输出为**一份 BP 自查报告**,面向员工本人阅读,以"每个 BP 目标"为主线底座,对照承诺逐条检查完成情况,在一个目标内串起"承诺对照、结果、举措、偏差问题与原因"。每个目标给灯色判断;最终给一句话自我结论(优秀/良好/合格/不足)。
## 核心业务概念
### 判断主轴
月报的灯色判断基本单位是**关键举措**,目标灯色从举措聚合得出。关键成果(KR)用于衡量标准参考和差距分析,但**不判灯**。
- **目标**:最终要达到的状态,定义"这条线在做什么"
- **关键成果**:对照衡量标准分析差距、输出判断理由,但不判灯色
- **衡量标准**:说明关键成果怎样算完成、怎样算偏离,是 KR 判断理由的参照系
- **关键举措**:灯色判断的基本单位,是证据抓手和灯色载体
一句话:灯色看"举措推进情况",KR 只做差距分析输出理由。
### 灯色判断层级
- **排除规则**:基于 `planDateRange`(计划时间范围)与汇报月份的**区间交叉**判断。草稿直接排除;其余状态按计划区间是否与汇报月份有交集决定(`planStartDate <= 月末 AND planEndDate >= 月初`)。这确保回溯历史月份时也能正确判断。详见 [references/traffic-light-rules.md](references/traffic-light-rules.md) 排除规则章节。
- **逐目标判断(Step 3c)**:以目标为维度,逐个精读证据——对**参与自查**的关键举措判灯(含完整四灯判断块),对关键成果只做差距分析输出判断理由(不判灯)。
- **最终报告**:举措级嵌入四灯判断块;KR 级输出判断理由(不嵌入四灯判断块);目标级灯色从该目标下所有参与自查的**举措**灯色聚合(有红则红,无红有黄则黄,全绿则绿,全黑则黑),每个目标嵌入目标级四灯判断块。
- 举措级和目标级的灯色判断点使用统一的**四灯判断块**格式(见 report-template-bp-self-check.md "四灯判断块标准模板"一节)。
### 灯规则版本
详见 [references/traffic-light-rules.md](references/traffic-light-rules.md) 。
### 证据引用编号
最终报告中引用汇报时使用 `R{序号}` 编号(如 `R201`、`R202`),不直接内联汇报正文或 reportId。编号在 Step 3b 证据台账中分配。
### 证据链接格式
正文中所有 R 编号引用均使用**汇报直链**格式,点击后直接打开对应汇报详情页:
```
[R301](huibao://view?id={reportId})
```
**不使用** `[R301](#R301)` 页内锚点链接(该格式在工作协同中无法跳转)。每个 R 编号对应的 reportId 在 Step 3b 证据台账中确定。
### 灯色 HTML 渲染
最终报告中所有灯色相关文字使用 HTML `<span>` 标签彩色加粗渲染:
| 灯色 | HTML 样式 |
|------|-----------|
| 🟢 | `<span style="color:#2e7d32; font-weight:700;">` |
| 🟡 | `<span style="color:#b26a00; font-weight:700;">` |
| 🔴 | `<span style="color:#c62828; font-weight:700;">` |
| ⚫ | `<span style="color:#212121; font-weight:700;">` |
## 生成流程
**禁止一步生成整篇报告。** 必须按以下步骤顺序执行。
### 第一步:确定目标员工与月份
用户需提供:
- **目标员工**:员工姓名、employeeId 或 groupId(个人分组 ID)
- **汇报月份**:格式 `YYYY-MM`,如 `2026-03`
- **灯规则版本**:`版本一`(默认)或 `版本二`
推荐输入协议:
```yaml
period_id: 1994002024299085826
groupId: 2029384010718834690
report_month: 2026-03
```
若用户只给了姓名,通过 `bp-data-viewer` 的 `search_group_by_name` 在个人分组中按名称匹配定位。若没有 `period_id`,用 `get_all_periods` 取 `status=1` 的启用周期。
### 第一步半:标记生成开始
确定 `groupId` 和月份后,**立即**调用 `update_report_status` 将月报状态标记为"生成中":
```bash
python3 .openclaw/skills/bp-monthly-report/scripts/monthly_report_api.py update_report_status \
--group_id "{groupId}" \
--month "{YYYY-MM}" \
--status 0
```
该调用会在 BP 系统中创建或更新一条月报记录,状态为 `0=生成中`。后续流程中任何步骤失败,都必须调用 `update_report_status --status 2 --fail_reason "失败原因"` 记录失败。
### 第二步:采集 BP 数据(分 2a 当月 + 2b 上月)
#### Step 2a: 采集当月 BP 数据与汇报(按目标维度拆分)
数据采集分两步执行:先拉全局概览(轻量),再逐目标独立采集详情和汇报。每个目标产出一个独立 JSON 文件,后续 Step 3c 逐目标循环时每轮只读一个文件。
##### Step 2a-i: 采集全局概览
```bash
python3 .openclaw/skills/bp-monthly-report/scripts/monthly_report_api.py collect_monthly_overview \
--group_id "{groupId}" \
--month "2026-03" \
--output "/tmp/monthly_overview_{groupId}.json"
```
该命令获取任务树并提取目标列表,输出轻量 JSON。
**输出 JSON 结构**:
| 字段 | 说明 |
|------|------|
| `taskTree` | 精简后的任务树(目标 → 关键成果 → 关键举措) |
| `goals` | 目标摘要列表,每条含 `goalId`、`name`、`fullLevelNumber`、`planDateRange`、`statusDesc` |
| `stats` | 统计信息:`totalGoals`(目标数)、`totalNodes`(总节点数) |
执行完成后,**Read** 此文件获取目标列表,确定有多少个目标需要逐个采集。
##### Step 2a-ii: 逐目标采集数据
读取 Step 2a-i 的 `goals` 列表,对每个目标独立采集:
```bash
# 对每个目标执行(goalId 从 goals 列表中取)
python3 .openclaw/skills/bp-monthly-report/scripts/monthly_report_api.py collect_goal_data \
--group_id "{groupId}" \
--goal_id "{goalId}" \
--month "2026-03" \
--output "/tmp/goal_data_{groupId}_{goalId}.json"
```
该命令在脚本内部自动完成:
1. 获取该目标的完整详情(含衡量标准、KR、举措、参与人)
2. 提取该目标下所有节点 ID(目标自身 + KR + 举措)
3. 对每个节点查询当月关联汇报列表
4. 拉取所有去重 reportId 的汇报**原文全文**(不截断)
5. 构建该目标内部的反向索引(reportId → taskId 列表)
6. 输出独立 JSON 文件
**输出 JSON 结构**:
| 字段 | 说明 |
|------|------|
| `goalId` | 目标 ID |
| `goalDetail` | 该目标的完整详情(含 KR 列表、举措列表、衡量标准等) |
| `uniqueReportMap` | reportId → 完整汇报内容的去重主表(**不截断**,保留原文全文) |
| `reportTaskMapping` | reportId → 关联的 taskId 列表(仅该目标范围内的反向索引) |
| `reports` | 按 taskId 分组的汇报引用 |
| `stats` | 统计信息:`nodeCount`、`uniqueReportCount`、`fetchedReportContents` |
| `errors` | 采集过程中的错误记录(如有) |
**汇报内容不截断**:汇报原文完整保存,由 AI 在 Step 3c 读取时按需总结(详见 Step 3c 的汇报摘要指引)。
##### 产出文件一览
```
/tmp/monthly_overview_{groupId}.json -- 全局概览(Step 2a-i)
/tmp/goal_data_{groupId}_{goalId_1}.json -- 目标1 数据(Step 2a-ii)
/tmp/goal_data_{groupId}_{goalId_2}.json -- 目标2 数据(Step 2a-ii)
/tmp/goal_data_{groupId}_{goalId_3}.json -- 目标3 数据(Step 2a-ii)
...
```
#### Step 2b: 采集上月汇报与评价(参考基线)
当月数据采集完成后,使用 `collect_previous_month_data` 采集上个月的汇报和评价作为参考:
```bash
python3 .openclaw/skills/bp-monthly-report/scripts/monthly_report_api.py collect_previous_month_data \
--group_id "{groupId}" \
--month "2026-02" \
--output "/tmp/prev_month_data_{groupId}.json"
```
该命令在脚本内部自动完成:
1. 调用 2.31 `listMonthlyReports` 获取上月所有月报的 `reportTypeDesc` + `reportRecordId`
2. 对每个 `reportRecordId`,通过工作协同接口拉取汇报正文
3. 调用 2.32 `getMonthlyEvaluation` 获取上月评价的翻译后 Markdown(自评 + 上级评价)
4. 将全部数据写入一个聚合 JSON 文件
**输出 JSON 结构**:
| 字段 | 说明 |
|------|------|
| `reports` | 上月各类型月报列表,每条含 `reportTypeDesc`、`reportRecordId`、`title`、`content` |
| `evaluations` | 上月评价 Markdown 列表,每条含 `evaluationTypeDesc`(自评/上级评价)和 `evaluationMarkdown` |
| `stats` | 统计信息:报告数、评价数 |
| `errors` | 采集过程中的错误记录(如有) |
**使用方式**:
- 上月报告正文作为本月汇报的纵向对比基线(上月做了什么 → 本月进展了什么)
- 上月评价 Markdown 中的评分和评语可用于本月灯色判断的辅助参考(上级评价要求、上月偏差是否已改善)
- 若上月数据为空(首月汇报),跳过此步骤,不影响后续流程
### 第三步:生成月报内容(分 4 个子步骤)
读取采集数据后,**必须按以下 4 个子步骤顺序执行**,不可跳步:3a → 3b → 3c → 3d。
核心设计:**全局的事情全局做(编号、去重),局部的事情局部做(判断、结论)**。Step 3c 以目标为维度逐个精读证据、判灯、组装章节,避免全量处理时证据"串台"。
#### Step 3a: 构建 BP 锚点图
从 `/tmp/monthly_overview_{groupId}.json` 读取任务树构建全局骨架,从各 `/tmp/goal_data_{groupId}_{goalId}.json` 的 `goalDetail` 中提取每个目标的详情。
对每个目标,提取:
- 目标名称、编号(fullLevelNumber)
- 上级组织 BP 对齐关系(upwardTaskList)
- 下属关键成果列表,每个关键成果提取:
- 名称、编号
- **衡量标准**(measureStandard,去除 HTML 标签)
- 监督人、承接人
- 计划时间范围(planDateRange)
- 当前状态(statusDesc)
- 下属关键举措列表,每个举措提取:
- 名称、编号
- 计划时间范围(planDateRange)
- 当前状态(statusDesc)
将此骨架写入 `/tmp/bp_anchor_{groupId}.md`,作为后续判断的锚点。
#### Step 3b: 构建证据台账(含 R 编号分配)
遍历所有 `/tmp/goal_data_{groupId}_{goalId}.json` 文件,汇总全局汇报数据。由于同一条汇报可能出现在多个目标文件中(关联到不同目标的节点),R 编号分配必须全局去重。
严格按 [references/evidence-rules.md](references/evidence-rules.md) 执行:
1. **按 reportId 全局去重**:从所有目标文件的 `uniqueReportMap` 中汇总,同一条汇报(相同 reportId)只算一次
2. **内容聚合**:内容高度相似的汇报(同一模板发给不同人)合并为一个工作事项,记录发送次数和对象列表
3. **证据分级**:
- 本人手动汇报(type=manual,作者是承接人本人)→ 主证据
- 他人手动汇报(type=manual,作者非本人)→ 辅证
- AI 汇报(type=ai)→ 仅辅助摘要,不能单独作为强结论依据
4. **分配 R 编号**:对去重聚合后的每个独立工作事项,按顺序分配 `R{月份}{序号}` 编号。例如 3 月月报的第 1 条证据编号为 `R301`,第 2 条为 `R302`;1 月月报为 `R101`、`R102`。编号规则:
- 月份取汇报月份的数字(1月=1,12月=12)
- 序号从 01 开始连续递增
- 每条编号附带汇报标题全文,格式:`R{编号}`《[汇报标题]》
- **每条编号必须同时记录汇报链接**,格式:`huibao://view?id={reportId}`,其中 `{reportId}` 取自 `uniqueReportMap` 中该条汇报的原始 reportId(字符串原样,不做任何转换)。若一条工作事项由多条汇报聚合而成,取其中**第一条**的 reportId 生成链接
5. **分配 RP 编号**(上月参考引用):从 Step 2b 采集的上月数据中,对每条上月汇报按顺序分配 `RP{序号}` 编号(`RP01`、`RP02`...)。编号规则:
- 序号从 01 开始,按 `reports` 列表顺序递增
- 每条 RP 编号记录 `reportTypeDesc`(类型描述)、汇报标题和 `reportRecordId`(用于生成 `huibao://view?id={reportRecordId}` 链接)
- RP 编号仅用于基线引用和附录 A.3,**不参与**当月证据分级和灯色判断
- 若 Step 2b 无数据(首月),不分配 RP 编号
6. **按节点归集**:利用 `reportTaskMapping` 确定每条汇报关联了哪些目标/关键成果/关键举措。一条汇报的内容如果明确涉及其他目标的关键词,应在对应节点的分析中交叉引用
7. **月份归集口径**:以汇报的 `createTime` 为准,不依赖关联时间或正文时间推断
将证据台账写入 `/tmp/evidence_ledger_{groupId}.md`,格式为:
```markdown
## 证据台账
### R 编号索引
| R 编号 | 汇报标题 | 证据级别 | 汇报链接 | 关联节点 |
|--------|---------|---------|---------|---------|
| R301 | 《[汇报标题]》 | 主证据 | [查看汇报](huibao://view?id=xxxxxxxxxxxxx) | 目标X / KRY |
| R302 | 《[汇报标题]》 | 辅证 | [查看汇报](huibao://view?id=xxxxxxxxxxxxx) | 举措Z |
| ... | ... | ... | ... | ... |
### 统计摘要
- 命中原始工作汇报:N 份
- 经批量通知归并后最终采纳:M 份
- 其中本人主证据:X 份、他人手动汇报:Y 份、AI 汇报:Z 份
### 按目标归集(逐 KR/举措明细)
#### 目标 [fullLevelNumber]: [目标名称]
**关键成果:**
| KR 编号 | KR 名称 | 关联 R 编号 | 证据级别分布 | 证据充分性 |
|---------|---------|------------|-------------|-----------|
| [fullLevelNumber] | [名称] | R301, R303 | 主证据×2 | 充分 |
| [fullLevelNumber] | [名称] | R302 | 辅证×1 | 仅辅证 |
| [fullLevelNumber] | [名称] | — | — | 无证据 |
**关键举措:**
| 举措编号 | 举措名称 | 关联 R 编号 | 证据级别分布 | 证据充分性 |
|---------|---------|------------|-------------|-----------|
| [fullLevelNumber] | [名称] | R301 | 主证据×1 | 充分 |
| [fullLevelNumber] | [名称] | — | — | 无证据 |
#### 目标 [fullLevelNumber]: [目标名称]
(同上结构)
```
**关键要求**:
- 证据台账是最终报告附录的**唯一数据源**。Step 3d 拼接附录时必须直接读取此文件,不得重新生成或凭记忆补写
- "按目标归集"部分是 Step 3c 逐目标处理的**输入索引**。每个目标循环开始时,先读取该目标对应的证据子集(R 编号 + 充分性),再从 `uniqueReportMap` 中读取对应汇报正文进行精读判断
#### Step 3c: 逐目标精读判断与章节组装
**核心原则**:以目标为维度,每次只聚焦一个目标的锚点和证据,在同一轮上下文中完成"精读证据 → 判灯 → 写章节"的完整闭环,避免全量处理时证据串台。
##### 预处理:目标级排除判断
在进入逐目标循环前,先对所有目标执行排除规则(详见 [references/traffic-light-rules.md](references/traffic-light-rules.md)):
- 草稿目标直接排除
- 计划区间与汇报月份无交集的目标排除
- 目标参与自查但其下**所有** KR 和举措均被排除 → 该目标也排除
将被排除的目标记录到**全局跳过列表**(`/tmp/excluded_goals_{groupId}.md`):
```markdown
## 本月不参与自查的节点
| 节点类型 | 名称 | 编号 | 计划时间范围 | 排除原因 |
|---------|------|------|-------------|---------|
| 目标 | [名称] | [编号] | [planDateRange] | 计划期未覆盖本月 / 草稿 |
| 关键成果 | [名称] | [编号] | [planDateRange] | 计划期未覆盖本月 / 草稿 |
| 关键举措 | [名称] | [编号] | [planDateRange] | 计划期未覆盖本月 / 草稿 |
```
##### 逐目标循环
对每个**参与自查的目标**,按以下 7 步闭环执行,产出一个独立的目标章节文件:
```
for 每个参与自查的目标 (goalIndex = 1, 2, 3, ...):
(i) 读取输入
(ii) 排除判断
(iii) 逐 KR 精读分析(不判灯,只输出判断理由)
(iv) 逐举措判灯
(v) 聚合目标级灯色(从举措灯色聚合)
(vi) 组装目标报告章节
(vii) 写入文件
```
**(i) 读取该目标的输入**
每个目标的数据来源明确、互不干扰:
- **目标数据文件**:直接读取 `/tmp/goal_data_{groupId}_{goalId}.json`,包含该目标的完整详情(`goalDetail`)和所有关联汇报原文(`uniqueReportMap`)
- **锚点**(从 Step 3a):该目标的名称、编号、衡量标准、KR/举措结构
- **证据子集**(从 Step 3b 的"按目标归集"):该目标下每个 KR/举措关联的 R 编号和证据充分性
- **汇报正文**(从目标数据文件的 `uniqueReportMap`):读取该目标关联的所有 R 编号对应的汇报原文内容。对超长汇报执行 AI 摘要(详见下方"汇报内容摘要指引")
**(ii) 该目标下的 KR/举措排除判断**
按 traffic-light-rules.md 的排除规则,判断该目标下哪些 KR/举措参与自查、哪些排除。被排除的 KR/举措记录到该目标的跳过子列表。
**(iii) 逐 KR 精读分析**
对每个**参与自查的关键成果**,精读其关联的汇报正文,对照衡量标准,生成 KR 分析卡片。**KR 不判灯色**,只输出详细判断理由。
**关键成果卡**:
```markdown
### KR卡: [关键成果名称] ([编号])
- 衡量标准:[从 measureStandard 提取]
- 计划时间范围:[planDateRange]
- 本月主证据:[R编号列表]
- 本月辅证:[R编号列表]
- 距离衡量标准的差距:[基于证据判断]
- 判断理由:[详细分析该成果目前的完成情况、与衡量标准的差距、主要支撑和不足,供目标级结论参考]
```
**(iv) 逐举措精读判灯**
对每个**参与自查的关键举措**,精读其关联的汇报正文,评估对 KR 的支撑度,生成举措判断卡片。
**关键举措卡**:
```markdown
### Action卡: [举措名称] ([编号])
- 计划时间范围:[planDateRange]
- 当前状态:[statusDesc]
- 本月推进动作:[从证据提取,引用R编号]
- 对关键成果的支撑情况:[说明支撑了哪个 KR,强/中/弱]
- 灯色判断:[🟢/🟡/🔴/⚫]
- 判断依据:
```
**(v) 聚合目标级灯色**
从该目标下所有参与自查的**举措**卡片灯色聚合:有任一红灯则红;无红灯有黄灯则黄;全绿则绿;全黑灯则黑。
**(vi) 组装该目标的报告章节**
按 [references/report-template-bp-self-check.md](references/report-template-bp-self-check.md) 的目标明细结构,组装该目标的完整报告章节,包含以下 **4 个必需子章节**:
- **承诺与实际对照**:必须包含 `承诺口径` / `本月实际` / `差异点(若有)` / `证据` 四个字段,证据引用格式为 `[R编号](huibao://view?id={reportId})《汇报标题》`
- **关键成果达成与举措推进**:每个 KR 输出完整分析单元,必须包含 `衡量标准` / `本月结果` / `距离衡量标准` / `环比上月` / `证据` / `判断理由` 六个子字段(KR 不嵌入四灯判断块);KR 下按「└ 支撑举措」层级展开,每个支撑举措必须包含 `推进动作摘要` / `对结果支撑【强/中/弱】` / `当前进度(含量化)` / `证据` / `嵌入举措级四灯判断块` 五个子字段。目标下若有部分被排除的 KR/举措,在该目标明细末尾一句话带过
- **偏差问题与原因分析**:若有偏差必须包含 `问题现象` / `影响` / `原因假设` / `当前应对` / `证据` 五个字段;若全绿则写"本目标本期无重大偏差"
- **目标级综合灯色结论**:`结论一句话:` + 嵌入四灯判断块
**(vii) 写入目标章节文件**
将判断卡片(作为内部过程记录)和报告章节一起写入 `/tmp/goal_section_{groupId}_{goalIndex}.md`,格式为:
```markdown
<!-- 内部判断过程(不搬入最终报告) -->
## 判断卡片
### KR卡: ...
(判断卡片内容)
### Action卡: ...
(判断卡片内容)
---
<!-- 以下为最终报告章节(搬入最终报告) -->
## 报告章节
##### [fullLevelNumber]|[BP目标全称]
(完整的 4 个子章节内容)
```
##### 汇报内容摘要指引
由于 Step 2a-ii 采集的汇报保留了原文全文(不截断),AI 在读取每个目标的数据文件时,需要对超长汇报进行摘要处理:
**触发条件**:汇报原文(`content` 字段)超过 2000 字时执行摘要。
**摘要保留内容**:
- 关键数据和量化指标(如完成率、金额、人数等)
- 时间节点和里程碑进展
- 结论性表述和决策结果
- 与该目标衡量标准直接相关的内容
- 问题/风险/偏差的描述
**摘要去除内容**:
- HTML 标签和格式噪音
- 重复段落和冗余信息
- 无实质内容的客套话和模板化开场白
- 与当前目标无关的内容(若汇报跨多个目标,只保留与当前目标相关的部分)
**摘要输出**:用自然语言重写为精简版本,控制在 500-800 字以内,保留原文中的关键数据原样不改。摘要仅用于 AI 判断,不直接出现在最终报告正文中。
#### Step 3d: 报告拼接与合规性校验
所有目标章节文件生成完成后,执行最终拼接和校验。
##### (i) 聚合灯色统计
读取所有 `/tmp/goal_section_{groupId}_{goalIndex}.md` 文件,提取每个目标的灯色结论,统计绿/黄/红/黑灯数量。
##### (ii) 生成全局章节
按 [references/report-template-bp-self-check.md](references/report-template-bp-self-check.md) **逐字段严格对照**组装全局部分。**模板是最终报告的精确结构定义,当 SKILL 描述与模板有冲突时,以模板为准。**
1. **元数据头**:填入周期、员工姓名、基线引用(RP 编号超链接,若首月则写"首月,无基线")、证据说明(含 R 和 RP 编号说明)、灯规则版本
2. **第 1 章 总体自查结论**:
- **1.1 结论**:必须包含 `一句话优势:` 和 `一句话短板:` 两个带标签的行,不可写成一整段
- **1.2 灯色分布概览**:使用 ` ```text ` 代码块,按目标级统计绿/黄/红/黑灯数量共 5 行。**被排除规则排除的目标不计入灯色统计**,单独注明"★ 未启动:[N] 个目标"
- **1.3 本月最关键偏差点**:每条偏差必须包含 `偏差点` + `影响` + `原因假设` + `下月纠偏方向` 四个子字段(可选,最多 3 条;若无偏差可不写本节)
3. **第 2 章 目标级自查明细**:
- **2.1 目标清单总览表**:必须 7 列(目标编号 / BP目标 / 本月承诺口径 / 本月实际 / 证据引用 / 目标灯色 / 结论一句话)。**所有目标均列入**,被排除的目标灯色用 `<span style="color:#2e7d32; font-weight:700;">★</span>`(绿色五角星)标记、结论写"未启动"、证据引用留空、承诺口径和本月实际写"—"
- **2.2 目标明细**:从各 `/tmp/goal_section_{groupId}_{goalIndex}.md` 中提取"报告章节"部分,按目标顺序拼接(仅对参与自查的目标展开明细)
4. **第 3 章 年度结果预判评分**:**严格按模板输出,仅包含一个年度结果预判评分链接**,格式为 `[点击进入本月:年度结果预判评分](https://sg-al-cwork-web.mediportal.com.cn/BP-manager/web/dist/#/monthly-review/self?groupId={groupId}&month={month})`。**不得**在此章节添加自我定性、结论解释或任何额外文字
5. **附录:证据索引**:从 `/tmp/evidence_ledger_{groupId}.md` 直接读取 R 编号索引表原样搬入,包含 A.1 统计摘要 + A.2 证据索引表 + **A.3 上月参考索引**(RP 编号表 + 上月评价 Markdown 原文嵌入;若首月则写"首月汇报,无上月参考基线。")
##### (iii) 语言清洗检查
见下方"语言清洗检查"规则。
##### (iv) 写入最终报告
写入 `/tmp/report_selfcheck_{groupId}.md`。
##### (v) 合规性校验(发送前必须执行)
对写入的报告文件逐项校验以下清单,**任一项不通过则回退修正后重新校验**,全部通过后方可进入发送流程:
| 序号 | 校验项 | 校验标准 |
|----|-----------------|----------------------------------------------------------------------------------------------------------------------------|
| 2 | **1.0 灯判断块** | 黄灯、红灯、黑灯判断块,必须严格按照[references/report-template-bp-self-check.md](references/report-template-bp-self-check.md) 四灯判断块标准模板结构输出 |
| 1 | **1.1 结论格式** | 必须包含 `一句话优势:` 和 `一句话短板:` 两个带标签的独立行 |
| 2 | **1.2 灯色分布格式** | 必须使用 ` ```text ` 代码块,包含 🟢/🟡/🔴/⚫/★ 未启动 共 5 行 |
| 3 | **1.3 偏差点子字段** | 每条偏差必须有 `偏差点` / `影响` / `原因假设` / `下月纠偏方向` 4 个子字段 |
| 4 | **2.1 总览表列数** | 必须 7 列:目标编号 / BP目标 / 本月承诺口径 / 本月实际 / 证据引用 / 目标灯色 / 结论一句话。所有目标均列入,被排除目标用 ★ 标记、结论写"未启动"、证据引用留空 |
| 5 | **目标明细 4 子章节** | 每个参与自查的目标必须包含:承诺与实际对照 → 关键成果达成与举措推进 → 偏差问题与原因分析 → 目标级综合灯色结论 |
| 6a | **KR 级完整分析单元** | 每个参与自查的 KR 必须有 6 个子字段:衡量标准 / 本月结果 / 距离衡量标准 / 环比上月 / 证据 / 判断理由(KR 不嵌入四灯判断块) |
| 6b | **举措级层级结构** | 每个举措必须有 5 个子字段:推进动作摘要 / 对结果支撑【强/中/弱】 / 当前进度(含量化:完成度百分比或里程碑阶段)/ 证据 / 举措级四灯判断块 |
| 7 | **证据引用带标题** | 正文中 R 引用格式:`[R编号](huibao://...)《汇报标题》`,不可只写链接不带标题 |
| 8 | **四灯判断块行数** | 绿灯 = 2 行;黄灯/红灯 = 8 行(含人工判断等占位);黑灯 = 9 行(额外含类型建议)。仅适用于举措级和目标级,KR 级无四灯判断块 |
| 9 | **第 3 章仅含链接** | Section 3 严格按模板:仅输出一个年度结果预判评分链接,不得包含自我定性、结论解释或其他文字 |
| 10 | **附录 A.2 条数一致** | R 编号总数必须等于证据台账中的条数,不可多也不可少 |
| 11 | **语言清洗 5 条规则** | 无技术字段泄漏、无空值直出、无模板括号注释、无系统流程说明、无 HTML 注释 |
| 12 | **数据完整性** | 各目标章节中的所有 R 编号、灯色判断、偏差字段是否完整搬入正文,不可遗漏 |
| 13 | **编码完整性** | 各个目标、成果、举措编码完整、正确 |
#### Step 4: 发送→保存
**校验通过后直接发送**,无需等待用户确认。
```bash
python3 .openclaw/skills/bp-monthly-report/scripts/monthly_report_api.py send_report \
--receiver_emp_id "{employeeId}" \
--title "{员工姓名} {YYYY年M月} BP自查报告" \
--content_file "/tmp/report_selfcheck_{groupId}.md"
```
> `--sender_id` 无需手动指定。脚本会自动通过第一个接收人的 empId 查询组织架构获取 corpId,匹配对应企业的 AI 助理(400001/400002/400003)。仅在需要覆盖时才传 `--sender_id`。
记录返回的 `data.id` → 记为 `report_record_id`,生成报告链接:`huibao://view?id={report_record_id}`
##### 保存到 BP 系统
发送成功后,保存到 BP 系统:
```bash
python3 .openclaw/skills/bp-monthly-report/scripts/monthly_report_api.py save_monthly_report \
--group_id "{groupId}" \
--month "{YYYY-MM}" \
--content_file "/tmp/report_selfcheck_{groupId}.md" \
--report_record_id "{report_record_id}"
```
##### 状态说明
`save_monthly_report` 接口默认将 `generateStatus` 设为 `1=成功`,因此保存成功即代表整个流程完成,**无需再单独调用 `update_report_status --status 1`**。
**失败处理**:若上述任何步骤失败(数据采集、报告生成、发送、保存),必须立即更新状态为"失败"并记录原因:
```bash
python3 .openclaw/skills/bp-monthly-report/scripts/monthly_report_api.py update_report_status \
--group_id "{groupId}" \
--month "{YYYY-MM}" \
--status 2 \
--fail_reason "具体失败原因描述"
```
##### 语言清洗检查(报告组装完成后、写入文件前必须执行)
对全文逐段扫描,确认以下五条规则全部通过后才能写入文件:
1. **禁止技术字段泄漏**:正文中不得出现 API 原始字段名,包括但不限于:
`reportId`、`authorEmpId`、`createTime`、`taskId`、`groupId`、`employeeId`、`type=manual`、`type=ai`、`contentHtml`、`planDateRange`、`statusDesc`、`measureStandard`、`fullLevelNumber`、`upwardTaskList`、`reportTaskMapping`。
如需表达相关含义,必须改用自然语言。
2. **句式自然化**:所有描述采用"主语 + 谓语 + 宾语"的自然句式。以下表述**严禁出现**:
- ~~"XX 为空"~~ → "本月尚未收到相关汇报"
- ~~"数据不完整"~~ → "当前可获取的信息有限,建议补充"
- ~~"举证原数据不完整"~~ → "本月关联的工作汇报内容较少,尚不足以全面评估"
- ~~"本月最关键的进展为空"~~ → "本月暂无可明确标注的关键进展,建议关注 XX 方向"
- ~~"无关联汇报"~~ → "本月该事项下暂未收到工作汇报"
3. **禁止空值直出**:空字段必须改写为有引导意义的自然语句。
4. **禁止模板括号注释泄漏**:最终报告是直接发送给员工阅读的,章节标题中的括号说明文字一律不得出现在最终报告中。以下为必须清洗的映射表:
- ~~`(目标主线)`~~ → 标题中不加此后缀
- ~~`(先给结论,再给依据)`~~ → 删除
- ~~`(四档)`~~ → 删除
- ~~`(目标级)`~~ → 删除
- ~~`(可选,最多 3 条)`~~ → 删除
- ~~`(本报告主体,按目标动态生成)`~~ → 删除
- ~~`(承诺 vs 实际,表格)`~~ → 删除
- ~~`(逐目标展开:承诺对照 + 结果 + 举措 + 偏差问题)`~~ → 删除
- ~~`(可验收口径)`~~ → 删除
- ~~`(按成果层级展开)`~~ → 删除
- ~~`(若无偏差写"本目标本期无重大偏差")`~~ → 删除
- ~~`(必填)`~~ → 删除
- ~~`(必须输出)`~~ → 删除
- ~~`(两句以内)`~~ → 删除
- 通用规则:**任何以中文括号 `()` 包裹的、用于指导 AI 生成行为的说明文字**,一律不输出
5. **禁止系统流程说明泄漏**:以下内容不得出现在最终报告中:
- "以下自 Step 3b 证据台账原样搬入"或类似提及内部步骤编号(Step 3a/3b/3c/3d 等)的文字
- "字段与台账一致;R 列已加页内锚点"等内部实现说明(含任何提及"页内锚点"的文字)
- "约束:本章小节数量必须随…"等以"约束:"开头的系统提示
- "AI 指引:"开头的任何文字
- 任何 `<!-- ... -->` HTML 注释标签及其内容
##### 附录搬运规则
- 报告的证据索引附录必须从 `/tmp/evidence_ledger_{groupId}.md` **直接读取并原样搬入**
- 若台账文件不存在或为空,必须回退重新执行对应步骤,**不得跳过或伪造**
## 工具脚本
### bp-data-viewer(数据底座,已有)
所有 BP 数据查询通过 `bp-data-viewer` 的 `bp_api.py` 执行,详见 [bp-data-viewer SKILL.md](../bp-data-viewer/SKILL.md)。
### monthly_report_api.py(本 Skill 新增)
工作协同侧的汇报操作脚本,从工作区根目录执行:
```bash
python3 .openclaw/skills/bp-monthly-report/scripts/monthly_report_api.py <action> [options]
```
| action | 说明 | 必填参数 | 可选参数 |
|--------|------|----------|----------|
| `collect_monthly_overview` | 采集全局概览:任务树 + 目标列表 + 统计(Step 2a-i) | `--group_id`、`--month` | `--output` |
| `collect_goal_data` | 按目标采集详情 + 汇报原文,不截断(Step 2a-ii) | `--goal_id`、`--month` | `--group_id`、`--output` |
| `collect_monthly_data` | [旧版] 一次性采集全量数据到单个 JSON,向后兼容 | `--group_id`、`--month` | `--output` |
| `collect_previous_month_data` | 采集上月汇报+评价(类型+正文+评价Markdown),作为本月参考基线 | `--group_id`、`--month`(上月YYYY-MM) | `--output` |
| `get_report_content` | 获取单条汇报正文内容 | `--report_id` | 无 |
| `send_report` | 发送报告(工作协同) | `--receiver_emp_id`、`--title`、`--content_file` | `--sender_id` |
| `save_monthly_report` | 保存月报到 BP 系统 | `--group_id`、`--month`、`--content_file`、`--report_record_id` | 无 |
| `update_report_status` | 更新月报生成状态(0=生成中, 1=成功, 2=失败) | `--group_id`、`--month`、`--status` | `--fail_reason`(status=2 时必填) |
## 批量生成
遍历全公司员工生成月报:
1. `get_all_periods` → 获取启用周期
2. `get_group_tree --only_personal` → 获取所有个人分组
3. 遍历每个个人分组,对每人执行:`collect_monthly_overview` → `collect_goal_data` × N → 分步生成报告 → 发送 → 保存到 BP 系统
**注意**:批量生成前必须征得用户明确同意,并告知预计耗时。
## 重要约束
### 通用约束
- 所有 ID 参数保持字符串原样传递,**严禁 parseInt 或 Number 转换**
- **严禁测试发送**:`send_report` 接口会将内容真实推送给员工,**绝对不允许**用测试数据、占位内容或 debug 用途调用此接口。只有在报告内容已完整生成且 Step 3d 合规性校验全部通过的情况下,才能调用 `send_report`。任何"试一下接口通不通"的行为都是禁止的
- **校验通过后直接发送**:报告必须走完"逐目标组装 → 拼接 → 合规性校验(Step 3d)→ 发送 → 保存"完整周期。校验通过后**不再**等待用户确认,直接发送
- **禁止一步生成整篇报告**,必须走 3a → 3b → 3c(逐目标循环)→ 3d(拼接+校验)四步
- 灯色文字使用 HTML `<span>` 彩色加粗渲染
- 灯色判断必须严格按 [references/traffic-light-rules.md](references/traffic-light-rules.md) 标准(注意灯规则版本)
- 证据处理必须严格按 [references/evidence-rules.md](references/evidence-rules.md) 标准
- 汇报计数以 reportId 去重后为准,内容相似的批量发送按一个工作事项计算
- 月份归集以汇报的 `createTime` 为准,不依赖关联时间
### 发送与保存约束
- **发送报错重试机制**:以下两种错误脚本会自动等待 60 秒后重试一次(API 按分钟限流):
- **"汇报人ID有误"**:先检查 appKey 是否与 sender 匹配,确认 key 正确后等待 60 秒再重试
- **resultCode=401 且参数正确**:视为接口限流,等待 60 秒后重试
- 发送后必须记录 `data.id` 并生成 `huibao://view?id={data.id}` 链接
- **发送人和 appKey 根据接收人的企业自动匹配**(corpId → sender + appKey 映射):
- `1509805893730611201` → sender=`400001`,appKey=`5xmsXv311OVq121d5hzb5yGJ6sO5AB04`
- `1509805893730611202` → sender=`400002`,appKey=`1xmsXv2yv11OVqkd3zb5yG441sO5AB04`
- `1515978849561276500` → sender=`400003`,appKey=`5xmsXvVyv11dskd5hzb5ys6ssswqAB04`
- 若多个接收人则以第一个接收人的企业为准,匹配失败时回退到默认 `400002`。汇报接收人是员工本人(`employeeId`),**不是** `groupId`
- **查询数据**使用用户提供的 key(`BP_OPEN_API_APP_KEY`),**发送汇报**使用与 sender 对应的机器人 key(已内置,自动匹配)
### 报告(BP自查报告)约束
- 目标数量必须与实际 BP 目标数量一致,**不可写死**
- **排除规则**:基于 `planDateRange` 区间交叉判断——草稿直接排除,其余按计划区间是否与汇报月份有交集决定。被排除的目标不生成明细章节、不计入灯色统计,在 2.1 总览表中用 ★ 标记为"未启动"
- 灯色判断到**举措级**和**目标级**:每个参与自查的举措嵌入举措级四灯判断块,目标级灯色从举措灯色聚合得出;KR 级不判灯,只输出详细判断理由
- 每个参与自查的目标嵌入目标级四灯判断块
- 报告以"目标"为底座组织内容,不把"目标/结果/举措/问题"拆成独立章节分别看
- 目标清单总览表展示所有目标(含被排除目标),被排除目标用绿色五角星 ★ 标记、结论写"未启动"、不填证据引用
### 证据引用约束
- 正文中**当月**证据引用使用 `[R编号](huibao://view?id={reportId})` 格式,点击直接打开对应汇报详情页
- 正文中**上月参考**引用使用 `[RP编号](huibao://view?id={reportRecordId})` 格式,RP 编号仅出现在元数据头基线行和附录 A.3
- `R` 编号(当月证据)和 `RP` 编号(上月参考)使用不同前缀,**严禁混用**
- **不使用** `[R编号](#R编号)` 页内锚点链接(在工作协同中无法跳转)
- 附录证据索引表中 R 编号直接展示文本(不需要 `<span id>` 锚点标签),汇报链接列保持 `[查看汇报](huibao://view?id={reportId})` 格式
- 汇报链接 reportId 从 `uniqueReportMap` 原样取用,reportRecordId 从 `collect_previous_month_data` 输出原样取用,**严禁伪造或编造**
- 附录 A.1 + A.2 必须从证据台账文件(`/tmp/evidence_ledger_{groupId}.md`)**直接读取并原样搬入**,确保条数完全一致
- 附录 A.3 从 Step 2b 采集的上月数据生成,若首月则整节替换为"首月汇报,无上月参考基线。"
- 附件读取为待接入能力,**不可伪造**
## 环境配置
复用 `bp-data-viewer` 的环境变量:
| 变量名 | 说明 | 默认值 |
|--------|------|--------|
| `BP_OPEN_API_APP_KEY` | 数据查询用 API 密钥(**必填**) | 无(用户提供) |
| `BP_OPEN_API_BASE_URL` | API 地址 | `https://sg-al-cwork-web.mediportal.com.cn/open-api` |
> 发送汇报的机器人 appKey 已按 sender(400001/400002/400003)内置在脚本中,根据接收人企业自动匹配,无需配置。
## 错误处理
- BP 数据获取失败时,提示用户检查 `BP_OPEN_API_APP_KEY` 配置
- 报告发送失败时,保留报告文件,提示用户可手动重试
- **"汇报人ID有误"或 401 限流**:脚本自动检查 key 并等待 60 秒后重试一次;若重试仍失败,保留报告内容并提示用户排查
- 某个目标下无汇报数据时,在报告中标注"本月暂未收到工作汇报"并按灯色规则判断,不中断整体流程
FILE:scripts/monthly_report_api.py
#!/usr/bin/env python3
"""Monthly Report API CLI — fetch report content, collect monthly data, and send reports.
Usage:
python monthly_report_api.py <action> [options]
Actions:
collect_monthly_overview Fetch task tree + goal list (lightweight, per-goal workflow step 1)
collect_goal_data Collect BP detail + reports for a single goal (per-goal workflow step 2)
collect_monthly_data [Legacy] Aggregate all BP data + reports into a single JSON
collect_previous_month_data Aggregate previous month's reports + evaluations as reference context
get_report_content Get report body content by report ID
send_report Send monthly report via work-report API
save_monthly_report Save monthly report to BP system (2.22 saveMonthlyReport)
update_report_status Update monthly report generation status (0=generating, 1=success, 2=failed)
Environment:
BP_OPEN_API_APP_KEY Authentication key (required)
BP_OPEN_API_BASE_URL API base URL (optional, has default)
"""
import argparse
import calendar
import json
import os
import sys
import time
from datetime import datetime
import requests
BASE_URL = os.environ.get(
"BP_OPEN_API_BASE_URL",
"https://sg-al-cwork-web.mediportal.com.cn/open-api",
)
APP_KEY = os.environ.get("BP_OPEN_API_APP_KEY", "")
TIMEOUT = 30
DEFAULT_SENDER_ID = "400002"
REPORT_CONTENT_MAX_CHARS = 2000
SEND_RETRY_DELAY_SECONDS = 60
QUERY_RETRY_DELAY_SECONDS = 60
QUERY_MAX_RETRIES = 1
CORP_ID_TO_SENDER = {
"1509805893730611201": "400001",
"1509805893730611202": "400002",
"1515978849561276500": "400003",
}
SENDER_TO_APP_KEY = {
"400001": "5xmsXv311OVq121d5hzb5yGJ6sO5AB04",
"400002": "1xmsXv2yv11OVqkd3zb5yG441sO5AB04",
"400003": "5xmsXvVyv11dskd5hzb5ys6ssswqAB04",
}
DEFAULT_SEND_APP_KEY = SENDER_TO_APP_KEY[DEFAULT_SENDER_ID]
def _log(msg):
print(f"[progress] {msg}", file=sys.stderr)
def _resolve_sender(receiver_emp_id):
"""Look up the receiver's corpId and return (sender_id, app_key).
Each AI assistant (sender) has its own appKey for sending reports.
Falls back to DEFAULT_SENDER_ID / DEFAULT_SEND_APP_KEY on failure.
"""
url = f"{BASE_URL}/cwork-user/employee/getEmployeeOrgInfo"
headers = {"appKey": APP_KEY}
for attempt in range(1 + QUERY_MAX_RETRIES):
try:
resp = requests.get(url, params={"empId": receiver_emp_id}, headers=headers, timeout=TIMEOUT)
resp.raise_for_status()
data = resp.json()
if data.get("resultCode") == 1 and data.get("data"):
corp_id = str(data["data"].get("corpId") or "")
sender = CORP_ID_TO_SENDER.get(corp_id)
if sender:
app_key = SENDER_TO_APP_KEY.get(sender, DEFAULT_SEND_APP_KEY)
_log(f"Resolved sender: receiver={receiver_emp_id} -> corpId={corp_id} -> sender={sender}")
return sender, app_key
_log(f"Unknown corpId={corp_id} for receiver={receiver_emp_id}, using default sender={DEFAULT_SENDER_ID}")
return DEFAULT_SENDER_ID, DEFAULT_SEND_APP_KEY
rc = data.get("resultCode")
is_retryable = rc in (401, 429) or (isinstance(rc, int) and rc >= 500)
if is_retryable and attempt < QUERY_MAX_RETRIES:
_log(f"Resolve sender got resultCode={rc}, waiting {QUERY_RETRY_DELAY_SECONDS}s before retry...")
time.sleep(QUERY_RETRY_DELAY_SECONDS)
continue
_log(f"Failed to get org info for receiver={receiver_emp_id}: {data.get('resultMsg')}, "
f"using default sender={DEFAULT_SENDER_ID}")
except requests.HTTPError as e:
rc = e.response.status_code
is_retryable = rc in (401, 429) or rc >= 500
if is_retryable and attempt < QUERY_MAX_RETRIES:
_log(f"Resolve sender got HTTP {rc}, waiting {QUERY_RETRY_DELAY_SECONDS}s before retry...")
time.sleep(QUERY_RETRY_DELAY_SECONDS)
continue
_log(f"Error resolving sender for receiver={receiver_emp_id}: {e}, using default sender={DEFAULT_SENDER_ID}")
except Exception as e:
_log(f"Error resolving sender for receiver={receiver_emp_id}: {e}, using default sender={DEFAULT_SENDER_ID}")
return DEFAULT_SENDER_ID, DEFAULT_SEND_APP_KEY
def _do_request(method, url, headers, params=None, json_body=None):
"""Execute a single HTTP request and return parsed result."""
if method == "GET":
resp = requests.get(url, params=params, headers=headers, timeout=TIMEOUT)
else:
req_headers = {**headers, "Content-Type": "application/json"}
resp = requests.post(url, params=params, json=json_body, headers=req_headers, timeout=TIMEOUT)
resp.raise_for_status()
data = resp.json()
if data.get("resultCode") != 1:
return {"error": data.get("resultMsg", "Unknown API error"), "resultCode": data.get("resultCode")}
return {"success": True, "data": data.get("data")}
def _request(method, path, *, params=None, json_body=None):
if not APP_KEY:
return {"error": "BP_OPEN_API_APP_KEY is not configured. Set it as an environment variable."}
url = f"{BASE_URL}{path}"
headers = {"appKey": APP_KEY}
for attempt in range(1 + QUERY_MAX_RETRIES):
try:
result = _do_request(method, url, headers, params=params, json_body=json_body)
except requests.HTTPError as e:
result = {"error": f"HTTP {e.response.status_code}: {e.response.text}",
"resultCode": e.response.status_code}
except Exception as e:
return {"error": str(e)}
if result.get("success"):
return result
rc = result.get("resultCode")
is_retryable = rc in (401, 429) or (isinstance(rc, int) and rc >= 500)
if is_retryable and attempt < QUERY_MAX_RETRIES:
_log(f"Query got resultCode={rc} on {path}, waiting {QUERY_RETRY_DELAY_SECONDS}s before retry...")
time.sleep(QUERY_RETRY_DELAY_SECONDS)
continue
return result
return result
# ─── Task tree helpers ────────────────────────────────────────────
_SLIM_TASK_FIELDS = ("id", "name", "fullLevelNumber", "type", "reportCycle",
"planDateRange", "statusDesc", "periodId", "groupId")
def _slim_task_tree(node):
"""Keep only essential fields in task tree (mirrors bp_api.py logic)."""
if node is None:
return None
if isinstance(node, list):
return [_slim_task_tree(n) for n in node]
keep = {k: node[k] for k in _SLIM_TASK_FIELDS if k in node}
children = node.get("children")
if children:
keep["children"] = [_slim_task_tree(c) for c in children]
return keep
def _collect_all_ids(nodes):
"""Recursively collect all task IDs from a (slim) task tree."""
ids = []
for node in (nodes or []):
nid = node.get("id")
if nid:
ids.append(str(nid))
ids.extend(_collect_all_ids(node.get("children")))
return ids
def _collect_goal_ids(nodes):
"""Collect IDs of top-level goal nodes (type contains '目标')."""
ids = []
for node in (nodes or []):
ntype = node.get("type", "")
if "目标" in ntype:
nid = node.get("id")
if nid:
ids.append(str(nid))
return ids
def _month_time_range(month_str):
"""Convert 'YYYY-MM' to (start, end) datetime strings."""
year, month = int(month_str[:4]), int(month_str[5:7])
last_day = calendar.monthrange(year, month)[1]
return (f"{year:04d}-{month:02d}-01 00:00:00",
f"{year:04d}-{month:02d}-{last_day:02d} 23:59:59")
def _truncate(text, max_chars=REPORT_CONTENT_MAX_CHARS):
if text and len(text) > max_chars:
return text[:max_chars] + " [...truncated]"
return text
def _collect_goal_summary(nodes):
"""Extract summary info for each top-level goal from slim task tree."""
goals = []
for node in (nodes or []):
ntype = node.get("type", "")
if "目标" in ntype:
goals.append({
"goalId": str(node["id"]) if node.get("id") else None,
"name": node.get("name", ""),
"fullLevelNumber": node.get("fullLevelNumber", ""),
"planDateRange": node.get("planDateRange", ""),
"statusDesc": node.get("statusDesc", ""),
})
return goals
def _extract_ids_from_goal_detail(goal_detail):
"""Recursively extract all node IDs from a goal detail response.
Handles both field naming conventions from the API:
- keyResultList / keyResults for KR list
- actionList / actions for action list
"""
ids = []
if not goal_detail:
return ids
gid = goal_detail.get("id")
if gid:
ids.append(str(gid))
kr_list = goal_detail.get("keyResultList") or goal_detail.get("keyResults") or []
for kr in kr_list:
kid = kr.get("id")
if kid:
ids.append(str(kid))
action_list = kr.get("actionList") or kr.get("actions") or []
for action in action_list:
aid = action.get("id")
if aid:
ids.append(str(aid))
return ids
def _build_report_content(rd, truncate=True):
"""Build a report content dict from raw API response."""
content_html = rd.get("contentHtml") or rd.get("content") or ""
return {
"reportId": str(rd.get("id") or rd.get("reportId") or ""),
"title": rd.get("main", ""),
"content": _truncate(content_html) if truncate else content_html,
"contentType": rd.get("contentType", ""),
"createTime": rd.get("createTime"),
"authorEmpId": rd.get("writeEmpId") or rd.get("empId") or rd.get("authorEmpId") or rd.get("createBy"),
"authorName": rd.get("writeEmpName") or rd.get("empName") or rd.get("authorName") or rd.get("createByName"),
}
# ─── collect_monthly_overview ─────────────────────────────────────
def collect_monthly_overview(args):
"""Fetch task tree and output goal list + global stats (lightweight).
This is the first step of the per-goal collection workflow:
1. Fetch task tree for the group
2. Extract goal summary list
3. Write lightweight JSON with tree + goals + stats
"""
if not args.group_id:
return {"error": "group_id is required for collect_monthly_overview"}
if not args.month:
return {"error": "month (YYYY-MM) is required for collect_monthly_overview"}
_log("Fetching task tree...")
tree_result = _request("GET", "/bp/task/v2/getSimpleTree", params={"groupId": args.group_id})
if not tree_result.get("success"):
return {"error": f"Failed to fetch task tree: {tree_result.get('error')}"}
raw_tree = tree_result["data"]
task_tree = _slim_task_tree(raw_tree) if raw_tree else []
all_ids = _collect_all_ids(task_tree)
goals = _collect_goal_summary(task_tree)
output = {
"groupId": args.group_id,
"month": args.month,
"collectTime": datetime.now().strftime("%Y-%m-%d %H:%M:%S"),
"taskTree": task_tree,
"goals": goals,
"stats": {"totalGoals": len(goals), "totalNodes": len(all_ids)},
}
output_path = args.output or f"/tmp/monthly_overview_{args.group_id}.json"
with open(output_path, "w", encoding="utf-8") as f:
json.dump(output, f, ensure_ascii=False, indent=2)
_log(f"Done! Overview written to {output_path} ({len(goals)} goals, {len(all_ids)} nodes)")
return {"success": True, "outputFile": output_path, "stats": output["stats"]}
# ─── collect_goal_data ────────────────────────────────────────────
def collect_goal_data(args):
"""Collect BP detail + reports for a single goal (per-goal granularity).
1. Fetch goal detail (with KRs and actions)
2. Extract all node IDs under this goal
3. Query reports for each node within the month
4. Fetch full report content for all unique report IDs (no truncation)
5. Build reverse index and per-task report data
6. Write independent JSON file for this goal
"""
if not args.goal_id:
return {"error": "goal_id is required for collect_goal_data"}
if not args.month:
return {"error": "month (YYYY-MM) is required for collect_goal_data"}
errors = []
time_start, time_end = _month_time_range(args.month)
_log(f"Fetching goal detail: {args.goal_id}")
detail = _request("GET", "/bp/task/v2/getGoalAndKeyResult", params={"id": args.goal_id})
if not detail.get("success"):
return {"error": f"Failed to fetch goal detail: {detail.get('error')}"}
goal_detail = detail["data"]
node_ids = _extract_ids_from_goal_detail(goal_detail)
_log(f"Goal has {len(node_ids)} nodes")
task_report_ids = {}
all_report_ids = set()
for i, tid in enumerate(node_ids, 1):
body = {
"taskId": tid,
"pageIndex": 1,
"pageSize": 200,
"businessTimeStart": time_start,
"businessTimeEnd": time_end,
}
result = _request("POST", "/bp/task/relation/pageAllReports", json_body=body)
if result.get("success"):
records = result["data"].get("list") or []
biz_ids = []
for rec in records:
bid = rec.get("bizId")
if bid:
bid_str = str(bid)
biz_ids.append({"bizId": bid_str, "type": rec.get("type", ""), "businessTime": rec.get("businessTime")})
all_report_ids.add(bid_str)
if biz_ids:
task_report_ids[tid] = biz_ids
else:
errors.append({"step": "task_reports", "id": tid, "error": result.get("error")})
_log(f"Found {len(all_report_ids)} unique reports across {len(task_report_ids)} nodes")
report_contents = {}
for i, rid in enumerate(sorted(all_report_ids), 1):
if i % 10 == 0 or i == len(all_report_ids):
_log(f" fetching report content {i}/{len(all_report_ids)}")
result = _request("GET", "/work-report/report/info", params={"reportId": rid})
if result.get("success") and result["data"]:
rc = _build_report_content(result["data"], truncate=False)
rc["reportId"] = rid
report_contents[rid] = rc
else:
errors.append({"step": "report_content", "id": rid, "error": result.get("error")})
report_task_mapping = {}
for tid, biz_entries in task_report_ids.items():
for entry in biz_entries:
bid = entry["bizId"]
report_task_mapping.setdefault(bid, [])
if tid not in report_task_mapping[bid]:
report_task_mapping[bid].append(tid)
reports_by_task = {}
for tid, biz_entries in task_report_ids.items():
task_reports = []
for entry in biz_entries:
bid = entry["bizId"]
rc = report_contents.get(bid)
if rc:
task_reports.append({**rc, "type": entry.get("type", ""), "businessTime": entry.get("businessTime")})
if task_reports:
reports_by_task[tid] = task_reports
output = {
"goalId": args.goal_id,
"groupId": getattr(args, "group_id", None) or "",
"month": args.month,
"collectTime": datetime.now().strftime("%Y-%m-%d %H:%M:%S"),
"goalDetail": goal_detail,
"uniqueReportMap": report_contents,
"reportTaskMapping": report_task_mapping,
"reports": reports_by_task,
"stats": {
"nodeCount": len(node_ids),
"uniqueReportCount": len(all_report_ids),
"fetchedReportContents": len(report_contents),
},
}
if errors:
output["errors"] = errors
output_path = args.output or f"/tmp/goal_data_{args.goal_id}.json"
with open(output_path, "w", encoding="utf-8") as f:
json.dump(output, f, ensure_ascii=False, indent=2)
_log(f"Done! Goal data written to {output_path}")
return {"success": True, "outputFile": output_path, "stats": output["stats"]}
# ─── collect_monthly_data (legacy, kept for backward compatibility) ──
def collect_monthly_data(args):
"""Aggregate all BP structure + report data for one employee/month.
Performs the following in a single invocation:
1. Fetch task tree for the group
2. Fetch goal details for each top-level goal
3. Query reports for every task node within the month
4. Fetch report content for all unique report IDs
5. Write aggregated JSON to --output file
"""
if not args.group_id:
return {"error": "group_id is required for collect_monthly_data"}
if not args.month:
return {"error": "month (YYYY-MM) is required for collect_monthly_data"}
errors = []
time_start, time_end = _month_time_range(args.month)
# Step 1: task tree
_log("Fetching task tree...")
tree_result = _request("GET", "/bp/task/v2/getSimpleTree", params={"groupId": args.group_id})
if not tree_result.get("success"):
return {"error": f"Failed to fetch task tree: {tree_result.get('error')}"}
raw_tree = tree_result["data"]
task_tree = _slim_task_tree(raw_tree) if raw_tree else []
all_ids = _collect_all_ids(task_tree)
goal_ids = _collect_goal_ids(task_tree)
_log(f"Task tree: {len(all_ids)} nodes, {len(goal_ids)} goals")
# Step 2: goal details
goal_details = {}
for i, gid in enumerate(goal_ids, 1):
_log(f"Fetching goal detail {i}/{len(goal_ids)}: {gid}")
detail = _request("GET", "/bp/task/v2/getGoalAndKeyResult", params={"id": gid})
if detail.get("success"):
goal_details[gid] = detail["data"]
else:
errors.append({"step": "goal_detail", "id": gid, "error": detail.get("error")})
# Step 3: query reports for each task node
_log(f"Querying reports for {len(all_ids)} task nodes...")
task_report_ids = {}
all_report_ids = set()
for i, tid in enumerate(all_ids, 1):
if i % 10 == 0 or i == len(all_ids):
_log(f" reports query {i}/{len(all_ids)}")
body = {
"taskId": tid,
"pageIndex": 1,
"pageSize": 200,
"businessTimeStart": time_start,
"businessTimeEnd": time_end,
}
result = _request("POST", "/bp/task/relation/pageAllReports", json_body=body)
if result.get("success"):
records = result["data"].get("list") or []
biz_ids = []
for rec in records:
bid = rec.get("bizId")
if bid:
bid_str = str(bid)
biz_ids.append({"bizId": bid_str, "type": rec.get("type", ""), "businessTime": rec.get("businessTime")})
all_report_ids.add(bid_str)
if biz_ids:
task_report_ids[tid] = biz_ids
else:
errors.append({"step": "task_reports", "id": tid, "error": result.get("error")})
_log(f"Found {len(all_report_ids)} unique reports across {len(task_report_ids)} tasks")
# Step 4: fetch report content for all unique report IDs
report_contents = {}
report_id_list = sorted(all_report_ids)
for i, rid in enumerate(report_id_list, 1):
if i % 10 == 0 or i == len(report_id_list):
_log(f" fetching report content {i}/{len(report_id_list)}")
result = _request("GET", "/work-report/report/info", params={"reportId": rid})
if result.get("success") and result["data"]:
rc = _build_report_content(result["data"], truncate=True)
rc["reportId"] = rid
report_contents[rid] = rc
else:
errors.append({"step": "report_content", "id": rid, "error": result.get("error")})
# Step 5: build reverse index — reportId -> list of associated taskIds
report_task_mapping = {}
for tid, biz_entries in task_report_ids.items():
for entry in biz_entries:
bid = entry["bizId"]
report_task_mapping.setdefault(bid, [])
if tid not in report_task_mapping[bid]:
report_task_mapping[bid].append(tid)
# Step 6: assemble per-task report data (backward-compatible)
reports_by_task = {}
for tid, biz_entries in task_report_ids.items():
task_reports = []
for entry in biz_entries:
bid = entry["bizId"]
rc = report_contents.get(bid)
if rc:
task_reports.append({
**rc,
"type": entry.get("type", ""),
"businessTime": entry.get("businessTime"),
})
if task_reports:
reports_by_task[tid] = task_reports
# Step 7: content dedup — group reports with near-identical content
_seen_titles = {}
unique_work_items = 0
for rid, rc in report_contents.items():
title_key = (rc.get("title") or "").strip()
if title_key and title_key in _seen_titles:
_seen_titles[title_key].append(rid)
else:
_seen_titles[title_key or rid] = [rid]
unique_work_items += 1
# Step 8: build output
output = {
"groupId": args.group_id,
"month": args.month,
"collectTime": datetime.now().strftime("%Y-%m-%d %H:%M:%S"),
"taskTree": task_tree,
"goalDetails": goal_details,
"uniqueReportMap": report_contents,
"reportTaskMapping": report_task_mapping,
"reports": reports_by_task,
"stats": {
"totalTasks": len(all_ids),
"totalGoals": len(goal_ids),
"totalReportQueries": len(task_report_ids),
"rawReportCount": sum(len(v) for v in task_report_ids.values()),
"uniqueReportCount": len(all_report_ids),
"fetchedReportContents": len(report_contents),
"uniqueWorkItemCount": unique_work_items,
},
}
if errors:
output["errors"] = errors
output_path = args.output
if not output_path:
output_path = f"/tmp/monthly_data_{args.group_id}.json"
with open(output_path, "w", encoding="utf-8") as f:
json.dump(output, f, ensure_ascii=False, indent=2)
_log(f"Done! Output written to {output_path}")
return {"success": True, "outputFile": output_path, "stats": output["stats"]}
def get_report_content(args):
"""GET /work-report/report/info?reportId={id}"""
if not args.report_id:
return {"error": "report_id is required for get_report_content"}
return _request("GET", "/work-report/report/info", params={"reportId": args.report_id})
def _do_send_report(url, headers, body):
"""Execute the actual HTTP POST for send_report."""
resp = requests.post(url, json=body, headers=headers, timeout=TIMEOUT)
resp.raise_for_status()
data = resp.json()
if data.get("resultCode") != 1:
return {"error": data.get("resultMsg", "Unknown API error"), "resultCode": data.get("resultCode")}
return {"success": True, "data": data.get("data")}
def _is_rate_limited(result):
"""Check if the error indicates API rate limiting (resultCode 401 with valid params)."""
return result.get("resultCode") == 401
def _should_retry_send(result):
"""Determine if send_report should retry based on error type."""
error_msg = str(result.get("error", ""))
if "汇报人ID有误" in error_msg:
return "emp_id_error"
if _is_rate_limited(result):
return "rate_limited"
return None
def send_report(args):
"""POST /work-report/report/record/submit — send monthly report.
Uses the built-in SEND_REPORT_APP_KEY (robot key), NOT the user's
BP_OPEN_API_APP_KEY. Retryable errors (rate limit 401, "汇报人ID有误"):
verify key, wait 60s, retry once.
"""
if not args.receiver_emp_id:
return {"error": "receiver_emp_id is required for send_report"}
if not args.title:
return {"error": "title is required for send_report"}
if not args.content_file:
return {"error": "content_file is required for send_report"}
content_path = args.content_file
if not os.path.isfile(content_path):
return {"error": f"Content file not found: {content_path}"}
with open(content_path, "r", encoding="utf-8") as f:
content = f.read()
if not content.strip():
return {"error": "Content file is empty"}
first_receiver = args.receiver_emp_id.split(",")[0].strip()
if args.sender_id:
sender_id = args.sender_id
send_app_key = SENDER_TO_APP_KEY.get(sender_id, DEFAULT_SEND_APP_KEY)
else:
sender_id, send_app_key = _resolve_sender(first_receiver)
receiver_list = [
{"empId": rid.strip()}
for rid in args.receiver_emp_id.split(",") if rid.strip()
]
body = {
"main": args.title,
"contentHtml": content,
"contentType": "markdown",
"comeFrom": "BP-API调用",
"templateId": 2044631241659035650,
"flowType": "merge_template_node",
"reportLevelList": [
{
"level": 1,
"levelUserList": receiver_list,
"nodeName": "建议",
"type": "suggest",
}
],
}
copy_ids = getattr(args, "copy_emp_ids", None)
if copy_ids:
body["copyEmpIdList"] = [cid.strip() for cid in copy_ids.split(",") if cid.strip()]
url = f"{BASE_URL}/work-report/report/record/submit"
headers = {"appKey": send_app_key, "Content-Type": "application/json"}
_log(f"Sending with sender={sender_id}, appKey={send_app_key[:8]}...")
try:
result = _do_send_report(url, headers, body)
except requests.RequestException as exc:
return {"error": f"Network error: {exc}"}
retry_reason = _should_retry_send(result) if result.get("error") else None
if retry_reason:
if retry_reason == "emp_id_error":
_log(f"Got '汇报人ID有误' for empId={args.receiver_emp_id}. "
f"Verifying appKey matches sender={sender_id}...")
elif retry_reason == "rate_limited":
_log(f"Got resultCode=401 (rate limited) for empId={args.receiver_emp_id}. "
f"Params look correct, treating as rate limit...")
expected_key = SENDER_TO_APP_KEY.get(sender_id, DEFAULT_SEND_APP_KEY)
if headers["appKey"] != expected_key:
_log(f"Key mismatch detected, switching to correct key for sender={sender_id}")
headers["appKey"] = expected_key
_log(f"Waiting {SEND_RETRY_DELAY_SECONDS}s before retry...")
time.sleep(SEND_RETRY_DELAY_SECONDS)
try:
result = _do_send_report(url, headers, body)
if result.get("success"):
_log("Retry succeeded.")
else:
_log(f"Retry failed: {result.get('error')}")
except requests.RequestException as exc:
return {"error": f"Network error on retry: {exc}"}
if result.get("success"):
_log(f"Report sent successfully. Receiver: {args.receiver_emp_id}, Sender: {sender_id}")
return result
def save_monthly_report(args):
"""POST /bp/monthly/report/save — persist report to BP system.
Uses the data-query APP_KEY (not the send-report robot key),
because the BP monthly report save API requires user-level permission.
reportRecordId is required — pass the id returned by send_report.
"""
if not args.group_id:
return {"error": "group_id is required for save_monthly_report"}
if not args.month:
return {"error": "month is required for save_monthly_report"}
if not args.content_file:
return {"error": "content_file is required for save_monthly_report"}
if not getattr(args, "report_record_id", None):
return {"error": "report_record_id is required for save_monthly_report (pass the id returned by send_report)"}
content_path = args.content_file
if not os.path.isfile(content_path):
return {"error": f"Content file not found: {content_path}"}
with open(content_path, "r", encoding="utf-8") as f:
content = f.read()
if not content.strip():
return {"error": "Content file is empty"}
if not APP_KEY:
return {"error": "BP_OPEN_API_APP_KEY is not configured. Required for save_monthly_report."}
body = {
"groupId": int(args.group_id),
"reportContent": content,
"reportMonth": args.month,
"reportRecordId": int(args.report_record_id),
}
_log(f"Saving monthly report: groupId={args.group_id}, month={args.month}")
result = _request("POST", "/bp/monthly/report/save", json_body=body)
if result.get("success"):
_log(f"Monthly report saved. groupId: {args.group_id}, month: {args.month}, "
f"reportId: {result['data']}")
return result
def update_report_status(args):
"""POST /bp/monthly/report/updateStatus — update monthly report generation status.
Uses the data-query APP_KEY (not the send-report robot key).
status: 0=generating, 1=success, 2=failed
fail_reason: required when status=2
"""
if not args.group_id:
return {"error": "group_id is required for update_report_status"}
if not args.month:
return {"error": "month (YYYY-MM) is required for update_report_status"}
if args.status is None:
return {"error": "status (0/1/2) is required for update_report_status"}
status_val = int(args.status)
if status_val not in (0, 1, 2):
return {"error": "status must be 0 (generating), 1 (success), or 2 (failed)"}
if status_val == 2 and not args.fail_reason:
return {"error": "fail_reason is required when status=2 (failed)"}
body = {
"groupId": int(args.group_id),
"reportMonth": args.month,
"generateStatus": status_val,
}
if args.fail_reason:
body["failReason"] = args.fail_reason
_log(f"Updating report status: groupId={args.group_id}, month={args.month}, status={status_val}")
result = _request("POST", "/bp/monthly/report/updateStatus", json_body=body)
if result.get("success"):
_log(f"Report status updated. reportId={result.get('data')}")
return result
def collect_previous_month_data(args):
"""Aggregate previous month's reports + evaluations as reference for current month.
1. Call 2.31 listMonthlyReports — get reportTypeDesc + reportRecordId for previous month
2. For each reportRecordId, fetch report content via work-report API
3. Call 2.32 getMonthlyEvaluation — get translated Markdown (self + manager)
4. Write aggregated JSON to --output file
"""
if not args.group_id:
return {"error": "group_id is required for collect_previous_month_data"}
if not args.month:
return {"error": "month (YYYY-MM, the previous month) is required for collect_previous_month_data"}
errors = []
prev_month = args.month
# Step 1: list monthly reports for previous month
_log(f"Fetching monthly report list for {prev_month}...")
reports_result = _request("GET", "/bp/monthly/report/listByMonth",
params={"groupId": args.group_id, "reportMonth": prev_month})
report_items = []
if reports_result.get("success"):
report_items = reports_result.get("data") or []
_log(f"Found {len(report_items)} report(s) for {prev_month}")
else:
errors.append({"step": "list_monthly_reports", "error": reports_result.get("error")})
# Step 2: fetch report content for each reportRecordId
report_contents = []
for item in report_items:
rid = item.get("reportRecordId")
type_desc = item.get("reportTypeDesc", "")
if not rid:
continue
_log(f"Fetching report content: {type_desc} (id={rid})")
content_result = _request("GET", "/work-report/report/info", params={"reportId": str(rid)})
if content_result.get("success") and content_result.get("data"):
rd = content_result["data"]
content_html = rd.get("contentHtml") or rd.get("content") or ""
report_contents.append({
"reportTypeDesc": type_desc,
"reportRecordId": str(rid),
"title": rd.get("main", ""),
"content": _truncate(content_html),
"createTime": rd.get("createTime"),
})
else:
errors.append({"step": "report_content", "id": str(rid), "error": content_result.get("error")})
# Step 3: fetch monthly evaluation Markdown
_log(f"Fetching monthly evaluation for {prev_month}...")
eval_result = _request("GET", "/bp/monthly/evaluation/query",
params={"groupId": args.group_id, "evaluationMonth": prev_month})
evaluations = []
if eval_result.get("success"):
evaluations = eval_result.get("data") or []
_log(f"Found {len(evaluations)} evaluation(s) for {prev_month}")
else:
errors.append({"step": "monthly_evaluation", "error": eval_result.get("error")})
# Step 4: build output
output = {
"groupId": args.group_id,
"previousMonth": prev_month,
"collectTime": datetime.now().strftime("%Y-%m-%d %H:%M:%S"),
"reports": report_contents,
"evaluations": evaluations,
"stats": {
"reportCount": len(report_contents),
"evaluationCount": len(evaluations),
},
}
if errors:
output["errors"] = errors
output_path = args.output
if not output_path:
output_path = f"/tmp/prev_month_data_{args.group_id}.json"
with open(output_path, "w", encoding="utf-8") as f:
json.dump(output, f, ensure_ascii=False, indent=2)
_log(f"Done! Previous month data written to {output_path}")
return {"success": True, "outputFile": output_path, "stats": output["stats"]}
ACTION_MAP = {
"collect_monthly_overview": collect_monthly_overview,
"collect_goal_data": collect_goal_data,
"collect_monthly_data": collect_monthly_data,
"collect_previous_month_data": collect_previous_month_data,
"get_report_content": get_report_content,
"send_report": send_report,
"save_monthly_report": save_monthly_report,
"update_report_status": update_report_status,
}
def main():
parser = argparse.ArgumentParser(
description="Monthly Report API — collect data, fetch report content, and send reports",
)
parser.add_argument(
"action",
choices=ACTION_MAP.keys(),
help="The action to perform",
)
parser.add_argument("--group_id", help="Personal group ID")
parser.add_argument("--goal_id", help="Goal ID (for collect_goal_data)")
parser.add_argument("--month", help="Target month YYYY-MM")
parser.add_argument("--output", help="Output JSON file path")
parser.add_argument("--report_id", help="Report ID (for get_report_content)")
parser.add_argument("--receiver_emp_id", help="Receiver employee ID (for send_report)")
parser.add_argument("--title", help="Report title (for send_report)")
parser.add_argument("--content_file", help="Path to markdown content file (for send_report)")
parser.add_argument("--sender_id", help=f"Sender system user ID (default: {DEFAULT_SENDER_ID})")
parser.add_argument("--report_record_id", help="Report record ID from send_report (for save_monthly_report)")
parser.add_argument("--copy_emp_ids", help="Comma-separated copy employee IDs (for send_report)")
parser.add_argument("--status", help="Generate status: 0=generating, 1=success, 2=failed (for update_report_status)")
parser.add_argument("--fail_reason", help="Failure reason (for update_report_status, required when status=2)")
args = parser.parse_args()
result = ACTION_MAP[args.action](args)
print(json.dumps(result, ensure_ascii=False, indent=2))
if result.get("error"):
sys.exit(1)
if __name__ == "__main__":
main()
FILE:references/changelog.md
# BP 个人月度汇报 Skill 变更记录
> 记录每次修改遇到的问题、影响点、修复方案以及后续预防措施。
---
## v1.2 — 2026-04-15 稳定性加固
### 问题 1:`_do_request` 中 headers 字典被 mutation
| 项目 | 说明 |
|------|------|
| **现象** | `_do_request` 在 POST 请求中直接修改传入的 `headers` 字典(添加 `Content-Type`),当 retry 循环复用同一 headers 时,GET 请求也会携带 `Content-Type: application/json`,虽然当前未造成实际错误,但属于隐患 |
| **影响** | retry 循环第二次请求可能携带不必要的 header,部分严格校验的服务端可能拒绝 |
| **修复** | POST 分支改为创建新 dict `req_headers = {**headers, "Content-Type": "application/json"}`,不修改原始 headers |
| **预防** | 所有构建请求 header 的地方禁止直接修改传入对象,统一用浅拷贝或新建 dict |
### 问题 2:`save_monthly_report` 绕过 `_request` 缺少 retry 能力
| 项目 | 说明 |
|------|------|
| **现象** | `save_monthly_report` 直接 `requests.post()` 调用 API,没有经过 `_request` 包装,因此不具备 retry 和标准化错误处理 |
| **影响** | 保存月报时遇到 401/429/5xx 不会自动重试,直接失败 |
| **修复** | 改为调用 `_request("POST", "/bp/monthly/report/save", json_body=body)`,统一走 retry 逻辑 |
| **预防** | 新增 API 调用一律使用 `_request` 包装,禁止直接 `requests.get/post` |
### 问题 3:`collect_monthly_data`(legacy)报告内容构建未统一
| 项目 | 说明 |
|------|------|
| **现象** | legacy 路径的 Step 4 中构建 report content 是内联代码,而 `collect_goal_data` 已有统一的 `_build_report_content()` 辅助函数 |
| **影响** | 两处逻辑不同步,如果 API 字段变化只改了一处,另一处会丢失数据 |
| **修复** | legacy 路径也改为调用 `_build_report_content(result["data"], truncate=True)` |
| **预防** | 报告内容解析统一由 `_build_report_content` 承担,禁止内联重复 |
---
## v1.1 — 2026-04-15 API 字段名修复 + 查询 retry
### 问题 1(Critical):`_extract_ids_from_goal_detail` 字段名不匹配
| 项目 | 说明 |
|------|------|
| **现象** | 函数硬编码使用 `keyResultList` 和 `actionList` 获取 KR 和 Action 列表,但 API 实际返回的字段名是 `keyResults` 和 `actions` |
| **影响** | `collect_goal_data` 只能获取到目标本身 1 个节点的 ID,无法获取其下所有 KR 和举措节点。导致查询汇报时只查目标层的汇报,丢失 KR 和举措的全部汇报数据 |
| **根因** | 开发时参考了旧版 API 文档或猜测字段名,未用实际 API 返回数据做验证 |
| **修复** | 改为兼容双字段名:`goal_detail.get("keyResultList") or goal_detail.get("keyResults") or []` |
| **验证** | 4 个目标全部重新采集验证,节点数和汇报数均与预期一致:G1=6节点/19报,G2=4节点/43报,G3=5节点/0报,G4=10节点/1报 |
### 问题 2:数据查询 API 缺少 retry 逻辑
| 项目 | 说明 |
|------|------|
| **现象** | `_request` 函数遇到 401/429/5xx 时直接返回错误,而 `send_report` 有独立的 retry 逻辑 |
| **影响** | 数据采集阶段遇到瞬时限流或服务端错误会直接失败,需要人工重跑 |
| **修复** | 重构 `_request`:拆出 `_do_request` 执行单次请求,`_request` 添加 retry 循环,对 401/429/5xx 等待 60 秒后重试一次 |
| **预防** | 所有数据查询统一走 `_request`,retry 策略集中管理 |
---
## v1.0 — 2026-04-13 KR 去灯色 + 2.1/2.2 合并
### 修改 1:KR 级别去掉灯色判断
| 项目 | 说明 |
|------|------|
| **变更** | 关键成果(KR)不再判灯色,只输出"判断理由"做差距分析;灯色判断仅在举措级和目标级执行 |
| **涉及文件** | `SKILL.md`(判断主轴/层级/KR卡片/合规清单)、`report-template-bp-self-check.md`(KR 明细/AI 指引)、`traffic-light-rules.md`(头部说明/灯色判断优先级) |
| **影响** | KR 卡片结构简化,四灯判断块仅在举措级和目标级出现;目标灯色从举措灯色聚合(跳过 KR) |
### 修改 2:2.1 目标清单总览与 2.2 合并
| 项目 | 说明 |
|------|------|
| **变更** | 原 2.1(参与自查目标表)和 2.2(未参与自查目标汇总)合并为统一的 2.1 目标清单总览表 |
| **影响** | 被排除的目标在合并表中用 `<span style="color:#2e7d32; font-weight:700;">★</span>` 标记灯色,结论写"未启动",证据引用留空。原 2.3 目标明细改编号为 2.2 |
| **涉及文件** | `SKILL.md`(合规清单/报告约束)、`report-template-bp-self-check.md`(2.1/2.2/2.3 章节结构)、`traffic-light-rules.md`(排除行为描述) |
---
## 后续预防措施
### 1. API 字段兼容性
- 新增 API 调用时,先用实际数据打印字段名,再编码
- 对关键字段(列表类型)统一使用 `or` 兼容多种命名:`obj.get("fieldA") or obj.get("fieldB") or []`
- 部门月报脚本和个人月报脚本使用的字段名需同步检查
### 2. 数据解析统一
- 报告内容构建统一使用 `_build_report_content()` 辅助函数
- 禁止在业务函数中内联重复的 JSON 字段解析代码
- 新增辅助函数时同步检查所有调用点
### 3. 网络调用统一
- 所有 API 调用(查询和写入)统一通过 `_request` 包装
- 禁止在业务函数中直接 `requests.get/post`(`send_report` 因需使用不同 APP_KEY 除外)
- retry 策略集中在 `_request` 管理
### 4. 测试验证
- 修改数据采集逻辑后,必须对所有目标重新运行 `collect_goal_data` 验证节点数和汇报数
- 对照修改前后的数据差异,确认修复效果
FILE:references/report-template-bp-self-check.md
# BP自查报告模板
<!-- AI 指引(不输出到最终报告):
定位:按"每个 BP 目标"为主线底座,对照承诺逐条检查完成情况,
并在一个目标内串起"承诺对照、结果、举措、偏差问题与原因"。
每个目标给灯色判断;最终给一句话自我结论(优秀/良好/合格/不足)。
-->
---
## 最终输出规则(AI 必读,不输出到报告)
<!-- 以下规则仅供 AI 在生成报告时参考,最终报告中不得出现本节任何内容 -->
### 禁止输出到最终报告的内容
1. **模板括号注释**:章节标题中的括号说明文字一律不输出。例如:
- ~~总体自查结论(先给结论,再给依据)~~ → `总体自查结论`
- ~~结论(四档)~~ → `结论`
- ~~灯色分布概览(目标级)~~ → `灯色分布概览`
- ~~本月最关键偏差点(可选,最多 3 条)~~ → `本月最关键偏差点`
- ~~目标级自查明细(本报告主体,按目标动态生成)~~ → `目标级自查明细`
- ~~目标清单总览(承诺 vs 实际,表格)~~ → `目标清单总览`
- ~~目标明细(逐目标展开:承诺对照 + 结果 + 举措 + 偏差问题)~~ → `目标明细`
- ~~承诺 vs 实际(可验收口径)~~ → `承诺与实际对照`
- ~~关键成果达成与举措推进(按成果层级展开)~~ → `关键成果达成与举措推进`
- ~~偏差问题与原因分析(若无偏差写"本目标本期无重大偏差")~~ → `偏差问题与原因分析`
- ~~目标级综合灯色结论(必填)~~ → `目标级综合灯色结论`
- ~~自我结论(必须输出)~~ → `自我结论`
- ~~自我定性(四档)~~ → `自我定性`
- ~~结论解释(两句以内)~~ → `结论解释`
- ~~(目标主线)~~ → 标题中不加此后缀
2. **系统规则说明**:以下文字不得出现在最终报告中:
- "以下表格从 Step 3b 证据台账原样搬入,禁止手动补写或删减"
- "正文中 R{编号} 引用使用锚点链接..."
- "约束:本章小节数量必须随..."
- "约束:本报告以..."
- 任何以"约束:"、"AI 指引:"、"注意:"开头的系统提示
- 任何提及 Step 3a/3b/3c/3d 等内部流程步骤的文字
3. **报告标题格式**:`# [员工姓名] [YYYY年M月] BP自查报告`(不加"目标主线"等后缀)
### 灯色渲染色值
| 灯色 | HTML 色值 |
|------|-----------|
| 🟢 绿灯 | `<span style="color:#2e7d32; font-weight:700;">` |
| 🟡 黄灯 | `<span style="color:#b26a00; font-weight:700;">` |
| 🔴 红灯 | `<span style="color:#c62828; font-weight:700;">` |
| ⚫ 黑灯 | `<span style="color:#212121; font-weight:700;">` |
### 四灯判断块标准模板
**绿灯判断块:**
```
- <span style="color:#2e7d32; font-weight:700;">四灯判断:🟢</span>
<span style="color:#2e7d32; font-weight:700;">判断理由:[AI 填写判断理由]</span>
```
**黄灯判断块:**
```
- <span style="color:#b26a00; font-weight:700;">四灯判断:🟡</span>
<span style="color:#b26a00; font-weight:700;">判断理由:[AI 填写判断理由]</span>
<div class="people-suggest">
<span style="color:#b26a00; font-weight:700;">人工判断:待确认(请填写:同意 / 不同意)</span>
<span style="color:#b26a00; font-weight:700;">若同意:请明确填写"同意"。</span>
<span style="color:#b26a00; font-weight:700;">若不同意:请填写理由类别(BP不清晰 / 举证材料不足 / AI判断错误 / 其他)及具体说明。</span>
<span style="color:#b26a00; font-weight:700;">整改方案:待补充</span>
<span style="color:#b26a00; font-weight:700;">承诺完成时间:待补充</span>
<span style="color:#b26a00; font-weight:700;">下周期具体举措:待补充</span>
</div>
```
**红灯判断块**:同黄灯结构,色值替换为 `#c62828`。
**黑灯判断块**:同黄灯结构,色值替换为 `#212121`。
---
## 以下为最终报告结构(AI 按此结构输出,标题使用清洗后的版本)
### 报告元数据头
```markdown
# [员工姓名] [YYYY年M月] BP自查报告
> 周期:`[BP周期名称]`
> 节点:`[员工姓名]`
> 基线:已参考上月 [RP01](huibao://view?id={reportRecordId})《BP自查报告》、[RP02](huibao://view?id={reportRecordId})《个人总结报告》及上月评价(详见附录 A.3);若首月则标注"首月,无基线"
> 证据说明:本报告中 R 编号(如 R101)为当月证据引用,RP 编号(如 RP01)为上月参考引用,点击均可直接查看对应汇报详情。
> 解释口径:灯色按目标级综合判断。
```
---
### 1. 总体自查结论
#### 1.1 结论
一句话优势:[例如"关键目标兑现率高且无明显短板"]
一句话短板:[例如"个别关键目标存在偏差,需在下月通过 X 纠偏"]
#### 1.2 灯色分布概览
<!-- AI 指引(不输出):仅统计参与自查的目标(排除规则见 traffic-light-rules.md),被排除的目标不计入灯色统计 -->
```text
🟢 目标数:[N]
🟡 目标数:[N]
🔴 目标数:[N]
⚫ 目标数:[N]
★ 未启动:[N] 个目标
```
#### 1.3 本月最关键偏差点
<!-- AI 指引:可选,最多 3 条;若无偏差可不写本节 -->
1) 偏差点:[一句话描述](对应目标:[fullLevelNumber])
影响:[对 BP/KR/节点的影响]
原因假设:[最可能原因 1-2 条]
下月纠偏方向:[一句话]
---
### 2. 目标级自查明细
<!-- AI 指引(不输出):
- 本章小节数量必须随"真实 BP 目标数"动态生成,不可写死
- 以"目标"为底座组织内容;不把"目标/结果/举措/问题"拆成独立章节
- KR 级输出完整分析单元(衡量标准对照 + 判断理由 + 环比),KR 不判灯色;目标级灯色从举措灯色聚合
-->
#### 2.1 目标清单总览
<!-- AI 指引(不输出):所有目标均列入此表(含被排除的目标)。被排除的目标灯色用绿色五角星 ★ 标记,结论一句话写"未启动",证据引用留空 -->
| 目标编号 | BP目标 | 本月承诺口径 | 本月实际 | 证据引用 | 目标灯色 | 结论一句话 |
|---------|--------|-------------|---------|----------|----------|------------|
| [fullLevelNumber] | [目标全称] | [阈值/里程碑/交付物] | [达成情况] | [R{编号}](huibao://view?id={reportId}) | 🟢/🟡/🔴/⚫ | [一句话] |
| [fullLevelNumber] | [目标全称] | [同上] | [同上] | [R{编号}](huibao://view?id={reportId}) | 🟢/🟡/🔴/⚫ | [同上] |
| [fullLevelNumber] | [目标全称] | — | — | | <span style="color:#2e7d32; font-weight:700;">★</span> | 未启动 |
#### 2.2 目标明细
<!-- AI 指引(不输出):仅对参与自查的目标展开明细,被排除的目标不展开 -->
##### [fullLevelNumber]|[BP目标全称]
**承诺与实际对照**
承诺口径:[阈值/里程碑/交付物]
本月实际:[达成情况 + 阶段结果]
差异点(若有):[一句话]
证据:[R{编号}](huibao://view?id={reportId})《[汇报标题]》
**关键成果达成与举措推进**
<!-- AI 指引(不输出):仅展开参与自查的KR及其支撑举措。目标下若有部分被排除的KR/举措,在本段末尾一句话说明 -->
**关键成果 1:[KR全称]**
- **衡量标准:** [从 measureStandard 提取的可验收口径]
- **本月结果:** [形成了什么结果 + 量化/阶段性]
- **距离衡量标准:** [已达成 / 差距X% / 尚未启动量化跟踪]
- **环比上月:** [延续推进 / 有所改善 / 基本停滞 / 首月无基线],[1句变化说明]
- **证据:** [R{编号}](huibao://view?id={reportId})《[汇报标题]》
- **判断理由:** [详细分析该成果距衡量标准的差距、完成情况、主要支撑和不足]
- **└ 支撑举措 1:[举措全称]**
- 推进动作摘要:[1-3 句]
- 对结果支撑:【强 / 中 / 弱】
- 当前进度:[完成度百分比或里程碑阶段] — [一句话说明]
- 证据:[R{编号}](huibao://view?id={reportId})《[汇报标题]》
- [嵌入举措级四灯判断块]
- **└ 支撑举措 2:** (同上结构)
**关键成果 2:[KR全称]**
- (同上结构)
<!-- AI 指引(不输出):若该目标下有被排除的KR/举措,在此处添加一句说明 -->
> 另有 [N] 个关键成果/举措计划期未覆盖本月,不纳入自查。
**偏差问题与原因分析**
<!-- AI 指引(不输出):若无偏差写"本目标本期无重大偏差" -->
问题现象:[可验证的现象]
影响:[对目标达成/进度/质量/协同/成本/风险的影响]
原因假设:[最可能原因 1-2 条]
当前应对:[已做/未做]
证据:[R{编号}](huibao://view?id={reportId})《[汇报标题]》
**目标级综合灯色结论**
结论一句话:[灯色 + 关键依据 + 关键短板/优势]
[嵌入四灯判断块]
##### [fullLevelNumber]|[BP目标全称]
(同上结构,仅对已参与自查的目标动态生成)
---
### 3. 年度结果预判评分
[点击进入本月:年度结果预判评分](https://sg-al-cwork-web.mediportal.com.cn/BP-manager/web/dist/#/monthly-review/self?groupId={groupId}&month={month})
---
### 附录:证据索引
<!-- AI 指引(不输出):从证据台账文件原样搬入,禁止手动补写或删减 -->
#### A.1 统计摘要
- 原始工作汇报:[N] 份
- 经批量通知归并后最终采纳:[M] 份
- 其中本人主证据:[X] 份、他人手动汇报:[Y] 份、AI 汇报:[Z] 份
#### A.2 证据索引表
| R 编号 | 汇报标题 | 证据级别 | 汇报链接 | 关联节点 |
|--------|---------|---------|---------|---------|
| R{编号} | 《[汇报标题]》 | 主证据/辅证/AI汇报 | [查看汇报](huibao://view?id={reportId}) | [目标/KR/举措名称] |
| ... | ... | ... | ... | ... |
#### A.3 上月参考索引
<!-- AI 指引(不输出):从 collect_previous_month_data 输出的 JSON 生成。若首月无数据则整节替换为"首月汇报,无上月参考基线。" -->
**上月汇报:**
| RP 编号 | 类型 | 标题 | 链接 |
|---------|------|------|------|
| RP01 | BP自查报告 | 《[上月汇报标题]》 | [查看汇报](huibao://view?id={reportRecordId}) |
| RP02 | 个人总结报告 | 《[上月汇报标题]》 | [查看汇报](huibao://view?id={reportRecordId}) |
**上月评价摘要:**
<!-- AI 指引(不输出):直接嵌入 collect_previous_month_data 返回的 evaluationMarkdown 原文。若无评价则写"上月无评价记录。" -->
[嵌入上月年度结果预判评分 Markdown:加权总分 + 各目标各维度评分表]
[嵌入上月上级评价 Markdown:总体要求 + 各目标评分表 + 逐目标评语]
FILE:references/evidence-rules.md
# 证据优先级规则
本文件定义月报生成过程中,如何对汇报数据进行去重、分级和归集。
## 一、去重规则
### 1. reportId 去重
同一条汇报(相同 reportId)可能关联到多个 BP 节点。在统计"汇报数量"时,以 reportId 为唯一键,**同一条汇报只计一次**。
### 2. 内容聚合
内容高度相似的汇报(如同一模板发给不同人、同一事项的批量发送),合并为**一个独立工作事项**。
判断"内容高度相似"的标准:
- 标题相同或仅有收件人差异
- 正文主体内容相同(忽略称呼、签名等格式差异)
- 发送时间在同一天内
聚合后记录:
- 工作事项摘要
- 原始发送次数
- 发送对象列表(如可获取)
### 3. 月报中的汇报计数
月报第 1 章"本月参考工作汇报数"应体现三层口径:
- 原始汇报条数(API 返回的总数)
- reportId 去重后的条数
- 内容聚合后的独立工作事项数
## 二、证据分级
按以下优先级从高到低排列。高优先级证据的判断权重高于低优先级。
### 第一优先级:本人手动汇报(主证据)
**定义**:承接人本人主动撰写并提交的汇报。
**识别方式**:
- 汇报作者(authorEmpId)与当前员工的 employeeId 一致
- 汇报类型为手动提交(非 AI 自动生成)
**权重**:最高。可直接作为灯色判断的核心依据。
### 第二优先级:本人回溯性汇报
**定义**:承接人本人事后补录的汇报(如月末集中补报)。
**识别方式**:
- 作者是本人
- 汇报创建时间明显晚于汇报内容描述的时间段
**权重**:高。内容可信但时效性略低,需注意是否有事后美化。
### 第三优先级:他人关联汇报(辅证)
**定义**:其他人的汇报被关联到了当前员工的 BP 节点。
**识别方式**:
- 汇报作者(authorEmpId)与当前员工的 employeeId 不一致
- 但该汇报通过 reportTaskMapping 关联到了当前员工的 BP 节点
**权重**:中。可作为辅助证据,但不能单独作为灯色判断的唯一依据。
### 第四优先级:AI 汇报(仅辅助摘要)
**定义**:系统自动生成的 AI 汇报。
**识别方式**:
- 汇报类型标记为 AI 生成
- 或内容中有明显的 AI 生成特征
**权重**:最低。仅作为辅助摘要参考,**不能单独作为灯色判断依据**。若某节点仅有 AI 汇报而无其他证据,应视为"无有效证据",触发黑灯判断。
## 三、归集规则
### 1. 月份归集口径
以汇报的 **createTime**(创建时间)为准,不依赖关联时间或正文中的时间推断。
例如:一条 1 月 28 日创建的汇报,即使其内容描述的是 12 月的工作,也归入 1 月。
### 2. 节点归集
利用 `reportTaskMapping`(reportId → taskId 列表)确定每条汇报关联了哪些 BP 节点。
**交叉引用**:一条汇报的内容如果明确涉及其他目标/关键成果的关键词,应在对应节点的证据台账中做交叉引用标注,但不改变其原始归集。
### 3. 证据充分性判断
对每个关键成果和关键举措,判断其证据是否充分:
| 情况 | 判断 |
|------|------|
| 有主证据(第一/第二优先级)| 证据充分,可正常判灯 |
| 仅有辅证(第三优先级)| 证据不充分,灯色判断需标注"仅基于辅证" |
| 仅有 AI 汇报(第四优先级)| 无有效证据,触发黑灯 |
| 无任何汇报 | 无有效证据,触发黑灯 |
## 四、特殊情况处理
### 批量发送
同一内容发送给多人的汇报,按"一个工作事项"计算,不按发送次数膨胀。
### 作者信息缺失
若 API 未返回 authorEmpId 字段,则:
- 默认将该汇报视为"本人汇报"(因为当前查询范围是该员工的 BP 节点)
- 在证据台账中标注"作者信息缺失,默认归为本人"
- 不影响灯色判断流程,但降低判断置信度
### 汇报内容为空
若汇报的 content/contentHtml 为空或仅有格式标签无实质内容,该汇报不计入有效证据。
## 五、证据链接与附录输出
### 1. 汇报链接生成
每条证据在分配 R 编号的同时,必须生成对应的汇报查看链接:
- **格式**:`huibao://view?id={reportId}`
- **reportId 来源**:取自 `uniqueReportMap` 中该条汇报的原始 reportId 字段,字符串原样使用,严禁做任何数值转换
- **聚合场景**:若一条工作事项由多条内容相似的汇报聚合而成,取其中第一条(按 createTime 最早)的 reportId 生成链接
- **链接缺失**:若因 API 异常导致 reportId 为空,链接列填写"暂不可用",不得伪造 ID
### 2. 正文证据引用格式
正文中所有 R 编号引用使用**汇报直链**格式,点击直接跳转到对应汇报详情页:
- **正文侧**:所有引用 R 编号的位置使用 `[R301](huibao://view?id={reportId})` 格式,其中 `{reportId}` 为该证据条目对应的原始 reportId
- 正文中完整的引用格式为:`(证据:[R301](huibao://view?id={reportId}))`
- **不使用** `[R301](#R301)` 页内锚点链接(在工作协同中无法跳转)
- **附录侧**:R 编号列直接展示文本(如 `R301`),不需要 `<span id>` 锚点标签;汇报链接列保持 `[查看汇报](huibao://view?id={reportId})` 格式
### 3. 证据台账作为附录唯一数据源
Step 3b 生成的证据台账文件(`/tmp/evidence_ledger_{groupId}.md`)是报告附录的**唯一数据源**:
- Step 3d 拼接报告附录时,必须直接读取证据台账文件并原样搬入,不得凭记忆重新生成
- 证据台账中的 R 编号索引表(含编号、标题、级别、汇报链接、关联节点)必须完整搬入附录
- 证据台账中的统计摘要必须完整搬入附录
- 若台账中有 N 条证据,附录表格也必须有 N 行,**不得遗漏**
- 证据索引在报告一(BP自查报告)和报告二(个人总结报告)中共享同一份数据
FILE:references/traffic-light-rules.md
# 灯色判断标准
灯色用于评估 BP 月报各判断点的月度进展状态。
- **逐目标判断(Step 3c)**:以目标为维度,逐个对**参与自查**的关键举措精读证据并判灯(参与条件见下方排除规则)。关键成果(KR)只做差距分析输出判断理由,不判灯色。
- **最终报告(Step 3d 拼接)**:
- **第 2 章**:灯色判断到**目标级**——综合该目标下所有参与自查的**举措**卡片灯色得出。若目标被排除规则整体排除,则不参与灯色统计,不展开自查明细。
- 举措级和目标级的灯色判断点使用统一的**四灯判断块**格式(见 report-template-bp-self-check.md)。KR 级不使用四灯判断块。
判断时需结合当月汇报证据、衡量标准、计划时间范围和实际推进情况综合评定。
---
## 排除规则——基于 `planDateRange` 区间交叉(在灯色判断之前执行)
判断一个节点是否参与某月自查,**以 `planDateRange`(计划时间范围)与汇报月份的区间交叉为准**,而非依赖 `statusDesc`(当前状态)。这确保回溯生成历史月份报告时也能正确判断。
设:
- **汇报月份区间** = `[月份 1 号, 月份最后一天]`
- **节点计划区间** = `[planStartDate, planEndDate]`(从 `planDateRange` 解析,格式 `yyyy-MM-dd ~ yyyy-MM-dd`)
### 判断决策树
按以下顺序逐条检查,命中即停:
| 序号 | 条件 | 结果 | 说明 |
|------|------|------|------|
| 1 | `statusDesc = "草稿"` | **排除** | 草稿未正式发布,任何月份都不参与自查 |
| 2 | `planStartDate` 和 `planEndDate` **均为空** | **参与** | 无法判断时从宽处理,避免误排除 |
| 3 | `planStartDate > 月份最后一天` | **排除** | 计划还没开始,该月不应纳入 |
| 4 | `planEndDate < 月份 1 号` | **排除** | 计划在该月之前已结束 |
| 5 | 以上均不满足 | **参与** | 计划区间与汇报月份有交集 |
**缺失字段的从宽处理**:
- 仅 `planStartDate` 为空 → 视为"已开始",只检查 `planEndDate >= 月份 1 号`
- 仅 `planEndDate` 为空 → 视为"未结束",只检查 `planStartDate <= 月份最后一天`
### statusDesc 的角色
`statusDesc` 不再作为排除主依据(除草稿外),但在报告中作为辅助展示信息:
- **草稿** → 直接排除
- **未启动 / 进行中 / 已关闭** → 由 `planDateRange` 区间交叉决定是否参与;在报告中展示当前状态供参考
### 排除行为
对于被排除的节点:
- **不生成判断卡片**,**不计入灯色统计**,**不展开自查明细**
- 被排除的目标在 2.1 目标清单总览表中用绿色五角星 ★ 标记,结论写"未启动",证据引用留空
- 被排除的 KR/举措在所属目标明细中一句话带过
### 目标级排除规则
- 若一个目标自身被排除(草稿 或 计划区间无交集) → 该目标整体排除(不论其下 KR/举措)
- 若一个目标参与自查,但其下**所有** KR 和举措均被排除 → 该目标也排除
- 若一个目标参与自查,其下**部分** KR/举措参与、部分被排除 → 该目标正常参与自查,仅对参与的 KR/举措生成卡片和展开明细;被排除的在目标明细中一句话带过("XX 举措计划期未覆盖本月,不纳入自查")
### 注意
- `planDateRange` 格式为 `yyyy-MM-dd ~ yyyy-MM-dd`,取 `~` 前面的日期为 `planStartDate`,后面的日期为 `planEndDate`
- `statusDesc` 从 BP 系统接口返回(Goal/KR/Action 的 Simple 和 Full 规格均包含),仅"草稿"用于排除,其余作为辅助信息
- 排除规则在所有灯色判断之前执行,排除后的节点不进入下方任何灯色判断流程
---
## 灯色判断优先级
对**参与自查**的关键举措(计划区间与汇报月份有交集且非草稿),严格按以下优先级判灯,命中即停。KR 不判灯色,只在 Step 3c 中输出判断理由。
### 1. 先判黑灯 ⚫
当月该举措下**没有任何有效汇报证据**(按 evidence-rules.md 标准) → ⚫
黑灯必须细分为以下三类之一(由 AI 建议,需人工复核):
| 类型 | 含义 | 典型场景 |
|------|------|----------|
| **A. 未开展** | 该项工作确实未启动 | 计划期已到但无任何动作 |
| **B. 已开展但未关联** | 工作在做,但汇报没有关联到对应 BP 节点 | 有相关汇报但挂在其他节点下 |
| **C. 体外开展无留痕** | 工作在体系外进行,系统内无记录 | 线下推进、外部平台执行 |
### 2. 再判红灯 🔴
有汇报证据,但显示**严重偏离、不可控** → 🔴
### 3. 再判黄灯 🟡
有汇报证据,但显示**明显偏差需整改** → 🟡
### 4. 最后判绿灯 🟢
以上均不满足 → 🟢
---
## 🟢 绿灯
**含义**:该关键举措进展正常,基于当前证据判断大概率能按时达成目标。
**允许的偏差**:
- 本月存在部分子节点未完成
- 存在一定时间滞后(晚于计划 1-2 周)
- 上述偏差不实质性影响最终按时达成的信心
**前提要求**:
- 有实质性推进证据(主证据或辅证中有实际内容)
- 没有出现严重失控风险
**应对等级**:合格
**行动要求**:无需整改,按正常节奏推进即可。
---
## 🟡 黄灯
**含义**:有真实推进证据,但已出现较为明显的偏差或风险,对按时达成衡量标准构成实质性威胁,必须采取整改措施。
**触发情形**(满足任一即标黄):
- 关键节点完成率低于 50%,且滞后明显
- 实际完成时间晚于计划超过 2 周,已影响后续关键节奏
- 存在高风险已触发,不干预将大概率无法达成
**应对等级**:整改(轻度)
**行动要求**:
- 制定整改计划:偏差原因、纠偏措施、预计转绿时间
- 上级跟踪,限期完成整改并汇报结果
**月报必填字段**:
- 偏差/问题描述
- 整改方案建议
- 建议承诺完成时间
---
## 🔴 红灯
**含义**:已严重偏离计划,关键风险不可控,按时达成衡量标准的可能性极低。
**触发情形**(满足任一即标红):
- 关键节点基本未完成,后续计划无法推进
- 实际完成时间严重滞后(超过 1 个月),已错过交付窗口
- 风险已造成不可逆影响,成果基本失效
**应对等级**:整改(重度)
**行动要求**:
- 详细整改计划:根因分析、资源调整、限期验收
- 纳入重点跟踪,上级定期检查
- 必要时申请资源调整或目标变更
**月报必填字段**:
- 偏差/问题描述
- 整改方案建议
- 建议承诺完成时间
---
## ⚫ 黑灯
**含义**:该关键举措当月没有有效汇报证据,无法判断进展。(进入本步判断的举措已通过排除规则确认参与自查。)
**触发条件**:
- 当月该举措下无任何有效汇报(按 evidence-rules.md 判定)
**黑灯三类细分**:
### A. 未开展
工作确实未启动。计划期已到但无任何动作迹象。
**AI 判断线索**:该节点及其子节点均无汇报,且无其他节点的汇报交叉提及该项工作。
### B. 已开展但未关联
工作在做,但汇报没有关联到对应 BP 节点,系统看不到。
**AI 判断线索**:其他节点的汇报中出现了与该节点相关的关键词或工作描述。
### C. 体外开展无留痕
工作在体系外进行(线下会议、外部平台等),系统内无记录。
**AI 判断线索**:该节点有明确的计划安排和参与人,但系统内完全无痕迹,且无法从其他汇报中推断。
**应对等级**:整改
**行动要求**:
- 与红灯相同,必须进行整改
- 立即补充有效证据或说明材料,同时提交整改计划
- 若持续无法提供有效证据,视为目标实际未推进
**月报必填字段**:
- 黑灯类型建议(A/B/C,需人工复核)
- 类型判断依据
- 下周期具体举措
- 是否持续提醒至下周期:是/否
---
## 年初特殊处理
对于 BP 周期的第 1-2 个月(通常为 1-2 月),许多关键成果和举措可能尚处于规划或启动阶段。此时:
- 若有启动性质的汇报(如制度征求意见、方案设计、需求调研),应视为有效证据,不应标黑灯
- 灯色判断应适当放宽,侧重"是否已启动"而非"是否已产出成果"
- 在判断卡片中注明"年初启动期,判断标准适当放宽"
AI情报助手技能包,覆盖模版检索与报告任务场景(创建、查询、章节编辑、版本查询)。
---
name: ai-intelligence-report
description: AI情报助手技能包,覆盖模版检索与报告任务场景(创建、查询、章节编辑、版本查询)。
skillcode: ai-intelligence-report
dependencies:
- cms-auth-skills
---
# AI情报助手 — 索引
本文件提供能力宪章、能力树与按需加载规则。详细参数与流程见各模块 `openapi/` 与 `examples/`。
**当前版本**: v1.6
**能力概览(3 块能力)**:
- `moban`:模版列表检索、模版详情查看、模版新建、模版编辑、模版发布/下架、模版删除
- `task`:任务创建、进度查询、报告详情、任务列表、章节改写、版本历史
- `capability`:能力咨询与边界说明(回答“你能做什么/不能做什么”)
**接口版本**: 所有业务接口统一使用 `https://cwork-api.mediportal.com.cn/ai-report/*` 前缀,鉴权类型按接口文档声明执行(本 skill 当前业务接口均为 `access-token`)。
统一规范:
- 认证与鉴权:`cms-auth-skills/SKILL.md`
- 通用约束:`cms-auth-skills/SKILL.md`
授权依赖:
- 当接口声明需要 `appKey` 或 `access-token` 时,先尝试读取 `cms-auth-skills/SKILL.md`
- 如果已安装,直接按 `cms-auth-skills/SKILL.md` 中的鉴权规则准备对应 `appKey` 或 `access-token`
- 如果未安装,先执行 `npx clawhub@latest install cms-auth-skills --force`
- 如果上面的安装方式不可用,再执行 `npx clawhub@latest install https://github.com/spzwin/cms-auth-skills.git --force`
- 安装完成后,再继续执行需要鉴权的操作
输入完整性规则(强制):
1. 模版场景至少提供分页信息(`pageNum`、`pageSize`)或明确 `mobanId`
2. 生成报告至少提供 `mobanId`、`taskName`
3. 查询详情或进度需提供 `taskId`
4. 章节改写与版本查询需提供 `questionId`
建议工作流(简版):
1. 读取 `SKILL.md` 与 `cms-auth-skills/SKILL.md`,明确能力范围、鉴权与安全约束。
2. 识别用户意图并路由模块:`moban`、`task`、`capability`,先打开对应 `api-index.md`。
3. 确认具体接口后,加载对应 endpoint 文档获取入参、出参与 Schema。
4. 补齐用户必需输入;若是能力咨询场景,仅返回标准能力边界,不执行脚本。
5. 参考 `examples/moban/README.md`、`examples/task/README.md`、`examples/capability/README.md` 组织话术与流程。
6. 对有接口的场景执行对应 Python 脚本,输出 JSON 结果并做最小必要信息提取。
脚本使用规则(强制):
1. 每个 `openapi/moban/*.md`、`openapi/task/*.md` 都必须有对应脚本。
2. 所有业务脚本输出必须为 JSON 格式。
3. 脚本必须可独立命令行执行。
4. 仅允许生产域名与生产协议。
5. 出错重试间隔 1 秒,最多 3 次,禁止无限重试。
6. 严禁虚构工具名或命令(例如 `oracle template query`)。若环境受限,只能说明受限条件与补齐步骤。
能力咨询标准回复(强制):
1. 当用户问“你可以做什么/你能做什么/你支持哪些操作/你是谁”时,必须路由到 `capability`。
2. `capability` 仅返回本 skill 的真实能力,不得扩展到未定义工具或外部命令。
3. 输出必须包含三段:能做什么、不能做什么、下一步引导(查模版、发起任务、查进度、改章节)。
4. 能力咨询场景禁止执行脚本,也禁止先下“环境变量缺失”的结论。
意图路由与加载规则(强制):
1. 模版选择与推荐 -> `moban`
2. 报告生成、进度与详情 -> `task`
3. 章节手改与版本追溯 -> `task`
4. 能力咨询与自我介绍 -> `capability`
5. 超出上述范围(如 chat 对话建模版)不在本 skill 范围内
宪章(必须遵守):
1. 本 skill 仅覆盖 `docs/AI情报agent说明(1).md` 与 `docs/ai情报agent_定义规范.md` 定义场景。
2. 不暴露 token、内部主键等敏感信息。
3. 不猜测缺失参数,必须追问补齐。
4. 不执行越权与危险操作。
5. 章节编辑保存前,必须先向用户展示编辑后的完整内容并等待明确确认。
6. 不杜撰不存在的命令、MCP 工具、CLI 工具或环境变量缺失结论。
7. 鉴权失败时,只能按 `cms-auth-skills/SKILL.md` 的鉴权规则检查,并明确已检查到哪一步、下一步缺什么。
模块路由与能力索引:
| 用户意图(示例) | 模块 | 能力摘要 | 接口文档 | 示例模板 | 脚本 |
|---|---|---|---|---|---|
| 找可用模版、按关键词筛选模版 | `moban` | 模版检索 | `./openapi/moban/api-index.md` | `./examples/moban/README.md` | `./scripts/moban/list-moban.py` |
| 查看模版章节结构 | `moban` | 模版详情 | `./openapi/moban/api-index.md` | `./examples/moban/README.md` | `./scripts/moban/moban-detail.py` |
| 新建模版(定义章节与提示词) | `moban` | 模版新建 | `./openapi/moban/api-index.md` | `./examples/moban/README.md` | `./scripts/moban/create-moban.py` |
| 编辑已有模版(更新结构与提示词) | `moban` | 模版编辑 | `./openapi/moban/api-index.md` | `./examples/moban/README.md` | `./scripts/moban/update-moban.py` |
| 模版发布/下架(控制模版可见状态) | `moban` | 模版状态切换 | `./openapi/moban/api-index.md` | `./examples/moban/README.md` | `./scripts/moban/change-moban-state.py` |
| 删除废弃模版 | `moban` | 模版删除 | `./openapi/moban/api-index.md` | `./examples/moban/README.md` | `./scripts/moban/delete-moban.py` |
| 发起报告任务 | `task` | 创建任务 | `./openapi/task/api-index.md` | `./examples/task/README.md` | `./scripts/task/start-task.py` |
| 查询任务进度和报告详情 | `task` | 进度与详情 | `./openapi/task/api-index.md` | `./examples/task/README.md` | `./scripts/task/check-task.py` |
| 手动修改某个章节并看版本历史 | `task` | 章节编辑 | `./openapi/task/api-index.md` | `./examples/task/README.md` | `./scripts/task/update-question-result.py` |
| 你可以做什么、你能做什么、你是谁 | `capability` | 能力边界说明 | `./openapi/capability/api-index.md` | `./examples/capability/README.md` | `./scripts/capability/README.md` |
能力树(实际目录结构):
```text
ai-intelligence-report/
├── SKILL.md
├── openapi/
│ ├── moban/
│ │ ├── api-index.md
│ │ ├── list-moban.md
│ │ ├── moban-detail.md
│ │ ├── create-moban.md
│ │ ├── update-moban.md
│ │ ├── change-moban-state.md
│ │ └── delete-moban.md
│ ├── capability/
│ │ └── api-index.md
│ └── task/
│ ├── api-index.md
│ ├── start-task.md
│ ├── check-task.md
│ ├── task-detail-v2.md
│ ├── list-task-by-page.md
│ ├── update-question-result.md
│ └── list-result-version.md
├── examples/
│ ├── moban/README.md
│ ├── capability/README.md
│ └── task/README.md
└── scripts/
├── capability/
│ └── README.md
├── moban/
│ ├── README.md
│ ├── list-moban.py
│ ├── moban-detail.py
│ ├── create-moban.py
│ ├── update-moban.py
│ ├── change-moban-state.py
│ └── delete-moban.py
└── task/
├── README.md
├── start-task.py
├── check-task.py
├── task-detail-v2.py
├── list-task-by-page.py
├── update-question-result.py
└── list-result-version.py
```
FILE:openapi/capability/api-index.md
# API 索引 — capability
接口列表:
- 本模块不提供外部业务接口,仅用于能力边界说明与能力咨询标准回复。
脚本映射:
- `../../scripts/capability/README.md`
FILE:openapi/task/update-question-result.md
# POST https://cwork-api.mediportal.com.cn/ai-report/task/updateQuestionResult
## 作用
将用户新内容直接覆盖指定子章节,完成章节手动修改。
**鉴权类型**
- `access-token`
## 前置确认(强制)
调用本接口前,必须先向用户完整展示编辑后的章节内容,并获得用户明确确认;未确认不得执行保存。
**Headers**
- `access-token`
- `Content-Type: application/json`
**Body**
| 字段 | 类型 | 必填 | 说明 |
|---|---|---|---|
| `questionId` | string | 是 | 子章节 `_id` |
| `result` | string | 是 | 新章节内容(Markdown) |
## 请求 Schema
```json
{
"$schema": "http://json-schema.org/draft-07/schema#",
"type": "object",
"required": ["questionId", "result"],
"properties": {
"questionId": { "type": "string" },
"result": { "type": "string" }
}
}
```
## 响应 Schema
```json
{
"$schema": "http://json-schema.org/draft-07/schema#",
"type": "object",
"properties": {
"resultCode": { "type": "number" },
"resultMsg": { "type": ["string", "null"] },
"data": { "type": "object" }
}
}
```
## 脚本映射
- `../../scripts/task/update-question-result.py`
FILE:openapi/task/list-task-by-page.md
# POST https://cwork-api.mediportal.com.cn/ai-report/task/listTaskByPage
## 作用
按目录、状态、关键词等条件分页查询 AI 情报报告列表。
**鉴权类型**
- `access-token`
**Headers**
- `access-token`
- `Content-Type: application/json`
**Body**
| 字段 | 类型 | 必填 | 说明 |
|---|---|---|---|
| `pageNum` | number | 是 | 页码,从 0 开始 |
| `pageSize` | number | 是 | 每页条数 |
| `dirId` | string | 否 | 目录 ID,按目录筛选 |
| `mobanTypeId` | string | 否 | 模版类型 ID |
| `state` | number | 否 | 状态:0 未开始,1 进行中,2 已完成,3 失败 |
| `searchKey` | string | 否 | 搜索关键词(报告标题) |
| `reportType` | number | 否 | 报告来源:1 普通,2 定时,3 系统 |
| `delFlag` | number | 否 | 删除标记,0 未删除 |
| `onlyMine` | string | 否 | 是否仅看我的,传 `true` |
| `onlyMineStatus` | number | 否 | 与 `onlyMine` 配合,筛选状态 |
## 请求 Schema
```json
{
"$schema": "http://json-schema.org/draft-07/schema#",
"type": "object",
"required": ["pageNum", "pageSize"],
"properties": {
"pageNum": { "type": "number", "minimum": 0 },
"pageSize": { "type": "number", "minimum": 1 },
"dirId": { "type": ["string", "null"] },
"mobanTypeId": { "type": ["string", "null"] },
"state": { "type": ["number", "null"] },
"searchKey": { "type": ["string", "null"] },
"reportType": { "type": "number" },
"delFlag": { "type": "number" },
"onlyMine": { "type": ["string", "null"] },
"onlyMineStatus": { "type": ["number", "null"] }
}
}
```
## 响应 Schema
```json
{
"$schema": "http://json-schema.org/draft-07/schema#",
"type": "object",
"properties": {
"resultCode": { "type": "number" },
"resultMsg": { "type": ["string", "null"] },
"data": {
"type": "object",
"properties": {
"pageContent": { "type": "array", "items": { "type": "object" } },
"total": { "type": "number" }
}
}
}
}
```
## 脚本映射
- `../../scripts/task/list-task-by-page.py`
FILE:openapi/task/api-index.md
# API 索引 — task
接口列表:
1. `POST https://cwork-api.mediportal.com.cn/ai-report/task/startTask`
- 文档:`./start-task.md`
2. `POST https://cwork-api.mediportal.com.cn/ai-report/task/checkTask`
- 文档:`./check-task.md`
3. `POST https://cwork-api.mediportal.com.cn/ai-report/task/taskDetailV2`
- 文档:`./task-detail-v2.md`
4. `POST https://cwork-api.mediportal.com.cn/ai-report/task/listTaskByPage`
- 文档:`./list-task-by-page.md`
5. `POST https://cwork-api.mediportal.com.cn/ai-report/task/updateQuestionResult`
- 文档:`./update-question-result.md`
6. `POST https://cwork-api.mediportal.com.cn/ai-report/task/listResultVersion`
- 文档:`./list-result-version.md`
脚本映射:
- `../../scripts/task/README.md`
FILE:openapi/task/task-detail-v2.md
# POST https://cwork-api.mediportal.com.cn/ai-report/task/taskDetailV2
## 作用
查询单个报告任务详情,包括章节与子章节内容。
**鉴权类型**
- `access-token`
**Headers**
- `access-token`
- `Content-Type: application/json`
**Body**
| 字段 | 类型 | 必填 | 说明 |
|---|---|---|---|
| `taskId` | string | 是 | 报告任务 ID |
## 请求 Schema
```json
{
"$schema": "http://json-schema.org/draft-07/schema#",
"type": "object",
"required": ["taskId"],
"properties": {
"taskId": { "type": "string" }
}
}
```
## 响应 Schema
```json
{
"$schema": "http://json-schema.org/draft-07/schema#",
"type": "object",
"properties": {
"resultCode": { "type": "number" },
"resultMsg": { "type": ["string", "null"] },
"data": { "type": "object" }
}
}
```
## 脚本映射
- `../../scripts/task/task-detail-v2.py`
FILE:openapi/task/check-task.md
# POST https://cwork-api.mediportal.com.cn/ai-report/task/checkTask
## 作用
查询任务进度,建议在创建任务后轮询直到完成或失败。
**鉴权类型**
- `access-token`
**Headers**
- `access-token`
- `Content-Type: application/json`
**Body**
| 字段 | 类型 | 必填 | 说明 |
|---|---|---|---|
| `taskId` | string | 是 | 报告任务 ID |
## 请求 Schema
```json
{
"$schema": "http://json-schema.org/draft-07/schema#",
"type": "object",
"required": ["taskId"],
"properties": {
"taskId": { "type": "string" }
}
}
```
## 响应 Schema
```json
{
"$schema": "http://json-schema.org/draft-07/schema#",
"type": "object",
"properties": {
"resultCode": { "type": "number" },
"resultMsg": { "type": ["string", "null"] },
"data": { "type": "object" }
}
}
```
## 脚本映射
- `../../scripts/task/check-task.py`
FILE:openapi/task/start-task.md
# POST https://cwork-api.mediportal.com.cn/ai-report/task/startTask
## 作用
基于指定模版发起异步报告生成任务,返回 `taskId`。
**鉴权类型**
- `access-token`
**Headers**
- `access-token`
- `Content-Type: application/json`
**Body**
| 字段 | 类型 | 必填 | 说明 |
|---|---|---|---|
| `mobanId` | string | 是 | 模版 ID |
| `taskName` | string | 是 | 报告名称 |
| `dirId` | string | 否 | 目录 ID(可选,不传时由后端按默认规则处理) |
| `context` | object | 否 | 报告生成上下文 |
## 请求 Schema
```json
{
"$schema": "http://json-schema.org/draft-07/schema#",
"type": "object",
"required": ["mobanId", "taskName"],
"properties": {
"mobanId": { "type": "string" },
"taskName": { "type": "string" },
"dirId": { "type": ["string", "null"] },
"context": { "type": ["object", "null"] }
}
}
```
## 响应 Schema
```json
{
"$schema": "http://json-schema.org/draft-07/schema#",
"type": "object",
"properties": {
"resultCode": { "type": "number" },
"resultMsg": { "type": ["string", "null"] },
"data": { "type": "object" }
}
}
```
## 脚本映射
- `../../scripts/task/start-task.py`
FILE:openapi/task/list-result-version.md
# POST https://cwork-api.mediportal.com.cn/ai-report/task/listResultVersion
## 作用
查询指定子章节历史修改版本,用于回溯与比较。
**鉴权类型**
- `access-token`
**Headers**
- `access-token`
- `Content-Type: application/json`
**Body**
| 字段 | 类型 | 必填 | 说明 |
|---|---|---|---|
| `questionId` | string | 是 | 子章节 `_id` |
## 请求 Schema
```json
{
"$schema": "http://json-schema.org/draft-07/schema#",
"type": "object",
"required": ["questionId"],
"properties": {
"questionId": { "type": "string" }
}
}
```
## 响应 Schema
```json
{
"$schema": "http://json-schema.org/draft-07/schema#",
"type": "object",
"properties": {
"resultCode": { "type": "number" },
"resultMsg": { "type": ["string", "null"] },
"data": { "type": "array", "items": { "type": "object" } }
}
}
```
## 脚本映射
- `../../scripts/task/list-result-version.py`
FILE:openapi/moban/update-moban.md
# POST https://cwork-api.mediportal.com.cn/ai-report/moban/updateMoban
## 作用
编辑已有模版,更新名称、章节结构和提示词内容。
**鉴权类型**
- `access-token`
**Headers**
- `access-token`
- `Content-Type: application/json`
**Body**
| 字段 | 类型 | 必填 | 说明 |
|---|---|---|---|
| `mobanId` | string | 是 | 模版 ID |
| `name` | string | 否 | 模版名称 |
| `desc` | string | 否 | 模版描述 |
| `dirId` | string | 否 | 目录 ID |
| `mobanTypeId` | string | 否 | 模版类型 ID(已废弃) |
| `prompt` | string | 否 | 任务级提示词 |
| `editNote` | string | 否 | 改动日志 |
| `public` | number | 否 | 公开状态:`0` 不公开,`1` 公开 |
| `thirdSystem` | string | 否 | 第三方系统标识 |
| `doSummary` | number | 否 | 是否总结:`0` 不总结,`1` 最后一章总结,`2` 第一章总结 |
| `summaryPrompt` | string | 否 | 总结提示词 |
| `aiType` | string | 否 | AI 类型 |
| `requireContext` | array | 否 | 上下文字段(`string[]`) |
| `sectionList` | array | 否 | 章节结构 |
## 请求 Schema
```json
{
"$schema": "http://json-schema.org/draft-07/schema#",
"type": "object",
"required": ["mobanId"],
"properties": {
"mobanId": { "type": "string", "minLength": 1 },
"name": { "type": ["string", "null"] },
"desc": { "type": ["string", "null"] },
"dirId": { "type": ["string", "null"] },
"mobanTypeId": { "type": ["string", "null"] },
"prompt": { "type": ["string", "null"] },
"editNote": { "type": ["string", "null"] },
"public": { "type": ["number", "null"], "enum": [0, 1, null] },
"thirdSystem": { "type": ["string", "null"] },
"doSummary": { "type": ["number", "null"], "enum": [0, 1, 2, null] },
"summaryPrompt": { "type": ["string", "null"] },
"aiType": { "type": ["string", "null"] },
"requireContext": {
"type": ["array", "null"],
"items": { "type": "string" }
},
"sectionList": {
"type": "array",
"minItems": 0,
"items": {
"type": "object",
"required": [],
"properties": {
"name": { "type": "string", "minLength": 1 },
"prompt": { "type": ["string", "null"] },
"questionList": {
"type": "array",
"minItems": 0,
"items": {
"type": "object",
"required": [],
"properties": {
"title": { "type": "string", "minLength": 1 },
"content": { "type": ["string", "null"] },
"prompt": { "type": ["string", "null"] },
"withNet": { "type": ["boolean", "null"] },
"dataSrc": {
"type": ["object", "null"],
"properties": {
"srcType": { "type": ["string", "null"] },
"docList": {
"type": ["array", "null"],
"items": {
"type": "object",
"properties": {
"name": { "type": ["string", "null"] },
"fileId": { "type": ["string", "null"] },
"docType": { "type": ["number", "null"] }
}
}
},
"customSrcId": { "type": ["string", "null"] },
"mcpServerIdList": { "type": ["string", "null"] },
"originDoc": { "type": ["boolean", "null"] }
}
}
}
}
}
}
}
}
}
}
```
## 响应 Schema
```json
{
"$schema": "http://json-schema.org/draft-07/schema#",
"type": "object",
"properties": {
"resultCode": { "type": "number" },
"resultMsg": { "type": ["string", "null"] },
"data": { "type": ["object", "null"] }
}
}
```
## 脚本映射
- `../../scripts/moban/update-moban.py`
FILE:openapi/moban/change-moban-state.md
# POST https://cwork-api.mediportal.com.cn/ai-report/moban/changeMobanState
## 作用
切换指定模版的发布状态(上架/下架)。
**鉴权类型**
- `access-token`
**Headers**
- `access-token`
- `Content-Type: application/json`
**Body**
| 字段 | 类型 | 必填 | 说明 |
|---|---|---|---|
| `mobanId` | string | 是 | 模版 ID |
| `state` | number | 是 | 状态:`0` 未发布(下架),`1` 已发布(上架) |
## 请求 Schema
```json
{
"$schema": "http://json-schema.org/draft-07/schema#",
"type": "object",
"required": ["mobanId", "state"],
"properties": {
"mobanId": { "type": "string", "minLength": 1 },
"state": { "type": "number", "enum": [0, 1] }
}
}
```
## 响应 Schema
```json
{
"$schema": "http://json-schema.org/draft-07/schema#",
"type": "object",
"properties": {
"resultCode": { "type": "number" },
"resultMsg": { "type": ["string", "null"] },
"data": { "type": ["object", "null"] }
}
}
```
## 脚本映射
- `../../scripts/moban/change-moban-state.py`
FILE:openapi/moban/delete-moban.md
# POST https://cwork-api.mediportal.com.cn/ai-report/moban/delMoban
## 作用
删除指定模版。仅创建者和管理员可操作。
**鉴权类型**
- `access-token`
**Headers**
- `access-token`
- `Content-Type: application/json`
**Body**
| 字段 | 类型 | 必填 | 说明 |
|---|---|---|---|
| `mobanId` | string | 是 | 目标模版 ID |
## 请求 Schema
```json
{
"$schema": "http://json-schema.org/draft-07/schema#",
"type": "object",
"required": ["mobanId"],
"properties": {
"mobanId": { "type": "string", "minLength": 1 }
}
}
```
## 响应 Schema
```json
{
"$schema": "http://json-schema.org/draft-07/schema#",
"type": "object",
"properties": {
"resultCode": { "type": "number" },
"resultMsg": { "type": ["string", "null"] },
"data": { "type": ["object", "boolean", "null"] }
}
}
```
## 脚本映射
- `../../scripts/moban/delete-moban.py`
FILE:openapi/moban/create-moban.md
# POST https://cwork-api.mediportal.com.cn/ai-report/moban/saveMoban
## 作用
创建新的报告模版,支持写入章节结构与子章节提示词。
**鉴权类型**
- `access-token`
**Headers**
- `access-token`
- `Content-Type: application/json`
**Body**
| 字段 | 类型 | 必填 | 说明 |
|---|---|---|---|
| `name` | string | 否 | 模版名称 |
| `desc` | string | 否 | 模版描述 |
| `dirId` | string | 否 | 目录 ID |
| `mobanTypeId` | string | 否 | 模版类型 ID(已废弃) |
| `prompt` | string | 否 | 任务级提示词 |
| `editNote` | string | 否 | 改动日志 |
| `public` | number | 否 | 公开状态:`0` 不公开,`1` 公开 |
| `thirdSystem` | string | 否 | 第三方系统标识 |
| `doSummary` | number | 否 | 是否总结:`0` 不总结,`1` 最后一章总结,`2` 第一章总结 |
| `summaryPrompt` | string | 否 | 总结提示词 |
| `aiType` | string | 否 | AI 类型 |
| `requireContext` | array | 否 | 上下文字段(`string[]`) |
| `sectionList` | array | 否 | 章节结构 |
## 请求 Schema
```json
{
"$schema": "http://json-schema.org/draft-07/schema#",
"type": "object",
"required": [],
"properties": {
"name": { "type": "string", "minLength": 1 },
"desc": { "type": ["string", "null"] },
"dirId": { "type": ["string", "null"] },
"mobanTypeId": { "type": ["string", "null"] },
"prompt": { "type": ["string", "null"] },
"editNote": { "type": ["string", "null"] },
"public": { "type": ["number", "null"], "enum": [0, 1, null] },
"thirdSystem": { "type": ["string", "null"] },
"doSummary": { "type": ["number", "null"], "enum": [0, 1, 2, null] },
"summaryPrompt": { "type": ["string", "null"] },
"aiType": { "type": ["string", "null"] },
"requireContext": {
"type": ["array", "null"],
"items": { "type": "string" }
},
"sectionList": {
"type": "array",
"minItems": 0,
"items": {
"type": "object",
"required": [],
"properties": {
"name": { "type": "string", "minLength": 1 },
"prompt": { "type": ["string", "null"] },
"questionList": {
"type": "array",
"minItems": 0,
"items": {
"type": "object",
"required": [],
"properties": {
"title": { "type": "string", "minLength": 1 },
"content": { "type": ["string", "null"] },
"prompt": { "type": ["string", "null"] },
"withNet": { "type": ["boolean", "null"] },
"dataSrc": {
"type": ["object", "null"],
"properties": {
"srcType": { "type": ["string", "null"] },
"docList": {
"type": ["array", "null"],
"items": {
"type": "object",
"properties": {
"name": { "type": ["string", "null"] },
"fileId": { "type": ["string", "null"] },
"docType": { "type": ["number", "null"] }
}
}
},
"customSrcId": { "type": ["string", "null"] },
"mcpServerIdList": { "type": ["string", "null"] },
"originDoc": { "type": ["boolean", "null"] }
}
}
}
}
}
}
}
}
}
}
```
## 响应 Schema
```json
{
"$schema": "http://json-schema.org/draft-07/schema#",
"type": "object",
"properties": {
"resultCode": { "type": "number" },
"resultMsg": { "type": ["string", "null"] },
"data": { "type": ["object", "null"] }
}
}
```
## 脚本映射
- `../../scripts/moban/create-moban.py`
FILE:openapi/moban/moban-detail.md
# POST https://cwork-api.mediportal.com.cn/ai-report/moban/mobanDetail
## 作用
读取指定模版的章节结构、子章节和提示词配置。
**鉴权类型**
- `access-token`
**Headers**
- `access-token`
- `Content-Type: application/json`
**Body**
| 字段 | 类型 | 必填 | 说明 |
|---|---|---|---|
| `mobanId` | string | 是 | 模版 ID |
## 请求 Schema
```json
{
"$schema": "http://json-schema.org/draft-07/schema#",
"type": "object",
"required": ["mobanId"],
"properties": {
"mobanId": { "type": "string" }
}
}
```
## 响应 Schema
```json
{
"$schema": "http://json-schema.org/draft-07/schema#",
"type": "object",
"properties": {
"resultCode": { "type": "number" },
"resultMsg": { "type": ["string", "null"] },
"data": { "type": "object" }
}
}
```
## 脚本映射
- `../../scripts/moban/moban-detail.py`
FILE:openapi/moban/api-index.md
# API 索引 — moban
接口列表:
1. `POST https://cwork-api.mediportal.com.cn/ai-report/moban/listMobanByPageV2`
- 文档:`./list-moban.md`
2. `POST https://cwork-api.mediportal.com.cn/ai-report/moban/mobanDetail`
- 文档:`./moban-detail.md`
3. `POST https://cwork-api.mediportal.com.cn/ai-report/moban/saveMoban`
- 文档:`./create-moban.md`
4. `POST https://cwork-api.mediportal.com.cn/ai-report/moban/updateMoban`
- 文档:`./update-moban.md`
5. `POST https://cwork-api.mediportal.com.cn/ai-report/moban/delMoban`
- 文档:`./delete-moban.md`
6. `POST https://cwork-api.mediportal.com.cn/ai-report/moban/changeMobanState`
- 文档:`./change-moban-state.md`
脚本映射:
- `../../scripts/moban/README.md`
FILE:openapi/moban/list-moban.md
# POST https://cwork-api.mediportal.com.cn/ai-report/moban/listMobanByPageV2
## 作用
按分页、关键词、目录、仅看我的条件检索模版列表。
**鉴权类型**
- `access-token`
**Headers**
- `access-token`
- `Content-Type: application/json`
**Body**
| 字段 | 类型 | 必填 | 说明 |
|---|---|---|---|
| `pageNum` | number | 是 | 页码,从 0 开始 |
| `pageSize` | number | 是 | 每页条数 |
| `dirId` | string | 否 | 目录筛选 |
| `searchKey` | string | 否 | 模版名关键词 |
| `onlyMine` | string | 否 | 是否仅看我的,传 `true` |
## 请求 Schema
```json
{
"$schema": "http://json-schema.org/draft-07/schema#",
"type": "object",
"required": ["pageNum", "pageSize"],
"properties": {
"pageNum": { "type": "number", "minimum": 0 },
"pageSize": { "type": "number", "minimum": 1 },
"dirId": { "type": ["string", "null"] },
"searchKey": { "type": ["string", "null"] },
"onlyMine": { "type": ["string", "null"] }
}
}
```
## 响应 Schema
```json
{
"$schema": "http://json-schema.org/draft-07/schema#",
"type": "object",
"properties": {
"resultCode": { "type": "number" },
"resultMsg": { "type": ["string", "null"] },
"data": {
"type": "object",
"properties": {
"pageContent": { "type": "array", "items": { "type": "object" } },
"total": { "type": "number" }
}
}
}
}
```
## 脚本映射
- `../../scripts/moban/list-moban.py`
FILE:examples/capability/README.md
# capability — 使用说明
## 什么时候使用
- 用户问“你可以做什么”“你能做什么”“你是谁”“你支持哪些操作”
- 用户希望确认当前 skill 的能力边界、可执行范围与下一步入口
## 标准流程
1. 读取 `SKILL.md`,提取当前 skill 的真实能力范围
2. 输出“能做什么 / 不能做什么 / 下一步引导”三段式回答
3. 不执行任何脚本,不进行鉴权排查,不虚构命令或外部工具
FILE:examples/task/README.md
# task — 使用说明
## 什么时候使用
- 一键生成报告 -> `start-task.py` + `check-task.py`
- 查询任务状态/报告列表/报告详情 -> `check-task.py` / `list-task-by-page.py` / `task-detail-v2.py`
- 手动修改章节与查看历史版本 -> `update-question-result.py` / `list-result-version.py`
## 标准流程
1. 先选模版并确定 `mobanId`
2. 先读取 `cms-auth-skills/SKILL.md`,按规则准备 `access-token`
3. 调用 `start-task.py` 创建任务并获得 `taskId`
4. 用 `check-task.py` 轮询状态
5. 完成后用 `task-detail-v2.py` 获取报告内容
6. 如需手改,先从详情里取 `questionId`,生成编辑后完整内容并展示给用户确认
7. 用户明确确认后,再调用 `update-question-result.py` 进行保存
8. 用 `list-result-version.py` 查看历史版本
FILE:examples/moban/README.md
# moban — 使用说明
## 什么时候使用
- 用户需要浏览可用模版 -> `list-moban.py`
- 用户需要查看某个模版章节结构 -> `moban-detail.py`
- 用户需要新建一份新模版 -> `create-moban.py`
- 用户需要更新已有模版结构或提示词 -> `update-moban.py`
- 用户需要发布或下架模版 -> `change-moban-state.py`
- 用户需要删除废弃模版 -> `delete-moban.py`
## 标准流程
1. 鉴权预检(先读取 `cms-auth-skills/SKILL.md`,按规则准备 `access-token`)
2. 获取模版列表并确认目标 `mobanId`
3. 查看模版详情,确认是否用于后续报告生成
4. 需要新模版时,准备 `MOBAN_NAME` 与 `MOBAN_SECTION_LIST`,调用 `create-moban.py`
5. 需要调整已有模版时,准备 `MOBAN_ID`、`MOBAN_NAME` 与 `MOBAN_SECTION_LIST`,调用 `update-moban.py`
6. 需要发布或下架模版时,准备 `MOBAN_ID` 与 `MOBAN_STATE`(`1` 上架,`0` 下架),调用 `change-moban-state.py`
7. 需要删除模版时,先确认目标 `MOBAN_ID` 后调用 `delete-moban.py`
FILE:scripts/capability/README.md
# 脚本清单 — capability
## 共享依赖
- 无。本模块不调用外部业务接口。
## 脚本列表
- 无业务脚本。能力咨询直接依据 `SKILL.md` 与 `examples/capability/README.md` 返回标准回答。
## 使用方式
- 不执行脚本。
## 输出说明
- 输出当前 skill 的能力边界说明,不回显内部实现细节,不触发接口调用。
## 规范
1. 不调用 API
2. 不虚构命令或工具
3. 不在能力咨询场景中排查鉴权
FILE:scripts/task/task-detail-v2.py
#!/usr/bin/env python3
"""
task / task-detail-v2 脚本
用途:获取报告任务详情
使用方式:
python3 scripts/task/task-detail-v2.py
环境变量:
XG_USER_TOKEN - access-token(必须)
TASK_ID - 报告任务 ID(必须)
"""
import json
import os
import sys
import time
import urllib.error
import urllib.request
API_URL = "https://cwork-api.mediportal.com.cn/ai-report/task/taskDetailV2"
AUTH_MODE = "access-token"
def call_api(token: str, task_id: str) -> dict:
req = urllib.request.Request(
API_URL,
data=json.dumps({"taskId": task_id}).encode("utf-8"),
headers={"access-token": token, "Content-Type": "application/json"},
method="POST",
)
last = None
for i in range(3):
try:
with urllib.request.urlopen(req, timeout=60) as resp:
return json.loads(resp.read().decode("utf-8"))
except (urllib.error.HTTPError, urllib.error.URLError, TimeoutError) as e:
last = e
if i < 2:
time.sleep(1)
raise last
def main() -> None:
token = os.environ.get("XG_USER_TOKEN")
task_id = os.environ.get("TASK_ID")
if not (token and task_id):
print("错误: 请设置 XG_USER_TOKEN 与 TASK_ID", file=sys.stderr)
sys.exit(1)
print(json.dumps(call_api(token, task_id), ensure_ascii=False))
if __name__ == "__main__":
main()
FILE:scripts/task/start-task.py
#!/usr/bin/env python3
"""
task / start-task 脚本
用途:基于模版发起异步报告生成任务
使用方式:
python3 scripts/task/start-task.py
环境变量:
XG_USER_TOKEN - access-token(必须)
MOBAN_ID - 模版 ID(必须)
TASK_NAME - 报告名称(必须)
TASK_DIR_ID - 目录 ID(可选)
TASK_CONTEXT - 上下文 JSON 字符串(可选)
"""
import json
import os
import sys
import time
import urllib.error
import urllib.request
API_URL = "https://cwork-api.mediportal.com.cn/ai-report/task/startTask"
AUTH_MODE = "access-token"
def call_api(token: str, payload: dict) -> dict:
req = urllib.request.Request(
API_URL,
data=json.dumps(payload).encode("utf-8"),
headers={"access-token": token, "Content-Type": "application/json"},
method="POST",
)
last = None
for i in range(3):
try:
with urllib.request.urlopen(req, timeout=60) as resp:
return json.loads(resp.read().decode("utf-8"))
except (urllib.error.HTTPError, urllib.error.URLError, TimeoutError) as e:
last = e
if i < 2:
time.sleep(1)
raise last
def main() -> None:
token = os.environ.get("XG_USER_TOKEN")
moban_id = os.environ.get("MOBAN_ID")
task_name = os.environ.get("TASK_NAME")
task_dir_id = os.environ.get("TASK_DIR_ID")
if not (token and moban_id and task_name):
print("错误: 请设置 XG_USER_TOKEN/MOBAN_ID/TASK_NAME", file=sys.stderr)
sys.exit(1)
payload = {"mobanId": moban_id, "taskName": task_name}
if task_dir_id:
payload["dirId"] = task_dir_id
if os.environ.get("TASK_CONTEXT"):
payload["context"] = json.loads(os.environ["TASK_CONTEXT"])
print(json.dumps(call_api(token, payload), ensure_ascii=False))
if __name__ == "__main__":
main()
FILE:scripts/task/check-task.py
#!/usr/bin/env python3
"""
task / check-task 脚本
用途:查询任务进度
使用方式:
python3 scripts/task/check-task.py
环境变量:
XG_USER_TOKEN - access-token(必须)
TASK_ID - 报告任务 ID(必须)
"""
import json
import os
import sys
import time
import urllib.error
import urllib.request
API_URL = "https://cwork-api.mediportal.com.cn/ai-report/task/checkTask"
AUTH_MODE = "access-token"
def call_api(token: str, task_id: str) -> dict:
req = urllib.request.Request(
API_URL,
data=json.dumps({"taskId": task_id}).encode("utf-8"),
headers={"access-token": token, "Content-Type": "application/json"},
method="POST",
)
last = None
for i in range(3):
try:
with urllib.request.urlopen(req, timeout=60) as resp:
return json.loads(resp.read().decode("utf-8"))
except (urllib.error.HTTPError, urllib.error.URLError, TimeoutError) as e:
last = e
if i < 2:
time.sleep(1)
raise last
def main() -> None:
token = os.environ.get("XG_USER_TOKEN")
task_id = os.environ.get("TASK_ID")
if not (token and task_id):
print("错误: 请设置 XG_USER_TOKEN 与 TASK_ID", file=sys.stderr)
sys.exit(1)
print(json.dumps(call_api(token, task_id), ensure_ascii=False))
if __name__ == "__main__":
main()
FILE:scripts/task/list-result-version.py
#!/usr/bin/env python3
"""
task / list-result-version 脚本
用途:查询子章节历史版本
使用方式:
python3 scripts/task/list-result-version.py
环境变量:
XG_USER_TOKEN - access-token(必须)
QUESTION_ID - 子章节 ID(必须)
"""
import json
import os
import sys
import time
import urllib.error
import urllib.request
API_URL = "https://cwork-api.mediportal.com.cn/ai-report/task/listResultVersion"
AUTH_MODE = "access-token"
def call_api(token: str, question_id: str) -> dict:
req = urllib.request.Request(
API_URL,
data=json.dumps({"questionId": question_id}).encode("utf-8"),
headers={"access-token": token, "Content-Type": "application/json"},
method="POST",
)
last = None
for i in range(3):
try:
with urllib.request.urlopen(req, timeout=60) as resp:
return json.loads(resp.read().decode("utf-8"))
except (urllib.error.HTTPError, urllib.error.URLError, TimeoutError) as e:
last = e
if i < 2:
time.sleep(1)
raise last
def main() -> None:
token = os.environ.get("XG_USER_TOKEN")
question_id = os.environ.get("QUESTION_ID")
if not (token and question_id):
print("错误: 请设置 XG_USER_TOKEN 与 QUESTION_ID", file=sys.stderr)
sys.exit(1)
print(json.dumps(call_api(token, question_id), ensure_ascii=False))
if __name__ == "__main__":
main()
FILE:scripts/task/README.md
# 脚本清单 — task
## 共享依赖
- `cms-auth-skills/SKILL.md` — 统一鉴权入口,执行前先按该技能准备 `access-token`
## 脚本列表
| 脚本 | 对应接口 | 用途 |
|---|---|---|
| `start-task.py` | `POST /ai-report/task/startTask` | 创建报告任务 |
| `check-task.py` | `POST /ai-report/task/checkTask` | 查询任务进度 |
| `list-task-by-page.py` | `POST /ai-report/task/listTaskByPage` | 分页查询报告列表 |
| `task-detail-v2.py` | `POST /ai-report/task/taskDetailV2` | 获取报告详情 |
| `update-question-result.py` | `POST /ai-report/task/updateQuestionResult` | 手工覆盖子章节内容 |
| `list-result-version.py` | `POST /ai-report/task/listResultVersion` | 查询子章节历史版本 |
## 使用方式
```bash
# 设置环境变量
export XG_USER_TOKEN="your-access-token"
# 创建任务
export MOBAN_ID="模板ID"
export TASK_NAME="报告名称"
# 可选:目录ID(不设置时由后端默认规则处理)
# export TASK_DIR_ID="目录ID"
python3 scripts/task/start-task.py
# 查询进度
export TASK_ID="报告ID"
python3 scripts/task/check-task.py
# 报告列表分页(可选:通过环境变量传入额外参数)
export PAGE_NUM=0
export PAGE_SIZE=10
export REPORT_TYPE=1
export DEL_FLAG=0
python3 scripts/task/list-task-by-page.py
# 获取报告详情
python3 scripts/task/task-detail-v2.py
# 章节编辑与版本查询
export QUESTION_ID="子章节ID"
export RESULT="新的章节内容"
python3 scripts/task/update-question-result.py
python3 scripts/task/list-result-version.py
```
## 输出说明
所有脚本的输出均为 **JSON 格式**。
## 规范
1. **必须使用 Python** 编写
2. **鉴权遵循** `cms-auth-skills/SKILL.md` 规范
3. **入参定义以** `openapi/` 文档为准
FILE:scripts/task/list-task-by-page.py
#!/usr/bin/env python3
"""
task / list-task-by-page 脚本
用途:分页查询报告列表
使用方式:
python3 scripts/task/list-task-by-page.py
环境变量:
XG_USER_TOKEN - access-token(必须)
PAGE_NUM - 页码,默认 0
PAGE_SIZE - 每页条数,默认 10
REPORT_TYPE - 报告来源,默认 1
DEL_FLAG - 删除标记,默认 0
DIR_ID - 目录 ID(可选)
MOBAN_TYPE_ID - 模版类型 ID(可选)
STATE - 任务状态(可选)
SEARCH_KEY - 关键词(可选)
ONLY_MINE - 是否仅看我的(可选)
ONLY_MINE_STATUS - 我的任务状态(可选)
"""
import json
import os
import sys
import time
import urllib.error
import urllib.request
API_URL = "https://cwork-api.mediportal.com.cn/ai-report/task/listTaskByPage"
AUTH_MODE = "access-token"
def call_api(token: str, payload: dict) -> dict:
req = urllib.request.Request(
API_URL,
data=json.dumps(payload).encode("utf-8"),
headers={"access-token": token, "Content-Type": "application/json"},
method="POST",
)
last = None
for i in range(3):
try:
with urllib.request.urlopen(req, timeout=60) as resp:
return json.loads(resp.read().decode("utf-8"))
except (urllib.error.HTTPError, urllib.error.URLError, TimeoutError) as e:
last = e
if i < 2:
time.sleep(1)
raise last
def main() -> None:
token = os.environ.get("XG_USER_TOKEN")
if not token:
print("错误: 请设置环境变量 XG_USER_TOKEN", file=sys.stderr)
sys.exit(1)
payload = {
"pageNum": int(os.environ.get("PAGE_NUM", "0")),
"pageSize": int(os.environ.get("PAGE_SIZE", "10")),
"reportType": int(os.environ.get("REPORT_TYPE", "1")),
"delFlag": int(os.environ.get("DEL_FLAG", "0")),
}
if os.environ.get("DIR_ID"):
payload["dirId"] = os.environ["DIR_ID"]
if os.environ.get("MOBAN_TYPE_ID"):
payload["mobanTypeId"] = os.environ["MOBAN_TYPE_ID"]
if os.environ.get("STATE"):
payload["state"] = int(os.environ["STATE"])
if os.environ.get("SEARCH_KEY"):
payload["searchKey"] = os.environ["SEARCH_KEY"]
if os.environ.get("ONLY_MINE"):
payload["onlyMine"] = os.environ["ONLY_MINE"]
if os.environ.get("ONLY_MINE_STATUS"):
payload["onlyMineStatus"] = int(os.environ["ONLY_MINE_STATUS"])
print(json.dumps(call_api(token, payload), ensure_ascii=False))
if __name__ == "__main__":
main()
FILE:scripts/task/update-question-result.py
#!/usr/bin/env python3
"""
task / update-question-result 脚本
用途:覆盖指定子章节内容
使用方式:
python3 scripts/task/update-question-result.py
环境变量:
XG_USER_TOKEN - access-token(必须)
QUESTION_ID - 子章节 ID(必须)
RESULT - 新章节内容(必须)
"""
import json
import os
import sys
import time
import urllib.error
import urllib.request
API_URL = "https://cwork-api.mediportal.com.cn/ai-report/task/updateQuestionResult"
AUTH_MODE = "access-token"
def call_api(token: str, question_id: str, result: str) -> dict:
req = urllib.request.Request(
API_URL,
data=json.dumps({"questionId": question_id, "result": result}).encode("utf-8"),
headers={"access-token": token, "Content-Type": "application/json"},
method="POST",
)
last = None
for i in range(3):
try:
with urllib.request.urlopen(req, timeout=60) as resp:
return json.loads(resp.read().decode("utf-8"))
except (urllib.error.HTTPError, urllib.error.URLError, TimeoutError) as e:
last = e
if i < 2:
time.sleep(1)
raise last
def main() -> None:
token = os.environ.get("XG_USER_TOKEN")
question_id = os.environ.get("QUESTION_ID")
result = os.environ.get("RESULT")
if not (token and question_id and result):
print("错误: 请设置 XG_USER_TOKEN/QUESTION_ID/RESULT", file=sys.stderr)
sys.exit(1)
print(json.dumps(call_api(token, question_id, result), ensure_ascii=False))
if __name__ == "__main__":
main()
FILE:scripts/moban/list-moban.py
#!/usr/bin/env python3
"""
moban / list-moban 脚本
用途:按分页条件检索模版列表
使用方式:
python3 scripts/moban/list-moban.py
环境变量:
XG_USER_TOKEN - access-token(必须)
MOBAN_PAGE_NUM - 页码,默认 0
MOBAN_PAGE_SIZE - 每页条数,默认 20
MOBAN_DIR_ID - 目录筛选(可选)
MOBAN_SEARCH_KEY - 关键词筛选(可选)
MOBAN_ONLY_MINE - 是否仅看我的(可选)
"""
import json
import os
import sys
import time
import urllib.error
import urllib.request
API_URL = "https://cwork-api.mediportal.com.cn/ai-report/moban/listMobanByPageV2"
AUTH_MODE = "access-token"
def call_api(token: str, payload: dict) -> dict:
req = urllib.request.Request(
API_URL,
data=json.dumps(payload).encode("utf-8"),
headers={"access-token": token, "Content-Type": "application/json"},
method="POST",
)
last = None
for i in range(3):
try:
with urllib.request.urlopen(req, timeout=60) as resp:
return json.loads(resp.read().decode("utf-8"))
except (urllib.error.HTTPError, urllib.error.URLError, TimeoutError) as e:
last = e
if i < 2:
time.sleep(1)
raise last
def main() -> None:
token = os.environ.get("XG_USER_TOKEN")
if not token:
print("错误: 请设置环境变量 XG_USER_TOKEN", file=sys.stderr)
sys.exit(1)
payload = {
"pageNum": int(os.environ.get("MOBAN_PAGE_NUM", "0")),
"pageSize": int(os.environ.get("MOBAN_PAGE_SIZE", "20")),
}
if os.environ.get("MOBAN_DIR_ID"):
payload["dirId"] = os.environ["MOBAN_DIR_ID"]
if os.environ.get("MOBAN_SEARCH_KEY"):
payload["searchKey"] = os.environ["MOBAN_SEARCH_KEY"]
if os.environ.get("MOBAN_ONLY_MINE"):
payload["onlyMine"] = os.environ["MOBAN_ONLY_MINE"]
print(json.dumps(call_api(token, payload), ensure_ascii=False))
if __name__ == "__main__":
main()
FILE:scripts/moban/README.md
# 脚本清单 — moban
## 共享依赖
- `cms-auth-skills/SKILL.md` — 统一鉴权入口,执行前先按该技能准备 `access-token`
## 脚本列表
| 脚本 | 对应接口 | 用途 |
|---|---|---|
| `list-moban.py` | `POST /ai-report/moban/listMobanByPageV2` | 模版列表检索 |
| `moban-detail.py` | `POST /ai-report/moban/mobanDetail` | 模版详情查看 |
| `create-moban.py` | `POST /ai-report/moban/saveMoban` | 新建模版 |
| `update-moban.py` | `POST /ai-report/moban/updateMoban` | 编辑模版 |
| `change-moban-state.py` | `POST /ai-report/moban/changeMobanState` | 模版发布/下架 |
| `delete-moban.py` | `POST /ai-report/moban/delMoban` | 删除模版 |
## 使用方式
```bash
# 设置环境变量
export XG_USER_TOKEN="your-access-token"
# 查询模版列表
export MOBAN_PAGE_NUM=0
export MOBAN_PAGE_SIZE=10
python3 scripts/moban/list-moban.py
# 查询模版详情
export MOBAN_ID="模板ID"
python3 scripts/moban/moban-detail.py
# 新建模版(sectionList 必填,JSON 字符串)
export MOBAN_NAME="创新药尽调模版"
export MOBAN_SECTION_LIST='[{"name":"项目概览","questionList":[{"title":"项目基本信息","content":"请总结项目背景与核心价值","prompt":"按业务、技术、竞争格局三个维度展开"}]}]'
python3 scripts/moban/create-moban.py
# 编辑模版(mobanId + sectionList 必填,JSON 字符串)
export MOBAN_ID="moban_1001"
export MOBAN_NAME="创新药尽调模版(更新版)"
export MOBAN_SECTION_LIST='[{"name":"项目概览","questionList":[{"title":"项目基本信息","prompt":"重点补充适应症市场空间"}]}]'
python3 scripts/moban/update-moban.py
# 模版发布/下架(1 上架,0 下架)
export MOBAN_ID="moban_1001"
export MOBAN_STATE=1
python3 scripts/moban/change-moban-state.py
# 删除模版
export MOBAN_ID="moban_1001"
python3 scripts/moban/delete-moban.py
```
## 输出说明
所有脚本的输出均为 **JSON 格式**。
## 规范
1. **必须使用 Python** 编写
2. **鉴权遵循** `cms-auth-skills/SKILL.md` 规范
3. **入参定义以** `openapi/` 文档为准
FILE:scripts/moban/change-moban-state.py
#!/usr/bin/env python3
"""
moban / change-moban-state 脚本
用途:模版发布/下架
使用方式:
python3 scripts/moban/change-moban-state.py
环境变量:
XG_USER_TOKEN - access-token(必须)
MOBAN_ID - 模版 ID(必须)
MOBAN_STATE - 模版状态(必须,0 下架 / 1 上架)
"""
import json
import os
import sys
import time
import urllib.error
import urllib.request
API_URL = "https://cwork-api.mediportal.com.cn/ai-report/moban/changeMobanState"
AUTH_MODE = "access-token"
def call_api(token: str, payload: dict) -> dict:
req = urllib.request.Request(
API_URL,
data=json.dumps(payload).encode("utf-8"),
headers={"access-token": token, "Content-Type": "application/json"},
method="POST",
)
last = None
for i in range(3):
try:
with urllib.request.urlopen(req, timeout=60) as resp:
return json.loads(resp.read().decode("utf-8"))
except (urllib.error.HTTPError, urllib.error.URLError, TimeoutError) as e:
last = e
if i < 2:
time.sleep(1)
raise last
def main() -> None:
token = os.environ.get("XG_USER_TOKEN")
moban_id = os.environ.get("MOBAN_ID")
state_raw = os.environ.get("MOBAN_STATE")
if not token:
print("错误: 请设置环境变量 XG_USER_TOKEN", file=sys.stderr)
sys.exit(1)
if not moban_id:
print("错误: 请设置环境变量 MOBAN_ID", file=sys.stderr)
sys.exit(1)
if state_raw is None:
print("错误: 请设置环境变量 MOBAN_STATE(0/1)", file=sys.stderr)
sys.exit(1)
try:
state = int(state_raw)
except ValueError:
print("错误: MOBAN_STATE 必须是 0 或 1", file=sys.stderr)
sys.exit(1)
if state not in (0, 1):
print("错误: MOBAN_STATE 只能是 0 或 1", file=sys.stderr)
sys.exit(1)
payload = {"mobanId": moban_id, "state": state}
print(json.dumps(call_api(token, payload), ensure_ascii=False))
if __name__ == "__main__":
main()
FILE:scripts/moban/update-moban.py
#!/usr/bin/env python3
"""
moban / update-moban 脚本
用途:编辑已有模版
使用方式:
python3 scripts/moban/update-moban.py
环境变量:
XG_USER_TOKEN - access-token(必须)
MOBAN_ID - 模版 ID(必须)
MOBAN_NAME - 模版名称(必须)
MOBAN_SECTION_LIST - 章节结构 JSON 字符串(必须)
MOBAN_DESC - 模版描述(可选)
MOBAN_DIR_ID - 目录 ID(可选)
MOBAN_TYPE_ID - 模版类型 ID(可选)
MOBAN_PROMPT - 任务级提示词(可选)
MOBAN_EDIT_NOTE - 改动日志(可选)
MOBAN_PUBLIC - 公开状态(可选,0/1)
MOBAN_THIRD_SYSTEM - 第三方系统标识(可选)
MOBAN_DO_SUMMARY - 是否总结(可选,0/1/2)
MOBAN_SUMMARY_PROMPT - 总结提示词(可选)
MOBAN_AI_TYPE - AI 类型(可选)
MOBAN_REQUIRE_CONTEXT - 上下文字段 JSON 字符串(可选,string[])
"""
import json
import os
import sys
import time
import urllib.error
import urllib.request
API_URL = "https://cwork-api.mediportal.com.cn/ai-report/moban/updateMoban"
AUTH_MODE = "access-token"
def call_api(token: str, payload: dict) -> dict:
req = urllib.request.Request(
API_URL,
data=json.dumps(payload).encode("utf-8"),
headers={"access-token": token, "Content-Type": "application/json"},
method="POST",
)
last = None
for i in range(3):
try:
with urllib.request.urlopen(req, timeout=60) as resp:
return json.loads(resp.read().decode("utf-8"))
except (urllib.error.HTTPError, urllib.error.URLError, TimeoutError) as e:
last = e
if i < 2:
time.sleep(1)
raise last
def parse_json_env(env_name: str, default):
raw = os.environ.get(env_name)
if not raw:
return default
return json.loads(raw)
def main() -> None:
token = os.environ.get("XG_USER_TOKEN")
moban_id = os.environ.get("MOBAN_ID")
name = os.environ.get("MOBAN_NAME")
if not token:
print("错误: 请设置环境变量 XG_USER_TOKEN", file=sys.stderr)
sys.exit(1)
if not moban_id:
print("错误: 请设置环境变量 MOBAN_ID", file=sys.stderr)
sys.exit(1)
if not name:
print("错误: 请设置环境变量 MOBAN_NAME", file=sys.stderr)
sys.exit(1)
try:
section_list = parse_json_env("MOBAN_SECTION_LIST", None)
if not isinstance(section_list, list) or len(section_list) == 0:
print("错误: MOBAN_SECTION_LIST 必须是非空 JSON 数组", file=sys.stderr)
sys.exit(1)
require_context = parse_json_env("MOBAN_REQUIRE_CONTEXT", None)
except json.JSONDecodeError as exc:
print(f"错误: JSON 环境变量解析失败: {exc}", file=sys.stderr)
sys.exit(1)
payload = {
"mobanId": moban_id,
"name": name,
"sectionList": section_list,
}
if os.environ.get("MOBAN_DESC"):
payload["desc"] = os.environ["MOBAN_DESC"]
if os.environ.get("MOBAN_DIR_ID"):
payload["dirId"] = os.environ["MOBAN_DIR_ID"]
if os.environ.get("MOBAN_TYPE_ID"):
payload["mobanTypeId"] = os.environ["MOBAN_TYPE_ID"]
if os.environ.get("MOBAN_PROMPT"):
payload["prompt"] = os.environ["MOBAN_PROMPT"]
if os.environ.get("MOBAN_EDIT_NOTE"):
payload["editNote"] = os.environ["MOBAN_EDIT_NOTE"]
if os.environ.get("MOBAN_PUBLIC"):
payload["public"] = int(os.environ["MOBAN_PUBLIC"])
if os.environ.get("MOBAN_THIRD_SYSTEM"):
payload["thirdSystem"] = os.environ["MOBAN_THIRD_SYSTEM"]
if os.environ.get("MOBAN_DO_SUMMARY"):
payload["doSummary"] = int(os.environ["MOBAN_DO_SUMMARY"])
if os.environ.get("MOBAN_SUMMARY_PROMPT"):
payload["summaryPrompt"] = os.environ["MOBAN_SUMMARY_PROMPT"]
if os.environ.get("MOBAN_AI_TYPE"):
payload["aiType"] = os.environ["MOBAN_AI_TYPE"]
if require_context is not None:
payload["requireContext"] = require_context
print(json.dumps(call_api(token, payload), ensure_ascii=False))
if __name__ == "__main__":
main()
FILE:scripts/moban/moban-detail.py
#!/usr/bin/env python3
"""
moban / moban-detail 脚本
用途:读取指定模版的章节结构与提示词配置
使用方式:
python3 scripts/moban/moban-detail.py
环境变量:
XG_USER_TOKEN - access-token(必须)
MOBAN_ID - 模版 ID(必须)
"""
import json
import os
import sys
import time
import urllib.error
import urllib.request
API_URL = "https://cwork-api.mediportal.com.cn/ai-report/moban/mobanDetail"
AUTH_MODE = "access-token"
def call_api(token: str, moban_id: str) -> dict:
req = urllib.request.Request(
API_URL,
data=json.dumps({"mobanId": moban_id}).encode("utf-8"),
headers={"access-token": token, "Content-Type": "application/json"},
method="POST",
)
last = None
for i in range(3):
try:
with urllib.request.urlopen(req, timeout=60) as resp:
return json.loads(resp.read().decode("utf-8"))
except (urllib.error.HTTPError, urllib.error.URLError, TimeoutError) as e:
last = e
if i < 2:
time.sleep(1)
raise last
def main() -> None:
token = os.environ.get("XG_USER_TOKEN")
moban_id = os.environ.get("MOBAN_ID")
if not (token and moban_id):
print("错误: 请设置 XG_USER_TOKEN 与 MOBAN_ID", file=sys.stderr)
sys.exit(1)
print(json.dumps(call_api(token, moban_id), ensure_ascii=False))
if __name__ == "__main__":
main()
FILE:scripts/moban/create-moban.py
#!/usr/bin/env python3
"""
moban / create-moban 脚本
用途:新建模版(调用 saveMoban)
使用方式:
python3 scripts/moban/create-moban.py
环境变量:
XG_USER_TOKEN - access-token(必须)
MOBAN_NAME - 模版名称(必须)
MOBAN_SECTION_LIST - 章节结构 JSON 字符串(必须)
MOBAN_DESC - 模版描述(可选)
MOBAN_DIR_ID - 目录 ID(可选)
MOBAN_TYPE_ID - 模版类型 ID(可选)
MOBAN_PROMPT - 任务级提示词(可选)
MOBAN_EDIT_NOTE - 改动日志(可选)
MOBAN_PUBLIC - 公开状态(可选,0/1)
MOBAN_THIRD_SYSTEM - 第三方系统标识(可选)
MOBAN_DO_SUMMARY - 是否总结(可选,0/1/2)
MOBAN_SUMMARY_PROMPT - 总结提示词(可选)
MOBAN_AI_TYPE - AI 类型(可选)
MOBAN_REQUIRE_CONTEXT - 上下文字段 JSON 字符串(可选,string[])
"""
import json
import os
import sys
import time
import urllib.error
import urllib.request
API_URL = "https://cwork-api.mediportal.com.cn/ai-report/moban/saveMoban"
AUTH_MODE = "access-token"
def call_api(token: str, payload: dict) -> dict:
req = urllib.request.Request(
API_URL,
data=json.dumps(payload).encode("utf-8"),
headers={"access-token": token, "Content-Type": "application/json"},
method="POST",
)
last = None
for i in range(3):
try:
with urllib.request.urlopen(req, timeout=60) as resp:
return json.loads(resp.read().decode("utf-8"))
except (urllib.error.HTTPError, urllib.error.URLError, TimeoutError) as e:
last = e
if i < 2:
time.sleep(1)
raise last
def parse_json_env(env_name: str, default):
raw = os.environ.get(env_name)
if not raw:
return default
return json.loads(raw)
def main() -> None:
token = os.environ.get("XG_USER_TOKEN")
name = os.environ.get("MOBAN_NAME")
if not token:
print("错误: 请设置环境变量 XG_USER_TOKEN", file=sys.stderr)
sys.exit(1)
if not name:
print("错误: 请设置环境变量 MOBAN_NAME", file=sys.stderr)
sys.exit(1)
try:
section_list = parse_json_env("MOBAN_SECTION_LIST", None)
if not isinstance(section_list, list) or len(section_list) == 0:
print("错误: MOBAN_SECTION_LIST 必须是非空 JSON 数组", file=sys.stderr)
sys.exit(1)
require_context = parse_json_env("MOBAN_REQUIRE_CONTEXT", None)
except json.JSONDecodeError as exc:
print(f"错误: JSON 环境变量解析失败: {exc}", file=sys.stderr)
sys.exit(1)
payload = {
"name": name,
"sectionList": section_list,
}
if os.environ.get("MOBAN_DESC"):
payload["desc"] = os.environ["MOBAN_DESC"]
if os.environ.get("MOBAN_DIR_ID"):
payload["dirId"] = os.environ["MOBAN_DIR_ID"]
if os.environ.get("MOBAN_TYPE_ID"):
payload["mobanTypeId"] = os.environ["MOBAN_TYPE_ID"]
if os.environ.get("MOBAN_PROMPT"):
payload["prompt"] = os.environ["MOBAN_PROMPT"]
if os.environ.get("MOBAN_EDIT_NOTE"):
payload["editNote"] = os.environ["MOBAN_EDIT_NOTE"]
if os.environ.get("MOBAN_PUBLIC"):
payload["public"] = int(os.environ["MOBAN_PUBLIC"])
if os.environ.get("MOBAN_THIRD_SYSTEM"):
payload["thirdSystem"] = os.environ["MOBAN_THIRD_SYSTEM"]
if os.environ.get("MOBAN_DO_SUMMARY"):
payload["doSummary"] = int(os.environ["MOBAN_DO_SUMMARY"])
if os.environ.get("MOBAN_SUMMARY_PROMPT"):
payload["summaryPrompt"] = os.environ["MOBAN_SUMMARY_PROMPT"]
if os.environ.get("MOBAN_AI_TYPE"):
payload["aiType"] = os.environ["MOBAN_AI_TYPE"]
if require_context is not None:
payload["requireContext"] = require_context
print(json.dumps(call_api(token, payload), ensure_ascii=False))
if __name__ == "__main__":
main()
FILE:scripts/moban/delete-moban.py
#!/usr/bin/env python3
"""
moban / delete-moban 脚本
用途:删除指定模版
使用方式:
python3 scripts/moban/delete-moban.py
环境变量:
XG_USER_TOKEN - access-token(必须)
MOBAN_ID - 模版 ID(必须)
"""
import json
import os
import sys
import time
import urllib.error
import urllib.request
API_URL = "https://cwork-api.mediportal.com.cn/ai-report/moban/delMoban"
AUTH_MODE = "access-token"
def call_api(token: str, payload: dict) -> dict:
req = urllib.request.Request(
API_URL,
data=json.dumps(payload).encode("utf-8"),
headers={"access-token": token, "Content-Type": "application/json"},
method="POST",
)
last = None
for i in range(3):
try:
with urllib.request.urlopen(req, timeout=60) as resp:
return json.loads(resp.read().decode("utf-8"))
except (urllib.error.HTTPError, urllib.error.URLError, TimeoutError) as e:
last = e
if i < 2:
time.sleep(1)
raise last
def main() -> None:
token = os.environ.get("XG_USER_TOKEN")
moban_id = os.environ.get("MOBAN_ID")
if not token:
print("错误: 请设置环境变量 XG_USER_TOKEN", file=sys.stderr)
sys.exit(1)
if not moban_id:
print("错误: 请设置环境变量 MOBAN_ID", file=sys.stderr)
sys.exit(1)
payload = {"mobanId": moban_id}
print(json.dumps(call_api(token, payload), ensure_ascii=False))
if __name__ == "__main__":
main()
玄关(工作协同)开放平台 API 使用指南。当开发人员询问玄关开放平台提供了哪些 API、如何调用、接口参数说明、代码示例时使用此 Skill。涵盖用户服务、文件服务、工作汇报服务、BP目标管理服务、知识库服务等模块。
---
name: my-skil-song-s
description: 玄关(工作协同)开放平台 API 使用指南。当开发人员询问玄关开放平台提供了哪些 API、如何调用、接口参数说明、代码示例时使用此 Skill。涵盖用户服务、文件服务、工作汇报服务、BP目标管理服务、知识库服务等模块。
---
# 玄关开放平台 API Skill
## 概述
本 Skill 提供玄关(工作协同)开放平台 OpenAPI 的完整使用指南,帮助开发人员快速接入和使用平台提供的各种服务。
## 服务模块
平台提供以下服务模块:
| 模块 | 路径前缀 | 功能说明 |
|------|----------|----------|
| **用户服务** | `/cwork-user` | 员工信息查询、搜索 |
| **文件服务** | `/cwork-file` | 文件上传、下载 |
| **工作汇报服务** | `/work-report` | 汇报发送、回复、待办查询 |
| **BP目标管理服务** | `/bp` | 目标周期、分组、任务管理 |
| **知识库服务** | `/document-database` | 文档库文件管理 |
## 快速开始
### 1. 基础信息
- **基础路径**: `/open-api`
- **认证方式**: Header 中携带 `appKey`,具体值可以从环境变量 `XG_BIZ_API_KEY` 获取
- **请求域名**: `https://cwork-api.mediportal.com.cn`
### 2. 通用请求头
```python
headers = {
"Content-Type": "application/json",
"appKey": "your_app_key_here", # 从环境变量 `XG_BIZ_API_KEY` 获取
"Accept": "application/json"
}
```
### 3. 统一返回结构
```json
{
"resultCode": 1,
"resultMsg": "success",
"data": {}
}
```
- `resultCode`: 1=成功,其他=失败
- `resultMsg`: 失败原因
- `data`: 业务数据
## 核心功能速查
### 用户相关
| 功能 | 接口 | 方法 |
|------|------|------|
| 搜索员工 | `/cwork-user/searchEmpByName` | GET |
| 批量获取员工 | `/cwork-user/employee/getByPersonIds/{corpId}` | POST |
### 文件相关
| 功能 | 接口 | 方法 |
|------|------|------|
| 上传文件 | `/cwork-file/uploadWholeFile` | POST (multipart) |
| 获取下载信息 | `/cwork-file/getDownloadInfo` | GET |
### 工作汇报相关
| 功能 | 接口 | 方法 |
|------|------|------|
| 发送汇报 | `/work-report/report/record/submit` | POST |
| 回复汇报 | `/work-report/report/record/reply` | POST |
| 获取待办 | `/work-report/todoTask/todoList` | POST |
| 获取汇报内容 | `/work-report/report/info` | GET |
| 查询工作任务 | `/work-report/report/plan/searchPage` | POST |
### BP目标管理相关
| 功能 | 接口 | 方法 |
|------|------|------|
| 查询周期列表 | `/bp/period/getAllPeriod` | GET |
| 获取分组树 | `/bp/group/getTree` | GET |
| 查询任务树 | `/bp/task/v2/getSimpleTree` | GET |
| 获取目标详情 | `/bp/task/v2/getGoalAndKeyResult` | GET |
### 知识库相关
| 功能 | 接口 | 方法 |
|------|------|------|
| 获取下级文件 | `/document-database/file/getChildFiles` | GET |
| 获取文件内容 | `/document-database/file/getFullFileContent` | GET |
## 详细参考文档
- **完整 API 文档**: 参见 [references/api-reference.md](references/api-reference.md)
- 所有接口的详细参数说明
- 请求/响应数据结构
- 字段类型和说明
- **代码示例**: 参见 [references/code-examples.md](references/code-examples.md)
- Python 调用示例
- 完整的请求代码
- 错误处理最佳实践
## 使用场景指南
### 场景 1: 查询员工信息
当需要获取员工信息时:
1. **模糊搜索**: 使用 `searchEmpByName` 接口,按姓名模糊搜索
2. **精确查询**: 使用 `getByPersonIds` 接口,传入 personId 列表批量获取
### 场景 2: 文件操作
1. **上传**: 调用 `uploadWholeFile`,返回文件 ID
2. **下载**:
- 先调用 `getDownloadInfo` 获取下载 URL(有效期1小时)
- 使用 URL 下载文件内容
### 场景 3: 发送工作汇报
1. **获取事项列表**: 调用 `listTemplates` 获取可用的事项
2. **构建层级参数**: 定义汇报的流转层级(建议、决策、传阅)
3. **发送汇报**: 调用 `submit` 接口提交
### 场景 4: 处理待办
1. **查询待办**: 调用 `todoList` 获取待处理列表
2. **获取详情**: 调用 `getReportInfo` 查看汇报内容
3. **回复处理**: 调用 `reply` 进行回复或审批
### 场景 5: BP目标管理
1. **获取周期**: 调用 `getAllPeriod` 获取可用的目标周期
2. **获取分组**: 调用 `getGroupTree` 获取周期下的分组树
3. **查询任务**: 调用 `getSimpleTree` 获取分组下的任务树
4. **查看详情**: 调用 `getGoalAndKeyResult` 查看目标详情
## 注意事项
1. **认证**: 所有请求必须在 Header 中携带有效的 `appKey`
2. **文件上传**: 使用 `multipart/form-data`,不要设置 `Content-Type` 头
3. **下载链接**: 通过 `getDownloadInfo` 获取的 URL 有效期为1小时
4. **分页**: 分页接口的 `pageNum` 从 1 开始
5. **ID 类型**: 大多数 ID 字段为 Long 类型(64位整数)
## 常见问题
**Q: 如何获取 appKey?**
A: 从环境变量 `XG_BIZ_API_KEY` 获取。
**Q: 文件上传大小有限制吗?**
A: 请参考工作协同系统的具体配置,通常有单文件大小限制。
**Q: 汇报的 levelParams 如何构建?**
A: level 从 1 开始递增,type 可选值:read(传阅)、suggest(建议)、decide(决策)。每个层级需要指定 nodeCode、nodeName 和 levelUserList。
FILE:api-reference.md
# 玄关开放平台 API 参考文档
## 1. 基础信息
### 1.1 基础路径
```
/open-api
```
### 1.2 认证方式
所有请求必须在 Header 中携带:
| 参数名 | 说明 |
|--------|------|
| `appKey` | 具体值可以从环境变量 `XG_BIZ_API_KEY` 获取 |
### 1.3 域名
`https://cwork-api.mediportal.com.cn`
---
## 2. 通用数据结构
### 2.1 统一返回结构
所有接口统一返回如下结构:
```json
{
"resultCode": 1,
"resultMsg": "success",
"data": {}
}
```
| 字段名 | 类型 | 说明 |
|--------|------|------|
| `resultCode` | Integer | 响应码:1=成功,其他=失败 |
| `resultMsg` | String | 失败原因 |
| `data` | Object | 真实业务数据 |
### 2.2 分页数据结构
如果接口返回分页数据,则 data 结构如下:
```json
{
"total": 100,
"list": [],
"pageNum": 1,
"pageSize": 10
}
```
| 字段名 | 类型 | 说明 |
|--------|------|------|
| `total` | Long | 总记录数 |
| `list` | T[] | 数据列表 |
| `pageNum` | Integer | 当前页码 |
| `pageSize` | Integer | 每页大小 |
---
## 3. API 接口列表
### 3.1 用户服务 (cwork-user)
#### 3.1.1 按姓名搜索员工(含外部联系人)
- **接口路径**: `GET /cwork-user/searchEmpByName`
- **完整地址**: `https://cwork-api.mediportal.com.cn/open-api/cwork-user/searchEmpByName`
- **请求参数**:
| 参数名 | 类型 | 必填 | 说明 |
|--------|------|------|------|
| `searchKey` | String | 是 | 搜索关键词:支持按姓名模糊搜索 |
#### 3.1.2 根据 personId+corpId 批量获取员工信息
- **接口路径**: `POST /cwork-user/employee/getByPersonIds/{corpId}`
- **请求参数**:
| 参数名 | 类型 | 必填 | 说明 |
|--------|------|------|------|
| `corpId` | Long | 是 | 企业ID(Path参数) |
| Body | Long[] | 是 | personId 列表 |
**响应数据结构 - EmployeeVO**:
| 字段名 | 类型 | 说明 |
|--------|------|------|
| `id` | Long | 员工id |
| `name` | String | 姓名 |
| `personId` | Long | 用户personId(与企业无关) |
| `title` | String | 职位 |
| `dingUserId` | String | 钉钉userId |
---
### 3.2 文件服务 (cwork-file)
#### 3.2.1 上传本地文件
- **接口路径**: `POST /cwork-file/uploadWholeFile`
- **Content-Type**: `multipart/form-data`
- **请求参数**:
| 参数名 | 类型 | 必填 | 说明 |
|--------|------|------|------|
| `file` | File | 是 | 文件 |
- **响应**: 返回文件id (Long)
#### 3.2.2 获取文件下载信息
- **接口路径**: `GET /cwork-file/getDownloadInfo`
- **请求参数**:
| 参数名 | 类型 | 必填 | 说明 |
|--------|------|------|------|
| `resourceId` | Long | 是 | 资源ID |
**响应数据结构 - DownloadFileVO**:
| 字段名 | 类型 | 说明 |
|--------|------|------|
| `downloadUrl` | String | 下载url(有效期1小时) |
| `fileName` | String | 文件名 |
| `resourceId` | Long | 资源id |
| `size` | Long | 文件大小(字节) |
| `suffix` | String | 文件后缀 |
---
### 3.3 知识库服务 (document-database)
#### 3.3.1 根据父id获取下级文件
- **接口路径**: `GET /document-database/file/getChildFiles`
- **请求参数**:
| 参数名 | 类型 | 必填 | 说明 |
|--------|------|------|------|
| `parentId` | Long | 是 | 父文件夹id |
| `type` | Integer | 否 | 类型:空为所有,1为文件夹,2为文件 |
| `order` | Integer | 否 | 排序:1倒序更新时间,2顺序更新时间,3倒序创建时间,4顺序创建时间,5倒序名字,6顺序名字,7倒序文件类型,8顺序文件类型 |
#### 3.3.2 根据文件id获取内容
- **接口路径**: `GET /document-database/file/getFullFileContent`
- **请求参数**:
| 参数名 | 类型 | 必填 | 说明 |
|--------|------|------|------|
| `fileId` | Long | 是 | 文件id |
#### 3.3.3 根据文件id和页码获取内容
- **接口路径**: `GET /document-database/file/getFileContent`
- **请求参数**:
| 参数名 | 类型 | 必填 | 说明 |
|--------|------|------|------|
| `fileId` | Long | 是 | 文件id |
| `pageNumber` | Integer | 否 | 页面,从第一页开始 |
**响应数据结构 - FileVO**:
| 字段名 | 类型 | 说明 |
|--------|------|------|
| `id` | Long | 主键id |
| `name` | String | 文件名 |
| `type` | Integer | 资源类型(1:文件夹 2:文件) |
| `parentId` | Long | 父id |
| `resourceId` | Long | 资源id |
| `size` | Long | 文件大小(字节) |
| `suffix` | String | 文件后缀 |
| `mimeType` | String | 文件mime类型 |
| `creator` | String | 创建人 |
| `createTime` | Long | 创建时间 |
| `permissions` | String[] | 权限:read阅读,download下载,delete删除,upload更新,create创建下级目录,admin管理员权限 |
---
### 3.4 工作汇报服务 (work-report)
#### 3.4.1 发送汇报
- **接口路径**: `POST /work-report/report/record/submit`
- **请求体**: 开放平台-提交汇报参数
#### 3.4.2 汇报回复
- **接口路径**: `POST /work-report/report/record/reply`
- **请求体**: ReportReplyInnerParam
| 字段名 | 类型 | 必填 | 说明 |
|--------|------|------|------|
| `reportRecordId` | String | 是 | 工作汇报id |
| `contentHtml` | String | 否 | 回复内容 |
| `isMedia` | Integer | 否 | 是否带附件:0-没有(默认)、1-有 |
| `mediaVOList` | ReportFileVO[] | 否 | 附件集合 |
| `addEmpIdList` | String[] | 否 | 被@的员工id集合 |
| `sendMsg` | Boolean | 否 | 是否发送通知到填写汇报人 |
#### 3.4.3 收件箱分页查询
- **接口路径**: `POST /work-report/report/record/inbox`
- **请求体**: 搜索汇报列表搜索条件
#### 3.4.4 待处理列表分页查询
- **接口路径**: `POST /work-report/todoTask/todoList`
- **请求体**: TodoTaskListParam
#### 3.4.5 获取汇报内容
- **接口路径**: `GET /work-report/report/info`
- **请求参数**:
| 参数名 | 类型 | 必填 | 说明 |
|--------|------|------|------|
| `reportId` | Long | 是 | 汇报id |
#### 3.4.6 获取事项列表
- **接口路径**: `POST /work-report/template/listTemplates`
- **请求体**: 最近处理过的事项列表参数
#### 3.4.7 根据事项ID列表获取事项信息
- **接口路径**: `POST /work-report/template/listByIds`
- **请求体**: Long[] (事项ID列表)
#### 3.4.8 插件-获取待办及未读汇报列表
- **接口路径**: `POST /work-report/plugin/report/list`
- **请求体**: 插件汇报查询参数
#### 3.4.9 插件-获取最新待办列表
- **接口路径**: `POST /work-report/plugin/report/latestList`
- **请求体**: 插件汇报查询参数
#### 3.4.10 插件-获取未读汇报列表
- **接口路径**: `POST /work-report/plugin/report/unreadList`
- **请求体**: 插件汇报查询参数
#### 3.4.11 工作任务列表查询
- **接口路径**: `POST /work-report/report/plan/searchPage`
- **请求体**: ReportPlanSearchPageParam
| 字段名 | 类型 | 说明 |
|--------|------|------|
| `pageIndex` | Integer | 从1开始、页数 |
| `pageSize` | Integer | 每页显示个数(默认30) |
| `keyWord` | String | 任务名称关键字 |
| `empIdList` | Long[] | 筛选框-人员ID列表 |
| `status` | Integer | 任务状态: 0-关闭、1-进行中 |
| `reportStatus` | Integer | 汇报状态: 0-关闭、1-待汇报、2-已汇报、3-逾期 |
| `isRead` | Integer | 任务读取状态:0-未读、1-已读 |
| `grades` | String[] | 优先级列表 |
| `labelList` | String[] | 标签名称列表 |
#### 3.4.12 获取用户创建的反馈类型待办列表
- **接口路径**: `GET /work-report/todoTask/listCreatedFeedbacks`
- **请求参数**:
| 参数名 | 类型 | 必填 | 说明 |
|--------|------|------|------|
| `empId` | Long | 否 | 反馈创建人ID,不传查询登陆用户 |
---
### 3.5 BP目标管理服务 (bp)
#### 3.5.1 查询周期列表
- **接口路径**: `GET /bp/period/getAllPeriod`
- **请求参数**:
| 参数名 | 类型 | 必填 | 说明 |
|--------|------|------|------|
| `name` | String | 否 | 周期名称(模糊搜索) |
#### 3.5.2 获取分组树
- **接口路径**: `GET /bp/group/getTree`
- **请求参数**:
| 参数名 | 类型 | 必填 | 说明 |
|--------|------|------|------|
| `periodId` | Long | 是 | 周期id |
#### 3.5.3 查询任务树
- **接口路径**: `GET /bp/task/v2/getSimpleTree`
- **请求参数**:
| 参数名 | 类型 | 必填 | 说明 |
|--------|------|------|------|
| `groupId` | Long | 是 | 分组id |
#### 3.5.4 分页查询所有汇报
- **接口路径**: `POST /bp/task/relation/pageAllReports`
- **请求体**: 分页查询任务关联详情请求参数
#### 3.5.5 获取目标及下所有数据
- **接口路径**: `GET /bp/task/v2/getGoalAndKeyResult`
- **请求参数**:
| 参数名 | 类型 | 必填 | 说明 |
|--------|------|------|------|
| `id` | Long | 是 | 目标id |
#### 3.5.6 获取关键成果及下所有的数据
- **接口路径**: `GET /bp/task/v2/getKeyResult`
- **请求参数**:
| 参数名 | 类型 | 必填 | 说明 |
|--------|------|------|------|
| `id` | Long | 是 | 关键成果id |
#### 3.5.7 获取关键举措详情
- **接口路径**: `GET /bp/task/v2/getAction`
- **请求参数**:
| 参数名 | 类型 | 必填 | 说明 |
|--------|------|------|------|
| `id` | Long | 是 | 关键举措id |
---
## 4. 常用数据结构
### 4.1 ReportFileVO(附件信息)
| 字段名 | 类型 | 说明 |
|--------|------|------|
| `fileId` | String | 文件id |
| `name` | String | 文件名称 (链接描述) |
| `type` | String | 文件类型:file=附件、url=超链、audio=音频、document=文档、document-database=知识库 |
| `fsize` | Integer | 文件大小 |
### 4.2 ReportLevelParam(汇报层级)
| 字段名 | 类型 | 说明 |
|--------|------|------|
| `level` | Integer | 层级: 1-20 |
| `type` | String | 类型: read-传阅、suggest-建议、decide-决策 |
| `nodeCode` | String | 节点编码,startNode表示发起节点 |
| `nodeName` | String | 节点名称 |
| `levelUserList` | ReportLevelUserParam[] | 当前层级用户列表 |
### 4.3 ReportLevelUserParam(层级用户)
| 字段名 | 类型 | 说明 |
|--------|------|------|
| `empId` | Long | 员工id |
| `requirement` | String | ai要求 |
### 4.4 PeriodVO(周期)
| 字段名 | 类型 | 说明 |
|--------|------|------|
| `id` | Long | 周期id |
| `name` | String | 周期名称 |
| `year` | Integer | 年份 |
| `type` | Integer | 周期类型(1:年度,2:季度等) |
| `startDate` | String | 开始日期 |
| `endDate` | String | 截止日期 |
| `status` | Integer | 周期状态 1=启用 0=未启用 |
### 4.5 ReportPlanSearchPageVO(工作任务)
| 字段名 | 类型 | 说明 |
|--------|------|------|
| `id` | Long | 任务id |
| `main` | String | 任务名称 |
| `needful` | String | 任务描述 |
| `target` | String | 任务目标 |
| `status` | Integer | 任务状态: 0-关闭、1-进行中、2-未启动 |
| `reportStatus` | Integer | 汇报状态: 0-关闭、1-待汇报、2-已汇报、3-逾期 |
| `isRead` | Integer | 任务读取状态:0-未读、1-已读 |
| `reporterList` | EmployeeSimpleVO[] | 汇报人信息 |
| `ruleType` | String | 提醒规则类型: once、day、week、month、n_week |
| `ruleValue` | Integer | 提醒规则下的间隔 |
| `requiredIndex` | String | 提醒日 |
| `requiredValue` | String | 提醒时间: 格式 HH:mm:ss |
| `budget` | BigDecimal | 任务预算 |
| `duration` | Integer | 执行时长,单位天 |
### 4.6 TodoTaskDetailVO(待办详情)
| 字段名 | 类型 | 说明 |
|--------|------|------|
| `id` | Long | 待办id |
| `reportRecordId` | Long | 汇报记录id |
| `main` | String | 标题 |
| `content` | String | 内容 |
| `writeEmpName` | String | 创建人 |
| `createTime` | Timestamp | 创建时间 |
| `reportRecordType` | Integer | 汇报类型: 1-工作交流、2-工作指引、3-文件签批、4-AI汇报、5-工作汇报 |
| `levelType` | String | 节点类型:suggest-建议节点、decide-决策节点 |
### 4.7 PluginItemDetailVO(插件汇报项)
| 字段名 | 类型 | 说明 |
|--------|------|------|
| `id` | Long | 汇报id |
| `main` | String | 标题 |
| `content` | String | 内容 |
| `employee` | String | 人员名称 |
| `writeEmpName` | String | 创建人 |
| `createTime` | Timestamp | 创建时间 |
| `reportRecordType` | Integer | 汇报类型 |
| `levelType` | String | 节点类型 |
| `todoId` | Long | 待办id |
FILE:api-reference.md
# 玄关开放平台 API 参考文档
## 1. 基础信息
### 1.1 基础路径
```
/open-api
```
### 1.2 认证方式
所有请求必须在 Header 中携带:
| 参数名 | 说明 |
|--------|------|
| `appKey` | 具体值可以从环境变量 `XG_BIZ_API_KEY` 获取 |
### 1.3 域名
`https://cwork-api.mediportal.com.cn`
---
## 2. 通用数据结构
### 2.1 统一返回结构
所有接口统一返回如下结构:
```json
{
"resultCode": 1,
"resultMsg": "success",
"data": {}
}
```
| 字段名 | 类型 | 说明 |
|--------|------|------|
| `resultCode` | Integer | 响应码:1=成功,其他=失败 |
| `resultMsg` | String | 失败原因 |
| `data` | Object | 真实业务数据 |
### 2.2 分页数据结构
如果接口返回分页数据,则 data 结构如下:
```json
{
"total": 100,
"list": [],
"pageNum": 1,
"pageSize": 10
}
```
| 字段名 | 类型 | 说明 |
|--------|------|------|
| `total` | Long | 总记录数 |
| `list` | T[] | 数据列表 |
| `pageNum` | Integer | 当前页码 |
| `pageSize` | Integer | 每页大小 |
---
## 3. API 接口列表
### 3.1 用户服务 (cwork-user)
#### 3.1.1 按姓名搜索员工(含外部联系人)
- **接口路径**: `GET /cwork-user/searchEmpByName`
- **完整地址**: `https://cwork-api.mediportal.com.cn/open-api/cwork-user/searchEmpByName`
- **请求参数**:
| 参数名 | 类型 | 必填 | 说明 |
|--------|------|------|------|
| `searchKey` | String | 是 | 搜索关键词:支持按姓名模糊搜索 |
#### 3.1.2 根据 personId+corpId 批量获取员工信息
- **接口路径**: `POST /cwork-user/employee/getByPersonIds/{corpId}`
- **请求参数**:
| 参数名 | 类型 | 必填 | 说明 |
|--------|------|------|------|
| `corpId` | Long | 是 | 企业ID(Path参数) |
| Body | Long[] | 是 | personId 列表 |
**响应数据结构 - EmployeeVO**:
| 字段名 | 类型 | 说明 |
|--------|------|------|
| `id` | Long | 员工id |
| `name` | String | 姓名 |
| `personId` | Long | 用户personId(与企业无关) |
| `title` | String | 职位 |
| `dingUserId` | String | 钉钉userId |
---
### 3.2 文件服务 (cwork-file)
#### 3.2.1 上传本地文件
- **接口路径**: `POST /cwork-file/uploadWholeFile`
- **Content-Type**: `multipart/form-data`
- **请求参数**:
| 参数名 | 类型 | 必填 | 说明 |
|--------|------|------|------|
| `file` | File | 是 | 文件 |
- **响应**: 返回文件id (Long)
#### 3.2.2 获取文件下载信息
- **接口路径**: `GET /cwork-file/getDownloadInfo`
- **请求参数**:
| 参数名 | 类型 | 必填 | 说明 |
|--------|------|------|------|
| `resourceId` | Long | 是 | 资源ID |
**响应数据结构 - DownloadFileVO**:
| 字段名 | 类型 | 说明 |
|--------|------|------|
| `downloadUrl` | String | 下载url(有效期1小时) |
| `fileName` | String | 文件名 |
| `resourceId` | Long | 资源id |
| `size` | Long | 文件大小(字节) |
| `suffix` | String | 文件后缀 |
---
### 3.3 知识库服务 (document-database)
#### 3.3.1 根据父id获取下级文件
- **接口路径**: `GET /document-database/file/getChildFiles`
- **请求参数**:
| 参数名 | 类型 | 必填 | 说明 |
|--------|------|------|------|
| `parentId` | Long | 是 | 父文件夹id |
| `type` | Integer | 否 | 类型:空为所有,1为文件夹,2为文件 |
| `order` | Integer | 否 | 排序:1倒序更新时间,2顺序更新时间,3倒序创建时间,4顺序创建时间,5倒序名字,6顺序名字,7倒序文件类型,8顺序文件类型 |
#### 3.3.2 根据文件id获取内容
- **接口路径**: `GET /document-database/file/getFullFileContent`
- **请求参数**:
| 参数名 | 类型 | 必填 | 说明 |
|--------|------|------|------|
| `fileId` | Long | 是 | 文件id |
#### 3.3.3 根据文件id和页码获取内容
- **接口路径**: `GET /document-database/file/getFileContent`
- **请求参数**:
| 参数名 | 类型 | 必填 | 说明 |
|--------|------|------|------|
| `fileId` | Long | 是 | 文件id |
| `pageNumber` | Integer | 否 | 页面,从第一页开始 |
**响应数据结构 - FileVO**:
| 字段名 | 类型 | 说明 |
|--------|------|------|
| `id` | Long | 主键id |
| `name` | String | 文件名 |
| `type` | Integer | 资源类型(1:文件夹 2:文件) |
| `parentId` | Long | 父id |
| `resourceId` | Long | 资源id |
| `size` | Long | 文件大小(字节) |
| `suffix` | String | 文件后缀 |
| `mimeType` | String | 文件mime类型 |
| `creator` | String | 创建人 |
| `createTime` | Long | 创建时间 |
| `permissions` | String[] | 权限:read阅读,download下载,delete删除,upload更新,create创建下级目录,admin管理员权限 |
---
### 3.4 工作汇报服务 (work-report)
#### 3.4.1 发送汇报
- **接口路径**: `POST /work-report/report/record/submit`
- **请求体**: 开放平台-提交汇报参数
#### 3.4.2 汇报回复
- **接口路径**: `POST /work-report/report/record/reply`
- **请求体**: ReportReplyInnerParam
| 字段名 | 类型 | 必填 | 说明 |
|--------|------|------|------|
| `reportRecordId` | String | 是 | 工作汇报id |
| `contentHtml` | String | 否 | 回复内容 |
| `isMedia` | Integer | 否 | 是否带附件:0-没有(默认)、1-有 |
| `mediaVOList` | ReportFileVO[] | 否 | 附件集合 |
| `addEmpIdList` | String[] | 否 | 被@的员工id集合 |
| `sendMsg` | Boolean | 否 | 是否发送通知到填写汇报人 |
#### 3.4.3 收件箱分页查询
- **接口路径**: `POST /work-report/report/record/inbox`
- **请求体**: 搜索汇报列表搜索条件
#### 3.4.4 待处理列表分页查询
- **接口路径**: `POST /work-report/todoTask/todoList`
- **请求体**: TodoTaskListParam
#### 3.4.5 获取汇报内容
- **接口路径**: `GET /work-report/report/info`
- **请求参数**:
| 参数名 | 类型 | 必填 | 说明 |
|--------|------|------|------|
| `reportId` | Long | 是 | 汇报id |
#### 3.4.6 获取事项列表
- **接口路径**: `POST /work-report/template/listTemplates`
- **请求体**: 最近处理过的事项列表参数
#### 3.4.7 根据事项ID列表获取事项信息
- **接口路径**: `POST /work-report/template/listByIds`
- **请求体**: Long[] (事项ID列表)
#### 3.4.8 插件-获取待办及未读汇报列表
- **接口路径**: `POST /work-report/plugin/report/list`
- **请求体**: 插件汇报查询参数
#### 3.4.9 插件-获取最新待办列表
- **接口路径**: `POST /work-report/plugin/report/latestList`
- **请求体**: 插件汇报查询参数
#### 3.4.10 插件-获取未读汇报列表
- **接口路径**: `POST /work-report/plugin/report/unreadList`
- **请求体**: 插件汇报查询参数
#### 3.4.11 工作任务列表查询
- **接口路径**: `POST /work-report/report/plan/searchPage`
- **请求体**: ReportPlanSearchPageParam
| 字段名 | 类型 | 说明 |
|--------|------|------|
| `pageIndex` | Integer | 从1开始、页数 |
| `pageSize` | Integer | 每页显示个数(默认30) |
| `keyWord` | String | 任务名称关键字 |
| `empIdList` | Long[] | 筛选框-人员ID列表 |
| `status` | Integer | 任务状态: 0-关闭、1-进行中 |
| `reportStatus` | Integer | 汇报状态: 0-关闭、1-待汇报、2-已汇报、3-逾期 |
| `isRead` | Integer | 任务读取状态:0-未读、1-已读 |
| `grades` | String[] | 优先级列表 |
| `labelList` | String[] | 标签名称列表 |
#### 3.4.12 获取用户创建的反馈类型待办列表
- **接口路径**: `GET /work-report/todoTask/listCreatedFeedbacks`
- **请求参数**:
| 参数名 | 类型 | 必填 | 说明 |
|--------|------|------|------|
| `empId` | Long | 否 | 反馈创建人ID,不传查询登陆用户 |
---
### 3.5 BP目标管理服务 (bp)
#### 3.5.1 查询周期列表
- **接口路径**: `GET /bp/period/getAllPeriod`
- **请求参数**:
| 参数名 | 类型 | 必填 | 说明 |
|--------|------|------|------|
| `name` | String | 否 | 周期名称(模糊搜索) |
#### 3.5.2 获取分组树
- **接口路径**: `GET /bp/group/getTree`
- **请求参数**:
| 参数名 | 类型 | 必填 | 说明 |
|--------|------|------|------|
| `periodId` | Long | 是 | 周期id |
#### 3.5.3 查询任务树
- **接口路径**: `GET /bp/task/v2/getSimpleTree`
- **请求参数**:
| 参数名 | 类型 | 必填 | 说明 |
|--------|------|------|------|
| `groupId` | Long | 是 | 分组id |
#### 3.5.4 分页查询所有汇报
- **接口路径**: `POST /bp/task/relation/pageAllReports`
- **请求体**: 分页查询任务关联详情请求参数
#### 3.5.5 获取目标及下所有数据
- **接口路径**: `GET /bp/task/v2/getGoalAndKeyResult`
- **请求参数**:
| 参数名 | 类型 | 必填 | 说明 |
|--------|------|------|------|
| `id` | Long | 是 | 目标id |
#### 3.5.6 获取关键成果及下所有的数据
- **接口路径**: `GET /bp/task/v2/getKeyResult`
- **请求参数**:
| 参数名 | 类型 | 必填 | 说明 |
|--------|------|------|------|
| `id` | Long | 是 | 关键成果id |
#### 3.5.7 获取关键举措详情
- **接口路径**: `GET /bp/task/v2/getAction`
- **请求参数**:
| 参数名 | 类型 | 必填 | 说明 |
|--------|------|------|------|
| `id` | Long | 是 | 关键举措id |
---
## 4. 常用数据结构
### 4.1 ReportFileVO(附件信息)
| 字段名 | 类型 | 说明 |
|--------|------|------|
| `fileId` | String | 文件id |
| `name` | String | 文件名称 (链接描述) |
| `type` | String | 文件类型:file=附件、url=超链、audio=音频、document=文档、document-database=知识库 |
| `fsize` | Integer | 文件大小 |
### 4.2 ReportLevelParam(汇报层级)
| 字段名 | 类型 | 说明 |
|--------|------|------|
| `level` | Integer | 层级: 1-20 |
| `type` | String | 类型: read-传阅、suggest-建议、decide-决策 |
| `nodeCode` | String | 节点编码,startNode表示发起节点 |
| `nodeName` | String | 节点名称 |
| `levelUserList` | ReportLevelUserParam[] | 当前层级用户列表 |
### 4.3 ReportLevelUserParam(层级用户)
| 字段名 | 类型 | 说明 |
|--------|------|------|
| `empId` | Long | 员工id |
| `requirement` | String | ai要求 |
### 4.4 PeriodVO(周期)
| 字段名 | 类型 | 说明 |
|--------|------|------|
| `id` | Long | 周期id |
| `name` | String | 周期名称 |
| `year` | Integer | 年份 |
| `type` | Integer | 周期类型(1:年度,2:季度等) |
| `startDate` | String | 开始日期 |
| `endDate` | String | 截止日期 |
| `status` | Integer | 周期状态 1=启用 0=未启用 |
### 4.5 ReportPlanSearchPageVO(工作任务)
| 字段名 | 类型 | 说明 |
|--------|------|------|
| `id` | Long | 任务id |
| `main` | String | 任务名称 |
| `needful` | String | 任务描述 |
| `target` | String | 任务目标 |
| `status` | Integer | 任务状态: 0-关闭、1-进行中、2-未启动 |
| `reportStatus` | Integer | 汇报状态: 0-关闭、1-待汇报、2-已汇报、3-逾期 |
| `isRead` | Integer | 任务读取状态:0-未读、1-已读 |
| `reporterList` | EmployeeSimpleVO[] | 汇报人信息 |
| `ruleType` | String | 提醒规则类型: once、day、week、month、n_week |
| `ruleValue` | Integer | 提醒规则下的间隔 |
| `requiredIndex` | String | 提醒日 |
| `requiredValue` | String | 提醒时间: 格式 HH:mm:ss |
| `budget` | BigDecimal | 任务预算 |
| `duration` | Integer | 执行时长,单位天 |
### 4.6 TodoTaskDetailVO(待办详情)
| 字段名 | 类型 | 说明 |
|--------|------|------|
| `id` | Long | 待办id |
| `reportRecordId` | Long | 汇报记录id |
| `main` | String | 标题 |
| `content` | String | 内容 |
| `writeEmpName` | String | 创建人 |
| `createTime` | Timestamp | 创建时间 |
| `reportRecordType` | Integer | 汇报类型: 1-工作交流、2-工作指引、3-文件签批、4-AI汇报、5-工作汇报 |
| `levelType` | String | 节点类型:suggest-建议节点、decide-决策节点 |
### 4.7 PluginItemDetailVO(插件汇报项)
| 字段名 | 类型 | 说明 |
|--------|------|------|
| `id` | Long | 汇报id |
| `main` | String | 标题 |
| `content` | String | 内容 |
| `employee` | String | 人员名称 |
| `writeEmpName` | String | 创建人 |
| `createTime` | Timestamp | 创建时间 |
| `reportRecordType` | Integer | 汇报类型 |
| `levelType` | String | 节点类型 |
| `todoId` | Long | 待办id |
FILE:code-examples.md
# 玄关开放平台 API 调用示例
## Python 调用示例
### 基础配置
```python
import requests
import json
# =============================
# 1. 环境配置
# =============================
# 测试环境
BASE_URL = "https://cwork-api.mediportal.com.cn/open-api"
# 环境变量 `XG_BIZ_API_KEY`
APP_KEY = "XG_BIZ_API_KEY"
# 通用请求头
headers = {
"Content-Type": "application/json",
"appKey": APP_KEY,
"Accept": "application/json"
}
```
---
### 示例 1: 按姓名搜索员工
```python
def search_employee_by_name(search_key):
"""
按姓名搜索员工(含外部联系人)
Args:
search_key: 搜索关键词,支持按姓名模糊搜索
Returns:
员工列表
"""
url = f"{BASE_URL}/cwork-user/searchEmpByName"
params = {
"searchKey": search_key
}
try:
response = requests.get(url, headers=headers, params=params)
if response.status_code == 200:
result = response.json()
if result.get("resultCode") == 1:
return result.get("data", {})
else:
print(f"接口返回错误: {result.get('resultMsg')}")
return None
else:
print(f"HTTP错误: {response.status_code}")
print(response.text)
return None
except Exception as e:
print(f"请求异常: {str(e)}")
return None
# 使用示例
if __name__ == "__main__":
result = search_employee_by_name("张三")
if result:
print("搜索结果:")
print(json.dumps(result, indent=4, ensure_ascii=False))
```
---
### 示例 2: 批量获取员工信息
```python
def get_employees_by_person_ids(corp_id, person_ids):
"""
根据 personId+corpId 批量获取员工信息
Args:
corp_id: 企业ID
person_ids: personId 列表
Returns:
员工信息列表
"""
url = f"{BASE_URL}/cwork-user/employee/getByPersonIds/{corp_id}"
try:
response = requests.post(
url,
headers=headers,
json=person_ids # Body 直接传数组
)
if response.status_code == 200:
result = response.json()
if result.get("resultCode") == 1:
return result.get("data", [])
else:
print(f"接口返回错误: {result.get('resultMsg')}")
return []
else:
print(f"HTTP错误: {response.status_code}")
return []
except Exception as e:
print(f"请求异常: {str(e)}")
return []
# 使用示例
if __name__ == "__main__":
corp_id = 12345
person_ids = [1001, 1002, 1003]
employees = get_employees_by_person_ids(corp_id, person_ids)
print(f"获取到 {len(employees)} 个员工信息")
```
---
### 示例 3: 上传文件
```python
def upload_file(file_path):
"""
上传本地文件
Args:
file_path: 本地文件路径
Returns:
文件ID
"""
url = f"{BASE_URL}/cwork-file/uploadWholeFile"
# 文件上传需要使用 multipart/form-data,不设置 Content-Type
file_headers = {
"appKey": APP_KEY,
"Accept": "application/json"
}
try:
with open(file_path, 'rb') as f:
files = {'file': f}
response = requests.post(
url,
headers=file_headers,
files=files
)
if response.status_code == 200:
result = response.json()
if result.get("resultCode") == 1:
file_id = result.get("data")
print(f"文件上传成功,文件ID: {file_id}")
return file_id
else:
print(f"接口返回错误: {result.get('resultMsg')}")
return None
else:
print(f"HTTP错误: {response.status_code}")
print(response.text)
return None
except Exception as e:
print(f"请求异常: {str(e)}")
return None
# 使用示例
if __name__ == "__main__":
file_id = upload_file("/path/to/your/file.pdf")
```
---
### 示例 4: 获取文件下载信息
```python
def get_file_download_info(resource_id):
"""
获取文件下载信息(下载链接有效期1小时)
Args:
resource_id: 资源ID
Returns:
下载信息,包含下载URL
"""
url = f"{BASE_URL}/cwork-file/getDownloadInfo"
params = {
"resourceId": resource_id
}
try:
response = requests.get(url, headers=headers, params=params)
if response.status_code == 200:
result = response.json()
if result.get("resultCode") == 1:
return result.get("data", {})
else:
print(f"接口返回错误: {result.get('resultMsg')}")
return None
else:
print(f"HTTP错误: {response.status_code}")
return None
except Exception as e:
print(f"请求异常: {str(e)}")
return None
def download_file(resource_id, save_path):
"""
下载文件到本地
Args:
resource_id: 资源ID
save_path: 保存路径
"""
# 1. 获取下载信息
download_info = get_file_download_info(resource_id)
if not download_info:
return False
download_url = download_info.get("downloadUrl")
file_name = download_info.get("fileName")
print(f"文件名: {file_name}")
print(f"文件大小: {download_info.get('size')} 字节")
# 2. 下载文件
try:
response = requests.get(download_url, stream=True)
if response.status_code == 200:
with open(save_path, 'wb') as f:
for chunk in response.iter_content(chunk_size=8192):
f.write(chunk)
print(f"文件已保存到: {save_path}")
return True
else:
print(f"下载失败: {response.status_code}")
return False
except Exception as e:
print(f"下载异常: {str(e)}")
return False
# 使用示例
if __name__ == "__main__":
resource_id = 12345
download_file(resource_id, "/path/to/save/downloaded_file.pdf")
```
---
### 示例 5: 知识库 - 获取文件夹内容
```python
def get_document_children(parent_id, file_type=None, order=None):
"""
根据父id获取下级文件/文件夹
Args:
parent_id: 父文件夹id
file_type: 类型,空为所有,1为文件夹,2为文件
order: 排序方式
Returns:
文件/文件夹列表
"""
url = f"{BASE_URL}/document-database/file/getChildFiles"
params = {
"parentId": parent_id
}
if file_type is not None:
params["type"] = file_type
if order is not None:
params["order"] = order
try:
response = requests.get(url, headers=headers, params=params)
if response.status_code == 200:
result = response.json()
if result.get("resultCode") == 1:
return result.get("data", [])
else:
print(f"接口返回错误: {result.get('resultMsg')}")
return []
else:
print(f"HTTP错误: {response.status_code}")
return []
except Exception as e:
print(f"请求异常: {str(e)}")
return []
# 使用示例
if __name__ == "__main__":
# 获取根目录内容(parent_id=0 或具体的根目录id)
files = get_document_children(0)
for file in files:
file_type = "文件夹" if file.get("type") == 1 else "文件"
print(f"[{file_type}] {file.get('name')}")
```
---
### 示例 6: 发送工作汇报
```python
def submit_report(template_id, main, content, level_params, file_list=None):
"""
发送工作汇报
Args:
template_id: 事项ID
main: 汇报标题
content: 汇报内容
level_params: 汇报层级参数列表
file_list: 附件列表(可选)
Returns:
汇报基本信息
"""
url = f"{BASE_URL}/work-report/report/record/submit"
payload = {
"templateId": template_id,
"main": main,
"contentHtml": content,
"levelParams": level_params
}
if file_list:
payload["fileList"] = file_list
try:
response = requests.post(url, headers=headers, json=payload)
if response.status_code == 200:
result = response.json()
if result.get("resultCode") == 1:
return result.get("data", {})
else:
print(f"接口返回错误: {result.get('resultMsg')}")
return None
else:
print(f"HTTP错误: {response.status_code}")
print(response.text)
return None
except Exception as e:
print(f"请求异常: {str(e)}")
return None
# 使用示例
if __name__ == "__main__":
# 构建汇报层级
level_params = [
{
"level": 1,
"type": "suggest", # suggest-建议、decide-决策、read-传阅
"nodeCode": "startNode",
"nodeName": "发起人",
"levelUserList": [
{"empId": 12345, "requirement": ""}
]
},
{
"level": 2,
"type": "decide",
"nodeCode": "approval",
"nodeName": "审批人",
"levelUserList": [
{"empId": 67890, "requirement": "请审批"}
]
}
]
result = submit_report(
template_id=1001,
main="本周工作汇报",
content="<p>本周完成了...</p>",
level_params=level_params
)
if result:
print(f"汇报发送成功,ID: {result.get('id')}")
```
---
### 示例 7: 回复汇报
```python
def reply_report(report_record_id, content, at_emp_ids=None, attachments=None):
"""
回复工作汇报
Args:
report_record_id: 汇报记录ID
content: 回复内容
at_emp_ids: @的员工ID列表(可选)
attachments: 附件列表(可选)
Returns:
回复ID
"""
url = f"{BASE_URL}/work-report/report/record/reply"
payload = {
"reportRecordId": str(report_record_id),
"contentHtml": content,
"isMedia": 1 if attachments else 0,
"sendMsg": True
}
if at_emp_ids:
payload["addEmpIdList"] = [str(emp_id) for emp_id in at_emp_ids]
if attachments:
payload["mediaVOList"] = attachments
try:
response = requests.post(url, headers=headers, json=payload)
if response.status_code == 200:
result = response.json()
if result.get("resultCode") == 1:
reply_id = result.get("data")
print(f"回复成功,回复ID: {reply_id}")
return reply_id
else:
print(f"接口返回错误: {result.get('resultMsg')}")
return None
else:
print(f"HTTP错误: {response.status_code}")
return None
except Exception as e:
print(f"请求异常: {str(e)}")
return None
# 使用示例
if __name__ == "__main__":
attachments = [
{
"fileId": "12345",
"name": "附件.pdf",
"type": "file",
"fsize": 1024000
}
]
reply_report(
report_record_id=10001,
content="<p>收到,已阅</p>",
at_emp_ids=[12345, 67890],
attachments=attachments
)
```
---
### 示例 8: 查询工作任务列表
```python
def search_work_tasks(keyword=None, status=None, page_index=1, page_size=30):
"""
查询工作任务列表
Args:
keyword: 任务名称关键字
status: 任务状态 0-关闭、1-进行中
page_index: 页码,从1开始
page_size: 每页数量
Returns:
分页任务列表
"""
url = f"{BASE_URL}/work-report/report/plan/searchPage"
payload = {
"pageIndex": page_index,
"pageSize": page_size
}
if keyword:
payload["keyWord"] = keyword
if status is not None:
payload["status"] = status
try:
response = requests.post(url, headers=headers, json=payload)
if response.status_code == 200:
result = response.json()
if result.get("resultCode") == 1:
data = result.get("data", {})
return {
"total": data.get("total"),
"list": data.get("list", []),
"pageNum": data.get("pageNum"),
"pageSize": data.get("pageSize")
}
else:
print(f"接口返回错误: {result.get('resultMsg')}")
return None
else:
print(f"HTTP错误: {response.status_code}")
return None
except Exception as e:
print(f"请求异常: {str(e)}")
return None
# 使用示例
if __name__ == "__main__":
result = search_work_tasks(
keyword="项目",
status=1, # 进行中
page_index=1,
page_size=10
)
if result:
print(f"共 {result['total']} 条记录")
for task in result['list']:
print(f"- {task['main']} (状态: {task['status']})")
```
---
### 示例 9: 查询 BP 周期列表
```python
def get_bp_periods(name=None):
"""
查询 BP 目标管理周期列表
Args:
name: 周期名称(模糊搜索)
Returns:
周期列表
"""
url = f"{BASE_URL}/bp/period/getAllPeriod"
params = {}
if name:
params["name"] = name
try:
response = requests.get(url, headers=headers, params=params)
if response.status_code == 200:
result = response.json()
if result.get("resultCode") == 1:
return result.get("data", [])
else:
print(f"接口返回错误: {result.get('resultMsg')}")
return []
else:
print(f"HTTP错误: {response.status_code}")
return []
except Exception as e:
print(f"请求异常: {str(e)}")
return []
# 使用示例
if __name__ == "__main__":
periods = get_bp_periods("2024")
for period in periods:
print(f"周期: {period['name']} ({period['year']})")
print(f" 时间: {period['startDate']} ~ {period['endDate']}")
print(f" 状态: {'启用' if period['status'] == 1 else '未启用'}")
```
---
### 示例 10: 获取待办列表
```python
def get_todo_list(page_index=1, page_size=30, report_record_type=None):
"""
获取待处理列表
Args:
page_index: 页码
page_size: 每页数量
report_record_type: 汇报类型 1-工作交流、2-工作指引、3-文件签批、4-AI汇报、5-工作汇报
Returns:
分页待办列表
"""
url = f"{BASE_URL}/work-report/todoTask/todoList"
payload = {
"pageIndex": page_index,
"pageSize": page_size
}
if report_record_type:
payload["reportRecordType"] = report_record_type
try:
response = requests.post(url, headers=headers, json=payload)
if response.status_code == 200:
result = response.json()
if result.get("resultCode") == 1:
data = result.get("data", {})
return {
"total": data.get("total"),
"list": data.get("list", []),
"pageNum": data.get("pageNum"),
"pageSize": data.get("pageSize")
}
else:
print(f"接口返回错误: {result.get('resultMsg')}")
return None
else:
print(f"HTTP错误: {response.status_code}")
return None
except Exception as e:
print(f"请求异常: {str(e)}")
return None
# 使用示例
if __name__ == "__main__":
result = get_todo_list(page_index=1, page_size=20)
if result:
print(f"共 {result['total']} 条待办")
for todo in result['list']:
print(f"- [{todo.get('levelType')}] {todo.get('main')}")
print(f" 来自: {todo.get('writeEmpName')}")
```
---
## 错误处理最佳实践
```python
class CworkAPIError(Exception):
"""玄关开放平台 API 错误"""
def __init__(self, message, result_code=None, result_msg=None):
super().__init__(message)
self.result_code = result_code
self.result_msg = result_msg
def api_request(method, url, **kwargs):
"""
统一的 API 请求封装
Args:
method: 请求方法 (get/post/put/delete)
url: 请求地址
**kwargs: 其他参数
Returns:
API 响应数据
Raises:
CworkAPIError: API 调用失败
"""
try:
response = requests.request(method, url, **kwargs)
# 检查 HTTP 状态码
if response.status_code != 200:
raise CworkAPIError(
f"HTTP错误: {response.status_code}",
result_code=response.status_code
)
# 解析响应
result = response.json()
# 检查业务状态码
if result.get("resultCode") != 1:
raise CworkAPIError(
f"业务错误: {result.get('resultMsg')}",
result_code=result.get("resultCode"),
result_msg=result.get("resultMsg")
)
return result.get("data")
except requests.exceptions.RequestException as e:
raise CworkAPIError(f"网络请求异常: {str(e)}")
except json.JSONDecodeError as e:
raise CworkAPIError(f"响应解析失败: {str(e)}")
# 使用示例
try:
data = api_request(
"get",
f"{BASE_URL}/cwork-user/searchEmpByName",
headers=headers,
params={"searchKey": "张三"}
)
print(f"搜索结果: {data}")
except CworkAPIError as e:
print(f"API调用失败: {e}")
print(f"错误码: {e.result_code}")
print(f"错误信息: {e.result_msg}")
```
FILE:code-examples.md
# 玄关开放平台 API 调用示例
## Python 调用示例
### 基础配置
```python
import requests
import json
# =============================
# 1. 环境配置
# =============================
# 测试环境
BASE_URL = "https://cwork-api.mediportal.com.cn/open-api"
# 环境变量 `XG_BIZ_API_KEY`
APP_KEY = "XG_BIZ_API_KEY"
# 通用请求头
headers = {
"Content-Type": "application/json",
"appKey": APP_KEY,
"Accept": "application/json"
}
```
---
### 示例 1: 按姓名搜索员工
```python
def search_employee_by_name(search_key):
"""
按姓名搜索员工(含外部联系人)
Args:
search_key: 搜索关键词,支持按姓名模糊搜索
Returns:
员工列表
"""
url = f"{BASE_URL}/cwork-user/searchEmpByName"
params = {
"searchKey": search_key
}
try:
response = requests.get(url, headers=headers, params=params)
if response.status_code == 200:
result = response.json()
if result.get("resultCode") == 1:
return result.get("data", {})
else:
print(f"接口返回错误: {result.get('resultMsg')}")
return None
else:
print(f"HTTP错误: {response.status_code}")
print(response.text)
return None
except Exception as e:
print(f"请求异常: {str(e)}")
return None
# 使用示例
if __name__ == "__main__":
result = search_employee_by_name("张三")
if result:
print("搜索结果:")
print(json.dumps(result, indent=4, ensure_ascii=False))
```
---
### 示例 2: 批量获取员工信息
```python
def get_employees_by_person_ids(corp_id, person_ids):
"""
根据 personId+corpId 批量获取员工信息
Args:
corp_id: 企业ID
person_ids: personId 列表
Returns:
员工信息列表
"""
url = f"{BASE_URL}/cwork-user/employee/getByPersonIds/{corp_id}"
try:
response = requests.post(
url,
headers=headers,
json=person_ids # Body 直接传数组
)
if response.status_code == 200:
result = response.json()
if result.get("resultCode") == 1:
return result.get("data", [])
else:
print(f"接口返回错误: {result.get('resultMsg')}")
return []
else:
print(f"HTTP错误: {response.status_code}")
return []
except Exception as e:
print(f"请求异常: {str(e)}")
return []
# 使用示例
if __name__ == "__main__":
corp_id = 12345
person_ids = [1001, 1002, 1003]
employees = get_employees_by_person_ids(corp_id, person_ids)
print(f"获取到 {len(employees)} 个员工信息")
```
---
### 示例 3: 上传文件
```python
def upload_file(file_path):
"""
上传本地文件
Args:
file_path: 本地文件路径
Returns:
文件ID
"""
url = f"{BASE_URL}/cwork-file/uploadWholeFile"
# 文件上传需要使用 multipart/form-data,不设置 Content-Type
file_headers = {
"appKey": APP_KEY,
"Accept": "application/json"
}
try:
with open(file_path, 'rb') as f:
files = {'file': f}
response = requests.post(
url,
headers=file_headers,
files=files
)
if response.status_code == 200:
result = response.json()
if result.get("resultCode") == 1:
file_id = result.get("data")
print(f"文件上传成功,文件ID: {file_id}")
return file_id
else:
print(f"接口返回错误: {result.get('resultMsg')}")
return None
else:
print(f"HTTP错误: {response.status_code}")
print(response.text)
return None
except Exception as e:
print(f"请求异常: {str(e)}")
return None
# 使用示例
if __name__ == "__main__":
file_id = upload_file("/path/to/your/file.pdf")
```
---
### 示例 4: 获取文件下载信息
```python
def get_file_download_info(resource_id):
"""
获取文件下载信息(下载链接有效期1小时)
Args:
resource_id: 资源ID
Returns:
下载信息,包含下载URL
"""
url = f"{BASE_URL}/cwork-file/getDownloadInfo"
params = {
"resourceId": resource_id
}
try:
response = requests.get(url, headers=headers, params=params)
if response.status_code == 200:
result = response.json()
if result.get("resultCode") == 1:
return result.get("data", {})
else:
print(f"接口返回错误: {result.get('resultMsg')}")
return None
else:
print(f"HTTP错误: {response.status_code}")
return None
except Exception as e:
print(f"请求异常: {str(e)}")
return None
def download_file(resource_id, save_path):
"""
下载文件到本地
Args:
resource_id: 资源ID
save_path: 保存路径
"""
# 1. 获取下载信息
download_info = get_file_download_info(resource_id)
if not download_info:
return False
download_url = download_info.get("downloadUrl")
file_name = download_info.get("fileName")
print(f"文件名: {file_name}")
print(f"文件大小: {download_info.get('size')} 字节")
# 2. 下载文件
try:
response = requests.get(download_url, stream=True)
if response.status_code == 200:
with open(save_path, 'wb') as f:
for chunk in response.iter_content(chunk_size=8192):
f.write(chunk)
print(f"文件已保存到: {save_path}")
return True
else:
print(f"下载失败: {response.status_code}")
return False
except Exception as e:
print(f"下载异常: {str(e)}")
return False
# 使用示例
if __name__ == "__main__":
resource_id = 12345
download_file(resource_id, "/path/to/save/downloaded_file.pdf")
```
---
### 示例 5: 知识库 - 获取文件夹内容
```python
def get_document_children(parent_id, file_type=None, order=None):
"""
根据父id获取下级文件/文件夹
Args:
parent_id: 父文件夹id
file_type: 类型,空为所有,1为文件夹,2为文件
order: 排序方式
Returns:
文件/文件夹列表
"""
url = f"{BASE_URL}/document-database/file/getChildFiles"
params = {
"parentId": parent_id
}
if file_type is not None:
params["type"] = file_type
if order is not None:
params["order"] = order
try:
response = requests.get(url, headers=headers, params=params)
if response.status_code == 200:
result = response.json()
if result.get("resultCode") == 1:
return result.get("data", [])
else:
print(f"接口返回错误: {result.get('resultMsg')}")
return []
else:
print(f"HTTP错误: {response.status_code}")
return []
except Exception as e:
print(f"请求异常: {str(e)}")
return []
# 使用示例
if __name__ == "__main__":
# 获取根目录内容(parent_id=0 或具体的根目录id)
files = get_document_children(0)
for file in files:
file_type = "文件夹" if file.get("type") == 1 else "文件"
print(f"[{file_type}] {file.get('name')}")
```
---
### 示例 6: 发送工作汇报
```python
def submit_report(template_id, main, content, level_params, file_list=None):
"""
发送工作汇报
Args:
template_id: 事项ID
main: 汇报标题
content: 汇报内容
level_params: 汇报层级参数列表
file_list: 附件列表(可选)
Returns:
汇报基本信息
"""
url = f"{BASE_URL}/work-report/report/record/submit"
payload = {
"templateId": template_id,
"main": main,
"contentHtml": content,
"levelParams": level_params
}
if file_list:
payload["fileList"] = file_list
try:
response = requests.post(url, headers=headers, json=payload)
if response.status_code == 200:
result = response.json()
if result.get("resultCode") == 1:
return result.get("data", {})
else:
print(f"接口返回错误: {result.get('resultMsg')}")
return None
else:
print(f"HTTP错误: {response.status_code}")
print(response.text)
return None
except Exception as e:
print(f"请求异常: {str(e)}")
return None
# 使用示例
if __name__ == "__main__":
# 构建汇报层级
level_params = [
{
"level": 1,
"type": "suggest", # suggest-建议、decide-决策、read-传阅
"nodeCode": "startNode",
"nodeName": "发起人",
"levelUserList": [
{"empId": 12345, "requirement": ""}
]
},
{
"level": 2,
"type": "decide",
"nodeCode": "approval",
"nodeName": "审批人",
"levelUserList": [
{"empId": 67890, "requirement": "请审批"}
]
}
]
result = submit_report(
template_id=1001,
main="本周工作汇报",
content="<p>本周完成了...</p>",
level_params=level_params
)
if result:
print(f"汇报发送成功,ID: {result.get('id')}")
```
---
### 示例 7: 回复汇报
```python
def reply_report(report_record_id, content, at_emp_ids=None, attachments=None):
"""
回复工作汇报
Args:
report_record_id: 汇报记录ID
content: 回复内容
at_emp_ids: @的员工ID列表(可选)
attachments: 附件列表(可选)
Returns:
回复ID
"""
url = f"{BASE_URL}/work-report/report/record/reply"
payload = {
"reportRecordId": str(report_record_id),
"contentHtml": content,
"isMedia": 1 if attachments else 0,
"sendMsg": True
}
if at_emp_ids:
payload["addEmpIdList"] = [str(emp_id) for emp_id in at_emp_ids]
if attachments:
payload["mediaVOList"] = attachments
try:
response = requests.post(url, headers=headers, json=payload)
if response.status_code == 200:
result = response.json()
if result.get("resultCode") == 1:
reply_id = result.get("data")
print(f"回复成功,回复ID: {reply_id}")
return reply_id
else:
print(f"接口返回错误: {result.get('resultMsg')}")
return None
else:
print(f"HTTP错误: {response.status_code}")
return None
except Exception as e:
print(f"请求异常: {str(e)}")
return None
# 使用示例
if __name__ == "__main__":
attachments = [
{
"fileId": "12345",
"name": "附件.pdf",
"type": "file",
"fsize": 1024000
}
]
reply_report(
report_record_id=10001,
content="<p>收到,已阅</p>",
at_emp_ids=[12345, 67890],
attachments=attachments
)
```
---
### 示例 8: 查询工作任务列表
```python
def search_work_tasks(keyword=None, status=None, page_index=1, page_size=30):
"""
查询工作任务列表
Args:
keyword: 任务名称关键字
status: 任务状态 0-关闭、1-进行中
page_index: 页码,从1开始
page_size: 每页数量
Returns:
分页任务列表
"""
url = f"{BASE_URL}/work-report/report/plan/searchPage"
payload = {
"pageIndex": page_index,
"pageSize": page_size
}
if keyword:
payload["keyWord"] = keyword
if status is not None:
payload["status"] = status
try:
response = requests.post(url, headers=headers, json=payload)
if response.status_code == 200:
result = response.json()
if result.get("resultCode") == 1:
data = result.get("data", {})
return {
"total": data.get("total"),
"list": data.get("list", []),
"pageNum": data.get("pageNum"),
"pageSize": data.get("pageSize")
}
else:
print(f"接口返回错误: {result.get('resultMsg')}")
return None
else:
print(f"HTTP错误: {response.status_code}")
return None
except Exception as e:
print(f"请求异常: {str(e)}")
return None
# 使用示例
if __name__ == "__main__":
result = search_work_tasks(
keyword="项目",
status=1, # 进行中
page_index=1,
page_size=10
)
if result:
print(f"共 {result['total']} 条记录")
for task in result['list']:
print(f"- {task['main']} (状态: {task['status']})")
```
---
### 示例 9: 查询 BP 周期列表
```python
def get_bp_periods(name=None):
"""
查询 BP 目标管理周期列表
Args:
name: 周期名称(模糊搜索)
Returns:
周期列表
"""
url = f"{BASE_URL}/bp/period/getAllPeriod"
params = {}
if name:
params["name"] = name
try:
response = requests.get(url, headers=headers, params=params)
if response.status_code == 200:
result = response.json()
if result.get("resultCode") == 1:
return result.get("data", [])
else:
print(f"接口返回错误: {result.get('resultMsg')}")
return []
else:
print(f"HTTP错误: {response.status_code}")
return []
except Exception as e:
print(f"请求异常: {str(e)}")
return []
# 使用示例
if __name__ == "__main__":
periods = get_bp_periods("2024")
for period in periods:
print(f"周期: {period['name']} ({period['year']})")
print(f" 时间: {period['startDate']} ~ {period['endDate']}")
print(f" 状态: {'启用' if period['status'] == 1 else '未启用'}")
```
---
### 示例 10: 获取待办列表
```python
def get_todo_list(page_index=1, page_size=30, report_record_type=None):
"""
获取待处理列表
Args:
page_index: 页码
page_size: 每页数量
report_record_type: 汇报类型 1-工作交流、2-工作指引、3-文件签批、4-AI汇报、5-工作汇报
Returns:
分页待办列表
"""
url = f"{BASE_URL}/work-report/todoTask/todoList"
payload = {
"pageIndex": page_index,
"pageSize": page_size
}
if report_record_type:
payload["reportRecordType"] = report_record_type
try:
response = requests.post(url, headers=headers, json=payload)
if response.status_code == 200:
result = response.json()
if result.get("resultCode") == 1:
data = result.get("data", {})
return {
"total": data.get("total"),
"list": data.get("list", []),
"pageNum": data.get("pageNum"),
"pageSize": data.get("pageSize")
}
else:
print(f"接口返回错误: {result.get('resultMsg')}")
return None
else:
print(f"HTTP错误: {response.status_code}")
return None
except Exception as e:
print(f"请求异常: {str(e)}")
return None
# 使用示例
if __name__ == "__main__":
result = get_todo_list(page_index=1, page_size=20)
if result:
print(f"共 {result['total']} 条待办")
for todo in result['list']:
print(f"- [{todo.get('levelType')}] {todo.get('main')}")
print(f" 来自: {todo.get('writeEmpName')}")
```
---
## 错误处理最佳实践
```python
class CworkAPIError(Exception):
"""玄关开放平台 API 错误"""
def __init__(self, message, result_code=None, result_msg=None):
super().__init__(message)
self.result_code = result_code
self.result_msg = result_msg
def api_request(method, url, **kwargs):
"""
统一的 API 请求封装
Args:
method: 请求方法 (get/post/put/delete)
url: 请求地址
**kwargs: 其他参数
Returns:
API 响应数据
Raises:
CworkAPIError: API 调用失败
"""
try:
response = requests.request(method, url, **kwargs)
# 检查 HTTP 状态码
if response.status_code != 200:
raise CworkAPIError(
f"HTTP错误: {response.status_code}",
result_code=response.status_code
)
# 解析响应
result = response.json()
# 检查业务状态码
if result.get("resultCode") != 1:
raise CworkAPIError(
f"业务错误: {result.get('resultMsg')}",
result_code=result.get("resultCode"),
result_msg=result.get("resultMsg")
)
return result.get("data")
except requests.exceptions.RequestException as e:
raise CworkAPIError(f"网络请求异常: {str(e)}")
except json.JSONDecodeError as e:
raise CworkAPIError(f"响应解析失败: {str(e)}")
# 使用示例
try:
data = api_request(
"get",
f"{BASE_URL}/cwork-user/searchEmpByName",
headers=headers,
params={"searchKey": "张三"}
)
print(f"搜索结果: {data}")
except CworkAPIError as e:
print(f"API调用失败: {e}")
print(f"错误码: {e.result_code}")
print(f"错误信息: {e.result_msg}")
```
Typed knowledge graph for structured agent memory and composable skills. Use when creating/querying entities (Person, Project, Task, Event, Document), linkin...
---
name: ontology-xgjk
description: Typed knowledge graph for structured agent memory and composable skills. Use when creating/querying entities (Person, Project, Task, Event, Document), linking related objects, enforcing constraints, planning multi-step actions as graph transformations, or when skills need to share state. Trigger on "remember", "what do I know about", "link X to Y", "show dependencies", entity CRUD, or cross-skill data access. XGJK official version.
---
# Ontology
A typed vocabulary + constraint system for representing knowledge as a verifiable graph.
## Core Concept
Everything is an **entity** with a **type**, **properties**, and **relations** to other entities. Every mutation is validated against type constraints before committing.
```
Entity: { id, type, properties, relations, created, updated }
Relation: { from_id, relation_type, to_id, properties }
```
## When to Use
| Trigger | Action |
|---------|--------|
| "Remember that..." | Create/update entity |
| "What do I know about X?" | Query graph |
| "Link X to Y" | Create relation |
| "Show all tasks for project Z" | Graph traversal |
| "What depends on X?" | Dependency query |
| Planning multi-step work | Model as graph transformations |
| Skill needs shared state | Read/write ontology objects |
## Core Types
```yaml
# Agents & People
Person: { name, email?, phone?, notes? }
Organization: { name, type?, members[] }
# Work
Project: { name, status, goals[], owner? }
Task: { title, status, due?, priority?, assignee?, blockers[] }
Goal: { description, target_date?, metrics[] }
# Time & Place
Event: { title, start, end?, location?, attendees[], recurrence? }
Location: { name, address?, coordinates? }
# Information
Document: { title, path?, url?, summary? }
Message: { content, sender, recipients[], thread? }
Thread: { subject, participants[], messages[] }
Note: { content, tags[], refs[] }
# Resources
Account: { service, username, credential_ref? }
Device: { name, type, identifiers[] }
Credential: { service, secret_ref } # Never store secrets directly
# Meta
Action: { type, target, timestamp, outcome? }
Policy: { scope, rule, enforcement }
```
## Storage
Default: `memory/ontology/graph.jsonl`
```jsonl
{"op":"create","entity":{"id":"p_001","type":"Person","properties":{"name":"Alice"}}}
{"op":"create","entity":{"id":"proj_001","type":"Project","properties":{"name":"Website Redesign","status":"active"}}}
{"op":"relate","from":"proj_001","rel":"has_owner","to":"p_001"}
```
Query via scripts or direct file ops. For complex graphs, migrate to SQLite.
### Append-Only Rule
When working with existing ontology data or schema, **append/merge** changes instead of overwriting files. This preserves history and avoids clobbering prior definitions.
## Workflows
### Create Entity
```bash
python3 scripts/ontology.py create --type Person --props '{"name":"Alice","email":"[email protected]"}'
```
### Query
```bash
python3 scripts/ontology.py query --type Task --where '{"status":"open"}'
python3 scripts/ontology.py get --id task_001
python3 scripts/ontology.py related --id proj_001 --rel has_task
```
### Link Entities
```bash
python3 scripts/ontology.py relate --from proj_001 --rel has_task --to task_001
```
### Validate
```bash
python3 scripts/ontology.py validate # Check all constraints
```
## Constraints
Define in `memory/ontology/schema.yaml`:
```yaml
types:
Task:
required: [title, status]
status_enum: [open, in_progress, blocked, done]
Event:
required: [title, start]
validate: "end >= start if end exists"
Credential:
required: [service, secret_ref]
forbidden_properties: [password, secret, token] # Force indirection
relations:
has_owner:
from_types: [Project, Task]
to_types: [Person]
cardinality: many_to_one
blocks:
from_types: [Task]
to_types: [Task]
acyclic: true # No circular dependencies
```
## Skill Contract
Skills that use ontology should declare:
```yaml
# In SKILL.md frontmatter or header
ontology:
reads: [Task, Project, Person]
writes: [Task, Action]
preconditions:
- "Task.assignee must exist"
postconditions:
- "Created Task has status=open"
```
## Planning as Graph Transformation
Model multi-step plans as a sequence of graph operations:
```
Plan: "Schedule team meeting and create follow-up tasks"
1. CREATE Event { title: "Team Sync", attendees: [p_001, p_002] }
2. RELATE Event -> has_project -> proj_001
3. CREATE Task { title: "Prepare agenda", assignee: p_001 }
4. RELATE Task -> for_event -> event_001
5. CREATE Task { title: "Send summary", assignee: p_001, blockers: [task_001] }
```
Each step is validated before execution. Rollback on constraint violation.
## Integration Patterns
### With Causal Inference
Log ontology mutations as causal actions:
```python
# When creating/updating entities, also log to causal action log
action = {
"action": "create_entity",
"domain": "ontology",
"context": {"type": "Task", "project": "proj_001"},
"outcome": "created"
}
```
### Cross-Skill Communication
```python
# Email skill creates commitment
commitment = ontology.create("Commitment", {
"source_message": msg_id,
"description": "Send report by Friday",
"due": "2026-01-31"
})
# Task skill picks it up
tasks = ontology.query("Commitment", {"status": "pending"})
for c in tasks:
ontology.create("Task", {
"title": c.description,
"due": c.due,
"source": c.id
})
```
## Quick Start
```bash
# Initialize ontology storage
mkdir -p memory/ontology
touch memory/ontology/graph.jsonl
# Create schema (optional but recommended)
python3 scripts/ontology.py schema-append --data '{
"types": {
"Task": { "required": ["title", "status"] },
"Project": { "required": ["name"] },
"Person": { "required": ["name"] }
}
}'
# Start using
python3 scripts/ontology.py create --type Person --props '{"name":"Alice"}'
python3 scripts/ontology.py list --type Person
```
## References
- `references/schema.md` — Full type definitions and constraint patterns
- `references/queries.md` — Query language and traversal examples
## Instruction Scope
Runtime instructions operate on local files (`memory/ontology/graph.jsonl` and `memory/ontology/schema.yaml`) and provide CLI usage for create/query/relate/validate; this is within scope. The skill reads/writes workspace files and will create the `memory/ontology` directory when used. Validation includes property/enum/forbidden checks, relation type/cardinality validation, acyclicity for relations marked `acyclic: true`, and Event `end >= start` checks; other higher-level constraints may still be documentation-only unless implemented in code.
FILE:_meta.json
{
"ownerId": "kn72dv4fm7ss7swbq47nnpad9x7zy2jh",
"slug": "ontology-xgjk",
"version": "1.0.4",
"publishedAt": 1773249559725
}
FILE:references/queries.md
# Query Reference
Query patterns and graph traversal examples.
## Basic Queries
### Get by ID
```bash
python3 scripts/ontology.py get --id task_001
```
### List by Type
```bash
# All tasks
python3 scripts/ontology.py list --type Task
# All people
python3 scripts/ontology.py list --type Person
```
### Filter by Properties
```bash
# Open tasks
python3 scripts/ontology.py query --type Task --where '{"status":"open"}'
# High priority tasks
python3 scripts/ontology.py query --type Task --where '{"priority":"high"}'
# Tasks assigned to specific person (by property)
python3 scripts/ontology.py query --type Task --where '{"assignee":"p_001"}'
```
## Relation Queries
### Get Related Entities
```bash
# Tasks belonging to a project (outgoing)
python3 scripts/ontology.py related --id proj_001 --rel has_task
# What projects does this task belong to (incoming)
python3 scripts/ontology.py related --id task_001 --rel part_of --dir incoming
# All relations for an entity (both directions)
python3 scripts/ontology.py related --id p_001 --dir both
```
### Common Patterns
```bash
# Who owns this project?
python3 scripts/ontology.py related --id proj_001 --rel has_owner
# What events is this person attending?
python3 scripts/ontology.py related --id p_001 --rel attendee_of --dir outgoing
# What's blocking this task?
python3 scripts/ontology.py related --id task_001 --rel blocked_by --dir incoming
```
## Programmatic Queries
### Python API
```python
from scripts.ontology import load_graph, query_entities, get_related
# Load the graph
entities, relations = load_graph("memory/ontology/graph.jsonl")
# Query entities
open_tasks = query_entities("Task", {"status": "open"}, "memory/ontology/graph.jsonl")
# Get related
project_tasks = get_related("proj_001", "has_task", "memory/ontology/graph.jsonl")
```
### Complex Queries
```python
# Find all tasks blocked by incomplete dependencies
def find_blocked_tasks(graph_path):
entities, relations = load_graph(graph_path)
blocked = []
for entity in entities.values():
if entity["type"] != "Task":
continue
if entity["properties"].get("status") == "blocked":
# Find what's blocking it
blockers = get_related(entity["id"], "blocked_by", graph_path, "incoming")
incomplete_blockers = [
b for b in blockers
if b["entity"]["properties"].get("status") != "done"
]
if incomplete_blockers:
blocked.append({
"task": entity,
"blockers": incomplete_blockers
})
return blocked
```
### Path Queries
```python
# Find path between two entities
def find_path(from_id, to_id, graph_path, max_depth=5):
entities, relations = load_graph(graph_path)
visited = set()
queue = [(from_id, [])]
while queue:
current, path = queue.pop(0)
if current == to_id:
return path
if current in visited or len(path) >= max_depth:
continue
visited.add(current)
for rel in relations:
if rel["from"] == current and rel["to"] not in visited:
queue.append((rel["to"], path + [rel]))
if rel["to"] == current and rel["from"] not in visited:
queue.append((rel["from"], path + [{**rel, "direction": "incoming"}]))
return None # No path found
```
## Query Patterns by Use Case
### Task Management
```bash
# All my open tasks
python3 scripts/ontology.py query --type Task --where '{"status":"open","assignee":"p_me"}'
# Overdue tasks (requires custom script for date comparison)
# See references/schema.md for date handling
# Tasks with no blockers
python3 scripts/ontology.py query --type Task --where '{"status":"open"}'
# Then filter in code for those with no incoming "blocks" relations
```
### Project Overview
```bash
# All tasks in project
python3 scripts/ontology.py related --id proj_001 --rel has_task
# Project team members
python3 scripts/ontology.py related --id proj_001 --rel has_member
# Project goals
python3 scripts/ontology.py related --id proj_001 --rel has_goal
```
### People & Contacts
```bash
# All people
python3 scripts/ontology.py list --type Person
# People in an organization
python3 scripts/ontology.py related --id org_001 --rel has_member
# What's assigned to this person
python3 scripts/ontology.py related --id p_001 --rel assigned_to --dir incoming
```
### Events & Calendar
```bash
# All events
python3 scripts/ontology.py list --type Event
# Events at a location
python3 scripts/ontology.py related --id loc_001 --rel located_at --dir incoming
# Event attendees
python3 scripts/ontology.py related --id event_001 --rel attendee_of --dir incoming
```
## Aggregations
For complex aggregations, use Python:
```python
from collections import Counter
def task_status_summary(project_id, graph_path):
"""Count tasks by status for a project."""
tasks = get_related(project_id, "has_task", graph_path)
statuses = Counter(t["entity"]["properties"].get("status", "unknown") for t in tasks)
return dict(statuses)
def workload_by_person(graph_path):
"""Count open tasks per person."""
open_tasks = query_entities("Task", {"status": "open"}, graph_path)
workload = Counter(t["properties"].get("assignee") for t in open_tasks)
return dict(workload)
```
FILE:references/schema.md
# Ontology Schema Reference
Full type definitions and constraint patterns for the ontology graph.
## Core Types
### Agents & People
```yaml
Person:
required: [name]
properties:
name: string
email: string?
phone: string?
organization: ref(Organization)?
notes: string?
tags: string[]?
Organization:
required: [name]
properties:
name: string
type: enum(company, team, community, government, other)?
website: url?
members: ref(Person)[]?
```
### Work Management
```yaml
Project:
required: [name]
properties:
name: string
description: string?
status: enum(planning, active, paused, completed, archived)
owner: ref(Person)?
team: ref(Person)[]?
goals: ref(Goal)[]?
start_date: date?
end_date: date?
tags: string[]?
Task:
required: [title, status]
properties:
title: string
description: string?
status: enum(open, in_progress, blocked, done, cancelled)
priority: enum(low, medium, high, urgent)?
assignee: ref(Person)?
project: ref(Project)?
due: datetime?
estimate_hours: number?
blockers: ref(Task)[]?
tags: string[]?
Goal:
required: [description]
properties:
description: string
target_date: date?
status: enum(active, achieved, abandoned)?
metrics: object[]?
key_results: string[]?
```
### Time & Location
```yaml
Event:
required: [title, start]
properties:
title: string
description: string?
start: datetime
end: datetime?
location: ref(Location)?
attendees: ref(Person)[]?
recurrence: object? # iCal RRULE format
status: enum(confirmed, tentative, cancelled)?
reminders: object[]?
Location:
required: [name]
properties:
name: string
address: string?
city: string?
country: string?
coordinates: object? # {lat, lng}
timezone: string?
```
### Information
```yaml
Document:
required: [title]
properties:
title: string
path: string? # Local file path
url: url? # Remote URL
mime_type: string?
summary: string?
content_hash: string?
tags: string[]?
Message:
required: [content, sender]
properties:
content: string
sender: ref(Person)
recipients: ref(Person)[]
thread: ref(Thread)?
timestamp: datetime
platform: string? # email, slack, whatsapp, etc.
external_id: string?
Thread:
required: [subject]
properties:
subject: string
participants: ref(Person)[]
messages: ref(Message)[]
status: enum(active, archived)?
last_activity: datetime?
Note:
required: [content]
properties:
content: string
title: string?
tags: string[]?
refs: ref(Entity)[]? # Links to any entity
created: datetime
```
### Resources
```yaml
Account:
required: [service, username]
properties:
service: string # github, gmail, aws, etc.
username: string
url: url?
credential_ref: ref(Credential)?
Device:
required: [name, type]
properties:
name: string
type: enum(computer, phone, tablet, server, iot, other)
os: string?
identifiers: object? # {mac, serial, etc.}
owner: ref(Person)?
Credential:
required: [service, secret_ref]
forbidden_properties: [password, secret, token, key, api_key]
properties:
service: string
secret_ref: string # Reference to secret store (e.g., "keychain:github-token")
expires: datetime?
scope: string[]?
```
### Meta
```yaml
Action:
required: [type, target, timestamp]
properties:
type: string # create, update, delete, send, etc.
target: ref(Entity)
timestamp: datetime
actor: ref(Person|Agent)?
outcome: enum(success, failure, pending)?
details: object?
Policy:
required: [scope, rule]
properties:
scope: string # What this policy applies to
rule: string # The constraint in natural language or code
enforcement: enum(block, warn, log)
enabled: boolean
```
## Relation Types
### Ownership & Assignment
```yaml
owns:
from_types: [Person, Organization]
to_types: [Account, Device, Document, Project]
cardinality: one_to_many
has_owner:
from_types: [Project, Task, Document]
to_types: [Person]
cardinality: many_to_one
assigned_to:
from_types: [Task]
to_types: [Person]
cardinality: many_to_one
```
### Hierarchy & Containment
```yaml
has_task:
from_types: [Project]
to_types: [Task]
cardinality: one_to_many
has_goal:
from_types: [Project]
to_types: [Goal]
cardinality: one_to_many
member_of:
from_types: [Person]
to_types: [Organization]
cardinality: many_to_many
part_of:
from_types: [Task, Document, Event]
to_types: [Project]
cardinality: many_to_one
```
### Dependencies
```yaml
blocks:
from_types: [Task]
to_types: [Task]
acyclic: true # Prevents circular dependencies
cardinality: many_to_many
depends_on:
from_types: [Task, Project]
to_types: [Task, Project, Event]
acyclic: true
cardinality: many_to_many
requires:
from_types: [Action]
to_types: [Credential, Policy]
cardinality: many_to_many
```
### References
```yaml
mentions:
from_types: [Document, Message, Note]
to_types: [Person, Project, Task, Event]
cardinality: many_to_many
references:
from_types: [Document, Note]
to_types: [Document, Note]
cardinality: many_to_many
follows_up:
from_types: [Task, Event]
to_types: [Event, Message]
cardinality: many_to_one
```
### Events
```yaml
attendee_of:
from_types: [Person]
to_types: [Event]
cardinality: many_to_many
properties:
status: enum(accepted, declined, tentative, pending)
located_at:
from_types: [Event, Person, Device]
to_types: [Location]
cardinality: many_to_one
```
## Global Constraints
```yaml
constraints:
# Credentials must never store secrets directly
- type: Credential
rule: "forbidden_properties: [password, secret, token]"
message: "Credentials must use secret_ref to reference external secret storage"
# Tasks must have valid status transitions
- type: Task
rule: "status transitions: open -> in_progress -> (done|blocked) -> done"
enforcement: warn
# Events must have end >= start
- type: Event
rule: "if end exists: end >= start"
message: "Event end time must be after start time"
# No orphan tasks (should belong to a project or have explicit owner)
- type: Task
rule: "has_relation(part_of, Project) OR has_property(owner)"
enforcement: warn
message: "Task should belong to a project or have an explicit owner"
# Circular dependency prevention
- relation: blocks
rule: "acyclic"
message: "Circular task dependencies are not allowed"
```
FILE:scripts/ontology.py
#!/usr/bin/env python3
"""
Ontology graph operations: create, query, relate, validate.
Usage:
python ontology.py create --type Person --props '{"name":"Alice"}'
python ontology.py get --id p_001
python ontology.py query --type Task --where '{"status":"open"}'
python ontology.py relate --from proj_001 --rel has_task --to task_001
python ontology.py related --id proj_001 --rel has_task
python ontology.py list --type Person
python ontology.py delete --id p_001
python ontology.py validate
"""
import argparse
import json
import uuid
from datetime import datetime, timezone
from pathlib import Path
DEFAULT_GRAPH_PATH = "memory/ontology/graph.jsonl"
DEFAULT_SCHEMA_PATH = "memory/ontology/schema.yaml"
def resolve_safe_path(
user_path: str,
*,
root: Path | None = None,
must_exist: bool = False,
label: str = "path",
) -> Path:
"""Resolve user path within root and reject traversal outside it."""
if not user_path or not user_path.strip():
raise SystemExit(f"Invalid {label}: empty path")
safe_root = (root or Path.cwd()).resolve()
candidate = Path(user_path).expanduser()
if not candidate.is_absolute():
candidate = safe_root / candidate
try:
resolved = candidate.resolve(strict=False)
except OSError as exc:
raise SystemExit(f"Invalid {label}: {exc}") from exc
try:
resolved.relative_to(safe_root)
except ValueError:
raise SystemExit(
f"Invalid {label}: must stay within workspace root '{safe_root}'"
)
if must_exist and not resolved.exists():
raise SystemExit(f"Invalid {label}: file not found '{resolved}'")
return resolved
def generate_id(type_name: str) -> str:
"""Generate a unique ID for an entity."""
prefix = type_name.lower()[:4]
suffix = uuid.uuid4().hex[:8]
return f"{prefix}_{suffix}"
def load_graph(path: str) -> tuple[dict, list]:
"""Load entities and relations from graph file."""
entities = {}
relations = []
graph_path = Path(path)
if not graph_path.exists():
return entities, relations
with open(graph_path) as f:
for line in f:
line = line.strip()
if not line:
continue
record = json.loads(line)
op = record.get("op")
if op == "create":
entity = record["entity"]
entities[entity["id"]] = entity
elif op == "update":
entity_id = record["id"]
if entity_id in entities:
entities[entity_id]["properties"].update(record.get("properties", {}))
entities[entity_id]["updated"] = record.get("timestamp")
elif op == "delete":
entity_id = record["id"]
entities.pop(entity_id, None)
elif op == "relate":
relations.append({
"from": record["from"],
"rel": record["rel"],
"to": record["to"],
"properties": record.get("properties", {})
})
elif op == "unrelate":
relations = [r for r in relations
if not (r["from"] == record["from"]
and r["rel"] == record["rel"]
and r["to"] == record["to"])]
return entities, relations
def append_op(path: str, record: dict):
"""Append an operation to the graph file."""
graph_path = Path(path)
graph_path.parent.mkdir(parents=True, exist_ok=True)
with open(graph_path, "a") as f:
f.write(json.dumps(record) + "\n")
def create_entity(type_name: str, properties: dict, graph_path: str, entity_id: str = None) -> dict:
"""Create a new entity."""
entity_id = entity_id or generate_id(type_name)
timestamp = datetime.now(timezone.utc).isoformat()
entity = {
"id": entity_id,
"type": type_name,
"properties": properties,
"created": timestamp,
"updated": timestamp
}
record = {"op": "create", "entity": entity, "timestamp": timestamp}
append_op(graph_path, record)
return entity
def get_entity(entity_id: str, graph_path: str) -> dict | None:
"""Get entity by ID."""
entities, _ = load_graph(graph_path)
return entities.get(entity_id)
def query_entities(type_name: str, where: dict, graph_path: str) -> list:
"""Query entities by type and properties."""
entities, _ = load_graph(graph_path)
results = []
for entity in entities.values():
if type_name and entity["type"] != type_name:
continue
match = True
for key, value in where.items():
if entity["properties"].get(key) != value:
match = False
break
if match:
results.append(entity)
return results
def list_entities(type_name: str, graph_path: str) -> list:
"""List all entities of a type."""
entities, _ = load_graph(graph_path)
if type_name:
return [e for e in entities.values() if e["type"] == type_name]
return list(entities.values())
def update_entity(entity_id: str, properties: dict, graph_path: str) -> dict | None:
"""Update entity properties."""
entities, _ = load_graph(graph_path)
if entity_id not in entities:
return None
timestamp = datetime.now(timezone.utc).isoformat()
record = {"op": "update", "id": entity_id, "properties": properties, "timestamp": timestamp}
append_op(graph_path, record)
entities[entity_id]["properties"].update(properties)
entities[entity_id]["updated"] = timestamp
return entities[entity_id]
def delete_entity(entity_id: str, graph_path: str) -> bool:
"""Delete an entity."""
entities, _ = load_graph(graph_path)
if entity_id not in entities:
return False
timestamp = datetime.now(timezone.utc).isoformat()
record = {"op": "delete", "id": entity_id, "timestamp": timestamp}
append_op(graph_path, record)
return True
def create_relation(from_id: str, rel_type: str, to_id: str, properties: dict, graph_path: str):
"""Create a relation between entities."""
timestamp = datetime.now(timezone.utc).isoformat()
record = {
"op": "relate",
"from": from_id,
"rel": rel_type,
"to": to_id,
"properties": properties,
"timestamp": timestamp
}
append_op(graph_path, record)
return record
def get_related(entity_id: str, rel_type: str, graph_path: str, direction: str = "outgoing") -> list:
"""Get related entities."""
entities, relations = load_graph(graph_path)
results = []
for rel in relations:
if direction == "outgoing" and rel["from"] == entity_id:
if not rel_type or rel["rel"] == rel_type:
if rel["to"] in entities:
results.append({
"relation": rel["rel"],
"entity": entities[rel["to"]]
})
elif direction == "incoming" and rel["to"] == entity_id:
if not rel_type or rel["rel"] == rel_type:
if rel["from"] in entities:
results.append({
"relation": rel["rel"],
"entity": entities[rel["from"]]
})
elif direction == "both":
if rel["from"] == entity_id or rel["to"] == entity_id:
if not rel_type or rel["rel"] == rel_type:
other_id = rel["to"] if rel["from"] == entity_id else rel["from"]
if other_id in entities:
results.append({
"relation": rel["rel"],
"direction": "outgoing" if rel["from"] == entity_id else "incoming",
"entity": entities[other_id]
})
return results
def validate_graph(graph_path: str, schema_path: str) -> list:
"""Validate graph against schema constraints."""
entities, relations = load_graph(graph_path)
errors = []
# Load schema if exists
schema = load_schema(schema_path)
type_schemas = schema.get("types", {})
relation_schemas = schema.get("relations", {})
global_constraints = schema.get("constraints", [])
for entity_id, entity in entities.items():
type_name = entity["type"]
type_schema = type_schemas.get(type_name, {})
# Check required properties
required = type_schema.get("required", [])
for prop in required:
if prop not in entity["properties"]:
errors.append(f"{entity_id}: missing required property '{prop}'")
# Check forbidden properties
forbidden = type_schema.get("forbidden_properties", [])
for prop in forbidden:
if prop in entity["properties"]:
errors.append(f"{entity_id}: contains forbidden property '{prop}'")
# Check enum values
for prop, allowed in type_schema.items():
if prop.endswith("_enum"):
field = prop.replace("_enum", "")
value = entity["properties"].get(field)
if value and value not in allowed:
errors.append(f"{entity_id}: '{field}' must be one of {allowed}, got '{value}'")
# Relation constraints (type + cardinality + acyclicity)
rel_index = {}
for rel in relations:
rel_index.setdefault(rel["rel"], []).append(rel)
for rel_type, rel_schema in relation_schemas.items():
rels = rel_index.get(rel_type, [])
from_types = rel_schema.get("from_types", [])
to_types = rel_schema.get("to_types", [])
cardinality = rel_schema.get("cardinality")
acyclic = rel_schema.get("acyclic", False)
# Type checks
for rel in rels:
from_entity = entities.get(rel["from"])
to_entity = entities.get(rel["to"])
if not from_entity or not to_entity:
errors.append(f"{rel_type}: relation references missing entity ({rel['from']} -> {rel['to']})")
continue
if from_types and from_entity["type"] not in from_types:
errors.append(
f"{rel_type}: from entity {rel['from']} type {from_entity['type']} not in {from_types}"
)
if to_types and to_entity["type"] not in to_types:
errors.append(
f"{rel_type}: to entity {rel['to']} type {to_entity['type']} not in {to_types}"
)
# Cardinality checks
if cardinality in ("one_to_one", "one_to_many", "many_to_one"):
from_counts = {}
to_counts = {}
for rel in rels:
from_counts[rel["from"]] = from_counts.get(rel["from"], 0) + 1
to_counts[rel["to"]] = to_counts.get(rel["to"], 0) + 1
if cardinality in ("one_to_one", "many_to_one"):
for from_id, count in from_counts.items():
if count > 1:
errors.append(f"{rel_type}: from entity {from_id} violates cardinality {cardinality}")
if cardinality in ("one_to_one", "one_to_many"):
for to_id, count in to_counts.items():
if count > 1:
errors.append(f"{rel_type}: to entity {to_id} violates cardinality {cardinality}")
# Acyclic checks
if acyclic:
graph = {}
for rel in rels:
graph.setdefault(rel["from"], []).append(rel["to"])
visited = {}
def dfs(node, stack):
visited[node] = True
stack.add(node)
for nxt in graph.get(node, []):
if nxt in stack:
return True
if not visited.get(nxt, False):
if dfs(nxt, stack):
return True
stack.remove(node)
return False
for node in graph:
if not visited.get(node, False):
if dfs(node, set()):
errors.append(f"{rel_type}: cyclic dependency detected")
break
# Global constraints (limited enforcement)
for constraint in global_constraints:
ctype = constraint.get("type")
relation = constraint.get("relation")
rule = (constraint.get("rule") or "").strip().lower()
if ctype == "Event" and "end" in rule and "start" in rule:
for entity_id, entity in entities.items():
if entity["type"] != "Event":
continue
start = entity["properties"].get("start")
end = entity["properties"].get("end")
if start and end:
try:
start_dt = datetime.fromisoformat(start)
end_dt = datetime.fromisoformat(end)
if end_dt < start_dt:
errors.append(f"{entity_id}: end must be >= start")
except ValueError:
errors.append(f"{entity_id}: invalid datetime format in start/end")
if relation and rule == "acyclic":
# Already enforced above via relations schema
continue
return errors
def load_schema(schema_path: str) -> dict:
"""Load schema from YAML if it exists."""
schema = {}
schema_file = Path(schema_path)
if schema_file.exists():
import yaml
with open(schema_file) as f:
schema = yaml.safe_load(f) or {}
return schema
def write_schema(schema_path: str, schema: dict) -> None:
"""Write schema to YAML."""
schema_file = Path(schema_path)
schema_file.parent.mkdir(parents=True, exist_ok=True)
import yaml
with open(schema_file, "w") as f:
yaml.safe_dump(schema, f, sort_keys=False)
def merge_schema(base: dict, incoming: dict) -> dict:
"""Merge incoming schema into base, appending lists and deep-merging dicts."""
for key, value in (incoming or {}).items():
if key in base and isinstance(base[key], dict) and isinstance(value, dict):
base[key] = merge_schema(base[key], value)
elif key in base and isinstance(base[key], list) and isinstance(value, list):
base[key] = base[key] + [v for v in value if v not in base[key]]
else:
base[key] = value
return base
def append_schema(schema_path: str, incoming: dict) -> dict:
"""Append/merge schema fragment into existing schema."""
base = load_schema(schema_path)
merged = merge_schema(base, incoming)
write_schema(schema_path, merged)
return merged
def main():
parser = argparse.ArgumentParser(description="Ontology graph operations")
subparsers = parser.add_subparsers(dest="command", required=True)
# Create
create_p = subparsers.add_parser("create", help="Create entity")
create_p.add_argument("--type", "-t", required=True, help="Entity type")
create_p.add_argument("--props", "-p", default="{}", help="Properties JSON")
create_p.add_argument("--id", help="Entity ID (auto-generated if not provided)")
create_p.add_argument("--graph", "-g", default=DEFAULT_GRAPH_PATH)
# Get
get_p = subparsers.add_parser("get", help="Get entity by ID")
get_p.add_argument("--id", required=True, help="Entity ID")
get_p.add_argument("--graph", "-g", default=DEFAULT_GRAPH_PATH)
# Query
query_p = subparsers.add_parser("query", help="Query entities")
query_p.add_argument("--type", "-t", help="Entity type")
query_p.add_argument("--where", "-w", default="{}", help="Filter JSON")
query_p.add_argument("--graph", "-g", default=DEFAULT_GRAPH_PATH)
# List
list_p = subparsers.add_parser("list", help="List entities")
list_p.add_argument("--type", "-t", help="Entity type")
list_p.add_argument("--graph", "-g", default=DEFAULT_GRAPH_PATH)
# Update
update_p = subparsers.add_parser("update", help="Update entity")
update_p.add_argument("--id", required=True, help="Entity ID")
update_p.add_argument("--props", "-p", required=True, help="Properties JSON")
update_p.add_argument("--graph", "-g", default=DEFAULT_GRAPH_PATH)
# Delete
delete_p = subparsers.add_parser("delete", help="Delete entity")
delete_p.add_argument("--id", required=True, help="Entity ID")
delete_p.add_argument("--graph", "-g", default=DEFAULT_GRAPH_PATH)
# Relate
relate_p = subparsers.add_parser("relate", help="Create relation")
relate_p.add_argument("--from", dest="from_id", required=True, help="From entity ID")
relate_p.add_argument("--rel", "-r", required=True, help="Relation type")
relate_p.add_argument("--to", dest="to_id", required=True, help="To entity ID")
relate_p.add_argument("--props", "-p", default="{}", help="Relation properties JSON")
relate_p.add_argument("--graph", "-g", default=DEFAULT_GRAPH_PATH)
# Related
related_p = subparsers.add_parser("related", help="Get related entities")
related_p.add_argument("--id", required=True, help="Entity ID")
related_p.add_argument("--rel", "-r", help="Relation type filter")
related_p.add_argument("--dir", "-d", choices=["outgoing", "incoming", "both"], default="outgoing")
related_p.add_argument("--graph", "-g", default=DEFAULT_GRAPH_PATH)
# Validate
validate_p = subparsers.add_parser("validate", help="Validate graph")
validate_p.add_argument("--graph", "-g", default=DEFAULT_GRAPH_PATH)
validate_p.add_argument("--schema", "-s", default=DEFAULT_SCHEMA_PATH)
# Schema append
schema_p = subparsers.add_parser("schema-append", help="Append/merge schema fragment")
schema_p.add_argument("--schema", "-s", default=DEFAULT_SCHEMA_PATH)
schema_p.add_argument("--data", "-d", help="Schema fragment as JSON")
schema_p.add_argument("--file", "-f", help="Schema fragment file (YAML or JSON)")
args = parser.parse_args()
workspace_root = Path.cwd().resolve()
if hasattr(args, "graph"):
args.graph = str(
resolve_safe_path(args.graph, root=workspace_root, label="graph path")
)
if hasattr(args, "schema"):
args.schema = str(
resolve_safe_path(args.schema, root=workspace_root, label="schema path")
)
if hasattr(args, "file") and args.file:
args.file = str(
resolve_safe_path(
args.file, root=workspace_root, must_exist=True, label="schema file"
)
)
if args.command == "create":
props = json.loads(args.props)
entity = create_entity(args.type, props, args.graph, args.id)
print(json.dumps(entity, indent=2))
elif args.command == "get":
entity = get_entity(args.id, args.graph)
if entity:
print(json.dumps(entity, indent=2))
else:
print(f"Entity not found: {args.id}")
elif args.command == "query":
where = json.loads(args.where)
results = query_entities(args.type, where, args.graph)
print(json.dumps(results, indent=2))
elif args.command == "list":
results = list_entities(args.type, args.graph)
print(json.dumps(results, indent=2))
elif args.command == "update":
props = json.loads(args.props)
entity = update_entity(args.id, props, args.graph)
if entity:
print(json.dumps(entity, indent=2))
else:
print(f"Entity not found: {args.id}")
elif args.command == "delete":
if delete_entity(args.id, args.graph):
print(f"Deleted: {args.id}")
else:
print(f"Entity not found: {args.id}")
elif args.command == "relate":
props = json.loads(args.props)
rel = create_relation(args.from_id, args.rel, args.to_id, props, args.graph)
print(json.dumps(rel, indent=2))
elif args.command == "related":
results = get_related(args.id, args.rel, args.graph, args.dir)
print(json.dumps(results, indent=2))
elif args.command == "validate":
errors = validate_graph(args.graph, args.schema)
if errors:
print("Validation errors:")
for err in errors:
print(f" - {err}")
else:
print("Graph is valid.")
elif args.command == "schema-append":
if not args.data and not args.file:
raise SystemExit("schema-append requires --data or --file")
incoming = {}
if args.data:
incoming = json.loads(args.data)
else:
path = Path(args.file)
if path.suffix.lower() == ".json":
with open(path) as f:
incoming = json.load(f)
else:
import yaml
with open(path) as f:
incoming = yaml.safe_load(f) or {}
merged = append_schema(args.schema, incoming)
print(json.dumps(merged, indent=2))
if __name__ == "__main__":
main()
LLM Wiki 知识编译引擎。将 URL、文章、视频等素材编译为结构化知识库。触发词:搜一下、帮我看、这个讲了什么、读一下、看看这个、调研、Ingest、知识编译。支持视频转写(阿里云NLS/本地Whisper)、网页智能抓取、Wiki 4连击 Ingest(source/entity/index/log)、知...
---
name: arc-reactor
description: "LLM Wiki 知识编译引擎。将 URL、文章、视频等素材编译为结构化知识库。触发词:搜一下、帮我看、这个讲了什么、读一下、看看这个、调研、Ingest、知识编译。支持视频转写(阿里云NLS/本地Whisper)、网页智能抓取、Wiki 4连击 Ingest(source/entity/index/log)、知识检索、健康检查、周报。"
metadata: {"openclaw": {"requires": {"bins": ["python3", "yt-dlp"]}, "install": [{"id": "pip", "kind": "pip", "label": "Install Python dependencies"}]}}
---
# ARC Reactor V4 — Compilation over Retrieval
# Version: 4.2.0 (Weekly Executive Brief Edition)
你是 **ARC Reactor v4.0**。你不仅是一个调研员,更是一个全职的 **LLM Wiki 编译器**。
你不再输出一次性的、会被遗忘的对话,你要做的是通过 **Ingest (摄入)**, **Query (检索)**, **Lint (整理)** 生成永续累积的知识复利。
---
## 📂 场景路由表(按需加载)
本 skill 使用渐进式加载。以下场景触发时,**必须先读对应文件再执行**:
| 场景 | 必读文件 | 说明 |
|------|---------|------|
| 收到 URL / 链接 / 视频 | `references/orchestrator-dispatch.md` | 派发规则,禁止自己执行 |
| spawn Worker 执行任务 | `references/spawn-template.md` | 4 种模板(含视频转录 Template 4) |
| 视频 / 音频处理 | `references/spawn-template.md` → Template 4 | 用 mlx_whisper,不用 whisper |
| 改代码 / 提 PR | `CONTRIBUTING.md` | Issue → branch → PR → merge |
| 调研 / 深度分析 | `references/verification-pipeline.md` | 声明切片→外探→可信度标注 |
| 输出内容给用户 | `references/output-style.md` | Display Layer ≤200字 + 判断力 |
| Ingest 前去重检查 | `references/dedup-rules.md` | 检查是否已有同类 source |
| 知识库架构理解 | `references/knowledge-rules.md` | 三层架构原理 |
| Obsidian 同步 | `references/dispatchers/obsidian.md` | 配置与验证 |
| 环境配置 | `references/env-setup.md` | 环境变量说明 |
---
## 🏗️ The Schema (工作流规范)
详见 `references/orchestrator-dispatch.md`(派发规则)和 `references/spawn-template.md`(Worker 模板)。
所有知识落地必须通过 `archive-manager.py --stdin` 落盘至 `arc-reactor-doc/`。
### 工作流速查
| 工作流 | 触发 | 核心动作 |
|--------|------|----------|
| **Ingest** | 收到 URL/链接、用户说"搜一下" | 4 连击:source → entity → index → log |
| **Query** | Orchestrator 需要汇总报告 | 读 index → 读相关页面 → Synthesize |
| **Lint** | 定期或 Orchestrator 下令 | 扫孤岛链接、合并矛盾 |
| **Injection** | 处理用户提问前静默执行 | 运行 context-injector.py,注入实体卡片 |
| **Weekly** | 用户下令"周报" | weekly-reporter.py --days 7 |
| **Fact-Index** | 事实密集型素材 | --type fact-index → index-facts.json |
> ⚠️ **Ingest 必须 spawn sub agent 执行,Orchestrator 禁止自己跑采集。** 详见 `references/orchestrator-dispatch.md`。
---
## 通道 1 & 2:Orchestrator + ARC-Worker
详见 `references/orchestrator-dispatch.md`(派发规则)和 `references/spawn-template.md`(4 种 Worker 模板)。
**任务注入强制声明**:
> "⚠️ MANDATORY: Use `cat << 'EOF' | python3 scripts/archive-manager.py --type [TYPE] --topic [NAME] --stdin` for ALL outputs. Execute 4-combo operations (source, entity, index, log) for Ingest!"
---
## 🔒 铁律 (The Iron Rules)
1. **禁止 Orchestrator 自己执行 Ingest**:收到素材后,**必须 spawn sub agent** 执行 Ingest 4 连击,主会话只负责 Display Layer + 判断力输出。
2. **禁止绕出管道且禁止变更目录 (NO CD)**:永远使用 `--stdin`,在当前工作目录执行脚本,**严禁先 cd 进 skill 目录再执行**。
3. **凭证核实防幻觉**:必须校验脚本输出 JSON 中含有 `"status": "success"`。
4. **输出解耦 (Two-Tier Output)**:成功回执静默存储在 Archive 层,**严禁**将 JSON 回执完整吐给用户。
5. **注入优先 (Injection Awareness)**:回答前检查 `<ARC_KNOWLEDGE_CONTEXT>`,如有则优先引用。
6. **主动建议 (Proactive Insight)**:任何 Ingest/Query 任务结尾必须包含"主观判断"与"行动方案建议"。
7. **治理至上 (AODW Enforcement)**:确保所有 Agent 的动作都有 RT 记录。
---
## 🔔 Ingest 交付清单(Orchestrator 必须执行)
4 连击完成后,Orchestrator **必须**按顺序执行以下 4 个动作:
### 1. ✅ Display Layer 回复(≤200字,结论先行,「·」列表)
**规范**:
- 字数限制:≤200 字
- 结构要求:结论先行,用「·」列出要点
- 风格要求:自然对话风格,避免技术细节
**示例**:
```
已完成 {主题} 的知识编译。
核心结论:
· 提取了 {主要实体1}、{主要实体2} 的关键信息
· 建立了 {数量} 个知识节点链接
· 已存入 Wiki 供后续查询使用
```
### 2. ✅ 判断力输出(重要性 / 行动建议 / 可信度评估)
**规范**:
- **重要性**:明确标出该信息对用户的价值
- **行动建议**:下一步建议用户做什么
- **可信度**:根据来源评估信息的真实性
**示例**:
```
**我的判断**:
- 重要性:高(核心技术与当前项目相关)
- 建议行动:立即研究其架构设计,考虑集成到现有系统
- 可信度:高(来自官方技术文档)
```
### 3. ✅ 通过 message tool 发送 source 文件附件给用户
**要求**:
- 必须使用 message tool 发送文件附件
- 附件格式:source 文件(Markdown)
- 发送渠道:根据用户使用的平台(Discord/Telegram/其他)
**注意事项**:
- 不要发送 JSON 回执或其他内部文件
- 只发送用户可读的 Markdown 格式文件
### 4. ✅ 禁止将 JSON 回执完整吐给用户(输出解耦)
**要求**:
- 成功回执静默存储在 Archive 层
- 严禁将 JSON 回执完整吐给用户
- 只向用户展示 Display Layer 格式的中文摘要
**错误示例**:
```
✅ 完成 Ingest 4 连击:
1. {"status": "success", "path": "arc-reactor-doc/wiki/sources/...", "size_bytes": 3394}
2. {"status": "success", ...}
```
---
### 交付流程总结
```
Ingest 4 连击完成
↓
Orchestrator 验证结果(Post-Worker Validation)
↓
执行交付清单:
1. Display Layer 回复(≤200字)
2. 判断力输出(重要性/建议/可信度)
3. 发送 source 文件附件
4. 确认无 JSON 回执泄露
↓
交付完成
```
### 检查清单快速参考
| 步骤 | 动作 | 状态 | 备注 |
|------|------|------|------|
| 1 | Display Layer 回复(≤200字) | ⬜ | 结论先行,「·」列表 |
| 2 | 判断力输出 | ⬜ | 重要性/建议/可信度 |
| 3 | 发送 source 文件附件 | ⬜ | 使用 message tool |
| 4 | 确认无 JSON 回执泄露 | ⬜ | 输出解耦 |
每次 Ingest 完成后,Orchestrator 必须确认所有 4 个步骤都已完成。
---
## 🛡️ 事后验证(Post-Worker Validation)
**强制性要求**:Worker 完成任务后,Orchestrator 必须验证执行结果,防止 Worker 幻觉或伪造执行。
### 验证流程
1. **检查 JSON 回执**:Worker 应输出包含 `"status": "success"` 的 JSON
2. **验证文件存在**:运行 `python3 skills/arc-reactor/scripts/archive-manager.py --validate`
3. **如果验证失败**:Orchestrator 必须手动重新归档文件
### 示例验证流程
```bash
# Worker 完成后,Orchestrator 运行验证
python3 skills/arc-reactor/scripts/archive-manager.py --validate
# 预期输出(成功):
# {"status": "ok", "action": "validate_wiki", "files_valid": 15, "files_invalid": 0, "files_empty": 0, "invalid_files": [], "message": "Validation complete: 15 valid, 0 invalid (0 empty)"}
# 预期输出(失败):
# {"status": "partial", "action": "validate_wiki", "files_valid": 14, "files_invalid": 1, "files_empty": 1, "invalid_files": [...], "message": "Validation complete: 14 valid, 1 invalid (1 empty)"}
```
### 验证失败处理
- 如果 `files_invalid > 0` 或 `files_empty > 0`,说明 Worker 撒谎或执行失败
- Orchestrator 必须重新执行失败的归档操作
- 记录验证失败情况到 RT 或 issue 跟踪
### 双向验证机制
这形成了"Worker 执行 → Orchestrator 验证"的双向验证闭环:
- **Worker**:负责执行归档操作,输出 JSON 回执
- **Orchestrator**:负责验证执行结果,确保数据一致性
---
## 🖥️ Display Layer(展示层)
每次响应用户时必须遵守此层规范。详见 `references/output-style.md`。
### 核心要点
- **长度**:≤200 字,结论先行
- **风格**:模拟群聊直观汇报,核心洞察用「·」列出
- **判断力 (Judgement)**:必须给出重要性 / 行动建议 / 可信度评估
- 用户说"详细"、"展开" → 提供 Archive 层内容
---
## 🔄 Obsidian 同步层(可选后处理)
详见 `references/dispatchers/obsidian.md`。
**触发**:Display Layer 输出完成后,异步执行
**前置**:`OBSIDIAN_VAULT_PATH` 已配置且 `AUTO_SYNC != false`
---
## 📱 Channel 自适应输出
目标平台:Discord / Telegram(手机端)
- 不用 Markdown 表格
- 不用超过3行的代码块
- 分段要短,关键信息放前面
- 列表用「·」或「1. 2. 3.」
---
## 💬 自然触发词
用户可以说:
- "搜一下"、"帮我看"、"这个讲了什么" → 自动触发 Ingest + Display
- 发送任意链接 → 自动触发 Ingest + Display
- "详细说说"、"展开" → 触发 Archive 层
---
## 🤝 多 Agent 协作规范 (AODW Governance)
详见 [CONTRIBUTING.md](./CONTRIBUTING.md)。
- **RT Core**:任何修改必须在 `RT/` 目录下有追踪记录
- **Commit 签名**:每个提交标注 Agent 名称,格式:`(by AgentName)`
- **工具主权**:严禁直接操作 Wiki,必须调用 `archive-manager.py`
---
## 📦 Release Workflow
1. 更新 `_meta.json` 版本号
2. 执行 `bash scripts/release-skill.sh`
3. ZIP 包生成在 `dist/`,上传至 GitHub Releases
---
*Powered by ARC Factory V4.0.5 | Karpathy Wiki Arch*
FILE:CONTRIBUTING.md
# CONTRIBUTING.md — ARC Reactor 协作规范
所有 Agent(Codex / Claude Code / OpenClaw 子 Agent / 人类)必须遵守。
## 多 Agent 同一账户注意事项
所有 Agent 共用 `evan-zhang` 账户。以下规则确保不撞车:
- **分支名必须加 Agent 前缀**(见下方命名规范),避免推到同一个分支
- **Commit message 末尾标注作者**:`(by Codex)` / `(by Claude)` / `(by Orchestrator)`
- **先 fetch 再 push**:`git fetch origin` 确保本地最新
- **Git 并发保护**:如果两个 Agent push 同一分支,第二个会被拒绝(fast-forward 检查)
## 工作流
```
① gh issue list(先查有没有相关 Issue)
② gh issue create(没有就新建,描述清楚要改什么)
③ 在 Issue 上评论 "认领 by {agent-name}"(避免重复劳动)
④ git fetch origin && git checkout -b {agent-prefix}/{number}-{简述} origin/main
⑤ 写代码 + 测试
⑥ git push origin {agent-prefix}/{number}-{简述}
⑦ gh pr create --base main(标题写 Closes #{number})
⑧ 审查自己的 PR(见下方审查清单)
⑨ gh pr merge(合入)
⑩ 通知用户新功能已上线
```
## 铁律
1. **永远从最新 main 拉分支**:`git fetch origin && git rebase origin/main`
2. **禁止直接 push main**:Branch Protection 已开启,force push 会被拒绝
3. **禁止跳过 PR**:所有改动必须走 PR,哪怕是一行修复
4. **改之前先查 Issue**:`gh issue list` 看有没有人已经在做了
5. **PR 合入前必须审查**:见下方审查清单
## PR 审查清单
合入自己的 PR 之前,必须确认:
- [ ] `git diff main...HEAD --stat` 看改了哪些文件
- [ ] 没有误删已有功能(特别是 `archive-manager.py`)
- [ ] `python3 scripts/archive-manager.py --help` 功能完整
- [ ] 没有跟其他 open PR 冲突(`gh pr list`)
- [ ] 如果有冲突:先 rebase 最新 main 再合
## 冲突处理
如果 PR 跟其他 PR 冲突:
1. `git fetch origin && git rebase origin/main`
2. 解决冲突
3. `git push origin {branch} --force-with-lease`(在自己的分支上 force push 是安全的)
4. 重新检查后合入
## Issue 命名规范
- `feat: xxx` — 新功能
- `fix: xxx` — 修 bug
- `refactor: xxx` — 重构
- `docs: xxx` — 文档
- `chore: xxx` — 杂项
## 分支命名规范
必须加 Agent 前缀,避免撞车:
- `codex/{number}-{简短描述}` — Codex Agent 的分支
- `claude/{number}-{简短描述}` — Claude Code Agent 的分支
- `orchestrator/{number}-{简短描述}` — 编排者的分支
- `manual/{number}-{简短描述}` — 人工操作
例:`codex/22-multi-kb`、`claude/27-fact-index`
## 谁来 Review?
当前阶段:Agent 自审 + 功能验证。
未来:可以配置 CODEOWNERS 要求交叉审查。
FILE:README.md
# ARC Reactor Skill - AI Agent 知识编译引擎
> **⚠️ Agent Entry Point**
> 此目录是 ARC Reactor 的分发核心。如果你是正在读取此文件的 AI Agent,请以 `SKILL.md` 作为你的行为准则。
## 🎯 功能概览 (v4.2.0)
本项目是基于 Karpathy **Compilation over Retrieval** 理念构建的知识库。
1. **Ingest**: 深度摄入,执行“四连击”归档(Source, Entity, Index, Log)。
2. **Context Injection**: 自动从 Wiki 中检索实体内容注入上下文,实现无感知识增强。
3. **Weekly Reporter**: 聚合过去一周的摄入内容,生成高维度洞察周报。
4. **Wiki Lint**: 自动化维护词条链接的完整性。
## 📦 目录结构
```text
skills/arc-reactor/
├── SKILL.md # Agent 核心指令集
├── arc-reactor-config.yaml # 多模型路由与功能配置
├── _meta.json # 元数据
├── scripts/
│ ├── archive-manager.py # 存储与归档逻辑
│ ├── context-injector.py # 潜意识注入探针
│ └── weekly-reporter.py # 周报聚合引擎
└── references/ # 模板与审计规范
```
## 🚀 核心指令
### 1. 知识归档 (Ingest)
```bash
cat << 'EOF' | python3 scripts/archive-manager.py --type source --topic "标题" --stdin
[Markdown Content]
EOF
```
### 2. 潜意识探测 (Inject)
```bash
python3 scripts/context-injector.py --query "[用户的问题]"
```
### 3. 周汇总 (Weekly)
```bash
python3 scripts/weekly-reporter.py --days 7
```
## ⚙️ 配置说明
编辑 `arc-reactor-config.yaml` 可以调整:
- **models**: 不同任务分派给哪些模型(GPT-4o vs Flash)。
- **injection**: 开启/关闭自动注入,调整最大实体提取数。
- **weekly_brief**: 调整周报扫描天数。
---
*Powered by ARC Reactor v4.2.0*
FILE:REVIEW-obsidian-sync.md
# 复审报告:Obsidian 同步实现
> **审查日期**: 2026-04-09
> **审查者**: 代码审查子代理
> **目标**: ARC Reactor Obsidian Sync 功能复审
> **参考规范**: `/tmp/arc-reactor/SPEC-obsidian-sync.md`
---
## 复审结果
### 通过项
- **`--sync-obsidian` 命令正确实现**:参数齐全(`--source`, `--vault`, `--target`, `--async`),与 SPEC 7.2 一致
- **`validate_obsidian_config()` 校验逻辑与 SPEC 4.3 高度吻合**:目录存在性检查 + 写入权限测试,返回 `(bool, message)` 元组
- **`sync_to_obsidian()` 重试逻辑(指数退避)实现正确**:`sleep_time = retry_delay * (2 ** attempt)`,符合 SPEC "重试策略:使用指数退避"
- **SKILL.md 插入位置正确**:Obsidian 同步小节在 Display Layer 之后、Channel 自适应输出之前
- **SKILL.md 配置变量表完整**:包含 `OBSIDIAN_VAULT_PATH`、`OBSIDIAN_TARGET_DIR`、`AUTO_SYNC` 三个变量
- **SKILL.md 铁律正确**:
- ✓ 同步失败不阻塞 Display Layer 输出
- ✓ 同步失败不重写 Display Layer 内容
- ✓ `AUTO_SYNC=false` 时完全不执行任何 Obsidian 相关代码
- **Display Layer 状态模板符合 SPEC**:提供了成功和失败两种场景的模板
- **SKILL.md 字数规范合理**:≤200字原则保持不变
---
### 问题项
#### 🔴 严重问题
1. **AUTO_SYNC 环境变量未实现** → `sync_to_obsidian()` 函数完全没有检查 `AUTO_SYNC` 环境变量
SPEC 3.1 明确要求:"`AUTO_SYNC` != `false`" 时才执行。但 `main()` 中 Obsidian sync 分支仅检查 `--source` 和 `vault_path`,未验证 `AUTO_SYNC`。
**影响**: `AUTO_SYNC=false` 无法完全关闭同步(与铁律矛盾)
**修复建议**: 在 `main()` 的 Obsidian sync 分支开头添加:
```python
auto_sync = os.environ.get('AUTO_SYNC', 'true')
if auto_sync.lower() == 'false':
print(json.dumps({"status": "skipped", "message": "AUTO_SYNC=false, Obsidian sync disabled"}))
sys.exit(0)
```
2. **`os.fork()` 仅支持 Unix/macOS,不支持 Windows** → `async_mode` 在 Windows 上会崩溃
`main()` 第 347-360 行使用 `os.fork()` 实现后台执行,但 Windows 没有 `fork()` 系统调用。
**修复建议**: 使用跨平台方案:
```python
if getattr(args, 'async_mode', False):
import threading
thread = threading.Thread(
target=lambda: sync_to_obsidian(args.source, vault_path, args.target),
daemon=True
)
thread.start()
print(json.dumps({"status": "pending", ...}))
sys.exit(0)
```
#### 🟡 中等问题
3. **重试间隔与 SPEC 不符(5分钟 vs 5秒)** → SPEC 6.0 表格注明 "间隔5分钟",实现使用 `retry_delay=5`(秒)
**影响**: 网络问题导致 iCloud 同步失败时,用户需要等待远超预期的总时长(5+10+20=35秒 vs 5+10+20=35分钟)
**修复建议**: 将 `sync_to_obsidian()` 的 `retry_delay` 默认值从 `5` 改为 `300`(5分钟×60秒)
4. **路径遍历未防护** → `target_dir` 或 `source_path` 含 `../` 时可逃逸 vault 目录
SPEC 5.0 没有明确提到路径注入风险,但这是安全最佳实践。
**修复建议**: 在 `sync_to_obsidian()` 中加入:
```python
dest_dir = os.path.normpath(os.path.join(os.path.expanduser(vault_path), resolved_target))
real_vault = os.path.normpath(os.path.expanduser(vault_path))
if not dest_dir.startswith(real_vault):
return {"status": "error", "error": "路径遍历被拒绝"}
```
5. **同步标记每次重试都追加** → 3次重试全部失败时,dest 文件会有 3 个 `同步状态` 标记
因为 `shutil.copy2()` 后 `open(dest_path, 'a')` 每次追加都会执行。
**修复建议**: 在 `sync_to_obsidian()` 的成功返回路径中,仅在首次成功时追加标记(当前逻辑已在成功路径追加,OK;但失败路径应该用 `else` 确保只在成功时追加)
#### 🔵 轻微问题
6. **SKILL.md 中 `{date}` 占位符说明不明确** → 仅在配置变量表 "说明" 列出现过,使用示例中未展示
**建议**: 在 "使用示例" 的 `--target` 参数中加入实际替换示例:
```bash
--target "github分享/AI调研/2026-04-09/" # {date} 将被替换为 2026-04-09
```
---
### SPEC 符合度
| 检查项 | 结果 | 说明 |
|--------|------|------|
| Display Layer 不感知同步失败 | ✓ | `validate_obsidian_config()` 失败仅返回 error dict,不影响主流程 |
| 同步不是第5步 | ✓ | SKILL.md 明确标注为 "附加动作,异步,不阻塞" |
| `AUTO_SYNC=false` 完全关闭 | ✗ | **实现缺失** — 代码中完全没有 `AUTO_SYNC` 检查 |
| 异步非阻塞 | ✓(有平台限制) | `os.fork()` 实现仅支持 Unix/macOS |
| Display Layer ≤200字 | ✓ | SKILL.md 规范正确 |
| 指数退避重试 | ✓ | `retry_delay * (2 ** attempt)` |
| 重试间隔5分钟 | ✗ | 实现为5秒 |
| 路径遍历防护 | ✗ | 未实现 |
| `validate_obsidian_config()` 符合 SPEC | ✓ | 逻辑与 SPEC 4.3 一致 |
| SKILL.md 插入位置正确 | ✓ | 在 Display Layer 之后 |
| 铁律正确 | ✓(除 `AUTO_SYNC` 外) | 三条铁律均正确 |
---
### 总体评价
**实现质量:良好(75/100)**
核心逻辑(`validate_obsidian_config`、`sync_to_obsidian` 重试机制、Display Layer 独立性)实现正确,SKILL.md 文档规范完善。
**主要缺陷**:
1. `AUTO_SYNC=false` 完全未实现 — 这是 SPEC 明确要求的功能
2. `os.fork()` 造成平台锁定 — Windows 用户无法使用 async 模式
3. 重试间隔单位错误(秒 vs 分钟)— 可能导致用户等待体验与预期不符
**建议优先级**:
1. **P0**: 补充 `AUTO_SYNC` 环境变量检查
2. **P0**: 替换 `os.fork()` 为 `threading.Thread`(跨平台兼容)
3. **P1**: 将 `retry_delay` 默认值改为 300(5分钟)
4. **P2**: 添加路径遍历防护
5. **P3**: SKILL.md 补充 `{date}` 占位符使用示例
---
*本报告为只读审查,不涉及代码修改。审查结果已保存至 `/tmp/arc-reactor/REVIEW-obsidian-sync.md`*
FILE:SPEC-obsidian-sync.md
# SPEC: Obsidian Sync 集成规范
> **版本**: v1.0
> **状态**: 设计规范
> **目标**: 将 Obsidian 同步功能无缝嵌入 ARC Reactor SKILL.md 主流程
---
## 1. 背景与设计目标
### 现状
- ARC Reactor 的 **4连击** (source → entity → index → log) 已完善,输出落地在本地 `arc-reactor-doc/` Wiki
- `references/dispatchers/obsidian.md` 定义了 Obsidian 同步调度逻辑,但**未集成**到 SKILL.md 主流程
- 用户需要在报告生成后手动同步到 Obsidian,流程割裂
### 设计目标
1. **透明集成**:Obsidian 同步作为 Display Layer 之后的附加动作,对主流程无侵入
2. **可选控制**:通过 `AUTO_SYNC=false` 完全关闭,不影响任何主流程
3. **异步非阻塞**:同步失败不阻塞用户,不影响 Display Layer 输出
4. **Display Layer 纯净**:≤200字原则不受影响,同步状态用超链接替代文字堆砌
---
## 2. 集成点设计
### 2.1 在 SKILL.md 中的插入位置
在 SKILL.md 的 **Display Layer** 小节之后、**Channel 自适应输出** 之前,插入新小节:
```
## 🔄 Obsidian 同步层(可选后处理)
触发时机:Display Layer 输出完成后,作为独立附加动作异步执行
前置条件:OBSIDIAN_VAULT_PATH 已配置且有效
```
### 2.2 完整流程顺序
```
用户输入
↓
[4连击 Ingest] (source → entity → index → log)
↓
[Display Layer] ≤200字,用户可见
↓
[Obsidian Sync] ← 附加动作,异步,不阻塞
↓
返回结果给 Orchestrator
```
**关键决策**:Obsidian 同步**不是第5步**,而是 Display Layer 之后的独立后处理动作。
理由:
- 4连击 是知识库写入的原子操作,职责单一
- Obsidian 同步是**消费**已生成的报告,不是生产知识
- Display Layer 必须在同步之前完成(否则会因同步失败而延迟用户可见输出)
---
## 3. 同步触发时机
### 3.1 触发条件(同时满足)
| 条件 | 说明 |
|------|------|
| `OBSIDIAN_VAULT_PATH` 已配置 | 未配置时跳过,不询问 |
| `AUTO_SYNC` != `false` | 默认为 `true`,用户可关闭 |
| 本次 Ingest 产生了 source 文件 | 无 source 时不同步(避免空操作) |
### 3.2 触发时机点
在 Display Layer 输出完成**之后**,作为独立步骤执行:
```python
# 伪代码
def after_display_layer(report_path):
if not AUTO_SYNC or AUTO_SYNC == "false":
return # 完全跳过
if not OBSIDIAN_VAULT_PATH:
return # 未配置,跳过
# 异步后台执行,不阻塞主流程
async_task = spawn(sync_to_obsidian, report_path)
```
### 3.3 同步文件选择
同步时按以下优先级选择要复制的文件:
1. **source 文件**(本次 Ingest 的原始报告):`arc-reactor-doc/wiki/sources/{date}/{topic}.md`
2. **不存在时**:使用 index.md 中本次相关的摘要行
---
## 4. 配置方式
### 4.1 环境变量(推荐)
```bash
# .env 或 openclaw.json
OBSIDIAN_VAULT_PATH=~/Library/Mobile Documents/iCloud~md~obsidian/Documents/
OBSIDIAN_TARGET_DIR=github分享/AI调研/{date}/
AUTO_SYNC=true
```
### 4.2 SKILL.md 中的引导流程(新增)
在 SKILL.md 的**铁律**之后、**Display Layer** 之前,插入 Obsidian 配置引导:
```
### Obsidian 同步配置(首次使用引导)
若 OBSIDIAN_VAULT_PATH 未设置,Agent 应主动引导:
1. 询问用户 Obsidian 根目录路径
2. 将路径写入 .env(OBSIDIAN_VAULT_PATH)
3. 执行自检脚本(见 obsidian.md 第3节)
4. 确认后告知用户已开启自动同步
```
### 4.3 配置校验逻辑
```python
def validate_obsidian_config():
vault = os.path.expanduser(OBSIDIAN_VAULT_PATH)
if not os.path.isdir(vault):
return False, "Obsidian 库路径不存在"
test_path = os.path.join(vault, '.arc-sync-test')
try:
with open(test_path, 'w') as f:
f.write('ping')
os.remove(test_path)
return True, "OK"
except:
return False, "无写入权限"
```
---
## 5. Display Layer 输出规范
### 5.1 原则
- Display Layer **永远独立**于 Obsidian 同步状态
- 同步成功/失败**不在 Display Layer 文字中体现**
- 用户通过超链接或附加标记感知 Obsidian 状态
### 5.2 Display Layer 输出模板
**场景A:同步成功(Display Layer 不变)**
```
报告已完成 ✅
· [[Claude-Code]]: Anthropic 系统级 AI 编程助手
· [[SWE-bench]]: 软件工程评测基准
· [[Devin]]: Cognition AI 的自主编程智能体
📁 已在 Obsidian 同步 → [打开 Obsidian](obsidian://open?vault=Documents)
```
**场景B:同步失败(Display Layer 仍显示成功)**
```
报告已完成 ✅
· [[Claude-Code]]: Anthropic 系统级 AI 编程助手
· [[SWE-bench]]: 软件工程评测基准
⚠️ Obsidian 同步暂不可用(后台重试中)
📁 报告已保存在本地 arc-reactor-doc/wiki/sources/
```
### 5.3 字数控制
Display Layer 核心文字(不含链接)≤180字,剩余空间留给同步状态标记。
| 场景 | 核心字数 | 状态标记字数 | 总计 |
|------|----------|--------------|------|
| 同步成功 | ~150字 | ~25字 | ≤200字 |
| 同步失败 | ~150字 | ~40字 | ≤200字 |
| 未配置同步 | ~190字 | 0 | ≤200字 |
### 5.4 同步状态附录标记
在源报告文件末尾追加(不影响 Display Layer):
```markdown
---
同步状态: ✅ Obsidian (时间: 2026-04-09 14:30)
---
```
或失败时:
```markdown
---
同步状态: ⚠️ Obsidian 失败 (原因: 路径无写入权限)
重试次数: 1/3
---
```
---
## 6. 错误处理策略
| 错误类型 | 处理方式 |
|----------|----------|
| `OBSIDIAN_VAULT_PATH` 未配置 | 跳过,不询问(按需引导) |
| 库路径不存在 | Display Layer 显示警告,超链接变为"配置 Obsidian" |
| 写入权限不足 | 重试1次,失败后记录到 log.md,跳过 |
| 网络问题(iCloud不同步) | 后台重试3次(间隔5分钟),Display Layer 无感知 |
| 目标目录创建失败 | 记录错误,不阻塞,Display Layer 无感知 |
**重试策略**:使用指数退避,最多3次,异步执行。
---
## 7. 实现方案
### 7.1 新增 SKILL.md 片段
在 SKILL.md Display Layer 小节**之后**插入:
```markdown
## 🔄 Obsidian 同步层(可选后处理)
**触发时机**:Display Layer 输出完成后,异步执行
**前置条件**:`OBSIDIAN_VAULT_PATH` 已配置且 `AUTO_SYNC != false`
### 配置变量
| 变量 | 默认值 | 说明 |
|------|--------|------|
| `OBSIDIAN_VAULT_PATH` | `~/Library/Mobile Documents/iCloud~md~obsidian/Documents/` | Obsidian 仓库根路径 |
| `OBSIDIAN_TARGET_DIR` | `github分享/AI调研/{date}/` | 目标子目录,`{date}` 自动替换 |
| `AUTO_SYNC` | `true` | 是否自动同步 |
### 执行流程
1. **校验配置**:`validate_obsidian_config()` 返回失败则跳过
2. **复制报告**:将本次 source 文件复制到 `{vault}/{target_dir}/{topic}.md`
3. **追加状态**:在源文件末尾追加 `同步状态: ✅ Obsidian (时间: ...)`
4. **Display Layer**:Display Layer 永远先于同步完成,用户无感知等待
### 铁律
- 同步失败**不阻塞** Display Layer 输出
- 同步失败**不重写** Display Layer 内容
- `AUTO_SYNC=false` 时完全不执行任何 Obsidian 相关代码
```
### 7.2 archive-manager.py 扩展
新增 `--sync-obsidian` 命令:
```bash
python3 scripts/archive-manager.py \
--sync-obsidian \
--source arc-reactor-doc/wiki/sources/2026-04-09/claude-code.md \
--vault "$OBSIDIAN_VAULT_PATH" \
--target "github分享/AI调研/{date}/" \
--async
```
返回 JSON 回执:
```json
{
"status": "success",
"action": "obsidian_sync",
"source": "...",
"destination": "...",
"obsidian_vault": "~/Library/.../Documents/",
"sync_time": "2026-04-09 14:30:05",
"message": "Obsidian sync complete."
}
```
### 7.3 Orchestrator 任务注入更新
Spawn 任务时,在 Display Layer 完成后追加:
```bash
# Obsidian 同步(如果已配置且 AUTO_SYNC != false)
python3 scripts/archive-manager.py \
--sync-obsidian \
--source "arc-reactor-doc/wiki/sources/{date}/{topic}.md" \
--vault "$OBSIDIAN_VAULT_PATH" \
--target "$OBSIDIAN_TARGET_DIR" \
--async
```
---
## 8. 测试用例
### TC-1:完整同步流程
```
前置条件:AUTO_SYNC=true, OBSIDIAN_VAULT_PATH 有效
步骤:
1. 执行 Ingest(4连击)
2. 观察 Display Layer 输出
3. 检查 Obsidian 目标目录是否有文件
预期:Display Layer 先显示,Obsidian 同步异步完成
```
### TC-2:同步失败不影响主流程
```
前置条件:AUTO_SYNC=true, OBSIDIAN_VAULT_PATH 指向不存在路径
步骤:
1. 执行 Ingest(4连击)
2. 观察 Display Layer 输出
预期:Display Layer 正常显示成功,Obsidian 失败有日志但不展示给用户
```
### TC-3:AUTO_SYNC=false 完全关闭
```
前置条件:AUTO_SYNC=false
步骤:
1. 执行 Ingest(4连击)
2. 检查是否有任何 Obsidian 相关日志或文件操作
预期:无任何 Obsidian 操作,Display Layer 正常
```
### TC-4:Display Layer 字数验证
```
前置条件:标准 Ingest 输出
步骤:
1. 捕获 Display Layer 输出
2. 统计字符数(含状态标记)
预期:≤200字
```
### TC-5:OBSIDIAN_VAULT_PATH 未配置时按需引导
```
前置条件:OBSIDIAN_VAULT_PATH 为空,用户首次提及 Obsidian
步骤:
1. 用户说"帮我搜 xxx,结果存到 Obsidian"
预期:Agent 主动询问 Obsidian 路径,完成配置引导
```
---
## 9. 文件变更清单
| 文件 | 变更类型 | 说明 |
|------|----------|------|
| `SKILL.md` | 修改 | 在 Display Layer 后插入 Obsidian 同步小节 |
| `scripts/archive-manager.py` | 修改 | 新增 `--sync-obsidian` 命令 |
| `references/dispatchers/obsidian.md` | 保持 | 现有调度器保持不变,作为参考 |
---
## 10. 关键设计决策记录
| 决策 | 理由 |
|------|------|
| 同步不是第5步 | 4连击是知识生产原子操作,Obsidian 是消费动作,时机不同 |
| Display Layer 不感知同步状态 | 保持≤200字原则,同步是后处理细节 |
| 失败不阻塞 | Obsidian 是锦上添花,不能因为 iCloud 问题影响核心体验 |
| 异步执行 | 用户不需要等待 iCloud 同步完成才能看到结果 |
| 配置通过环境变量 | 与现有 .env 体系一致,无需新配置接口 |
---
*本规范为设计文档,实现请同步更新 SKILL.md 和 archive-manager.py*
FILE:_meta.json
{
"name": "arc-reactor",
"version": "4.3.0",
"description": "ARC (Acquire/Research/Catalogue) - 个人知识建设智能调研引擎",
"author": "evan",
"tags": ["research", "knowledge-management", "automation"],
"created": "2026-04-08",
"rt": "RT-002"
}
FILE:arc-reactor-config.yaml
# ARC Reactor Routing Configuration (V4.0)
# 此文件用于映射不同编译任务所属的 LLM Model,以兼顾智商与成本。
# Orchestrator (指挥官) 应在 Spawn Worker 时参考此表。
version: "4.0"
models:
# 顶级推理模型 (不计成本,最高智商)
# 用于:解析生肉、生成 Source、提取 Entity、处理高度矛盾的重度任务。
ingest_heavy: "openai-gpt-4o"
# 次级高速模型 (较便宜,速度极快)
# 用于:日常 Query 读取 Wiki 合成报告,或者生成单维度的分析。
query_medium: "claude-3-5-sonnet-20240620"
# 轻量级流水线模型 (最便宜,最高速)
# 用于:Lint 整理,跑批扫描孤立的 index.md 和断链,处理日常语法修正。
lint_light: "gemini-1.5-flash"
workflows:
ingest:
required_combo: [source, entity, index, log]
preferred_model: "ingest_heavy"
query:
required_combo: [output]
preferred_model: "query_medium"
lint:
required_combo: [index, log, metadata_fix]
preferred_model: "lint_light"
knowledge_bases:
- name: personal-learning
root: arc-reactor-doc
description: "External research, papers, open-source analysis"
auto_route:
sources: ["web_fetch", "web_search", "youtube", "manual"]
tags: ["research", "paper", "open-source", "tutorial", "agent", "ai"]
- name: work-collaboration
root: cwork-kb
description: "CWork inbox/outbox, contracts, HR decisions, project progress"
auto_route:
sources: ["cwork-api", "cwork-query-report", "cwork-send-report"]
tags: ["合同", "汇报", "人事", "审批", "待办", "任务"]
# 潜意识注入 (RT-007) 配置
injection:
enabled: true
max_entities: 3 # 单次命中最大调取的实体数量
max_chars: 10000 # 注入上下文的最大字符长度限制
match_index: "index.md"
# 周报系统 (RT-008) 配置
weekly_brief:
default_days: 7
summarizer_model: "claude-3-haiku" # 高性价比摘要模型
FILE:references/dedup-rules.md
# 去重检测规则 (Dedup Rules)
## 检测时机
在 ARC 进入 Acquire 阶段**之前**,必须先执行去重检测。
## 三级匹配
### L1: URL 精确匹配
- 扫描 `reports/` 和 `knowledge/entities/` 中所有已记录的 URL
- 完全相同 → 直接跳过,汇报"此链接已于 X 日调研过"
### L2: 主体匹配
- 从输入中提取核心实体名(项目名/人物名/公司名)
- 与 `knowledge/entities/` 目录下的文件名做模糊匹配
- 命中 → 静默进入合并模式
### L3: 语义相似(自主判断)
- 对输入的标题/摘要与已有报告做语义相似度判断
- 核心主体 ≥70% 重叠 → 自动合并
- 仅部分关联 → 独立建档,但在两份报告中互相标注 `关联调研`
- 一个输入涉及多个主体 → 一分为二,各自归入对应实体
- 用户明确要求独立 → 尊重用户指令覆盖自动判断
## 合并模式 (Merge Mode)
### 增量信息提取
- 仅提取新源中旧报告**不包含**的信息
- 标注每条新增信息的来源:`[来源: 平台/作者]`
### 报告更新
- **基本信息表**:追加新的来源行
- **正文章节**:新发现插入对应章节末尾,或新增独立章节
- **附录**:追加新的原始素材路径
### 源追踪表
每份报告底部维护多源追踪表:
```markdown
## 源追踪
| # | 来源平台 | URL | 内容类型 | 采集日期 | 贡献内容 |
|---|----------|-----|----------|----------|----------|
| 1 | 今日头条 | ... | 文章 | 2026-04-06 | 初始调研 |
| 2 | 抖音 | ... | 视频 | 2026-04-07 | 补充架构细节 |
```
## 冲突处理
- **数据类**(Star 数、版本号):用新值覆盖旧值,标注更新时间
- **观点类**(评价、结论):两者并存,标注各自来源
FILE:references/dispatchers/obsidian.md
# Obsidian 同步调度器 (Obsidian Dispatcher)
在使用 ARC 之前,若你想将报告自动同步到本地的 Obsidian 知识库中,必须完成此配置与检测。
## 1. 核心配置清单
在 Agent 初始化或接收到首次"设置同步"指令时,引导用户确认以下变量(若未设置,请向用户询问):
| 配置项 | 默认值 | 说明 |
|--------|--------|------|
| `OBSIDIAN_VAULT_PATH` | `~/Library/Mobile Documents/iCloud~md~obsidian/Documents/` | (以 iCloud 同步为例) 你的 Obsidian 仓库本地绝对路径 |
| `OBSIDIAN_TARGET_DIR` | `github分享/AI调研/{date}/` | 你希望报告在 Obsidian 库内存放的具体文件夹 |
| `AUTO_SYNC` | `true` | 是否在生成报告后立即触发同步 |
> **提示(Agent 必读)**:如果用户是在 Windows 下,默认路径通常为 `C:\Users\用户名\Documents\Obsidian\`;如果是 Mac iCloud 则多如默认值。引导他们找到自己的真准路径!
## 2. Agent 引导配置流程
当用户需要开启此功能时,**你作为 Agent 必须主动按以下步骤引导**:
1. **询问路径**:"嗨,为了让调研报告直接发到你的 Obsidian,请告诉我你的 Obsidian 根目录物理路径在哪儿?"
2. **生成环境变量**:将用户提供的路径写入工作区的 `.env` 文件(或 `openclaw.json` 配置内),注册 `OBSIDIAN_VAULT_PATH`。
3. **执行自检脚本**(见第 3 节),并在聊天中汇报检测结果。
## 3. 自检与生效验证机制 (Validation)
配置完成后,Agent 需要立即执行环境自检,确保配置可用。你可以直接在系统中执行以下 `bash` 命令来进行验证:
```bash
# Agent 自检执行命令
VAULT="OBSIDIAN_VAULT_PATH"
TARGET="OBSIDIAN_TARGET_DIR/\{date\/$(date +%Y-%m-%d)}"
FULL_PATH="VAULT/TARGET"
# 1. 检查根目录是否存在
if [ ! -d "$VAULT" ]; then
echo "❌ 失败: 找不到 Obsidian 库根路径 ($VAULT)!请检查路径或权限设置。"
exit 1
fi
# 2. 测试创建目标结构与探测写入权限
mkdir -p "$FULL_PATH"
if touch "$FULL_PATH/.arc-test-ping"; then
echo "✅ 成功: Obsidian 同步链路检测通过。目标目录 ($FULL_PATH) 具有读写权限。"
rm "$FULL_PATH/.arc-test-ping"
else
echo "❌ 失败: 没有权限在 ($FULL_PATH) 中写入文件,请检查目录权限!"
exit 1
fi
```
**反馈逻辑**:
- 如果输出 `✅ 成功`,请用愉快的语气告诉用户:"搞定啦!Obsidian 路径连接畅通无阻,马上为你自动保存以后的报告。"
- 如果输出 `❌ 失败`,必须把错误信息告知用户,并协助纠正路径格式(如排查空格逃逸、Mac 的 iCloud 重定向问题等)。
## 4. 运行时同步规则
- 报告正式完成后,提取执行时刻的 `YYYY-MM-DD` 替换 `{date}`。
- 将生成的报告文件完整复制过去。
- 同步后,在源工作区本地报告的附录处追加标记:`同步状态: ✅ Obsidian (时间: YYYY-MM-DD HH:MM)`。
FILE:references/env-setup.md
# ARC Reactor 环境挂载与凭证鉴权指南 (Environment Setup)
作为新一代跨域全模态采集网闸,ARC 已经不再只依赖大语言模型自身,而是需要外引一系列网络侦察爬虫节点与跨域 Search 鉴权。
> **何时触发**:当这是系统第一次挂载 ARC Reactor,或者当 Orchestrator 要派遣采集器却发现缺少依赖时,**必须第一时间要求用户完成下述参数设置,并在 `.env` 里注册**。如果用户不知道如何获取,请指导他们!
## 1. 广域网络搜索引擎配置 (Web Search API)
> 用于实现 ARC-Worker 突破单点网页盲区,去搜查周边竞品及对性能评定做异源真伪核查用的雷达探测。
| 配置项环境变量 | 类型 | 适用方 | 说明 |
|------------|-------|------|------|
| `SEARCH_PROVIDER` | `string` | 必填 | 首选抓包探针。比如 `tavily`, `google_news`, 或基于 OpenAI Search 的 API |
| `SEARCH_API_KEY` | `string` | 必填 | 对应的鉴权私钥。提示:引导用户从相应的开发者平台获取。 |
## 2. 第三方 Clawhub 反风控挂件绑定 (Scraping Skills)
> 如果遇到了被厚重心墙风控的目标群体(推特、油管字幕、抖音视频等),纯文本嗅探脚本会碰壁。我们必须通过系统指令调用专属的反风控挂机插件。
**配置指引:引导用户打开 Clawhub 或 OpenClaw 的 Market 去分别安装对应媒体的专业捕捞网,确保主 Agent 能访问它们的 tools:**
| 所需挂件 (示例) | 探测媒体类型范围 | ARC 调用时机 |
|-----------------|----------------|-------------|
| `youtube-dl-agent` / `whisper-sub` | 视频流 | 当目标 URL 是 Bilibili/YouTube 时,调用该挂件扒原视频及字幕转录。 |
| `xhs-claw` / `douyin-spider` | 封闭流媒体社交 | 当目标属于强反爬私域(小红书/抖音等)时进行免密滑块穿透。 |
| `twitter-mcp` / `reddit-scraper` | 文本强社交 | 当目标是 X 平台,获取其底下的回帖与社交反响。 |
## 3. 自检协议
跟 Obsidian 的验证一样,在装配完毕后,Agent 应该用测试指令试探一下环境引擎是否就绪:
- 搜一下 "今天的天气" 验证搜索引擎 Key。
- 启动指定的 Clawhub 挂件查查它的 `tools` 表判断是否可用。
如果失败,向用户报错。
FILE:references/knowledge-rules.md
# 知识编译规则 (Knowledge Compilation Rules)
> 借鉴 Karpathy LLM Wiki 理念:报告是"快照",知识库是"活体"。
## 三层架构
```
Layer 1: Raw(原始素材层)
reports/YYYY-MM-DD/raw/
转录稿、截图、PDF — 只读存储,先全收
Layer 2: Wiki(知识编译层)
knowledge/
├── entities/ 实体页(项目/人物/公司)
├── concepts/ 概念页(术语/方法论)
├── comparisons/ 对比页(A vs B)
└── index.md 知识导航目录
Layer 3: Schema(规则层)
本文件 — 定义编译行为和交叉引用规则
```
## 编译触发
每次 ARC 完成一份报告后,自动执行知识编译:
### 1. 实体提取
- 从报告中识别关键实体(项目名、人物、公司)
- 已存在 `knowledge/entities/[实体].md` → **追加更新**
- 不存在 → **新建实体页**
### 2. 概念关联
- 识别报告中的核心概念
- 更新 `knowledge/concepts/`
### 3. 交叉引用
- 在相关实体页之间建立 `[[双向链接]]`(Obsidian 格式)
- 确保知识网络互相连通
### 4. 索引更新
- 更新 `knowledge/index.md` 目录
- 记录新增/更新的实体和概念
## 实体页模板
```markdown
# [实体名]
## 基本信息
| 字段 | 内容 |
|------|------|
| **类型** | 项目/人物/公司 |
| **首次调研** | YYYY-MM-DD |
| **最后更新** | YYYY-MM-DD |
| **信息来源数** | N |
## 核心描述
[一段话总结]
## 关键数据
[表格化的核心指标]
## 关联实体
- [[相关实体1]]
- [[相关实体2]]
## 来源报告
- [报告1](../reports/YYYY-MM-DD/xxx-调研报告.md)
- [报告2](../reports/YYYY-MM-DD/xxx-调研报告.md)
```
## 矛盾检测 (Self-Challenge)
当新调研内容与已有知识页存在矛盾时:
- 自动生成 `knowledge/conflicts/YYYY-MM-DD-[主题].md`
- 列出旧观点、新矛盾点、建议修正方案
- 数据类矛盾:直接用新值覆盖
- 观点类矛盾:两者并存,标注来源
FILE:references/orchestrator-dispatch.md
# 编排与派发行动准则 (Orchestrator Dispatch Protocol)
> **目标对象**:作为系统最前台收口主力的 **Orchestrator 主 Agent**。
> **禁止越权**:你本身不要去读取万字长文、不要做抽取分析,你的任务就是把活儿漂亮地分发出去。
## 1. 原则: Announce-then-act
当用户在会话中直接丢给你(或附带了多个)URL 分析,或者长篇文本分析的情报时。你唯一需要在当前对话池内做的事情如下。
### 严禁:
- 先发一条“好的,我明白了,马上干”。
- 再空转思考如何去抓链接。
### 第一反应步骤:
你的下一条也是唯一的回复必须满足 **Announce-then-act**,而且要带上实际的工具调用!
- 回复内容必须类似:“✅ 收到。您的链接已被接管。我正在利用并发机制开启 ARC 子矿工进程进行全网潜底抓取... 您的聊天主线不受影响。”
- **同时在产生这一条文本回复的一瞬间 (同一个调用内)**,你必须使用系统的 `spawn` (若支持) 或原生 sub-agent 创造工具进行任务派送。
## 2. 派发参数
主 Agent 生成衍生任务时,必须要向后台子代理传递清晰的初始指令:
- **Context/Task Prompt**: "你是 ARC Reactor 的后台子执行代理(ARC-Worker)。请从目标 URL:`[原链接]` 进行抓取,并完整执行针对该文章的 [获取->二次审查->实体合并卡片创建等知识编译] 全流程记录。请参考配置 `arc-reactor/SKILL.md` 的通道 2 指南干活!一切结果必须物理生成文件,不能在后台自言自语。"
## 3. 并发派发 (Multi-URL Scenario)
如果你一次性看到用户丢给你这三个文章的 URL 想快速了解:
- `https://A...`
- `https://B...`
- `https://C...`
**不要排队阻塞,并发全开!**
- 同步且并发地 `spawn()` 出三个独立的 ARC-Worker 分配目标 A、B 和 C 去分析。
- 确保并嘱咐它们对于同源主题要基于**追加写入与 Merge 模型**来避免锁死(`references/dedup-rules.md`)。
## 4. Yield-after-spawn (立刻退行与守护)
所有的任务交接丢给 Worker 以后,作为主 Agent:
1. 立刻调用您的系统级打断机制 `yield` 或关闭你的当前 Turn 焦点窗口。
2. 将关注力归还给用户本身。用户可以在此时问你今天几度、或者跟你闲聊昨晚的比赛。
3. 一直等到 Worker 在系统总线上跑完爬虫流程,通过“物理生成文件附件并弹窗跨域投送大群”时,任务完成闭环。你不用在主线轮询死等这个状态。
FILE:references/output-style.md
# Output Style Guide — Display Layer vs Archive Layer
## 概述
ARC Reactor 输出分两层:**Display Layer**(展示层)和 **Archive Layer**(归档层)。Display Layer 是用户直接看到的回复;Archive Layer 是存储到知识库的结构化内容。
---
## 🖥️ Display Layer(展示层)
**用途**:直接回复用户,是唯一的"对外"输出层。
### 规范
| 属性 | 要求 |
|------|------|
| **字数** | ≤200 字 |
| **风格** | 模拟人类对话,像在聊天 |
| **结构** | 结论先行,用「·」列出要点 |
| **语气** | 自然、口语化,避免机械感 |
### 格式示例
```
这个项目讲的是 [一句话概括]。
核心结论:
· 要点1
· 要点2
· 要点3
想了解更多可以追问。
```
### 触发规则
- 用户说"详细"、"展开"、"展开说说" → 提供 Archive 层内容
- 用户说"搜一下"、"帮我看"、"这个讲了什么" → 自动触发 Ingest + Display
---
## 📦 Archive Layer(归档层)
**用途**:存入知识库,供长期查阅和检索。是内部存储格式。
### 规范
| 属性 | 要求 |
|------|------|
| **格式** | Markdown + YAML frontmatter |
| **结构** | `title`, `date`, `sources`, `tags` |
| **内容** | 完整的事实提炼,包含 `[[wiki-link]]` |
| **命名** | 使用 `--type source/entity/concept` 通过 archive-manager.py 归档 |
### 格式示例
```yaml
---
title: Hermes Agent 架构
date: 2026-04-09
sources: ["raw/hermes-paper.pdf"]
tags: [agent, llm, architecture]
---
# 正文内容
提到了 [[OpenClaw]] 框架...
```
---
## 📱 Channel 自适应规则
目标平台:Discord / Telegram(手机端)
### 规则列表
1. **不用 Markdown 表格** — 用「·」列表或「1. 2. 3.」代替
2. **不用超过3行的代码块** — 超过则折叠或改用描述
3. **分段要短** — 每段≤3行,关键信息放最前面
4. **列表用「·」或「1. 2. 3.」** — 不用「-」或「*」
### 自适应对照表
| 场景 | 标准格式 | Channel 自适应 |
|------|----------|----------------|
| 多项并列 | Markdown 表格 | 「·」列表 |
| 代码展示 | 多行代码块 | 单行 or 折叠描述 |
| 详细信息 | 段落展开 | 关键句 + "追问获取更多" |
---
## ✍️ 字数限制说明
| 层级 | 上限 | 说明 |
|------|------|------|
| Display Layer(展示层) | 200 字 | 用户直接看到的回复 |
| Archive Layer(归档层) | 无限制 | 知识库存档,完整为上 |
| 每段落(Channel 自适应) | 3 行 | Discord/Telegram 阅读友好 |
| 代码块(Channel 自适应) | 3 行 | 超过则折叠 |
---
## 🔄 分层协作流程
```
用户输入
↓
[自然触发词判断]
↓
Ingest + Query(底层4连击)
↓
Synthesize → Display Layer → 用户可见回复
↘ Archive Layer → 知识库存档
```
### Display Layer 优先
每次响应用户时,**必须**先输出 Display Layer。如果用户追问,再提供更深层的 Archive 内容。
### Archive Layer 按需触发
以下情况将内容存入 Archive Layer:
- Ingest 时生成 source/entity/concept
- Query 的答案具有长期留存价值
- Lint 时发现并修复了孤岛链接
FILE:references/spawn-template.md
# ARC-Worker Spawn Templates
Orchestrator 在派生 Worker 时,使用以下模板替换 `{占位符}`,避免每次手写 500+ tokens 的重复指令。
---
## Template 1: Ingest(标准 4 连击)
```markdown
⚠️ MANDATORY: Use `cat << 'EOF' | python3 skills/arc-reactor/scripts/archive-manager.py --type [TYPE] --topic [NAME] --stdin` for ALL outputs. Execute 4-combo (source, entity, index, log) for Ingest! Do not write flat files. You MUST verify JSON receipt contains "status": "success" after each operation. Run all commands from workspace root: {WORKSPACE_ROOT}
⚠️ OUTPUT CONSTRAINT: All user-facing output MUST follow Display Layer规范 (≤200字中文摘要),禁止暴露JSON回执、status、path、size_bytes等内部字段。详见 `references/output-style.md`。
你是 ARC-Worker 矿工,执行 v4.0 Ingest 工作流。
[任务目标]: {TASK_DESCRIPTION}
素材获取指引:
1. 用 web_search 搜索 "{SEARCH_QUERY_1}" 获取中文报道
2. 用 web_search 搜索 "{SEARCH_QUERY_2}" 获取英文技术细节
3. 尝试 web_fetch 获取原始素材页面
[执行协议 - 4 连击 Ingest]:
### Hit 1: Source 页
```bash
cd {WORKSPACE_ROOT} && \
cat << 'EOF_ARC_DOC' | python3 skills/arc-reactor/scripts/archive-manager.py --type source --topic "{TOPIC_SLUG}" --stdin
---
title: "{TOPIC_TITLE}"
sources: [{SOURCE_URLS}]
tags: [{TAGS}]
---
(你的完整 Source 内容)
EOF_ARC_DOC
```
### Hit 2: Entity 页
```bash
cat << 'EOF_ARC_DOC' | python3 skills/arc-reactor/scripts/archive-manager.py --type entity --topic "{ENTITY_SLUG}" --stdin
(实体词条,使用 [[Wiki-Link]] 引用相关实体)
EOF_ARC_DOC
```
### Hit 3: Index 追加
```bash
cat << 'EOF_ARC_DOC' | python3 skills/arc-reactor/scripts/archive-manager.py --type index --topic "{TOPIC_SLUG}-index" --stdin
- [[{Entity-1}]]: 一句话描述
- [[{Entity-2}]]: 一句话描述
EOF_ARC_DOC
```
### Hit 4: Log 追加
```bash
cat << 'EOF_ARC_DOC' | python3 skills/arc-reactor/scripts/archive-manager.py --type log --topic "{TOPIC_SLUG}-log" --stdin
Ingested {TOPIC_SLUG}: source + entity + index completed
EOF_ARC_DOC
```
每次执行后必须验证 JSON 回执包含 "status": "success"。
### 最终交付
4 连击全部完成后:
1. **按 Display Layer 规范输出**(见 `references/output-style.md`):
- 中文摘要,≤200字
- 结论先行,用「·」列出要点
- 自然对话风格,避免技术细节
2. **禁止向用户展示**:JSON 回执、status、path、size_bytes 等内部字段
3. **发送附件**:通过 message tool (channel=telegram, target={USER_ID}) 将 source 文件发送给用户
**Display Layer 示例**:
```
已完成 {TOPIC_TITLE} 的知识编译。
核心结论:
· 提取了 {主要实体1}、{主要实体2} 的关键信息
· 建立了 {数量} 个知识节点链接
· 已存入 Wiki 供后续查询使用
```
```
---
## Template 2: Query(知识查询)
```markdown
你是 ARC-Worker 矿工,执行 v4.0 Query 工作流。
[任务目标]: 回答用户关于 {TOPIC} 的问题。
[执行协议]:
1. 读取 {WORKSPACE_ROOT}/arc-reactor-doc/wiki/index.md 找相关 [[wiki-link]]
2. 读取对应 entity/source 文件内容
3. 综合已有知识回答问题
4. 如果发现知识缺口,建议新的 Ingest 目标
[已有 Wiki 实体]:
{EXISTING_ENTITIES_LIST}
```
---
## Template 3: Lint(健康检查)
```markdown
你是 ARC-Worker 矿工,执行 v4.0 Lint 工作流。
[任务目标]: 对 Wiki 知识库执行健康检查。
[执行协议]:
1. 遍历 {WORKSPACE_ROOT}/arc-reactor-doc/wiki/entities/ 下所有文件
2. 检查每个 [[wiki-link]] 是否有对应实体文件
3. 检查 index.md 是否覆盖了所有实体
4. 检查 source 文件是否都有 date 字段
5. 输出检查报告,列出:孤岛链接、缺失索引、缺失日期
6. 如发现可自动修复的问题,直接修复并汇报
```
---
## Template 4: Video/Audio Ingest
```markdown
(在 Template 1 基础上,Hit 0 增加转录步骤)
### Hit 0: 视频/音频转录
```bash
# 下载音频
yt-dlp -x --audio-format mp3 -o "/tmp/arc-audio.%(ext)s" "{VIDEO_URL}"
# 转录(Apple Silicon 本地)
mlx_whisper /tmp/arc-audio.mp3 --language zh --output-format txt --output-dir /tmp/
# 读取转录文本作为 source 内容
```
然后执行标准 4 连击,source 内容使用转录文本。
```
---
## 占位符速查
| 占位符 | 说明 | 示例 |
|--------|------|------|
| `{WORKSPACE_ROOT}` | Agent 工作区绝对路径 | `/Users/evan/.openclaw/.../workspace` |
| `{TASK_DESCRIPTION}` | 任务描述 | "对 xxx 进行深度知识编译" |
| `{SEARCH_QUERY_1}` | 中文搜索词 | "xxx 技术 教程 2026" |
| `{SEARCH_QUERY_2}` | 英文搜索词 | "xxx tutorial setup 2026" |
| `{TOPIC_SLUG}` | URL-safe 主题名 | "minimax-hermes-integration" |
| `{TOPIC_TITLE}` | 可读标题 | "MiniMax 与 Hermes Agent 联动" |
| `{ENTITY_SLUG}` | 实体文件名 | "minimax" |
| `{SOURCE_URLS}` | 素材 URL 列表 | `"url1", "url2"` |
| `{TAGS}` | 标签列表 | `"minimax", "hermes", "agent"` |
| `{USER_ID}` | 用户 Telegram ID | `5930392031` |
| `{EXISTING_ENTITIES_LIST}` | 已有实体列表 | `- [[Hermes-Agent]]\n- [[OpenClaw]]` |
FILE:references/templates/practical_record.md
# {{TITLE}} 实战记录
> 日期:{{DATE}}
> 执行者:{{EXECUTOR}}
> 目标:{{OBJECTIVE}}
---
## 一、方案设计 (The Design)
### 1.1 架构图
```mermaid
{{MERMAID_DIAGRAM}}
```
### 1.2 核心组件
| 组件 | 位置 | 功能 |
| :--- | :--- | :--- |
| {{COMP_1}} | `{{PATH_1}}` | {{FUNC_1}} |
---
## 二、实现步骤 (Implementation)
### 2.1 {{STEP_TITLE_1}}
```bash
{{COMMANDS_OR_CODE}}
```
{{STEP_DESC_1}}
---
## 三、关键发现与哲学 (Philosophy & Lessons)
> **{{CORE_PRINCIPLE}}**
| 维度 | 关键经验 |
| :--- | :--- |
| {{LAT_1}} | {{LESSON_1}} |
---
## 四、后续优化方案 (Next Steps)
- [ ] {{BACKLOG_1}}
---
*生成于 ARC Reactor v2.3 | 实战经验档案*
FILE:references/templates/report-template.md
# ARC 标准报告模板
> 所有 ARC 输出必须遵循此模板。
```markdown
# [主题] 调研报告
> 每份报告自包含完整,开箱即用。
---
## 基本信息
| 字段 | 内容 |
|------|------|
| **主题** | [标题] |
| **来源** | [来源平台] |
| **原文 URL** | [链接] |
| **内容类型** | [ARTICLE/VIDEO/REPO/PAPER/SOCIAL/TOPIC] |
| **调研日期** | [YYYY-MM-DD] |
| **调研人** | [Agent 名称] |
| **可信度** | [VERIFIED / UNVERIFIED / DISPUTED] |
---
## 一、[第一章标题]
[内容]
## 二、[第二章标题]
[内容]
## 三、[第三章标题(按需扩展至 3-8 章)]
[内容]
---
## 结论与建议
- 关键发现摘要
- 对我们的价值判断
- 建议后续行动
---
## 源追踪
| # | 来源平台 | URL | 内容类型 | 采集日期 | 贡献内容 |
|---|----------|-----|----------|----------|----------|
| 1 | [平台] | [链接] | [类型] | [日期] | [贡献] |
## 附录
- **关联调研**:[相关报告链接]
- **原始素材**:[转录稿/截图等存储路径]
- **验证记录**:[哪些声明被验证/存疑]
```
## 命名规范
文件名:`[主题简称]-调研报告.md`(全中文,便于 Obsidian 检索)
## 标签体系
在 `knowledge/index.md` 中标注:
| 标签 | 说明 |
|------|------|
| `#工具` | 开发工具、SDK、框架 |
| `#架构` | 系统设计、架构模式 |
| `#AI` | AI/LLM 相关技术 |
| `#产品` | 产品分析、竞品调研 |
| `#方法论` | 工作方法、流程设计 |
| `#人物` | 人物/团队调研 |
FILE:references/templates/research_survey.md
# {{TITLE}}:{{ONE_SENTENCE_DESCRIPTOR}}
> 来源:{{SOURCE_LINK}}
> 调研日期:{{DATE}}
> 调研人:{{RESEARCHER}}
---
## 一、项目/工具概览
| 字段 | 内容 |
| :--- | :--- |
| **定位** | {{POSITIONING}} |
| **核心指标** | {{METRICS}} (如 Star 数, 性能参数) |
| **一句话宗旨** | {{SLOGAN}} |
---
## 二、核心特性与能力
{{DESCRIPTION_OF_FEATURES}}
| 特性 | 关键点 | 备注 |
| :--- | :--- | :--- |
| {{FEATURE_1}} | {{POINT_1}} | {{REMARK_1}} |
| {{FEATURE_2}} | {{POINT_2}} | {{REMARK_2}} |
---
## 三、技术实现逻辑 (Mechanism)
{{ARCH_DIAGRAM_OR_TEXT}}
{{DEEP_DIVE_ON_LOGIC}}
---
## 四、与本分部/项目的关系 (Relationship)
| {{PROJECT_NAME}} | 本调研对象 ({{TITLE}}) | 差异/启示 |
| :--- | :--- | :--- |
| {{OUR_APPROACH}} | {{THEIR_APPROACH}} | {{INSIGHT}} |
---
## 五、结论与下一步行动 (Next Actions)
- [ ] {{ACTION_1}}
- [ ] {{ACTION_2}}
---
*生成于 ARC Reactor v2.3 | 知识编译档案*
FILE:references/templates/tech_analysis.md
# EP{{EP_NO}}:{{TITLE}}深度解析
> 来源:{{SOURCE_LINK}}
> 核心受众:{{TARGET_AUDIENCE}}
> 调研日期:{{DATE}}
---
## 一、问题背景与痛点 (The Pain Points)
在 {{TOPIC}} 场景中,目前面临的核心挑战:
1. {{PAIN_1}}
2. {{PAIN_2}}
---
## 二、核心配置/逻辑解析 (Key Logic)
### 2.1 {{LOGIC_TITLE_1}}
- **位置**: `{{FILE_PATH_OR_LOCATION}}`
- **逻辑**: {{LOGIC_DESC}}
- **效果**: {{EFFECT_DESC}}
### 2.2 {{LOGIC_TITLE_2}}
- **位置**: `{{FILE_PATH_OR_LOCATION}}`
- **逻辑**: {{LOGIC_DESC}}
- **效果**: {{EFFECT_DESC}}
---
## 三、Before vs After 效果对比
| 维度 | 之前 (Before) | 之后 (After) |
| :--- | :--- | :--- |
| {{DIM_1}} | {{BEFORE_VAL_1}} | {{AFTER_VAL_1}} |
| {{DIM_2}} | {{BEFORE_VAL_2}} | {{AFTER_VAL_2}} |
---
## 四、对当前项目的启示 (Actionable Insights)
| 关键发现 | 建议操作 | 优先级 |
| :--- | :--- | :--- |
| {{DISCOVERY_1}} | {{ACTION_1}} | {{PRIORITY_1}} |
---
*生成于 ARC Reactor v2.3 | 源码与配置深度内化档案*
FILE:references/verification-pipeline.md
# 广角阵列与二次复检管线 (Extended Probing & Verification Pipeline)
> **极度重要指令**:从 v2.0 开始,你的 R (Research) 环节**必须具备侵略性与自主外发**能力。绝对不能只充当单页网页的“文字排头兵总结器”。你要像真正的分析专家一样,主动去挖外网、找竞品、查旧账!
## Step 1: 原本主体的“声明切片” (Triage)
将原始采集到的原文核心声明切成三类,放上砧板:
| 类别 | 需要接受的挑战 |
|------|--------------|
| **客观标榜** | Star 数、融资金额、开源许可类别、创始人履历。 |
| **碾压性宣告** | "性能极速"、"超越GPT"、"唯一的"、"十倍提效"。 |
| **独门绝技** | 原网页上提到的自己的核心护城河特性。 |
## Step 2: 主动外探与交叉验证 (Active Outbound Verification) 🚀
**此部分要求具有长篇资料外的自主性。无论原作者怎么写,你【必须调用你的 web_search 等搜索引擎工具】对外界展开求证!**
- **真实验证 (Data Scrubbing)**:对于“客观标榜”,去官方源查验其真实数据是否有水分(例如吹牛 90万星其实只有 9万)。
- **打假与争端 (Dispute Sniffing)**:针对“碾压性宣告”,使用你的联网能力主动寻找 Hacker News, Reddit 或 V2EX 上的真实风评,如果找到驳斥声音(如“测试环境被污染”、“其实很容易崩溃”),**必须捕获为 `[DISPUTED]` 档案!**。
- **竞品测绘 (Landscape & Alternatives)**:这一步是你的灵魂!不要只讲这是什么,启动搜索引擎主动搜寻并补加“目前市面上它的最大替代品 (Alternatives) 是哪些,以及它与这些替代品的比较与各自优劣象限。”(如果是方法论,就抓它的同类或对立方法论进行对比说明)
## Step 3: 可信度标注与报告融合
经过你的外网出击后,你将携带着带有驳斥和竞争维度的信息归来,给最初的特性附上真正的可信评级表,合并进最终的调研报告结构中。
| 等级 | 标识 | 触发条件 |
|------|------|----------|
| ✅ | `[VERIFIED]` | 第三方官网或权威平台佐证通过的数据 |
| ⚠️ | `[UNVERIFIED]` | 作者自己口嗨的性能,你在全网也搜不出任何第三方测评或使用记录 |
| ❌ | `[DISPUTED]` | 发现有外部论坛打脸的矛盾信息、存在重大历史风波、甚至存在更靠谱的成熟竞品替代选项 |
| 💬 | `[OPINION]` | 纯主观输出 |
FILE:scripts/archive-manager.py
#!/usr/bin/env python3
import os
import sys
import argparse
import json
import hashlib
import shutil
import time
from datetime import datetime
import re
FACT_INDEX_FILENAME = 'index-facts.json'
FACT_SECTION_RE = re.compile(r'^###\s+(IN|OUT)-\d+:\s*(.+?)\s*$')
FACT_FIELD_RE = re.compile(r'^-\s+\*\*(.+?)\*\*:\s*(.*)$')
DATE_RE = re.compile(r'(\d{4}-\d{2}-\d{2})')
AMOUNT_RE = re.compile(r'(\d+(?:\.\d+)?\s*(?:万|元))')
PROJECT_RE = re.compile(r'([A-Za-z0-9\u4e00-\u9fff][A-Za-z0-9\u4e00-\u9fff\-_/()()· ]{0,80}?(?:系统|项目|产品|平台|Framework|Skill))')
def _fact_type_from_label(label, title='', summary=''):
"""将 CWork 类型字段映射为标准 fact 类型。"""
normalized = (label or '').strip()
context = ' '.join([normalized, title or '', summary or ''])
lowered = context.lower()
if normalized == '合同管理(OPS)':
return 'contract'
if normalized == '会议室预约':
return 'meeting'
if normalized == '销售项目':
return 'project-progress'
if '日报' in context:
return 'daily-report'
if '测试' in context or 'test' in lowered:
return 'skill-test'
if normalized == '其他汇报':
return 'other'
return 'other'
def _extract_fact_status(text):
"""基于标题与摘要做轻量状态推断。"""
normalized = (text or '').strip()
if not normalized:
return 'unknown'
if re.search(r'全部通过|通过率\s*100%|已完成|全部修复|现已全部修复|成功跑通|(?<![未不])完成(?!度)', normalized):
return 'completed'
if re.search(r'申请|放行|审批', normalized):
return 'requested'
if re.search(r'进展|进行中|跟进|处理中|待办|追踪', normalized):
return 'in-progress'
if re.search(r'会议|沟通会|预约', normalized):
return 'scheduled'
return 'unknown'
def _split_fact_sentences(text):
"""将摘要切分为句子列表。"""
cleaned = re.sub(r'\s+', ' ', (text or '').strip())
if not cleaned:
return []
parts = re.split(r'(?<=[。!?!?;;])\s*', cleaned)
sentences = [part.strip() for part in parts if part.strip()]
if sentences:
return sentences
return [cleaned]
def _extract_amount(text):
"""提取首个金额信息。"""
match = AMOUNT_RE.search(text or '')
if not match:
return None
return match.group(1).replace(' ', '')
def _extract_project(title, summary, entities):
"""尽量提取项目名/系统名。"""
candidates = []
if title:
candidates.append(title)
if summary:
candidates.append(summary)
candidates.extend(entities or [])
for candidate in candidates:
if not candidate:
continue
direct = candidate.strip()
if re.search(r'(系统|项目|产品|平台|Framework|Skill)$', direct):
return direct
match = PROJECT_RE.search(direct)
if match:
return match.group(1).strip()
return None
def _normalize_fact_value(value):
"""标准化过滤比较值。"""
if value is None:
return None
return str(value).strip().lower()
def _load_fact_index(index_path):
"""读取事实索引文件,异常时回退为空列表并记录警告。"""
if not os.path.exists(index_path):
return []
try:
with open(index_path, 'r', encoding='utf-8') as file_obj:
data = json.load(file_obj)
if isinstance(data, list):
return data
except json.JSONDecodeError as exc:
print(json.dumps({"status": "warning", "message": f"Fact index JSON corrupted at {index_path}: {exc}"}), file=sys.stderr)
except Exception as exc:
print(json.dumps({"status": "warning", "message": f"Unexpected error loading fact index {index_path}: {exc}"}), file=sys.stderr)
return []
def _write_fact_index(index_path, entries):
"""写回事实索引文件。"""
os.makedirs(os.path.dirname(index_path), exist_ok=True)
with open(index_path, 'w', encoding='utf-8') as file_obj:
json.dump(entries, file_obj, ensure_ascii=False, indent=2)
def parse_fact_index_entries(markdown_text):
"""解析 Markdown 快照中的事实条目。"""
sections = []
current = None
for raw_line in (markdown_text or '').splitlines():
line = raw_line.rstrip()
section_match = FACT_SECTION_RE.match(line)
if section_match:
if current is not None:
sections.append(current)
current = {
'section': section_match.group(1),
'title': section_match.group(2).strip(),
'lines': []
}
continue
if current is not None:
current['lines'].append(line)
if current is not None:
sections.append(current)
entries = []
for section in sections:
fields = {}
for line in section['lines']:
field_match = FACT_FIELD_RE.match(line.strip())
if not field_match:
continue
fields[field_match.group(1).strip()] = field_match.group(2).strip()
title = section['title']
author = fields.get('作者', '')
time_text = fields.get('时间', '')
type_label = fields.get('类型', '')
summary = fields.get('摘要', '')
entities_text = fields.get('关键实体', '')
date_match = DATE_RE.search(time_text)
date_value = date_match.group(1) if date_match else None
entities = [item.strip() for item in re.split(r'[,,、;;]', entities_text) if item.strip()]
context_text = '\n'.join([title, summary, type_label, entities_text])
stable_id_seed = f"{title}|{date_value or ''}|{author}"
entries.append({
'id': hashlib.sha256(stable_id_seed.encode('utf-8')).hexdigest()[:24],
'type': _fact_type_from_label(type_label, title=title, summary=summary),
'title': title,
'author': author,
'date': date_value,
'status': _extract_fact_status(context_text),
'entities': entities,
'key_facts': _split_fact_sentences(summary),
'amount': _extract_amount(' '.join([title, summary])),
'project': _extract_project(title, summary, entities),
'source_file': None,
})
return entries
def ingest_fact_index(doc_root, markdown_text):
"""将 Markdown 快照增量写入事实索引。"""
index_path = os.path.join(doc_root, 'wiki', FACT_INDEX_FILENAME)
existing_entries = _load_fact_index(index_path)
parsed_entries = parse_fact_index_entries(markdown_text)
existing_keys = {
(_normalize_fact_value(item.get('title')), _normalize_fact_value(item.get('date')))
for item in existing_entries if isinstance(item, dict)
}
added_entries = []
for entry in parsed_entries:
dedup_key = (_normalize_fact_value(entry.get('title')), _normalize_fact_value(entry.get('date')))
if dedup_key in existing_keys:
continue
existing_keys.add(dedup_key)
added_entries.append(entry)
final_entries = existing_entries + added_entries
_write_fact_index(index_path, final_entries)
return {
'status': 'success',
'action': 'fact_index',
'path': index_path,
'entries_parsed': len(parsed_entries),
'entries_added': len(added_entries),
'total_entries': len(final_entries),
'message': 'Fact index updated.'
}
def query_facts(root_name, filters=None):
"""按条件查询事实索引。"""
cwd = os.getcwd()
doc_root = find_doc_root(cwd, root_name)
index_path = os.path.join(doc_root, 'wiki', FACT_INDEX_FILENAME)
entries = [item for item in _load_fact_index(index_path) if isinstance(item, dict)]
parsed_filters = []
for raw_filter in filters or []:
if '=' not in raw_filter:
continue
field, value = raw_filter.split('=', 1)
field = field.strip()
value = value.strip()
if not field or value == '':
continue
parsed_filters.append((field, value))
def matches(entry):
for field, expected in parsed_filters:
expected_normalized = _normalize_fact_value(expected)
if field == 'text':
haystacks = [
entry.get('title', ''),
' '.join(entry.get('key_facts', []) or []),
' '.join(entry.get('entities', []) or []),
]
searchable = _normalize_fact_value(' '.join(haystacks)) or ''
if expected_normalized not in searchable:
return False
continue
actual = entry.get(field)
if isinstance(actual, list):
normalized_list = [_normalize_fact_value(item) for item in actual]
if expected_normalized not in normalized_list:
return False
continue
if _normalize_fact_value(actual) != expected_normalized:
return False
return True
return [entry for entry in entries if matches(entry)]
def _split_inline_list(text):
"""拆分 YAML 内联列表,支持简单引号场景。"""
items = []
current = []
quote_char = None
for char in text:
if char in ('"', "'"):
if quote_char == char:
quote_char = None
elif quote_char is None:
quote_char = char
if char == ',' and quote_char is None:
item = ''.join(current).strip()
if item:
items.append(item)
current = []
continue
current.append(char)
tail = ''.join(current).strip()
if tail:
items.append(tail)
return items
def _parse_yaml_scalar(value):
"""解析简单 YAML 标量/内联列表。"""
value = value.strip()
if value == '':
return ''
if value.startswith('[') and value.endswith(']'):
inner = value[1:-1].strip()
if not inner:
return []
return [_parse_yaml_scalar(item) for item in _split_inline_list(inner)]
if (value.startswith('"') and value.endswith('"')) or (value.startswith("'") and value.endswith("'")):
return value[1:-1]
lowered = value.lower()
if lowered == 'true':
return True
if lowered == 'false':
return False
if lowered in ('null', 'none'):
return None
if re.fullmatch(r'-?\d+', value):
return int(value)
if re.fullmatch(r'-?\d+\.\d+', value):
return float(value)
return value
def _parse_simple_yaml(filepath):
"""使用标准库解析简单 YAML:顶层键值、嵌套字典、列表字典。"""
with open(filepath, 'r', encoding='utf-8') as file_obj:
raw_lines = file_obj.readlines()
processed = []
for raw_line in raw_lines:
if not raw_line.strip() or raw_line.lstrip().startswith('#'):
continue
indent = len(raw_line) - len(raw_line.lstrip(' '))
processed.append({
'indent': indent,
'text': raw_line.strip()
})
def parse_mapping(index, indent):
data = {}
while index < len(processed):
line = processed[index]
if line['indent'] < indent:
break
if line['indent'] > indent:
index += 1
continue
if line['text'].startswith('- '):
break
key, sep, remainder = line['text'].partition(':')
if not sep:
index += 1
continue
key = key.strip()
remainder = remainder.strip()
if remainder:
data[key] = _parse_yaml_scalar(remainder)
index += 1
continue
next_index = index + 1
if next_index < len(processed) and processed[next_index]['indent'] > indent:
child_indent = processed[next_index]['indent']
if processed[next_index]['text'].startswith('- '):
data[key], index = parse_list(next_index, child_indent)
else:
data[key], index = parse_mapping(next_index, child_indent)
else:
data[key] = {}
index += 1
return data, index
def parse_list(index, indent):
items = []
while index < len(processed):
line = processed[index]
if line['indent'] < indent:
break
if line['indent'] > indent:
index += 1
continue
if not line['text'].startswith('- '):
break
body = line['text'][2:].strip()
if ':' in body:
key, _, remainder = body.partition(':')
item = {key.strip(): _parse_yaml_scalar(remainder.strip()) if remainder.strip() else {}}
index += 1
if index < len(processed) and processed[index]['indent'] > indent:
child_block, index = parse_mapping(index, processed[index]['indent'])
item.update(child_block)
items.append(item)
continue
if body:
items.append(_parse_yaml_scalar(body))
index += 1
continue
index += 1
if index < len(processed) and processed[index]['indent'] > indent:
child_indent = processed[index]['indent']
if processed[index]['text'].startswith('- '):
item, index = parse_list(index, child_indent)
else:
item, index = parse_mapping(index, child_indent)
items.append(item)
return items, index
parsed, _ = parse_mapping(0, 0)
return parsed
def _find_config_path(start_path=None):
"""向上查找配置文件路径。"""
search_dir = os.path.abspath(start_path or os.getcwd())
if os.path.isfile(search_dir):
search_dir = os.path.dirname(search_dir)
while True:
direct_path = os.path.join(search_dir, 'arc-reactor-config.yaml')
nested_path = os.path.join(search_dir, 'skills', 'arc-reactor', 'arc-reactor-config.yaml')
if os.path.exists(direct_path):
return direct_path
if os.path.exists(nested_path):
return nested_path
parent_dir = os.path.dirname(search_dir)
if parent_dir == search_dir:
return None
search_dir = parent_dir
def load_config(start_path=None):
"""查找并解析 `arc-reactor-config.yaml`。"""
config_path = _find_config_path(start_path)
if not config_path:
return {}
return _parse_simple_yaml(config_path)
def resolve_kb_root(content_source, tags=None):
"""根据来源或标签自动匹配知识库根目录。"""
config = load_config()
knowledge_bases = config.get('knowledge_bases', []) or []
normalized_tags = {str(tag) for tag in (tags or [])}
for kb_entry in knowledge_bases:
auto_route = kb_entry.get('auto_route', {}) or {}
sources = auto_route.get('sources', []) or []
if content_source in sources:
return kb_entry.get('root')
for kb_entry in knowledge_bases:
auto_route = kb_entry.get('auto_route', {}) or {}
route_tags = {str(tag) for tag in (auto_route.get('tags', []) or [])}
if normalized_tags & route_tags:
return kb_entry.get('root')
return None
def _format_kb_yaml_entry(kb_entry):
"""格式化 knowledge_bases 条目为 YAML 文本。"""
name = kb_entry['name']
root = kb_entry['root']
description = kb_entry.get('description', '')
auto_route = kb_entry.get('auto_route', {}) or {}
sources = auto_route.get('sources', []) or []
tags = auto_route.get('tags', []) or []
sources_str = ', '.join(json.dumps(item, ensure_ascii=False) for item in sources)
tags_str = ', '.join(json.dumps(item, ensure_ascii=False) for item in tags)
return (
f" - name: {name}\n"
f" root: {root}\n"
f" description: {json.dumps(description, ensure_ascii=False)}\n"
f" auto_route:\n"
f" sources: [{sources_str}]\n"
f" tags: [{tags_str}]\n"
)
def kb_init(root_name, name=None, description=None):
"""初始化一个新的多实例知识库并写回配置。"""
config_path = _find_config_path()
if not config_path:
return {"status": "error", "message": "arc-reactor-config.yaml 未找到"}
config = load_config()
knowledge_bases = config.get('knowledge_bases', []) or []
kb_name = name or root_name
kb_description = description or ''
for kb_entry in knowledge_bases:
if kb_entry.get('name') == kb_name:
return {"status": "error", "message": f"知识库名称已存在: {kb_name}"}
if kb_entry.get('root') == root_name:
return {"status": "error", "message": f"知识库根目录已存在: {root_name}"}
workspace_root = os.path.dirname(os.path.dirname(os.path.dirname(config_path)))
kb_root = os.path.join(workspace_root, root_name)
dirs_to_create = [
os.path.join(kb_root, 'wiki', 'sources'),
os.path.join(kb_root, 'wiki', 'entities'),
os.path.join(kb_root, 'wiki', 'concepts'),
os.path.join(kb_root, 'raw'),
]
files_to_create = [
os.path.join(kb_root, 'wiki', 'index.md'),
os.path.join(kb_root, 'wiki', 'log.md'),
os.path.join(kb_root, 'wiki', FACT_INDEX_FILENAME),
]
created = []
try:
for dir_path in dirs_to_create:
if not os.path.exists(dir_path):
os.makedirs(dir_path, exist_ok=True)
created.append(dir_path)
for file_path in files_to_create:
if not os.path.exists(file_path):
with open(file_path, 'w', encoding='utf-8') as file_obj:
if file_path.endswith(FACT_INDEX_FILENAME):
file_obj.write('[]')
else:
file_obj.write('')
created.append(file_path)
new_entry = {
'name': kb_name,
'root': root_name,
'description': kb_description,
'auto_route': {
'sources': [],
'tags': [],
},
}
with open(config_path, 'r', encoding='utf-8') as file_obj:
config_text = file_obj.read().rstrip()
yaml_entry = _format_kb_yaml_entry(new_entry)
if 'knowledge_bases:' in config_text:
updated_text = f"{config_text}\n\n{yaml_entry.rstrip()}\n"
else:
updated_text = f"{config_text}\n\nknowledge_bases:\n{yaml_entry}"
with open(config_path, 'w', encoding='utf-8') as file_obj:
file_obj.write(updated_text)
return {
"status": "success",
"action": "kb_init",
"name": kb_name,
"root": root_name,
"path": kb_root,
"config": config_path,
"created": created,
"message": "Knowledge base initialized."
}
except Exception as exc:
return {"status": "error", "message": f"知识库初始化失败: {str(exc)}"}
def kb_list():
"""列出所有已配置知识库的统计信息。"""
config = load_config()
knowledge_bases = config.get('knowledge_bases', []) or []
config_path = _find_config_path()
if not config_path:
return []
workspace_root = os.path.dirname(os.path.dirname(os.path.dirname(config_path)))
results = []
for kb_entry in knowledge_bases:
kb_root = os.path.join(workspace_root, kb_entry.get('root', ''))
sources_dir = os.path.join(kb_root, 'wiki', 'sources')
entities_dir = os.path.join(kb_root, 'wiki', 'entities')
concepts_dir = os.path.join(kb_root, 'wiki', 'concepts')
def count_markdown_files(root_dir):
count = 0
if not os.path.exists(root_dir):
return count
for _, _, files in os.walk(root_dir):
count += sum(1 for filename in files if filename.endswith('.md'))
return count
latest_mtime = None
if os.path.exists(kb_root):
for root, _, files in os.walk(kb_root):
for filename in files:
file_path = os.path.join(root, filename)
file_mtime = os.path.getmtime(file_path)
if latest_mtime is None or file_mtime > latest_mtime:
latest_mtime = file_mtime
results.append({
'name': kb_entry.get('name'),
'root': kb_entry.get('root'),
'description': kb_entry.get('description', ''),
'sources_count': count_markdown_files(sources_dir),
'entities_count': count_markdown_files(entities_dir),
'concepts_count': count_markdown_files(concepts_dir),
'last_modified': datetime.fromtimestamp(latest_mtime).strftime('%Y-%m-%d %H:%M:%S') if latest_mtime else None,
'exists': os.path.exists(kb_root),
})
return results
def slugify(text):
"""简单规范化文件名"""
text = str(text).lower()
text = re.sub(r'[^\w\s-]', '', text)
text = re.sub(r'[\s_-]+', '-', text).strip('-')
return text if text else "untitled"
WIKI_LINK_RE = re.compile(r'\[\[([^\]]+)\]\]')
FRONTMATTER_RE = re.compile(r'^---\s*\n(.*?)\n---\s*\n', re.DOTALL)
def resolve_entity_path(doc_root, topic):
"""在 entities 或 concepts 目录中寻找实体的物理路径。"""
slug = slugify(topic)
paths = [
os.path.join(doc_root, 'wiki', 'entities', f"{slug}.md"),
os.path.join(doc_root, 'wiki', 'concepts', f"{slug}.md")
]
for p in paths:
if os.path.exists(p):
return p
return None
def _count_link_mentions_in_sources(sources_dir, link_text):
"""统计 wiki/sources/ 目录中提及指定 link 文本的 .md 文件数量。
使用 grep -rl 扫描,返回命中文件数(非行数),用于判断该 link 是否值得保留。
"""
import subprocess
if not os.path.exists(sources_dir):
return 0
pattern = re.escape(link_text)
try:
cmd = ["grep", "-rl", "--include=*.md", pattern, sources_dir]
result = subprocess.run(cmd, capture_output=True, text=True)
if result.returncode == 0:
return len([l for l in result.stdout.splitlines() if l.strip()])
except Exception:
pass
return 0
def sanitize_wiki_links(content, doc_root, existing_content=None):
"""扫描并清理孤儿 wiki-link,防止产生大量指向不存在页面的 [[link]]。
处理逻辑:
1. 提取所有 [[wiki-link]]
2. 检查 wiki/entities/{slug}.md 和 wiki/concepts/{slug}.md 是否存在
3. 若目标不存在,统计 wiki/sources/ 中被提及的文件数:
- >= 3 次:保留 [[link]](值得建页)
- 2 次:降级为 **link**(加粗)
- 1 或 0 次:降级为纯文本(去掉 [[]])
4. 如果传入了 existing_content,只对新增部分做清理,不动已有内容。
"""
# 若为追加模式(append),分离已有内容与新增内容,只清理新增部分
if existing_content:
# 用最长公共前缀定位新增内容的起点
common_len = 0
min_len = min(len(existing_content), len(content))
for i in range(min_len):
if existing_content[i] == content[i]:
common_len += 1
else:
break
prefix = content[:common_len]
suffix = content[common_len:]
# 只对新增后缀做 sanitization
sanitized_suffix = sanitize_wiki_links(suffix, doc_root, existing_content=None)
return prefix + sanitized_suffix
sources_dir = os.path.join(doc_root, 'wiki', 'sources')
def _replacer(match):
link_text = match.group(1)
slug = slugify(link_text)
# 检查目标实体/概念文件是否存在
entity_path = os.path.join(doc_root, 'wiki', 'entities', f"{slug}.md")
concept_path = os.path.join(doc_root, 'wiki', 'concepts', f"{slug}.md")
if os.path.exists(entity_path) or os.path.exists(concept_path):
# 目标页面已存在,保留 wiki-link
return match.group(0)
# 目标不存在,统计在 sources 中的提及次数
mention_count = _count_link_mentions_in_sources(sources_dir, link_text)
if mention_count >= 3:
# 高频提及,保留 wiki-link(未来值得建页)
return match.group(0)
elif mention_count == 2:
# 中频提及,降级为加粗
return f"**{link_text}**"
else:
# 低频提及(1 或 0),降级为纯文本
return link_text
return WIKI_LINK_RE.sub(_replacer, content)
def find_backlinks(doc_root, topic):
"""使用 grep 扫描 wiki/sources 目录,寻找引用了该实体的源文件。"""
sources_dir = os.path.join(doc_root, 'wiki', 'sources')
if not os.path.exists(sources_dir):
return []
import subprocess
# 搜索格式为 [[Topic]] 或 [[topic]]
pattern = f"\\[\\[{topic}\\]\\]"
# 使用 grep -rl 获取包含该字符串的文件名列表
try:
cmd = ["grep", "-rl", "--include=*.md", pattern, sources_dir]
result = subprocess.run(cmd, capture_output=True, text=True)
if result.returncode == 0:
return [line.strip() for line in result.stdout.splitlines() if line.strip()]
except Exception:
pass
return []
def export_entity(doc_root, topic, output_dir=None):
"""导出实体及其关联上下文为单一 Markdown 文档。"""
entity_path = resolve_entity_path(doc_root, topic)
if not entity_path:
return {"status": "error", "message": f"未找到实体: {topic}"}
wiki_dir = os.path.join(doc_root, 'wiki')
if not output_dir:
output_dir = os.path.join(doc_root, 'exports')
os.makedirs(output_dir, exist_ok=True)
# 1. 读取实体内容
with open(entity_path, 'r', encoding='utf-8') as f:
entity_content = f.read()
# 2. 发现关联素材 (Sources)
# A. 从 Frontmatter 读取
linked_sources = []
fm_match = FRONTMATTER_RE.match(entity_content)
if fm_match:
fm_text = fm_match.group(1)
# 简单解析 YAML 列表
sources_match = re.search(r'sources:\s*\[(.*?)\]', fm_text)
if sources_match:
raw_sources = sources_match.group(1)
# 这里的路径通常是相对路径或 Slug
linked_sources = [s.strip().strip('"').strip("'") for s in raw_sources.split(',')]
# B. 发现反向链接
backlinks = find_backlinks(doc_root, topic)
# 3. 发现关联实体 (Related Entities)
related_entities = WIKI_LINK_RE.findall(entity_content)
unique_related = list(set(slugify(e) for e in related_entities if slugify(e) != slugify(topic)))
# 4. 构建导出文档
now_str = datetime.now().strftime('%Y-%m-%d %H:%M:%S')
lines = [
f"# {topic} — Knowledge Export",
f"**Generated**: {now_str} | **Source**: ARC Reactor Wiki",
"\n---",
"\n## 1. Overview (Entity Content)",
entity_content,
"\n---",
"\n## 2. Connections (Related Entities)"
]
if not unique_related:
lines.append("\n*No direct wiki-entity connections found.*")
else:
for r_slug in unique_related:
r_path = resolve_entity_path(doc_root, r_slug)
if r_path:
with open(r_path, 'r', encoding='utf-8') as f:
r_content = f.read()
lines.append(f"\n### [[{r_slug}]]")
lines.append(r_content)
lines.append("\n---")
lines.append("\n## 3. Origins (Source Materials)")
all_source_paths = set()
# 尝试解析 Frontmatter 里的 source 路径
for s_ref in linked_sources:
# 这里逻辑较简化,尝试在 wiki/sources 递归寻找对应文件
for root, _, files in os.walk(os.path.join(wiki_dir, 'sources')):
for filename in files:
if s_ref in filename or slugify(s_ref) in filename:
all_source_paths.add(os.path.join(root, filename))
# 添加反向链接命中的路径
for b_path in backlinks:
all_source_paths.add(b_path)
if not all_source_paths:
lines.append("\n*No original source materials found.*")
else:
for s_path in all_source_paths:
rel_name = os.path.relpath(s_path, os.path.join(wiki_dir, 'sources'))
with open(s_path, 'r', encoding='utf-8') as f:
s_content = f.read()
lines.append(f"\n### Source: {rel_name}")
lines.append(s_content)
# 5. 写入文件
dest_path = os.path.join(output_dir, f"{slugify(topic)}-bundle.md")
with open(dest_path, 'w', encoding='utf-8') as f:
f.write('\n'.join(lines))
return {
"status": "success",
"action": "export_entity",
"topic": topic,
"path": dest_path,
"connections_found": len(unique_related),
"sources_found": len(all_source_paths)
}
def find_doc_root(start_path, root_name='arc-reactor-doc'):
"""Walk up from start_path to find the workspace root."""
curr_dir = start_path
while curr_dir and curr_dir != '/':
if os.path.exists(os.path.join(curr_dir, root_name)):
return os.path.join(curr_dir, root_name)
if os.path.exists(os.path.join(curr_dir, '.git')):
return os.path.join(curr_dir, root_name)
curr_dir = os.path.dirname(curr_dir)
return os.path.join(start_path, root_name)
def lint_wiki(doc_root, fix=False):
"""Health check for Wiki integrity. Returns issues found."""
issues = []
fixed = []
wiki_dir = os.path.join(doc_root, 'wiki')
if not os.path.exists(wiki_dir):
return {"status": "error", "message": f"Wiki directory not found: {wiki_dir}"}
# 1. Scan all entity files
entities_dir = os.path.join(wiki_dir, 'entities')
existing_entities = set()
if os.path.exists(entities_dir):
for f in os.listdir(entities_dir):
if f.endswith('.md'):
existing_entities.add(f[:-3]) # strip .md
# 2. Scan all source files for wiki-links
all_links = set()
sources_dir = os.path.join(wiki_dir, 'sources')
all_files = []
# Collect all markdown files
for root, dirs, files in os.walk(wiki_dir):
for f in files:
if f.endswith('.md'):
all_files.append(os.path.join(root, f))
for fpath in all_files:
try:
with open(fpath, 'r', encoding='utf-8') as f:
content = f.read()
# Find all [[wiki-links]]
links = WIKI_LINK_RE.findall(content)
for link in links:
slug = slugify(link)
all_links.add(slug)
# Check for orphan links
if slug not in existing_entities:
rel_path = os.path.relpath(fpath, doc_root)
issues.append({
"type": "orphan_link",
"link": f"[[{link}]]",
"slug": slug,
"found_in": rel_path,
"fix": f"Create entity: wiki/entities/{slug}.md"
})
# Auto-fix: create stub entity
if fix and not os.path.exists(os.path.join(entities_dir, f"{slug}.md")):
stub = f"---\ndate: {datetime.now().strftime('%Y-%m-%d')}\n---\n\n# {link}\n\n> Stub entity — awaiting Ingest completion.\n"
stub_path = os.path.join(entities_dir, f"{slug}.md")
with open(stub_path, 'w', encoding='utf-8') as ef:
ef.write(stub)
fixed.append(f"Created stub: wiki/entities/{slug}.md")
except Exception:
continue
# 3. Check entities not in index.md
index_path = os.path.join(wiki_dir, 'index.md')
indexed_entities = set()
if os.path.exists(index_path):
with open(index_path, 'r', encoding='utf-8') as f:
index_content = f.read()
indexed_links = WIKI_LINK_RE.findall(index_content)
for link in indexed_links:
indexed_entities.add(slugify(link))
for entity in existing_entities:
if entity not in indexed_entities:
issues.append({
"type": "missing_from_index",
"entity": entity,
"fix": f"Add [[{entity}]] to wiki/index.md"
})
# 4. Check source files missing date
if os.path.exists(sources_dir):
for root, dirs, files in os.walk(sources_dir):
for f in files:
if f.endswith('.md'):
fpath = os.path.join(root, f)
try:
with open(fpath, 'r', encoding='utf-8') as sf:
content = sf.read()
if 'date:' not in content[:500].lower():
rel_path = os.path.relpath(fpath, doc_root)
issues.append({
"type": "missing_date",
"file": rel_path,
"fix": "Add date field to frontmatter"
})
except Exception:
continue
# 5. Check for empty files (< 50 bytes)
for fpath in all_files:
try:
if os.path.getsize(fpath) < 50:
rel_path = os.path.relpath(fpath, doc_root)
issues.append({
"type": "empty_file",
"file": rel_path,
"fix": "Populate or remove empty file"
})
except Exception:
continue
return {
"status": "lint_complete",
"total_files": len(all_files),
"total_entities": len(existing_entities),
"total_links": len(all_links),
"issues_found": len(issues),
"issues_fixed": len(fixed),
"issues": issues,
"fixed": fixed
}
def validate_files(doc_root, paths=None):
"""验证文件是否存在且有效。
Args:
doc_root: 文档根目录路径
paths: 要验证的文件路径列表(相对于 doc_root),如果为 None 则验证整个 Wiki
Returns:
包含验证结果的 JSON 可序列化字典
"""
if not paths:
# 验证整个 Wiki 结构
wiki_dir = os.path.join(doc_root, 'wiki')
if not os.path.exists(wiki_dir):
return {"status": "error", "message": f"Wiki directory not found: {wiki_dir}"}
files_valid = 0
files_invalid = []
files_empty = []
# 遍历所有 Markdown 文件
for root, dirs, files in os.walk(wiki_dir):
for f in files:
if f.endswith('.md'):
fpath = os.path.join(root, f)
try:
# 验证文件可读且非空
with open(fpath, 'r', encoding='utf-8') as file_obj:
content = file_obj.read()
if not content.strip():
files_empty.append(fpath)
files_invalid.append(fpath)
else:
files_valid += 1
except Exception as e:
files_invalid.append(fpath)
return {
"status": "ok" if not files_invalid else "partial",
"action": "validate_wiki",
"files_valid": files_valid,
"files_invalid": len(files_invalid),
"files_empty": len(files_empty),
"invalid_files": files_invalid,
"message": f"Validation complete: {files_valid} valid, {len(files_invalid)} invalid ({len(files_empty)} empty)"
}
else:
# 验证特定文件
results = []
all_valid = True
for path in paths:
full_path = os.path.join(doc_root, path)
if os.path.exists(full_path):
try:
with open(full_path, 'r', encoding='utf-8') as file_obj:
content = file_obj.read()
if content.strip():
results.append({
"path": path,
"status": "valid",
"size": len(content)
})
else:
results.append({
"path": path,
"status": "empty",
"size": 0
})
all_valid = False
except Exception as e:
results.append({
"path": path,
"status": "error",
"error": str(e)
})
all_valid = False
else:
results.append({
"path": path,
"status": "not_found"
})
all_valid = False
return {
"status": "ok" if all_valid else "partial",
"action": "validate_files",
"results": results,
"message": f"Validated {len(results)} files: {sum(1 for r in results if r['status'] == 'valid')} valid"
}
def validate_obsidian_config(vault_path):
"""Validate Obsidian vault configuration."""
vault = os.path.expanduser(vault_path)
if not os.path.isdir(vault):
return False, "Obsidian 库路径不存在"
test_path = os.path.join(vault, '.arc-sync-test')
try:
with open(test_path, 'w') as f:
f.write('ping')
os.remove(test_path)
return True, "OK"
except Exception as e:
return False, f"无写入权限: {str(e)}"
def sync_to_obsidian(source_path, vault_path, target_dir, max_retries=3, retry_delay=300):
"""Sync a source file to Obsidian vault with exponential backoff."""
now_str = datetime.now().strftime('%Y-%m-%d %H:%M:%S')
now_date = datetime.now().strftime('%Y-%m-%d')
is_valid, msg = validate_obsidian_config(vault_path)
if not is_valid:
return {"status": "error", "action": "obsidian_sync", "source": source_path, "error": msg, "retry_count": 0, "message": f"Obsidian sync failed: {msg}"}
if not os.path.exists(source_path):
return {"status": "error", "action": "obsidian_sync", "source": source_path, "error": "源文件不存在", "retry_count": 0, "message": "Obsidian sync failed: source file not found"}
resolved_target = target_dir.replace('{date}', now_date)
dest_dir = os.path.join(os.path.expanduser(vault_path), resolved_target)
filename = os.path.basename(source_path)
dest_path = os.path.join(dest_dir, filename)
last_error = None
for attempt in range(max_retries):
try:
os.makedirs(dest_dir, exist_ok=True)
shutil.copy2(source_path, dest_path)
sync_marker = f"\n\n---\n同步状态: ✅ Obsidian (时间: {now_str})\n---\n"
with open(dest_path, 'a', encoding='utf-8') as f:
f.write(sync_marker)
return {"status": "success", "action": "obsidian_sync", "source": source_path, "destination": dest_path, "obsidian_vault": vault_path, "sync_time": now_str, "retry_count": attempt, "message": "Obsidian sync complete."}
except Exception as e:
last_error = str(e)
if attempt < max_retries - 1:
time.sleep(retry_delay * (2 ** attempt))
continue
return {"status": "error", "action": "obsidian_sync", "source": source_path, "error": last_error, "retry_count": max_retries, "message": f"Obsidian sync failed after {max_retries} retries: {last_error}"}
def main():
parser = argparse.ArgumentParser(description='ARC Reactor V4 Archive Manager (Karpathy Wiki Edition)')
parser.add_argument('--lint', action='store_true', help='Run Wiki health check')
parser.add_argument('--fix', action='store_true', help='Auto-fix issues found during lint')
parser.add_argument('--validate', action='store_true', help='Validate file integrity and existence')
parser.add_argument('--kb-init', action='store_true', help='初始化新的知识库实例')
parser.add_argument('--kb-list', action='store_true', help='列出所有已配置知识库')
parser.add_argument('--query-facts', action='store_true', help='查询事实索引')
parser.add_argument('--export-entity', help='导出实体及其关联上下文')
parser.add_argument('--type', choices=[
'raw', 'source', 'entity', 'concept', 'index', 'log', 'template', 'fact-index'
], required=False, help='归档进入的 Wiki 圈层类型')
parser.add_argument('--sync-obsidian', action='store_true', help='Sync source file to Obsidian vault')
parser.add_argument('--source', required=False, default=None, help='Source file path (used with --sync-obsidian)')
parser.add_argument('--vault', required=False, default=None, help='Obsidian vault path (used with --sync-obsidian)')
parser.add_argument('--target', required=False, default='github分享/AI调研/{date}/', help='Target subdirectory in vault')
parser.add_argument('--async', dest='async_mode', action='store_true', help='Async mode: return immediately, sync in background')
parser.add_argument('--topic', required=False, default='knowledge-node', help='话题/实体名 (用于生成文件名)')
parser.add_argument('--stdin', action='store_true', help='强制通过标准输入读取内容 (防止转义错误)')
parser.add_argument('--root', default='arc-reactor-doc', help='文档根目录名称')
parser.add_argument('--filter', action='append', default=None, help='事实查询过滤条件,格式为 field=value,可重复传入')
parser.add_argument('--name', required=False, default=None, help='KB 显示名称,仅用于 --kb-init')
parser.add_argument('--description', required=False, default=None, help='KB 描述,仅用于 --kb-init')
parser.add_argument('--date', required=False, default=None, help='指定的日期戳,缺省为今日')
parser.add_argument('--dedup', choices=['merge', 'skip', 'overwrite'], default='overwrite',
help='去重策略: merge=增量合并, skip=跳过, overwrite=覆盖(默认)')
parser.add_argument('--url', help='直接从 URL 抓取正文内容 (支持智能反爬)')
parser.add_argument('--output-dir', help='导出目标目录')
args = parser.parse_args()
# Obsidian sync mode
if args.sync_obsidian:
auto_sync = os.environ.get('AUTO_SYNC', 'true')
if auto_sync.lower() in ('false', '0', 'no'):
print(json.dumps({"status": "skipped", "action": "obsidian_sync", "reason": "AUTO_SYNC=false", "message": "Obsidian sync disabled via AUTO_SYNC=false"}, ensure_ascii=False))
sys.exit(0)
if not args.source:
print(json.dumps({"status": "error", "message": "--sync-obsidian requires --source"}))
sys.exit(1)
vault_path = args.vault or os.environ.get('OBSIDIAN_VAULT_PATH', '')
if not vault_path:
print(json.dumps({"status": "error", "message": "OBSIDIAN_VAULT_PATH not configured"}))
sys.exit(1)
if getattr(args, 'async_mode', False):
import subprocess
sync_cmd = [sys.executable, os.path.abspath(__file__), '--sync-obsidian', '--source', args.source, '--vault', vault_path, '--target', args.target]
subprocess.Popen(sync_cmd, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, start_new_session=True)
print(json.dumps({"status": "pending", "action": "obsidian_sync", "source": args.source, "message": "Obsidian sync started in background."}, ensure_ascii=False))
sys.exit(0)
else:
result = sync_to_obsidian(args.source, vault_path, args.target)
print(json.dumps(result, ensure_ascii=False, indent=2))
sys.exit(0 if result.get('status') == 'success' else 1)
if args.query_facts:
result = query_facts(args.root, filters=args.filter)
print(json.dumps(result, ensure_ascii=False, indent=2))
sys.exit(0)
if args.kb_init:
result = kb_init(args.root, name=args.name, description=args.description)
print(json.dumps(result, ensure_ascii=False, indent=2))
sys.exit(0)
if args.export_entity:
cwd = os.getcwd()
doc_root = find_doc_root(cwd, args.root)
result = export_entity(doc_root, args.export_entity, output_dir=args.output_dir)
print(json.dumps(result, ensure_ascii=False, indent=2))
sys.exit(0)
if args.kb_list:
result = kb_list()
print(json.dumps(result, ensure_ascii=False, indent=2))
sys.exit(0)
# Lint mode — separate flow
if args.lint:
cwd = os.getcwd()
doc_root = find_doc_root(cwd, args.root)
result = lint_wiki(doc_root, fix=args.fix)
print(json.dumps(result, ensure_ascii=False, indent=2))
sys.exit(0)
# Validate mode — verify file integrity
if args.validate:
cwd = os.getcwd()
doc_root = find_doc_root(cwd, args.root)
result = validate_files(doc_root)
print(json.dumps(result, ensure_ascii=False, indent=2))
sys.exit(0)
# Normal archive mode — type is required
if not args.type:
print(json.dumps({"status": "error", "message": "归档模式需要 --type 参数,或使用 --lint / --kb-init / --kb-list"}))
sys.exit(1)
# 0. 预计算常用值
topic_slug = slugify(args.topic)
now_date = datetime.now().strftime('%Y-%m-%d')
now_str = datetime.now().strftime('%Y-%m-%d %H:%M:%S')
# 1. 获取内容 (来自 URL 或 STDIN)
content_to_write = ""
if args.url:
try:
# P5: Ensure scripts directory is in path for dynamic import
script_dir = os.path.dirname(os.path.abspath(__file__))
if script_dir not in sys.path:
sys.path.append(script_dir)
import smart_fetcher
print(json.dumps({"status": "processing", "message": f"正在尝试从 URL 摄入: {args.url}"}, ensure_ascii=False))
content_to_write = smart_fetcher.smart_extract(args.url)
if not content_to_write:
print(json.dumps({"status": "error", "message": "无法从该 URL 提取有效内容,抓取器返回为空"}))
sys.exit(1)
except ImportError:
# 如果没法直接 import(比如在不同目录),尝试 subprocess 调用
import subprocess
script_path = os.path.join(os.path.dirname(__file__), 'smart_fetcher.py')
result = subprocess.run([sys.executable, script_path, args.url], capture_output=True, text=True)
if result.returncode == 0:
# smart_fetcher.py 在 __main__ 下会打印内容,需要通过正则提取
full_out = result.stdout
match = re.search(r'--- EXTRACTED CONTENT START ---\n(.*?)\n--- EXTRACTED CONTENT END ---', full_out, re.DOTALL)
if match:
content_to_write = match.group(1)
else:
content_to_write = full_out # Fallback
else:
print(json.dumps({"status": "error", "message": "smart_fetcher 脚本执行失败", "details": result.stderr}))
sys.exit(1)
elif args.stdin:
content_to_write = sys.stdin.read()
else:
print(json.dumps({"status": "error", "message": "安全限制: 必须提供 --url 或使用 --stdin 通过管道传参"}))
sys.exit(1)
if not content_to_write or not content_to_write.strip():
print(json.dumps({"status": "error", "message": "抓取或读取的内容为空"}))
sys.exit(1)
# 2. 确定物理路径
cwd = os.getcwd()
doc_root = find_doc_root(cwd, args.root)
if args.type == 'fact-index':
result = ingest_fact_index(doc_root, content_to_write)
print(json.dumps(result, ensure_ascii=False, indent=2))
sys.exit(0)
target_dir = ""
filename = ""
# topic_slug already computed above
# Wiki 层路由判定
if args.type == 'raw':
target_dir = os.path.join(doc_root, 'raw')
filename = f"{topic_slug}.md"
elif args.type == 'source':
date_dir = args.date if args.date else now_date
target_dir = os.path.join(doc_root, 'wiki', 'sources', date_dir)
filename = f"{topic_slug}.md"
elif args.type == 'entity':
target_dir = os.path.join(doc_root, 'wiki', 'entities')
filename = f"{topic_slug}.md"
elif args.type == 'concept':
target_dir = os.path.join(doc_root, 'wiki', 'concepts')
filename = f"{topic_slug}.md"
elif args.type == 'index':
target_dir = os.path.join(doc_root, 'wiki')
filename = "index.md"
elif args.type == 'log':
target_dir = os.path.join(doc_root, 'wiki')
filename = "log.md"
elif args.type == 'template':
target_dir = os.path.join(doc_root, 'references', 'templates')
filename = f"custom_{topic_slug}.md"
# 3. 稳健创建父目录
try:
os.makedirs(target_dir, exist_ok=True)
except Exception as e:
print(json.dumps({"status": "error", "message": f"目录自愈创建失败: {str(e)}"}))
sys.exit(1)
# 4. 路由特定的写入模式 (Append vs Overwrite)
target_path = os.path.join(target_dir, filename)
mode = 'w'
final_write_content = content_to_write
# now_str and now_date already computed above
if args.type in ['index', 'log']:
# index 和 log 永远是增量追加
mode = 'a'
if args.type == 'log':
final_write_content = f"\n- **[{now_str}]** {content_to_write.strip()}"
else: # index.md
final_write_content = f"\n{content_to_write.strip()}"
elif args.type in ['entity', 'concept'] and os.path.exists(target_path):
# 知识节点增量追加
mode = 'a'
final_write_content = f"\n\n---\n## 增量知识点合入 ({now_str})\n\n" + content_to_write
# 自动探测并注入 Frontmatter 时间戳 (仅对新建的 Markdown 有效)
if mode == 'w' and args.type in ['source', 'entity', 'concept', 'raw', 'fact-index']:
# 使用正则表达式匹配 Frontmatter 块
fm_match = FRONTMATTER_RE.match(final_write_content.lstrip())
if fm_match:
fm_text = fm_match.group(1)
# 检查内部是否已有 date 字段
if not re.search(r'^date:', fm_text, re.MULTILINE | re.IGNORECASE):
# 在第一个 --- 下方插入 date
insertion = f"---\ndate: {now_date}\n"
final_write_content = re.sub(r'^---\s*\n', insertion, final_write_content.lstrip(), count=1)
else:
# 完全没有 YAML Frontmatter 格式,强制注入一个
final_write_content = f"---\ndate: {now_date}\n---\n\n" + final_write_content.lstrip()
# 4.5 去重检查 (dedup check)
dedup_status = "new"
if os.path.exists(target_path) and args.dedup != 'overwrite':
existing_size = os.path.getsize(target_path)
if args.dedup == 'skip':
with open(target_path, 'rb') as ef:
existing_checksum = hashlib.sha256(ef.read()).hexdigest()
receipt = {
"status": "skipped",
"dedup": "skipped",
"type_routed": args.type,
"path": target_path,
"size_bytes": existing_size,
"checksum": existing_checksum,
"message": f"Entity already exists ({existing_size} bytes). Skipped per --dedup skip."
}
print(json.dumps(receipt, ensure_ascii=False))
sys.exit(0)
elif args.dedup == 'merge':
# For entity/concept: append (mode already set above)
# For source: refuse to merge, warn instead
if args.type == 'source':
receipt = {
"status": "skipped",
"dedup": "source_exists",
"type_routed": args.type,
"path": target_path,
"message": f"Source already exists. Use --dedup overwrite to replace, or pick a different topic."
}
print(json.dumps(receipt, ensure_ascii=False))
sys.exit(0)
dedup_status = "merged"
# 4.6 Link sanitization — 仅对 entity/concept 写入生效,防止孤儿 wiki-link
if args.type in ['entity', 'concept']:
existing_content_for_sanitize = None
if mode == 'a' and os.path.exists(target_path):
# 追加模式:读取已有内容,只清理新增部分
try:
with open(target_path, 'r', encoding='utf-8') as ef:
existing_content_for_sanitize = ef.read()
except Exception:
pass
final_write_content = sanitize_wiki_links(
final_write_content, doc_root,
existing_content=existing_content_for_sanitize
)
# 5. 原子落盘及防幻觉回执生成
try:
with open(target_path, mode, encoding='utf-8') as f:
f.write(final_write_content)
with open(target_path, 'rb') as f:
file_bytes = f.read()
checksum = hashlib.sha256(file_bytes).hexdigest()
size_bytes = len(file_bytes)
receipt = {
"status": "success",
"dedup": dedup_status,
"type_routed": args.type,
"path": target_path,
"size_bytes": size_bytes,
"checksum": checksum,
"date": now_date,
"message": "Karpathy Wiki Layer write valid."
}
print(json.dumps(receipt, ensure_ascii=False))
sys.exit(0)
except Exception as e:
print(json.dumps({"status": "error", "message": f"I/O 崩溃: {str(e)}"}))
sys.exit(1)
if __name__ == "__main__":
main()
FILE:scripts/context-injector.py
#!/usr/bin/env python3
import os
import sys
import argparse
import json
import re
from datetime import datetime
# 复用 slugify 逻辑
def slugify(text):
text = str(text).lower()
text = re.sub(r'[^\w\s-]', '', text)
text = re.sub(r'[\s_-]+', '-', text).strip('-')
return text if text else "untitled"
def find_doc_root(start_path, root_name='arc-reactor-doc'):
"""向上查找工作区根目录"""
curr_dir = os.path.abspath(start_path)
while curr_dir and curr_dir != '/':
if os.path.exists(os.path.join(curr_dir, root_name)):
return os.path.join(curr_dir, root_name)
if os.path.exists(os.path.join(curr_dir, '.git')):
return os.path.join(curr_dir, root_name)
parent = os.path.dirname(curr_dir)
if parent == curr_dir:
break
curr_dir = parent
return os.path.join(start_path, root_name)
def _parse_simple_yaml(filepath):
"""简单 YAML 解析,用于读取配置"""
if not os.path.exists(filepath):
return {}
config = {}
try:
with open(filepath, 'r', encoding='utf-8') as f:
for line in f:
if ':' in line and not line.strip().startswith('#'):
key, val = line.split(':', 1)
# 剥离行内注释
val = val.split('#', 1)[0].strip()
config[key.strip()] = val.strip('"').strip("'")
except:
pass
return config
def main():
parser = argparse.ArgumentParser(description='ARC Reactor Context Injector')
parser.add_argument('--query', required=True, help='User input query to match against knowledge base')
parser.add_argument('--root', default='arc-reactor-doc', help='Document root name')
args = parser.parse_args()
cwd = os.getcwd()
doc_root = find_doc_root(cwd, args.root)
wiki_dir = os.path.join(doc_root, 'wiki')
index_path = os.path.join(wiki_dir, 'index.md')
entities_dir = os.path.join(wiki_dir, 'entities')
# 1. 加载配置限额
config_path = os.path.join(os.path.dirname(os.path.dirname(__file__)), 'arc-reactor-config.yaml')
config = _parse_simple_yaml(config_path)
max_entities = int(config.get('max_entities', 3))
max_chars = int(config.get('max_chars', 10000))
if not os.path.exists(index_path):
# 如果索引不存在,静默退出
sys.exit(0)
# 2. 从 index.md 提取所有实体
entities_in_index = []
try:
with open(index_path, 'r', encoding='utf-8') as f:
content = f.read()
# 匹配 [[Entity Name]]
wiki_links = re.findall(r'\[\[([^\]]+)\]\]', content)
for link in wiki_links:
entities_in_index.append({
"original": link,
"slug": slugify(link)
})
except Exception as e:
print(f"Error reading index: {e}", file=sys.stderr)
sys.exit(1)
# 3. 匹配检索词 (简单关键词碰撞)
query_lower = args.query.lower()
hit_entities = []
seen_slugs = set()
for item in entities_in_index:
if item['slug'] in seen_slugs:
continue
# 匹配逻辑:如果实体名在 query 中,或者 query 的词在实体名中
if item['original'].lower() in query_lower or item['slug'].replace('-', ' ') in query_lower:
hit_entities.append(item)
seen_slugs.add(item['slug'])
if len(hit_entities) >= max_entities:
break
# 4. 提取实体内容并格式化输出
if not hit_entities:
sys.exit(0)
output_blocks = []
total_chars = 0
for entity in hit_entities:
entity_path = os.path.join(entities_dir, f"{entity['slug']}.md")
if os.path.exists(entity_path):
try:
with open(entity_path, 'r', encoding='utf-8') as f:
content = f.read()
block = f"### Entity: {entity['original']}\nURL: wiki/entities/{entity['slug']}.md\n\n{content}"
if total_chars + len(block) > max_chars:
break
output_blocks.append(block)
total_chars += len(block)
except:
continue
if output_blocks:
print("\n<ARC_KNOWLEDGE_CONTEXT>")
print("以下是从您的私人百科全书(ARC Wiki)中自动检索到的关联知识条目,请结合这些背景信息回答用户:")
print("\n---\n".join(output_blocks))
print("</ARC_KNOWLEDGE_CONTEXT>\n")
if __name__ == "__main__":
main()
FILE:scripts/governance-audit.py
import os
import sys
import subprocess
import re
import json
def run(cmd):
result = subprocess.run(cmd, shell=True, capture_output=True, text=True)
return result.stdout.strip(), result.returncode
def audit():
print("=== [ARC Reactor Governance Audit] ===")
issues = []
# 1. Check Commit Signature
last_commit_msg, _ = run("git log -1 --pretty=%B")
if not re.search(r'\(by \w+\)', last_commit_msg):
issues.append(f"Commit message missing signature '(by AgentName)'. Last msg: '{last_commit_msg}'")
else:
print("✅ Commit signature found.")
# 2. Check RT Association for Modified Files
# Check staged changes (between HEAD and Index)
diff_files, _ = run("git diff --cached --name-only")
files = diff_files.splitlines()
code_files = [f for f in files if f.endswith(('.py', '.sh', '.yaml', '.json')) and not f.startswith('RT/')]
if code_files:
# Check if RT/ was also touched in this commit or recently
rt_files = [f for f in files if f.startswith('RT/RT-')]
if not rt_files:
# Check recent RT hits (optional, simplified for now)
issues.append(f"Code changes detected in {code_files} but no matching RT spec was found in this commit.")
else:
print(f"✅ RT association verified for {len(code_files)} files.")
# 3. Check Wiki Integrity (No manual edits in arc-reactor-doc/wiki/ without record)
# We check if wiki files were modified but archive-manager.py log wasn't updated
wiki_files = [f for f in files if 'arc-reactor-doc/wiki/' in f and not f.endswith('log.md')]
if wiki_files:
log_updated = any('log.md' in f for f in files)
if not log_updated:
issues.append("Wiki files modified manually without updating the operational log.md. Use archive-manager.py!")
else:
print("✅ Wiki integrity check passed.")
# 4. Final Verdict
if issues:
print("\n❌ Governance Violations Found:")
for i in issues:
print(f" - {i}")
return 1
print("\n🌟 All systems compliant. Governance protocol followed.")
return 0
if __name__ == "__main__":
sys.exit(audit())
FILE:scripts/media-extractor.py
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
media-extractor.py - 视频/音频转写模块
从 YouTube 等平台下载视频并转写为文本。
支持的 ASR Provider:
1. 阿里云 NLS(优先)
2. 本地 mlx_whisper(降级)
3. 降级模式(仅抓取标题/字幕/描述)
依赖:
- yt-dlp: 视频下载
- ffmpeg: 音频格式转换
- requests: HTTP 请求
- mlx_whisper (可选): 本地转写
用法:
python3 scripts/media-extractor.py --url "https://youtu.be/xxx" --output /tmp/transcript.txt
python3 scripts/media-extractor.py --url "https://youtu.be/xxx" --json
python3 scripts/media-extractor.py --url "https://youtu.be/xxx" --provider aliyun-nls
python3 scripts/media-extractor.py --check
python3 scripts/media-extractor.py --url "https://youtu.be/xxx" --info-only
"""
import argparse
import atexit
import base64
import hashlib
import hmac
import json
import os
import re
import shutil
import subprocess
import sys
import tempfile
import time
import uuid
from pathlib import Path
from typing import Optional
import requests
# ============================================================================
# 全局变量
# ============================================================================
# 临时文件列表,用于退出时清理
_temp_files = []
_temp_dirs = []
def _cleanup():
"""清理临时文件"""
for f in _temp_files:
try:
if os.path.exists(f):
os.remove(f)
except Exception:
pass
for d in _temp_dirs:
try:
if os.path.exists(d):
shutil.rmtree(d)
except Exception:
pass
atexit.register(_cleanup)
def _add_temp_file(path: str):
"""注册临时文件"""
_temp_files.append(path)
def _add_temp_dir(path: str):
"""注册临时目录"""
_temp_dirs.append(path)
# ============================================================================
# 工具函数
# ============================================================================
def get_available_memory_gb() -> float:
"""
获取 macOS 可用内存(GB)
使用 vm_stat 命令获取页面信息,计算可用内存。
"""
try:
result = subprocess.run(
["vm_stat"],
capture_output=True,
text=True,
timeout=10
)
output = result.stdout
# 解析 vm_stat 输出
# Pages free: xxxxxx.
# Pages active: xxxxxx.
free_match = re.search(r"Pages free:\s+(\d+)", output)
active_match = re.search(r"Pages active:\s+(\d+)", output)
inactive_match = re.search(r"Pages inactive:\s+(\d+)", output)
speculative_match = re.search(r"Pages speculative:\s+(\d+)", output)
# macOS 页面大小(ARM Mac 为 16384 字节)
page_size = 16384
free_pages = int(free_match.group(1)) if free_match else 0
active_pages = int(active_match.group(1)) if active_match else 0
inactive_pages = int(inactive_match.group(1)) if inactive_match else 0
speculative_pages = int(speculative_match.group(1)) if speculative_match else 0
# 可用内存 = free + inactive + speculative(更准确的可用内存估算)
available_pages = free_pages + inactive_pages + speculative_pages
available_bytes = available_pages * page_size
available_gb = available_bytes / (1024 ** 3)
return round(available_gb, 2)
except Exception:
return 0.0
def check_command_exists(cmd: str) -> bool:
"""检查命令是否存在"""
return shutil.which(cmd) is not None
def format_duration(seconds: int) -> str:
"""将秒数格式化为 HH:MM:SS 格式"""
hours = seconds // 3600
minutes = (seconds % 3600) // 60
secs = seconds % 60
if hours > 0:
return f"{hours:02d}:{minutes:02d}:{secs:02d}"
return f"{minutes:02d}:{secs:02d}"
# ============================================================================
# ASR Provider 检测
# ============================================================================
def check_mlx_whisper() -> dict:
"""
检测 mlx_whisper 是否可用
Returns:
dict: {"available": bool, "reason": str, "model": str or None}
"""
result = {
"available": False,
"reason": "",
"model": None,
"memory_gb": 0.0
}
# 检查是否已安装 mlx_whisper
try:
import mlx_whisper
result["available"] = True
except ImportError:
result["reason"] = "mlx_whisper not installed"
return result
# 检查 Apple Silicon
import platform
if platform.system() != "Darwin" or platform.machine() != "arm64":
result["available"] = False
result["reason"] = "mlx_whisper requires Apple Silicon (arm64)"
return result
# 检查可用内存
memory_gb = get_available_memory_gb()
result["memory_gb"] = memory_gb
if memory_gb < 6:
result["available"] = False
result["reason"] = f"Insufficient memory ({memory_gb:.1f}GB < 6GB required)"
return result
# 根据内存选择模型
if memory_gb >= 10:
result["model"] = "mlx-community/whisper-large-v3"
elif memory_gb >= 6:
result["model"] = "mlx-community/whisper-medium"
else:
result["available"] = False
result["reason"] = f"Insufficient memory ({memory_gb:.1f}GB < 6GB required)"
return result
def check_aliyun_nls() -> dict:
"""
检测阿里云 NLS 是否可用
Returns:
dict: {"available": bool, "has_appkey": bool, "has_access_key": bool}
"""
result = {
"available": False,
"has_appkey": False,
"has_access_key": False
}
appkey = os.environ.get("ALIYUN_NLS_APPKEY", "")
access_key_id = os.environ.get("ALIYUN_ACCESS_KEY_ID", "")
access_key_secret = os.environ.get("ALIYUN_ACCESS_KEY_SECRET", "")
result["has_appkey"] = bool(appkey)
result["has_access_key"] = bool(access_key_id and access_key_secret)
result["available"] = result["has_appkey"] and result["has_access_key"]
return result
def check_all_providers() -> dict:
"""
检查所有 ASR Provider 的可用性
Returns:
dict: 包含各 provider 状态的字典
"""
aliyun = check_aliyun_nls()
mlx = check_mlx_whisper()
# 推荐 provider
recommended = None
message = ""
if aliyun["available"]:
recommended = "aliyun-nls"
message = "ASR 可用。将使用阿里云 NLS 进行语音转写。"
elif mlx["available"]:
recommended = "mlx-whisper"
message = f"ASR 可用。将使用 mlx_whisper ({mlx['model']}) 进行语音转写。"
else:
message = "未检测到 ASR 配置。视频转写需要以下任一服务:"
return {
"aliyun_nls": aliyun,
"mlx_whisper": mlx,
"recommended": recommended,
"message": message
}
def print_provider_guidance():
"""打印 ASR 配置引导信息到 stderr"""
guidance = """
⚠️ 未检测到 ASR 配置。视频转写需要以下任一服务:
方案 A(推荐):阿里云 NLS(¥0.14/30分钟)
- 注册阿里云 → 开通智能语音交互 → 获取 AppKey + AccessKey
- 配置环境变量:ALIYUN_NLS_APPKEY, ALIYUN_ACCESS_KEY_ID, ALIYUN_ACCESS_KEY_SECRET
方案 B(免费):本地 mlx_whisper(需 Apple Silicon + 5GB 内存)
- pip install mlx-whisper
未配置 ASR 时,将尝试抓取视频标题、字幕、描述等可用信息。
"""
print(guidance, file=sys.stderr)
# ============================================================================
# 阿里云 NLS 相关函数
# ============================================================================
def get_nls_token(access_key_id: str, access_key_secret: str) -> str:
"""
获取阿里云 NLS Token(使用 AccessKey 签名方式)
Args:
access_key_id: 阿里云 AccessKey ID
access_key_secret: 阿里云 AccessKey Secret
Returns:
str: NLS Token ID
Raises:
Exception: 获取失败时抛出异常
"""
params = {
"AccessKeyId": access_key_id,
"Action": "CreateToken",
"Format": "JSON",
"RegionId": "cn-shanghai",
"SignatureMethod": "HMAC-SHA1",
"SignatureNonce": str(uuid.uuid4()),
"SignatureVersion": "1.0",
"Timestamp": time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime()),
"Version": "2019-02-28",
}
# 按字母顺序排序参数
sorted_params = sorted(params.items())
query_string = urllib_parse_urlencode(sorted_params)
# 构建待签名字符串
string_to_sign = "GET&%2F&" + urllib_parse_quote(query_string, safe="")
# 计算签名
signature = base64.b64encode(
hmac.new(
(access_key_secret + "&").encode("utf-8"),
string_to_sign.encode("utf-8"),
hashlib.sha1
).digest()
).decode("utf-8")
params["Signature"] = signature
# 构建请求 URL
url = "https://nls-meta.cn-shanghai.aliyuncs.com/?" + urllib_parse_urlencode(params)
# 发送请求
try:
resp = requests.get(url, timeout=30)
resp.raise_for_status()
data = resp.json()
if "Token" in data and "Id" in data["Token"]:
return data["Token"]["Id"]
else:
raise Exception(f"获取 Token 失败: {data}")
except requests.exceptions.RequestException as e:
raise Exception(f"获取 NLS Token 网络请求失败: {e}")
def urllib_parse_urlencode(params):
"""兼容的 url_encode 实现"""
import urllib.parse
return urllib.parse.urlencode(params)
def urllib_parse_quote(s, safe=""):
"""兼容的 quote 实现"""
import urllib.parse
return urllib.parse.quote(s, safe=safe)
def split_audio_chunks(wav_path: str, chunk_duration: int = 55) -> list:
"""
将 WAV 音频文件按指定时长分割
Args:
wav_path: WAV 文件路径
chunk_duration: 每段时长(秒),默认 55 秒
Returns:
list: 每段音频文件的路径列表
"""
# 获取音频总时长
result = subprocess.run(
["ffprobe", "-v", "error", "-show_entries", "format=duration",
"-of", "default=noprint_wrappers=1:nokey=1", wav_path],
capture_output=True,
text=True,
timeout=30
)
total_duration = float(result.stdout.strip())
chunks = []
temp_dir = tempfile.mkdtemp(prefix="arc-chunks-")
_add_temp_dir(temp_dir)
# 按 chunk_duration 分割
start = 0.0
chunk_idx = 0
while start < total_duration:
end = min(start + chunk_duration, total_duration)
chunk_path = os.path.join(temp_dir, f"chunk_{chunk_idx:04d}.wav")
cmd = [
"ffmpeg", "-y",
"-i", wav_path,
"-ss", str(start),
"-t", str(end - start),
"-ar", "16000",
"-ac", "1",
"-acodec", "pcm_s16le",
chunk_path
]
subprocess.run(
cmd,
capture_output=True,
text=True,
timeout=60
)
chunks.append(chunk_path)
start = end
chunk_idx += 1
return chunks
def transcribe_audio_chunk(token: str, app_key: str, audio_path: str) -> str:
"""
转写单个音频片段(调用阿里云一句话识别 API)
Args:
token: NLS Token
app_key: 阿里云 AppKey
audio_path: 音频文件路径
Returns:
str: 识别文本
Raises:
Exception: API 调用失败时抛出异常
"""
endpoint = "https://nls-gateway.cn-shanghai.aliyuncs.com/stream/v1/asr"
headers = {
"X-NLS-Token": token,
"Content-Type": "application/octet-stream"
}
params = {
"appkey": app_key,
"format": "wav",
"sample_rate": "16000",
"enable_punctuation_prediction": "true",
"enable_inverse_text_normalization": "true"
}
try:
with open(audio_path, "rb") as f:
audio_data = f.read()
resp = requests.post(
endpoint,
headers=headers,
params=params,
data=audio_data,
timeout=30
)
resp.raise_for_status()
data = resp.json()
if "result" in data:
return data["result"]
else:
# 可能返回其他格式,尝试直接返回
return str(data.get("text", ""))
except requests.exceptions.Timeout:
raise Exception("NLS API 请求超时(30秒)")
except requests.exceptions.RequestException as e:
raise Exception(f"NLS API 请求失败: {e}")
def transcribe_aliyun_nls(wav_path: str) -> str:
"""
使用阿里云 NLS 转写 WAV 音频
Args:
wav_path: WAV 文件路径(16kHz mono)
Returns:
str: 完整转写文本
"""
access_key_id = os.environ.get("ALIYUN_ACCESS_KEY_ID")
access_key_secret = os.environ.get("ALIYUN_ACCESS_KEY_SECRET")
app_key = os.environ.get("ALIYUN_NLS_APPKEY")
if not all([access_key_id, access_key_secret, app_key]):
raise Exception("缺少阿里云 NLS 环境变量")
# 1. 获取 Token
print("正在获取阿里云 NLS Token...", file=sys.stderr)
token = get_nls_token(access_key_id, access_key_secret)
# 2. 分割音频
print("正在分割音频(每段 55 秒)...", file=sys.stderr)
chunks = split_audio_chunks(wav_path, chunk_duration=55)
print(f"共分割为 {len(chunks)} 个片段", file=sys.stderr)
# 3. 逐段转写
results = []
for i, chunk in enumerate(chunks):
print(f"\r转写中: {i + 1}/{len(chunks)}", end="", file=sys.stderr)
sys.stderr.flush()
try:
text = transcribe_audio_chunk(token, app_key, chunk)
if text:
results.append(text)
except Exception as e:
print(f"\n片段 {i + 1} 转写失败: {e}", file=sys.stderr)
# 继续处理下一段
print("", file=sys.stderr) # 换行
# 4. 拼接结果
full_text = " ".join(results)
return full_text
# ============================================================================
# mlx_whisper 转写
# ============================================================================
def transcribe_mlx_whisper(wav_path: str) -> str:
"""
使用 mlx_whisper 转写音频
Args:
wav_path: WAV 文件路径
Returns:
str: 转写文本
"""
import mlx_whisper
memory_gb = get_available_memory_gb()
if memory_gb >= 10:
model_path = "mlx-community/whisper-large-v3"
elif memory_gb >= 6:
model_path = "mlx-community/whisper-medium"
else:
raise Exception(f"内存不足({memory_gb:.1f}GB),无法使用 mlx_whisper")
print(f"正在使用 mlx_whisper ({model_path}) 转写...", file=sys.stderr)
result = mlx_whisper.transcribe(
wav_path,
language="zh",
path_or_hf_repo=model_path
)
return result.get("text", "")
# ============================================================================
# 视频下载与信息获取
# ============================================================================
def get_video_info(url: str) -> dict:
"""
获取视频信息(标题、描述、时长、字幕等)
Args:
url: 视频 URL
Returns:
dict: 视频信息
"""
temp_dir = tempfile.mkdtemp(prefix="arc-info-")
_add_temp_dir(temp_dir)
info_file = os.path.join(temp_dir, "info.json")
# 使用 yt-dlp 命令行工具(独立安装)
ytdlp_cmd = shutil.which("yt-dlp")
if not ytdlp_cmd:
ytdlp_cmd = "yt-dlp" # fallback to PATH
cmd = [
ytdlp_cmd,
"--dump-json",
"--no-download",
"--no-warnings",
url
]
try:
result = subprocess.run(
cmd,
capture_output=True,
text=True,
timeout=60
)
if result.returncode != 0:
print(f"获取视频信息失败: {result.stderr}", file=sys.stderr)
return {}
# 尝试解析输出
try:
info = json.loads(result.stdout.strip().split("\n")[-1])
except json.JSONDecodeError:
# 可能输出包含多行,尝试最后一行
lines = result.stdout.strip().split("\n")
for line in reversed(lines):
if line.strip().startswith("{"):
try:
info = json.loads(line)
break
except json.JSONDecodeError:
continue
else:
return {}
# 提取自动字幕(优先中文)
subtitles = {}
if "subtitles" in info:
subtitles = info.get("subtitles", {})
elif "automatic_captions" in info:
subtitles = info.get("automatic_captions", {})
auto_subtitle = None
# 优先找中文相关字幕
for lang in ["zh-Hans", "zh-CN", "zh", "zh-TW", "en"]:
if lang in subtitles and subtitles[lang]:
# 获取字幕内容
sub_url = subtitles[lang][0].get("url")
if sub_url:
try:
sub_resp = requests.get(sub_url, timeout=30)
sub_resp.raise_for_status()
auto_subtitle = sub_resp.text
break
except Exception:
continue
return {
"title": info.get("title", ""),
"description": info.get("description", ""),
"duration": info.get("duration", 0),
"url": info.get("webpage_url", url),
"thumbnail": info.get("thumbnail", ""),
"subtitles": auto_subtitle,
"full_info": info
}
except subprocess.TimeoutExpired:
print("获取视频信息超时(60秒)", file=sys.stderr)
return {}
except Exception as e:
print(f"获取视频信息异常: {e}", file=sys.stderr)
return {}
def download_audio(url: str) -> tuple:
"""
下载视频音频并转换为 16kHz mono WAV
Args:
url: 视频 URL
Returns:
tuple: (wav_path, video_info)
Raises:
Exception: 下载或转换失败时抛出异常
"""
# 创建临时目录
temp_dir = tempfile.mkdtemp(prefix="arc-audio-")
_add_temp_dir(temp_dir)
# 下载音频为 WAV
raw_wav = os.path.join(temp_dir, "raw_audio.wav")
_add_temp_file(raw_wav)
print("正在下载音频...", file=sys.stderr)
# 步骤 1: 用 yt-dlp 下载为 wav
ytdlp_cmd = shutil.which("yt-dlp")
if not ytdlp_cmd:
ytdlp_cmd = "yt-dlp"
cmd1 = [
ytdlp_cmd,
"-x",
"--audio-format", "wav",
"--audio-quality", "0",
"-o", raw_wav,
"--no-warnings",
url
]
result = subprocess.run(
cmd1,
capture_output=True,
text=True,
timeout=120 # 下载超时 2 分钟
)
if result.returncode != 0:
raise Exception(f"音频下载失败: {result.stderr}")
# yt-dlp 可能输出文件为 .wav.wav 或其他扩展名,查找实际文件
actual_files = list(Path(temp_dir).glob("*.wav"))
if actual_files:
raw_wav = str(actual_files[0])
elif not os.path.exists(raw_wav):
raise Exception(f"音频文件未找到: {raw_wav}")
# 步骤 2: 用 ffmpeg 转换为 16kHz mono WAV
final_wav = os.path.join(temp_dir, "audio_16k.wav")
_add_temp_file(final_wav)
print("正在转换音频格式...", file=sys.stderr)
cmd2 = [
"ffmpeg",
"-y",
"-i", raw_wav,
"-ar", "16000",
"-ac", "1",
"-acodec", "pcm_s16le",
final_wav
]
result2 = subprocess.run(
cmd2,
capture_output=True,
text=True,
timeout=120 # 转换超时 2 分钟
)
if result2.returncode != 0:
raise Exception(f"音频格式转换失败: {result2.stderr}")
# 获取视频信息
video_info = get_video_info(url)
return final_wav, video_info
# ============================================================================
# 主转写函数
# ============================================================================
def transcribe(url: str, provider: str = "auto") -> dict:
"""
转写视频音频
Args:
url: 视频 URL
provider: ASR Provider ("auto", "aliyun-nls", "mlx-whisper", "fallback")
Returns:
dict: 转写结果
"""
result = {
"status": "pending",
"provider": provider,
"source_url": url,
"title": "",
"duration_seconds": 0,
"transcript": "",
"word_count": 0,
"cost_estimate": "",
"error": ""
}
# 检查 provider 可用性
if provider == "auto":
providers = check_all_providers()
if providers["recommended"]:
provider = providers["recommended"]
else:
print_provider_guidance()
provider = "fallback"
# 下载音频
try:
wav_path, video_info = download_audio(url)
result["title"] = video_info.get("title", "")
result["duration_seconds"] = video_info.get("duration", 0)
except Exception as e:
result["status"] = "error"
result["error"] = f"音频下载失败: {e}"
return result
# 根据 provider 转写
try:
if provider == "aliyun-nls":
transcript = transcribe_aliyun_nls(wav_path)
result["provider"] = "aliyun-nls"
# 估算费用:约 ¥0.14/30分钟
duration_min = result["duration_seconds"] / 60
cost = 0.14 * (duration_min / 30)
result["cost_estimate"] = f"{cost:.2f} CNY"
elif provider == "mlx-whisper":
transcript = transcribe_mlx_whisper(wav_path)
result["provider"] = "mlx-whisper"
result["cost_estimate"] = "0.00 CNY (本地)"
else:
# 降级模式:尝试使用字幕或描述
transcript = ""
# 尝试使用自动字幕
if video_info.get("subtitles"):
# 解析 srt/vtt 字幕为纯文本
sub_text = video_info["subtitles"]
# VTT 格式处理
if sub_text.startswith("WEBVTT"):
lines = sub_text.split("\n")
for line in lines:
# 跳过时间码和标签
if "-->" in line or line.strip().startswith("-"):
continue
if line.strip() and not line.strip().startswith(">"):
transcript += " " + line.strip()
else:
transcript = sub_text
transcript = re.sub(r"<[^>]+>", "", transcript)
transcript = re.sub(r"\s+", " ", transcript).strip()
# 如果没有字幕,使用描述
if not transcript and video_info.get("description"):
transcript = video_info["description"]
# 截取前 5000 字符
transcript = transcript[:5000]
result["provider"] = "fallback"
result["cost_estimate"] = "0.00 CNY (字幕/描述)"
result["transcript"] = transcript
result["word_count"] = len(transcript.replace(" ", ""))
result["status"] = "success"
except Exception as e:
result["status"] = "error"
result["error"] = str(e)
return result
# ============================================================================
# CLI 接口
# ============================================================================
def cmd_check():
"""检查 ASR Provider 可用性"""
result = check_all_providers()
# 如果没有可用 provider,打印引导
if not result["recommended"]:
print_provider_guidance()
# 输出 JSON
print(json.dumps(result, ensure_ascii=False, indent=2))
def cmd_info(url: str):
"""获取视频信息(不转写)"""
info = get_video_info(url)
if not info:
print(json.dumps({
"status": "error",
"error": "无法获取视频信息"
}, ensure_ascii=False, indent=2))
sys.exit(1)
# 简化输出
output = {
"status": "success",
"title": info.get("title", ""),
"description": info.get("description", ""),
"duration_seconds": info.get("duration", 0),
"duration_formatted": format_duration(info.get("duration", 0)),
"url": info.get("url", url),
"has_subtitles": bool(info.get("subtitles"))
}
print(json.dumps(output, ensure_ascii=False, indent=2))
def cmd_transcribe(url: str, output_file: Optional[str], json_output: bool, provider: str):
"""转写视频"""
result = transcribe(url, provider)
if json_output:
print(json.dumps(result, ensure_ascii=False, indent=2))
else:
# 纯文本输出
if result["status"] == "success":
if result["title"]:
print(f"# {result['title']}")
print()
print(result["transcript"])
else:
print(f"转写失败: {result.get('error', '未知错误')}", file=sys.stderr)
sys.exit(1)
# 写入文件
if output_file:
with open(output_file, "w", encoding="utf-8") as f:
f.write(result["transcript"])
print(f"转写结果已保存到: {output_file}", file=sys.stderr)
def main():
parser = argparse.ArgumentParser(
description="视频/音频转写工具 - 从 YouTube 等平台下载视频并转写为文本",
formatter_class=argparse.RawDescriptionHelpFormatter,
epilog="""
示例:
python3 scripts/media-extractor.py --check
python3 scripts/media-extractor.py --url "https://youtu.be/xxx" --json
python3 scripts/media-extractor.py --url "https://youtu.be/xxx" --output /tmp/transcript.txt
python3 scripts/media-extractor.py --url "https://youtu.be/xxx" --provider aliyun-nls
python3 scripts/media-extractor.py --url "https://youtu.be/xxx" --info-only
"""
)
parser.add_argument(
"--url",
type=str,
help="视频 URL(支持 YouTube 等平台)"
)
parser.add_argument(
"--output", "-o",
type=str,
help="输出文件路径(转写文本)"
)
parser.add_argument(
"--json", "-j",
action="store_true",
help="输出 JSON 格式(含元数据)"
)
parser.add_argument(
"--provider", "-p",
type=str,
default="auto",
choices=["auto", "aliyun-nls", "mlx-whisper", "fallback"],
help="ASR Provider(默认 auto)"
)
parser.add_argument(
"--check", "-c",
action="store_true",
help="检查 ASR Provider 可用性"
)
parser.add_argument(
"--info-only", "-i",
action="store_true",
help="仅获取视频信息(不转写)"
)
args = parser.parse_args()
# 检查命令依赖
if not check_command_exists("ffmpeg"):
print("错误: 未找到 ffmpeg,请先安装: brew install ffmpeg", file=sys.stderr)
sys.exit(1)
if not check_command_exists("yt-dlp"):
print("错误: 未找到 yt-dlp,请先安装: pip install yt-dlp", file=sys.stderr)
sys.exit(1)
# 执行对应命令
if args.check:
cmd_check()
elif args.info_only:
if not args.url:
parser.error("--info-only 需要指定 --url")
cmd_info(args.url)
elif args.url:
cmd_transcribe(args.url, args.output, args.json, args.provider)
else:
parser.print_help()
sys.exit(1)
if __name__ == "__main__":
main()
FILE:scripts/smart_fetcher.py
#!/usr/bin/env python3
import os
import sys
import json
import requests
import re
from datetime import datetime
# 配置:高反爬域名黑名单,这些域名强制走专业提取接口
HIGH_ANTIBOT_DOMAINS = [
'toutiao.com',
'juejin.cn',
'mp.weixin.qq.com',
'zhihu.com',
'weibo.com'
]
def is_high_antibot(url):
"""检测 URL 是否属于高倾斜反爬域名"""
return any(domain in url for domain in HIGH_ANTIBOT_DOMAINS)
def fetch_via_tavily(url, api_key):
"""使用 Tavily Extract API 提取正文 (目前最稳的方法)"""
if not api_key:
return None
print(f"[INFO] 正在调用 Tavily 专业级提取引擎: {url}")
try:
# Tavily Extract API 结构
res = requests.post(
"https://api.tavily.com/extract",
json={
"api_key": api_key,
"urls": [url]
},
timeout=30
)
data = res.json()
if data.get('results') and len(data['results']) > 0:
result = data['results'][0]
# Tavily 返回的是对象,包含 raw_content 或 markdown
content = result.get('raw_content') or result.get('markdown')
return content
except Exception as e:
print(f"[WARNING] Tavily 提取失败: {e}")
return None
def fetch_via_jina(url):
"""使用 Jina.ai Reader 代理 (免费方案)"""
print(f"[INFO] 正在尝试 Jina Reader 轻量提取: {url}")
try:
reader_url = f"https://r.jina.ai/{url}"
headers = {
"Accept": "text/markdown",
"X-Target-Selector": "article, .content, .main" # 提示提取范围
}
res = requests.get(reader_url, headers=headers, timeout=20)
if res.status_code == 200 and len(res.text) > 200:
return res.text
except Exception as e:
print(f"[WARNING] Jina 提取失败: {e}")
return None
def fetch_via_llm_reader(url):
"""备选的 LLM-Reader 接口"""
print(f"[INFO] 正在尝试 LLM-Reader 节点提取: {url}")
try:
# 这是一个针对字节跳动优化过的公共接口镜像
reader_url = f"https://reader.llm.report/api/read?url={url}"
res = requests.get(reader_url, timeout=20)
data = res.json()
return data.get('markdown', '')
except:
return None
def fetch_basic(url):
"""最基础的多头 Requests 抓取 (针对 80% 的普通网站)"""
print(f"[INFO] 正在执行标准请求协议: {url}")
headers = {
"User-Agent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36",
"Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8",
"Accept-Language": "zh-CN,zh;q=0.9,en;q=0.8"
}
try:
res = requests.get(url, headers=headers, timeout=15)
res.encoding = res.apparent_encoding
# 这里返回原始内容,后面可以接 Readability 提取
return res.text
except Exception as e:
print(f"[ERROR] 标准抓取彻底瘫痪: {e}")
return None
def smart_extract(url):
"""全自动智能路由提取逻辑"""
# 1. 检测域名属性
is_hard = is_high_antibot(url)
# 2. 获取环境变量中的密钥
tavily_key = os.environ.get("TAVILY_API_KEY")
apify_token = os.environ.get("APIFY_TOKEN")
content = None
# 3. 路由策略:
# 如果是硬骨头网站,优先走 Tavily
if is_hard:
if tavily_key:
content = fetch_via_tavily(url, tavily_key)
if not content:
content = fetch_via_llm_reader(url)
if not content:
content = fetch_via_jina(url)
else:
# 普通网站先试免费的 Jina 或 LLM-Reader,效果最好
content = fetch_via_jina(url)
if not content:
content = fetch_via_llm_reader(url)
if not content:
content = fetch_basic(url)
if not content or len(content.strip()) < 50:
print(f"[FATAL] 所有的抓取链路均被拦截或无法提取正文。")
return None
return content
if __name__ == "__main__":
if len(sys.argv) < 2:
print("Usage: python3 smart_fetcher.py <url>")
sys.exit(1)
target_url = sys.argv[1]
result = smart_extract(target_url)
if result:
print("\n--- EXTRACTED CONTENT START ---")
print(result)
print("--- EXTRACTED CONTENT END ---\n")
else:
sys.exit(1)
FILE:scripts/weekly-reporter.py
#!/usr/bin/env python3
import os
import sys
import argparse
import json
import re
from datetime import datetime, timedelta
def find_doc_root(start_path, root_name='arc-reactor-doc'):
"""向上查找工作区根目录"""
curr_dir = os.path.abspath(start_path)
while curr_dir and curr_dir != '/':
if os.path.exists(os.path.join(curr_dir, root_name)):
return os.path.join(curr_dir, root_name)
if os.path.exists(os.path.join(curr_dir, '.git')):
return os.path.join(curr_dir, root_name)
parent = os.path.dirname(curr_dir)
if parent == curr_dir:
break
curr_dir = parent
return os.path.join(start_path, root_name)
def _parse_frontmatter(content):
"""提取 Markdown 的 YAML Frontmatter"""
meta = {}
if content.strip().startswith('---'):
parts = content.split('---', 2)
if len(parts) >= 3:
header = parts[1]
for line in header.splitlines():
if ':' in line:
k, v = line.split(':', 1)
meta[k.strip().lower()] = v.strip().strip('"').strip("'")
return meta
def main():
parser = argparse.ArgumentParser(description='ARC Reactor Weekly Reporter')
parser.add_argument('--days', type=int, default=7, help='聚合过去几天的内容')
parser.add_argument('--root', default='arc-reactor-doc', help='文档根目录名称')
args = parser.parse_args()
cwd = os.getcwd()
doc_root = find_doc_root(cwd, args.root)
sources_dir = os.path.join(doc_root, 'wiki', 'sources')
if not os.path.exists(sources_dir):
print(json.dumps({"status": "error", "message": f"Sources directory not found: {sources_dir}"}))
sys.exit(1)
# 1. 计算时间窗口
end_date = datetime.now()
start_date = end_date - timedelta(days=args.days)
target_dates = []
for i in range(args.days + 1):
target_dates.append((start_date + timedelta(days=i)).strftime('%Y-%m-%d'))
# 2. 搜集符合时间窗的文件
aggregated_items = []
for date_str in target_dates:
date_path = os.path.join(sources_dir, date_str)
if os.path.exists(date_path):
for filename in os.listdir(date_path):
if filename.endswith('.md'):
fpath = os.path.join(date_path, filename)
try:
with open(fpath, 'r', encoding='utf-8') as f:
content = f.read()
meta = _parse_frontmatter(content)
# 提取首段摘要 (跳过 Frontmatter)
body = content.split('---', 2)[-1].strip() if '---' in content else content
summary = body.split('\n\n')[0][:300].strip()
aggregated_items.append({
"title": meta.get('title', filename),
"date": date_str,
"tags": meta.get('tags', []),
"summary": summary,
"path": os.path.relpath(fpath, doc_root)
})
except:
continue
# 3. 输出汇总
if not aggregated_items:
print(f"### ARC Reactor 周报 ({start_date.strftime('%Y-%m-%d')} ~ {end_date.strftime('%Y-%m-%d')})\n\n> 本周暂无新增归档内容,休息一下吧!☕️")
sys.exit(0)
# 格式化输出 (供 LLM 进一步处理或直接展示)
print(f"# ARC Reactor 知识周报\n区间:{start_date.strftime('%Y-%m-%d')} 至 {end_date.strftime('%Y-%m-%d')}\n")
print(f"## 📚 本周收录概览 (共 {len(aggregated_items)} 篇)\n")
for item in aggregated_items:
print(f"- **{item['title']}** ({item['date']})")
print(f" > {item['summary']}...")
print(f" [查阅原文]({item['path']})\n")
print("\n---\n## 🧠 自动生成的洞察提炼 (Prompt 建议)\n")
print("Orchestrator, 请根据以上聚合内容,回答:")
print("1. 本周大家主要在研究什么?有没有形成明显的知识集群?")
print("2. 哪些词条在不同文章里被反复提及?推荐我下一步该去深入了解哪个实体?")
# 返回 JSON 回执以便 Orchestrator 审计
receipt = {
"status": "success",
"action": "weekly_report",
"days_scanned": args.days,
"items_found": len(aggregated_items),
"start_date": start_date.strftime('%Y-%m-%d'),
"end_date": end_date.strftime('%Y-%m-%d')
}
# 在 stderr 打印 JSON 回执,避免干扰正文
print(json.dumps(receipt, ensure_ascii=False), file=sys.stderr)
if __name__ == "__main__":
main()
FILE:test_obsidian_sync.py
#!/usr/bin/env python3
"""Simple test for Obsidian sync functionality."""
import sys
import os
# Add scripts dir to path
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
import importlib.util
spec = importlib.util.spec_from_file_location("archive_manager", "scripts/archive-manager.py")
am = importlib.util.module_from_spec(spec)
spec.loader.exec_module(am)
validate_obsidian_config = am.validate_obsidian_config
sync_to_obsidian = am.sync_to_obsidian
slugify = am.slugify
def test_validate_obsidian_config():
"""Test validate_obsidian_config with non-existent path."""
is_valid, msg = validate_obsidian_config("/nonexistent/path")
assert is_valid == False, f"Expected False for non-existent path, got {is_valid}"
assert "不存在" in msg, f"Expected '不存在' in message, got {msg}"
print("✓ validate_obsidian_config() correctly rejects non-existent path")
def test_sync_to_obsidian_missing_source():
"""Test sync_to_obsidian with non-existent source file."""
result = sync_to_obsidian("/nonexistent/file.md", "/tmp", "test/")
assert result["status"] == "error", f"Expected error status, got {result['status']}"
assert "源文件不存在" in result["error"], f"Expected '源文件不存在', got {result['error']}"
print("✓ sync_to_obsidian() correctly handles missing source file")
def test_slugify():
"""Test slugify function."""
assert slugify("Claude Code") == "claude-code"
assert slugify("SWE-bench") == "swe-bench"
print("✓ slugify() works correctly")
def test_sync_to_obsidian_invalid_vault():
"""Test sync_to_obsidian with invalid vault path."""
result = sync_to_obsidian("/tmp/test.md", "/nonexistent/vault", "test/")
assert result["status"] == "error", f"Expected error status, got {result['status']}"
assert result["action"] == "obsidian_sync"
print("✓ sync_to_obsidian() correctly handles invalid vault")
if __name__ == "__main__":
test_slugify()
test_validate_obsidian_config()
test_sync_to_obsidian_missing_source()
test_sync_to_obsidian_invalid_vault()
print("\n✅ All tests passed!")
BP管理助手 — 查看/管理自己与下级的BP(目标/关键成果/关键举措)、AI质量检查。触发词:bp/BP/BP管理/BP目标/BP成果/BP举措/衡量标准/对齐/关键任务/关键成果/上级BP/下级BP/承接/目标管理/OKR/KR/我的目标/我的成果/查看BP/查看目标/检查BP/审计BP。
---
name: cms-bp-manager
description: BP管理助手 — 查看/管理自己与下级的BP(目标/关键成果/关键举措)、AI质量检查。触发词:bp/BP/BP管理/BP目标/BP成果/BP举措/衡量标准/对齐/关键任务/关键成果/上级BP/下级BP/承接/目标管理/OKR/KR/我的目标/我的成果/查看BP/查看目标/检查BP/审计BP。
metadata:
homepage: https://github.com/xgjk/bp-skills/tree/main/cms-bp-manager
version: v2.1.0
status: ACTIVE
tools_provided:
- name: bp_client
category: exec
risk_level: low
permission: exec
description: BP系统API客户端,封装所有BP只读与审计相关接口调用
status: active
- name: commands
category: exec
risk_level: low
permission: exec
description: BP管理命令集合(查看/搜索/检查BP)
status: active
dependencies:
- cms-auth-skills
---
# BP Manager(读 + 审计)
> BP 管理助手 — 查看/管理自己与下级的BP,AI质量检查(基于康哲规则)
---
## 角色定位
BP Manager 是面向管理者和员工的 BP 日常管理工具,本技能承载**只读查询 + AI 审计检查**能力:
1. **BP 查看**:查看自己或下级的 BP(目标/关键成果/关键举措)
2. **AI 检查**:基于康哲规则检查 BP 质量(结构/承接/衡量标准)
3. **汇报查看**:查看任务关联的汇报历史
4. **搜索功能**:按名称搜索任务或分组
5. **月度汇报查询**:按分组+月份查询月度汇报
6. **下级BP建议**:为下级的 BP 提供改进建议
> **写入能力(新增KR/新增举措/延期提醒)已拆分至** `cms-bp-manager-write`
---
## 核心场景
### 场景一:查看 BP
**用户意图**:快速了解 BP 全貌
**触发词**:
- "查看我的 BP"
- "查看下属的 BP"
- "查看产品中心的 BP"
**执行流程**:
1. 识别用户身份(通过员工ID)
2. 获取周期列表,选择当前周期
3. 获取分组树,定位到目标分组
4. 调用 `getGroupMarkdown` 获取完整 BP
5. 格式化输出给用户
**示例**:
```
用户:查看我的 BP
助手:正在获取您的 BP...
[输出 Markdown 格式的 BP 内容]
```
---
### 场景二:AI 检查 BP
**用户意图**:检查 BP 是否符合康哲规则
**触发词**:
- "检查我的 BP"
- "BP 质量检查"
- "帮我检查一下这个 BP"
- "审计 BP"
**执行流程**:
1. 获取目标 BP 内容(Markdown 格式)
2. 基于康哲规则进行检查:
- 结构完整性:是否按 G-R-A 三层拆解
- 承接关系:下级目标是否承接上级关键举措
- 衡量标准:是否符合 SMART 原则
- 层级深度:是否符合所在层级要求
3. 输出检查结果和改进建议
**检查规则**(来自 `references/kangzhe-rules.md`):
- **集团层级**:拆解到 2 层(目标 → 关键成果)
- **中心/部门/员工层级**:拆解到 3 层(目标 → 关键成果 → 关键举措)
- **衡量标准**:必须包含指标/阈值/口径/数据源/统计频率
- **承接规则**:下级目标承接上级关键成果/关键举措
**示例**:
```
用户:检查我的 BP
助手:正在检查您的 BP...
检查结果:
✅ 结构完整性:通过(3层结构完整)
✅ 承接关系:通过(目标承接了上级关键举措)
⚠️ 衡量标准:关键成果 1.2 缺少数据源
✅ 层级深度:通过(符合部门层级要求)
建议:
- 为关键成果 1.2 补充数据源信息
```
---
### 场景三:下级 BP 建议
**用户意图**:为下级的 BP 提供创建关键举措的建议
**触发词**:
- "帮我看看下属的 BP"
- "给下级提点建议"
- "为下属的 BP 提建议"
**执行流程**:
1. 获取下级 BP 内容
2. 分析 BP 内容,识别:
- 哪些关键成果缺少关键举措
- 哪些关键举措过于空泛,需要拆解
- 哪些关键举措缺少承接人
3. 基于分析结果,生成改进建议
4. 输出建议列表
**示例**:
```
用户:帮我看看张三的 BP,给他提点建议
助手:正在分析张三的 BP...
建议为张三的 BP 改进以下内容:
**关键成果 1.1:客户拜访量达到50家**
- 建议新增举措:建立拜访记录系统,记录每次拜访的详细信息
- 建议新增举措:制定月度拜访计划,明确每周拜访目标
**关键成果 1.2:签约客户数达到20家**
- 建议新增举措:重点客户专项跟进,建立客户档案
- 建议新增举措:每周五回报跟进进度,及时调整策略
如需创建这些关键举措,请使用写入技能 cms-bp-manager-write
```
---
### 场景四:查看汇报历史
**用户意图**:查看某个任务关联的汇报历史
**触发词**:
- "查看目标 X 的汇报历史"
- "这个任务的汇报记录"
**执行流程**:
1. 识别目标任务
2. 调用 `pageAllReports` 接口(支持时间范围过滤)
3. 格式化输出汇报列表
**示例**:
```
用户:查看目标 A4-1 的汇报历史
助手:正在获取汇报历史...
目标【A4-1】的汇报历史(共5条):
1. 手动汇报 - 2026-03-10
- 标题: Q1 进度汇报
- 业务时间: 2026-03-08
2. AI汇报 - 2026-03-05
- 标题: AI进度分析
- 业务时间: 2026-03-01
...
```
---
### 场景五:搜索任务
**用户意图**:按名称搜索 BP 任务
**触发词**:
- "搜索关于客户拜访的任务"
- "找一下包含'全栈'的任务"
**执行流程**:
1. 识别搜索关键词
2. 确定搜索范围(分组ID)
3. 调用 `searchTaskByName` 接口
4. 输出搜索结果
**示例**:
```
用户:搜索关于客户拜访的任务
助手:正在搜索...
找到 2 个相关任务:
1. 【关键成果】客户拜访量达到50家
- 分组: 技术部
- 状态: 进行中
- 承接人: 张三
2. 【关键举措】每周拜访5家客户
- 分组: 技术部
- 状态: 进行中
- 承接人: 李四
```
---
### 场景六:查询月度汇报
**用户意图**:按分组+月份查询月度汇报
**触发词**:
- "查看3月份的月度汇报"
- "月度汇报查询"
**执行流程**:
1. 确认分组ID和汇报月份(YYYY-MM)
2. 调用 `getMonthlyReportByMonth` 接口
3. 格式化输出月度汇报内容
---
## 写入能力(已拆分)
以下场景已拆分至 `cms-bp-manager-write`,本技能不再承载:
- **新增关键成果**:为某个目标添加新的关键成果 → `cms-bp-manager-write`
- **新增关键举措**:为某个关键成果添加关键举措 → `cms-bp-manager-write`
- **延期提醒**:向指定员工发送延期提醒 → `cms-bp-manager-write`
---
## 环境变量
| 变量名 | 说明 | 获取方式 |
|--------|------|----------|
| BP_APP_KEY | BP 系统 API 密钥 | 从玄关开放平台获取 |
### 自动更新检查(每次运行 commands.py 默认启用)
| 变量名 | 说明 |
|--------|------|
| BP_MANAGER_SKIP_UPDATE_CHECK | 设为 `1/true/yes` 时跳过更新检查 |
| BP_MANAGER_PROMPT_UPDATE | 设为 `1/true/yes` 时检测到新版本会提示是否更新(仅 TTY 生效) |
| BP_MANAGER_AUTO_UPDATE | 设为 `1/true/yes` 时检测到新版本自动执行更新命令 |
更新命令固定为:`npx clawhub@latest install cms-bp-manager --force`
---
## API 接口清单
### 本技能使用的接口(只读)
| 接口 | 方法 | 用途 |
|-----|------|------|
| `GET /bp/period/list` | 获取周期列表 | 选择工作周期 |
| `GET /bp/group/list` | 获取分组树 | 导航到目标分组 |
| `POST /bp/group/getPersonalGroupIds` | 批量获取个人分组ID | 快速定位员工 |
| `GET /bp/task/v2/getSimpleTree` | 获取BP任务树 | 了解完整结构 |
| `GET /bp/goal/list` | 获取目标列表 | 查看目标概览 |
| `GET /bp/goal/{goalId}/detail` | 获取目标详情 | 查看单个目标完整信息 |
| `GET /bp/keyResult/list` | 获取关键成果列表 | |
| `GET /bp/keyResult/{keyResultId}/detail` | 获取关键成果详情 | |
| `GET /bp/action/list` | 获取关键举措列表 | |
| `GET /bp/action/{actionId}/detail` | 获取关键举措详情 | |
| `GET /bp/group/markdown` | 获取分组BP Markdown | AI 分析友好 |
| `POST /bp/group/batchGetKeyPositionMarkdown` | 批量获取关键岗位详情 | |
| `GET /bp/task/children` | 获取任务子树骨架 | |
| `POST /bp/task/relation/pageAllReports` | 查询任务关联汇报 | 支持时间范围过滤 |
| `GET /bp/delayReport/list` | 查询延期汇报历史 | |
| `GET /bp/task/v2/searchByName` | 按名称搜索任务 | |
| `GET /bp/group/searchByName` | 按名称搜索分组 | |
| `GET /bp/monthly/report/getByMonth` | 按分组+月份查询月度汇报 | |
### 写入接口(归属 cms-bp-manager-write)
| 接口 | 方法 | 用途 |
|-----|------|------|
| `POST /bp/task/v2/addKeyResult` | 新增关键成果 | → cms-bp-manager-write |
| `POST /bp/task/v2/addAction` | 新增关键举措 | → cms-bp-manager-write |
| `POST /bp/delayReport/send` | 发送延期提醒汇报 | → cms-bp-manager-write |
---
## 数据模型
### Period(周期)
```typescript
interface Period {
id: string; // 周期 ID
name: string; // 周期名称
status: number; // 1=启用,0=未启用
}
```
### Group(分组)
```typescript
interface Group {
id: string; // 分组 ID
name: string; // 分组名称
type: 'org' | 'personal'; // 组织/个人
levelNumber: string; // 层级编码
employeeId?: string; // 个人分组时的员工ID
parentId?: string; // 父分组 ID
childCount?: number; // 下级分组数量
children?: Group[]; // 子分组
}
```
### Goal(目标)
```typescript
interface Goal {
id: string; // 目标 ID
name: string; // 目标名称
fullLevelNumber: string; // 目标编码
statusDesc: string; // 状态描述
reportCycle: string; // 汇报周期
planDateRange: string; // 计划时间范围
taskUsers: TaskUser[]; // 参与人
krCount?: number; // 关键成果数量
actionCount?: number; // 关键举措数量
keyResults?: KeyResult[];// 关键成果列表
}
```
### KeyResult(关键成果)
```typescript
interface KeyResult {
id: string; // 关键成果 ID
name: string; // 关键成果名称
fullLevelNumber: string; // 编码
statusDesc: string; // 状态描述
measureStandard: string; // 衡量标准
reportCycle: string; // 汇报周期
planDateRange: string; // 计划时间范围
taskUsers: TaskUser[]; // 参与人
actionCount?: number; // 关键举措数量
actions?: Action[]; // 关键举措列表
}
```
### Action(关键举措)
```typescript
interface Action {
id: string; // 关键举措 ID
name: string; // 关键举措名称
fullLevelNumber: string; // 编码
statusDesc: string; // 状态描述
reportCycle: string; // 汇报周期
planDateRange: string; // 计划时间范围
taskUsers: TaskUser[]; // 参与人
}
```
### TaskUser(任务参与人)
```typescript
interface TaskUser {
taskId: string; // 任务 ID
role: string; // 角色:承接人/协办人/抄送人/监督人/观察人
empList: Employee[]; // 员工列表
}
```
### Employee(员工)
```typescript
interface Employee {
id: string; // 员工 ID
name: string; // 员工姓名
}
```
---
## 错误处理
| 错误码 | 说明 | 处理建议 |
|--------|------|----------|
| 1 | 请求成功 | 正常处理 |
| 0 | 通用失败 | 提示用户稍后重试 |
| 610002 | appKey 无效 | 检查 BP_APP_KEY 环境变量 |
| 610015 | 无访问权限 | 提示用户无权限访问该资源 |
---
## 使用注意事项
1. **API 限制**:当前系统不支持编辑和删除操作,只能通过 Web UI 进行
2. **权限控制**:部分接口有数据权限校验,无权限时返回空列表
3. **周期管理**:建议每次操作前先确认当前周期
4. **性能考虑**:`getGroupMarkdown` 返回完整 BP,Token 消耗较大,适合 AI 分析场景
5. **鉴权依赖**:所有接口调用统一依赖 `cms-auth-skills`,脚本不实现登录与换 token
---
## 审计输出要求(强制)
- 结论必须精确引用到具体对象(编号+名称)
- 不允许使用"部分/某些/个别"等模糊指代
- 问题必须包含:**对象精确引用 + 严重等级 + 原因 + 建议**
- 审计维度覆盖:基础合规、向上对齐、向下承接、GAP 分析
---
## 能力树
```text
cms-bp-manager/
├── SKILL.md
├── README.md
├── setup.md
├── design/
│ └── design.md
├── references/
│ ├── api-endpoints.md
│ ├── api-request--20260404.md
│ ├── kangzhe-rules.md
│ ├── maintenance.md
│ └── audit/
│ └── README.md
└── scripts/
├── bp_client.py
└── commands.py
```
---
## 版本历史
| 版本 | 日期 | 变更说明 |
|------|------|----------|
| v1.0.0 | 2026-04-04 | 初版,包含 BP 查看/管理/检查/提醒功能(原 bp-manager) |
| v2.0.0 | 2026-04-08 | 重构:基于原版 bp-manager 重建,写入能力拆分至 cms-bp-manager-write;新增月度汇报查询、时间范围过滤、UTF-8 兼容 |
FILE:README.md
# BP Manager - API 需求
**状态**: 待确认
---
## 背景
在开发 BP Manager Skill 过腾中,发现, **现有 BP API 无法直接查询"分配给我的关键举措"**,这对于员工和管理者来说,这是一个非常常见且重要的场景。
---
## 问题说明
### 用户场景
| 角色 | 需求 |
|------|------|
| 噮通员工 | "哪些关键举措是指派给我承接的?" "我当前有多少个待承接任务?" |
| 管理者 | "我的下属承接了哪些任务?" "我给他们分配了多少任务?" |
| 系统管理员 | "哪些任务没有被及时承接?" "需要催促跟进 |
### 知期行为临时方案(不可行)
- 鯏遍历上级分组的所有任务
- 检查每个任务的 `taskUsers`
- 籇筛选承接人包含我的任务
**问题**:
- 🔴 鯏用次数多(可能数百次)
- 🔴 Token 消耗大
- 🔴 性能差(用户等待时间长)
---
## API 需求
### 推荐方案:新增"我的任务"接口
**路径**: `GET /bp/task/myTasks`
**请求参数**:
| 参数 | 类型 | 必填 | 说明 |
|------|------|------|------|
| periodId | String | 是 | 周期 ID |
| employeeId | String | 是 | 员工 ID |
| taskType | String | 否 | 任务类型筛选(可选):"goal" / "keyResult" / "action") |
**响应示例**:
```json
{
"resultCode": 1,
"resultMsg": null,
"data": {
"assignedTasks": [
{
"id": "2001628713670279169",
"name": "每周拜访5家客户",
"type": "action",
"statusDesc": "进行中",
"reportCycle": "1周1次",
"planDateRange": "2026-01-01 ~ 2026-03-31",
"parentTask": {
"id": "2001628715230560258",
"name": "客户拜访量达到50家",
"type": "keyResult"
},
"groupInfo": {
"id": "1993982002185506818",
"name": "技术部",
"levelNumber": "A4"
},
"taskUsers": [
{
"taskId": "2001628713670279169",
"role": "承接人",
"empList": [
{
"id": "1234567890123456789",
"name": "张三"
}
]
}
]
}
],
"stats": {
" totalCount": 3,
" pendingCount": 1,
" completedCount": 0
}
}
}
```
**返回字段说明**:
| 字段 | 类型 | 说明 |
|------|------|------|
| `id` | String | 任务 ID |
| `name` | String | 任务名称 |
| `type` | String | 任务类型:"goal" / "keyResult" / "action" |
| `statusDesc` | String | 空状态描述 |
| `reportCycle` | String | 汇报周期 |
| `planDateRange` | String | 计划时间范围 |
| `parentTask` | Object | 猏任务信息(可选) |
| `groupInfo` | object | 所属分组信息 |
| `taskUsers` | Array | 参与人列表 |
---
### 夋选方案二:新增"按承接人查询任务列表接口
**路径**: `GET /bp/task/listByOwner`
**请求参数**:
| 参数 | 类型 | 必填 | 说明 |
|------|------|------|------|
| periodId | String | 是 | 周期 ID |
| employeeId | String | 是 | 员工 ID |
| role | String | 否 | 角色筛选(可选):"owner" / "collaborator" / "supervisor" / "observer", 默认" " | owner |
| taskId | String | 否 | 猇按任务 ID 获取特定任务 |
**响应**: 与方案一类似,但 仅返回指定角色的任务列表
---
## 使用场景示例
### 场景1: 埥看我的待承接任务
```
用户: 查看我的待承接任务
系统:
1. 获取当前周期和用户 ID
2. 调用 `/bp/task/myTasks`
3. 返回所有分配给用户的任务(目标/关键成果/关键举措)
4. 显示任务详情
```
### 场景2: 按角色筛选
```
用户: 查看分配给我作为"承接人"的任务
系统:
1. 调用 `/bp/task/listByOwner?role=owner`
2. 仅返回承接人是我的任务
```
### 场景3: 任务统计
```
管理员: 查看部门内任务分配情况
系统:
1. 按员工 ID 批量查询
2. 生成统计报表
```
---
## 技术说明
### 数据来源
- 使用现有的 `taskUsers` 数据结构
- 按承接人的 `empList` 知识筛选
- 支持分页(如果任务数量大)
### 性能考虑
- 巻加缓存层支持
- 巻加分页支持
- 考虑添加批量查询接口
---
## 相关资源
- BP Manager Skill 仓库: `05_products/bp-manager/`
- 抋告人: @evan (Telegram)
FILE:design/design.md
# BP Manager 设计文档
## 产品定位
BP Manager 是面向管理者和员工的 BP 日常管理助手,通过自然语言交互帮助用户高效管理 BP。
本技能承载"读 + 审计"能力,"写入"能力拆分至 `cms-bp-manager-write`。
## 核心能力
### 1. BP 查看
- 查看自己的 BP(通过员工ID自动定位)
- 查看下属的 BP(通过姓名搜索)
- 查看任意分组的 BP(通过分组ID)
### 2. AI 检查
- 基于康哲规则检查 BP 质量
- 检查项:结构完整性、衡量标准、承接关系、举措可执行性、时间合理性
### 3. 汇报查看
- 查看任务关联的汇报历史
- 分页查询,支持时间范围过滤
### 4. 搜索功能
- 按名称搜索任务
- 按名称搜索分组
### 5. 月度汇报查询
- 按分组+月份查询月度汇报内容
### 6. 写入能力(已拆分)
- 新增关键成果(挂在目标下) → `cms-bp-manager-write`
- 新增关键举措(挂在关键成果下) → `cms-bp-manager-write`
- 延期提醒 → `cms-bp-manager-write`
- 注:编辑和删除操作需要通过 Web UI 完成
---
## 用户场景
### 场景一:管理者查看下属 BP
```
用户:查看张三的 BP
助手:
1. 搜索分组"张三"
2. 获取分组 BP Markdown
3. 输出 BP 内容
4. (可选)基于康哲规则给出建议
```
### 场景二:AI 检查 BP 质量
```
用户:检查产品中心的 BP
助手:
1. 获取分组 BP Markdown
2. 基于康哲规则逐项检查
3. 输出检查结果与改进建议
```
### 场景三:查看汇报历史
```
用户:查看目标 A4-1 的汇报历史
助手:
1. 调用汇报查询接口
2. 格式化输出汇报列表
```
---
## 技术架构
### 模块划分
```
cms-bp-manager/
├── SKILL.md # Skill 定义
├── README.md # API 需求文档
├── setup.md # 安装说明
├── scripts/
│ ├── bp_client.py # API 客户端(只读+审计)
│ └── commands.py # 命令实现(只读+审计)
├── references/
│ ├── api-endpoints.md # API 端点参考
│ ├── kangzhe-rules.md # 康哲规则参考(审计核心)
│ ├── maintenance.md # 维护信息
│ ├── api-request--20260404.md # API 需求申请
│ └── audit/
│ └── README.md # 审计模块说明
├── design/
│ └── design.md # 本文档
└── references/read/
└── README.md # 只读模块说明
```
### 依赖
- Python 3.8+
- 环境变量:BP_APP_KEY(必需,通过 cms-auth-skills 注入)
### API 调用流程
```
1. 获取周期列表 → 选择当前周期
2. 获取分组树 → 定位目标分组
3. 获取 BP Markdown 或调用管理接口
4. 格式化输出或执行操作
```
---
## 错误处理
### 常见错误
1. **缺少 BP_APP_KEY**:提示用户设置环境变量
2. **找不到分组**:提示用户检查姓名或权限
3. **无访问权限**:提示用户联系管理员
4. **API 调用失败**:记录错误并提示用户稍后重试
### 错误响应格式
```json
{
"success": false,
"error": "错误描述",
"details": "详细信息(可选)"
}
```
---
## 扩展计划
### Phase 1(当前)
- ✅ BP 查看
- ✅ AI 检查(基于康哲规则)
- ✅ 汇报查看(支持时间过滤)
- ✅ 搜索功能
- ✅ 月度汇报查询
### Phase 2(未来)
- ⏳ AI 深度分析(基于 LLM 对 Markdown 做结构化审计)
- ⏳ 批量操作
- ⏳ 定时检查和提醒
---
## 性能考虑
1. **Markdown 接口**:Token 消耗较大,建议用于 AI 分析场景
2. **列表接口**:轻量级,适合快速预览
3. **详情接口**:按需获取,避免一次性加载过多数据
4. **批量接口**:用于批量查询,减少请求次数
---
## 安全考虑
1. **API Key 保护**:不硬编码,通过环境变量传递(依赖 cms-auth-skills)
2. **权限控制**:依赖后端权限校验,前端不绕过
3. **敏感信息**:不在日志中输出完整的 BP 内容
4. **错误信息**:不暴露系统内部细节
FILE:references/api-endpoints.md
# BP API 端点参考
本文档整理了 BP 系统的所有 API 端点,供 Skill 开发参考。
**数据源**: 玄关开放平台 - BP系统API说明.md
**最后更新**: 2026-04-04
---
## 接口清单
### 周期管理
| 接口 | 方法 | 路径 | 说明 |
|-----|------|------|------|
| listPeriods | GET | /bp/period/list | 查询周期列表 |
| getPeriodDetail | GET | /bp/period/{periodId}/detail | 获取周期详情 |
### 分组管理
| 接口 | 方法 | 路径 | 说明 |
|-----|------|------|------|
| listGroups | GET | /bp/group/list | 获取分组树 |
| getPersonalGroupIds | POST | /bp/group/getPersonalGroupIds | 批量查询员工个人类型分组ID |
| searchGroups | GET | /bp/group/searchByName | 按名称搜索分组 |
| getGroupMarkdown | GET | /bp/group/markdown | 获取分组完整BP的Markdown |
| batchGetKeyPositionMarkdown | POST | /bp/group/batchGetKeyPositionMarkdown | 批量获取关键岗位详情Markdown |
| getKeyPositionDetail | GET | /bp/group/getKeyPositionDetail | 获取关键岗位详情(已废弃) |
### 任务管理
| 接口 | 方法 | 路径 | 说明 |
|-----|------|------|------|
| getSimpleTree | GET | /bp/task/v2/getSimpleTree | 查询BP任务树(简要信息) |
| searchTasks | GET | /bp/task/v2/searchByName | 按名称搜索任务 |
| getTaskChildren | GET | /bp/task/children | 获取任务子树骨架 |
| pageAllReports | POST | /bp/task/relation/pageAllReports | 分页查询所有汇报 |
### 目标管理
| 接口 | 方法 | 路径 | 说明 |
|-----|------|------|------|
| listGoals | GET | /bp/goal/list | 获取目标列表 |
| getGoalDetail | GET | /bp/goal/{goalId}/detail | 获取目标详情 |
| addKeyResult | POST | /bp/task/v2/addKeyResult | 根据目标ID新增关键成果 |
### 关键成果管理
| 接口 | 方法 | 路径 | 说明 |
|-----|------|------|------|
| listKeyResults | GET | /bp/keyResult/list | 获取关键成果列表 |
| getKeyResultDetail | GET | /bp/keyResult/{keyResultId}/detail | 获取关键成果详情 |
| addAction | POST | /bp/task/v2/addAction | 根据成果ID新增关键举措 |
### 关键举措管理
| 接口 | 方法 | 路径 | 说明 |
|-----|------|------|------|
| listActions | GET | /bp/action/list | 获取关键举措列表 |
| getActionDetail | GET | /bp/action/{actionId}/detail | 获取关键举措详情 |
### 延期提醒
| 接口 | 方法 | 路径 | 说明 |
|-----|------|------|------|
| sendDelayReport | POST | /bp/delayReport/send | 发送AI延期提醒汇报 |
| listDelayReports | GET | /bp/delayReport/list | 查询AI延期提醒汇报历史 |
---
## 数据模型
### Period(周期)
- id: 周期ID
- name: 周期名称
- status: 状态(1=启用,0=未启用)
### Group(分组)
- id: 分组ID
- name: 分组名称
- type: 类型(org/personal)
- levelNumber: 层级编码
- employeeId: 员工ID(个人分组)
- parentId: 父分组ID
- childCount: 下级分组数量
- children: 子分组列表
### Goal(目标)
- id: 目标ID
- name: 目标名称
- fullLevelNumber: 目标编码
- statusDesc: 状态描述
- reportCycle: 汇报周期
- planDateRange: 计划时间范围
- taskUsers: 参与人列表
- krCount: 关键成果数量
- actionCount: 关键举措数量
- keyResults: 关键成果列表(详情)
### KeyResult(关键成果)
- id: 关键成果ID
- name: 关键成果名称
- fullLevelNumber: 编码
- statusDesc: 状态描述
- measureStandard: 衡量标准
- reportCycle: 汇报周期
- planDateRange: 计划时间范围
- taskUsers: 参与人列表
- actionCount: 关键举措数量
- actions: 关键举措列表(详情)
### Action(关键举措)
- id: 关键举措ID
- name: 关键举措名称
- fullLevelNumber: 编码
- statusDesc: 状态描述
- reportCycle: 汇报周期
- planDateRange: 计划时间范围
- taskUsers: 参与人列表
### TaskUser(任务参与人)
- taskId: 任务ID
- role: 角色(承接人/协办人/抄送人/监督人/观察人)
- empList: 员工列表
### Employee(员工)
- id: 员工ID
- name: 员工姓名
---
## 通用说明
### 认证方式
所有接口需要在请求头中携带 `appKey`:
```
appKey: YOUR_APP_KEY
```
### 响应格式
```json
{
"resultCode": 1, // 1表示成功
"resultMsg": null, // 错误信息
"data": {} // 响应数据
}
```
### 常用字段说明
- `fullLevelNumber`: 完整层级编码,如 A4-1.1.1
- `reportCycle`: 汇报周期,格式为 `{ruleType}+{index}`,如 `week+1`
- `planDateRange`: 计划时间范围,格式为 `yyyy-MM-dd ~ yyyy-MM-dd`
---
## 注意事项
1. **编辑和删除**:当前系统不支持编辑和删除操作,只能通过 Web UI 进行
2. **权限控制**:部分接口有数据权限校验,无权限时返回空列表
3. **周期管理**:建议每次操作前先确认当前周期
4. **性能考虑**:`getGroupMarkdown` 返回完整 BP,Token 消耗较大
FILE:references/api-request--20260404.md
# BP 系统 API 需求申请
**申请人**: Evan
**日期**: 2026-04-04
**联系方式**: @evan(Telegram)
---
## 背景
我们正在开发 BP Manager Skill,用于帮助员工和管理者高效管理 BP。 在开发过程中,发现现有 API 无法满足"查询分配给我的关键举措"这一核心场景。
---
## 问题说明
### 用户场景
作为 BP 系统的用户(员工/管理者),我需要快速查看:
- **哪些关键举措是指派给我承接的**
- **我需要承接多少个关键举措**
- **当前承接情况如何**
### 当前 API 的局限
| 掃查方向 | 綴果 API | 说明 |
|---------|---------|------|
| 按承接人查询任务列表 | ❌ 不存在 | 只能按分组 ID 或名称搜索 |
| 按员工 ID 查询"我的任务" | ❌ 不存在 | 没有类似 "myTasks" 的接口 |
| 查询"待承接"任务列表 | ❌ 不存在 | 无法直接获取分配给我的任务 |
### 知期行为临时方案(不可行)
虽然可以通过以下方式间接获取:
1. 遍历上级分组的所有任务
2. 检查每个任务的 `taskUsers` 字段
3. 筛选出承接人包含我的任务
**问题**:
- 鰃用次数多(可能数百次)
- Token 消耗大(每个任务详情都包含大量数据)
- 性能差(用户等待时间长)
---
## API 需求申请
### 方案一:新增"我的任务"接口(推荐)
**接口路径**: `GET /bp/task/myTasks`
**请求参数**:
| 参数 | 类型 | 必填 | 说明 |
|------|------|------|------|
| periodId | Long | 是 | 周期 ID |
| employeeId | Long | 是 | 当前用户员工 ID |
| taskType | String | 否 | 任务类型过滤:`goal`/`keyResult`/`action`,不传则返回所有类型 |
| status | String | 否 | 猉态过滤:`未启动`/`进行中`/`已关闭` 等 |
**响应结构**:
```json
{
"resultCode": 1,
"resultMsg": null,
"data": [
{
"id": "2000831992769347585",
"name": "每周拜访5家客户",
"type": "关键举措",
"fullLevelNumber": "1.1.1",
"statusDesc": "进行中",
"reportCycle": "week+1",
"planDateRange": "2026-01-01 ~ 2026-02-28",
"taskUsers": [
{
"taskId": "2000831992769347585",
"role": "承接人",
"empList": [
{ "id": "1234567890123456789", "name": "张三" }
]
}
],
"parentTask": {
"id": "2000831992475746305",
"name": "客户拜访量达到50家",
"type": "关键成果"
},
"groupInfo": {
"id": "1998216739737055234",
name": "玄关健康",
type": "org"
}
}
]
}
```
**数据流向说明**:
- 返回当前用户作为"承接人"的所有任务
- 按 `upwardTaskList` 逻辑,返回指向我的上级任务
- 包含任务的完整上下文(父任务、分组信息)
---
### 方案二:新增"按承接人查询任务列表"接口
**接口路径**: `GET /bp/task/listByOwner`
**请求参数**:
| 参数 | 类型 | 必填 | 说明 |
|------|------|------|------|
| employeeId | Long | 是 | 员工 ID |
| role | String | 否 | 角色过滤:`承接人`/`协办人`等,默认 `承接人` |
| periodId | Long | 是 | 周期 ID |
| taskType | String | 否 | 任务类型过滤 |
| page | Integer | 否 | 页码,默认 1 |
| size | Integer | 否 | 每页数量,默认 20 |
**响应结构**:
```json
{
"resultCode": 1,
"data": {
"total": 5,
"list": [
{
"id": "2000831992769347585",
"name": "每周拜访5家客户",
"type": "关键举措",
"statusDesc": "进行中",
"taskUsers": [...],
"parentTask": {...},
"groupInfo": {...}
}
]
}
}
```
---
## 使用场景示例
### 场景一:员工查看自己需要承接的任务
**用户说**:
> 查看我需要承接的关键举措
**AI 响应**:
1. 调用 `GET /bp/task/myTasks?periodId=xxx&employeeId=xxx&taskType=action`
2. 返回:
```
您当前需要承接 3 个关键举措:
1. **每周拜访5家客户** (来自:玄关健康 > 目标1 > 关键成果1.1)
- 状态:进行中
- 计划时间:2026-01-01 ~ 2026-02-28
- 汇报周期:每周
2. **完成XX项目的技术方案** (来自:技术部 > 目标2 > 关键成果2.1)
- 状态:未启动
- 计划时间:2026-03-01 ~ 2026-05-31
- 汇报周期:双周
3. **参加产品评审会** (来自:产品中心 > 目标3 > 关键成果3.1)
- 状态:进行中
- 计划时间:2026-02-01 ~ 2026-02-15
- 汇报周期:单次
```
---
### 场景二:管理者查看下属需要承接的任务
**用户说**:
> 帮我看看张三需要承接哪些任务
**AI 响应**:
1. 调用 `GET /bp/task/myTasks?periodId=xxx&employeeId=张三的ID`
2. 返回张三的所有承接任务
3. 提供建议
---
## 优先级
**高优先级** - 这是 BP 管理的核心场景之一,几乎每个员工都会用到。
**预期收益**:
- 员工可以快速了解自己的工作任务
- 管理者可以快速了解下属的工作负荷
- 减少手动沟通成本
- 提升系统使用体验
---
## 其他说明
### 为什么不用现有接口
| 现有接口 | 问题 |
|---------|------|
| `searchByName` | 只能按名称搜索,无法按承接人搜索 |
| `getGoalDetail` 等 | 需要先知道任务 ID,但用户不知道分配给自己的任务 ID |
| 遍历所有任务 | 性能差,API 调用次数多,用户体验不好 |
### 对数据模型的要求
- 必须返回 `taskUsers`(参与人)
- 必须返回 `parentTask`(父任务)
- 必须返回 `groupInfo`(所属分组)
- 建议返回 `upwardTaskList`(上对齐任务,用于追溯任务来源)
---
## 联系方式
如有疑问或需要进一步讨论,请联系:
- Telegram: @evan
- 邮件: [您的邮箱]
- 电话: [您的电话]
---
## 附录:现有 API 接口清单
| 接口 | 方法 | 说明 |
|------|------|------|
| `GET /bp/period/list` | 查询周期列表 | ✅ |
| `GET /bp/group/list` | 获取分组树 | ✅ |
| `POST /bp/group/getPersonalGroupIds` | 批量获取个人分组ID | ✅ |
| `GET /bp/task/v2/getSimpleTree` | 查询BP任务树 | ✅ |
| `GET /bp/goal/{goalId}/detail` | 获取目标详情 | ✅ |
| `GET /bp/keyResult/{keyResultId}/detail` | 获取关键成果详情 | ✅ |
| `GET /bp/action/{actionId}/detail` | 获取关键举措详情 | ✅ |
| `GET /bp/task/v2/searchByName` | 按名称搜索任务 | ✅ |
| `GET /bp/group/markdown` | 获取分组BP Markdown | ✅ |
| `GET /bp/task/listByOwner` | 按承接人查询任务 | ❌ **需要新增** |
| `GET /bp/task/myTasks` | 查询我的任务 | ❌ **需要新增** |
FILE:references/audit/README.md
## audit 模块说明(独立入口保留)
### 适用场景
用于对 BP 进行审计校验(基础合规、向上对齐、向下承接、GAP 分析)。
该模块必须支持“仅审计不写入”的独立入口;同时也应被写入更新模块在变更前后调用,形成强联动闭环。
### 审计输出要求
- 结论必须精确引用到具体对象(编号+名称)
- 不允许使用“部分/某些/个别”等模糊指代
- 问题必须包含严重等级、原因与建议
FILE:references/kangzhe-rules.md
# BP 康哲规则参考
本文档整理了 BP 系统的康哲实施规则与行为约束,供 AI 检查和生成 BP 参考。
**数据源**: 玄关开放平台 - BP系统业务说明.md
**最后更新**: 2026-04-04
---
## BP 三层结构
### 结构定义
BP 采用三层结构:**目标(Goal) → 关键成果(Key Result) → 关键举措(Action)**
每一层必须回答:
- **目标**:要达成什么(结果状态)
- **关键成果**:如何判断达成(可验收状态)
- **关键举措**:靠哪些关键行动做成(可执行动作)
### 边界规则(硬规则)
#### 目标(Goal)边界
- ✅ **只写结果状态**,严格禁动词
- ✅ **推荐静态事实定格句式**:「[业务对象]已处于[理想状态]」
- ❌ **禁止**:推动、建立、提升、加强、推进、完成、落地、实施等动作性表述
- ❌ **禁止**:在目标句子中堆数字(衡量细节放到成果的衡量标准里)
**违规示例**:
- ❌ "推动XX落地"
- ❌ "建立XX机制"
- ❌ "完成XX建设"
**合规示例**:
- ✅ "XX机制在全范围内按标准运行并可被审计"
- ✅ "XX交付能力达到约定SLA并稳定运行"
#### 关键成果(Key Result)边界
- ✅ **必须符合SMART**:具体、可衡量、有时限
- ✅ **必须配置衡量标准**:指标+阈值+口径+数据源+频率
- ✅ **成果之间尽量满足MECE**:相互独立、共同穷尽
- ❌ **禁止**:不可验收的模糊表述
**违规示例**:
- ❌ "客户满意度明显提升"
- ❌ "业务持续增长"
**合规示例**:
- ✅ "NPS达到X(问卷口径,季度统计)"
- ✅ "P0/P1问题7日内闭环率≥Y%(工单系统,月度统计)"
#### 关键举措(Action)边界
- ✅ **必须可指派、可承接、可追踪**
- ✅ **必须挂在对应成果下**
- ❌ **禁止**:把结果写成举措
- ❌ **禁止**:空泛动作(积极配合、持续跟进、加强沟通)
**违规示例**:
- ❌ "达成收入5亿"(把结果写成举措)
- ❌ "积极配合"(不可追踪)
**合规示例**:
- ✅ "建立月度经营分析会机制并形成复盘纪要(固定模板、固定节奏)"
- ✅ "完成XX方案评审→试点→推广三阶段交付并留痕归档"
---
## 组织 BP vs 个人 BP
### 组织 BP
- ✅ 可拆解、可协作
- ✅ 目标与成果由组织负责人对结果负责
- ✅ 举措可以指派给下级部门或个人承接
### 个人 BP
- ❌ 不可向外拆解或指派
- ✅ 完全由个人负责
- ✅ 目标/成果/举措均应体现"本人承接的责任与贡献"
- ✅ **必须承接组织 BP**
---
## 层级拆解深度
| 层级 | 拆解深度 | 说明 |
|------|---------|------|
| 集团层级 | 2层(目标 → 关键成果) | 不拆关键举措 |
| 中心层级 | 3层(目标 → 关键成果 → 关键举措) | 举措必须可供一级部门承接 |
| 一级部门层级 | 3层(目标 → 关键成果 → 关键举措) | 举措必须可供核心员工承接 |
| 核心员工层级 | 3层(目标 → 关键成果 → 关键举措) | 举措必须具体到可执行动作 |
---
## 承接规则
### 承接规则总句
> **下级用"目标"承接上级的"关键成果/关键举措"**
### 承接链路
```
集团关键成果 → 中心目标(成果→目标)
中心关键举措 → 一级部门目标(举措→目标)
一级部门关键举措 → 核心员工目标(举措→目标)
```
### 人员对应规则
- 承接上级**关键举措**时:**下级目标责任人 = 上级关键举措承接人**
- 承接上级**关键成果**时:下级目标责任人应与该成果在下级的实际负责方一致
---
## 质量检查清单
### 结构完整性
- [ ] 是否按"目标→关键成果→关键举措"拆解
- [ ] 拆解深度是否符合所在层级要求
- [ ] 是否把举措写在目标层(禁止)
- [ ] 目标下是否有关键成果支撑
- [ ] 关键成果下是否有关键举措落地
### 衡量标准
- [ ] 所有关键成果是否都配置了衡量标准
- [ ] 衡量标准是否包含:指标+阈值+口径/数据源+统计频率
- [ ] 是否把衡量标准错误编码/拆成了多个举措(禁止)
### 承接关系
- [ ] 承接关系是否正确
- [ ] 关键举措是否具备可承接性
- [ ] 下级目标能否清晰对应承接上级关键成果/关键举措
### 举措颗粒度
- [ ] 举措是否具体到"可执行动作"
- [ ] 举措是否足以让下级直接转写为"静态事实定格"的目标终态
### 时间边界
- [ ] 汇报周期是否满足:**举措汇报周期 ≤ 关键成果汇报周期 ≤ 目标汇报周期**
- [ ] 子任务时间是否在父任务时间范围内
### 必填项
- [ ] 责任人/承接人是否填写
- [ ] 起止时间是否填写
- [ ] 汇报周期是否设置
---
## 数量关系建议
- **数量关系**:1个目标 → N个成果 → 每个成果M个举措
- **建议上限**:
- 每个目标下关键成果:2–5条(优先2–3条)
- 每条成果下关键举措:2–6条(优先2–3条)
- **超过上限时**:必须证明"多的必要性"
---
## 常见违规模式
### 1. 目标层违规
- ❌ 目标写成了动作("推动XX"、"建立XX")
- ❌ 目标缺少结果状态
- ❌ 目标包含衡量细节
### 2. 成果层违规
- ❌ 成果不可验收("明显提升"、"大幅优化")
- ❌ 成果缺少衡量标准
- ❌ 成果之间重复计功
### 3. 举措层违规
- ❌ 举措写成结果("达成XX"、"实现XX")
- ❌ 举措不可追踪("积极配合"、"持续跟进")
- ❌ 举措挂在目标层
### 4. 承接违规
- ❌ 个人 BP 不承接组织 BP
- ❌ 承接关系不清晰
- ❌ 责任人/承接人不匹配
### 5. 结构违规
- ❌ 集团层拆解到关键举措
- ❌ 中心/一级部门/员工层未拆解到关键举措
- ❌ 关键成果下无关键举措
---
## AI 检查建议
当使用 AI 检查 BP 时,应该:
1. **逐层检查**:从目标层开始,检查每一层的合规性
2. **横向检查**:检查同层级的完整性和 MECE
3. **纵向检查**:检查承接关系的正确性
4. **数据检查**:检查衡量标准的完整性和可验收性
5. **时间检查**:检查时间边界和汇报周期的合理性
对于发现的问题,应该:
- 明确指出违规位置(编码/名称)
- 说明违规类型
- 提供合规建议
FILE:references/maintenance.md
# 维护信息
## 基本信息
- 版本:见 `_meta.json`
- ClawHub slug:`bp-manager`
## GitHub 地址
- 仓库:https://github.com/xgjk/bp-skills
- Skill 目录:`cms-bp-manager/`
## 如何提 Issue
1. 访问 https://github.com/xgjk/bp-skills/issues/new
2. 选择 Label:`cms-bp-manager`
3. 填写问题描述 + 复现步骤
## 如何更新
**工厂内部开发:**
1. 修改 Skill 内容
2. 更新 `_meta.json` 版本号
3. 执行 `clawhub publish`
**ClawHub 用户:**
```bash
clawhub update bp-manager
```
---
*最后更新:2026-04-05*
FILE:scripts/bp_client.py
#!/usr/bin/env python3
"""
BP API 客户端封装(只读 + 审计)
基于原版 bp-manager/scripts/bp_client.py 重建,移除写入方法(归属 cms-bp-manager-write)。
增强:UTF-8 终端兼容、时间范围过滤、月度汇报查询。
约束:
- 仅封装只读接口
- appKey 由运行时注入(依赖 cms-auth-skills),不在代码中硬编码、不回显
"""
import json
import os
import sys
import urllib.parse
import urllib.request
from typing import Any, Dict, List, Optional
def _configure_io_encoding() -> None:
"""兼容 LANG=en_US 等非 UTF-8 终端环境,避免中文输出/异常信息导致编码崩溃。"""
try:
sys.stdout.reconfigure(encoding="utf-8", errors="replace")
except Exception:
pass
try:
sys.stderr.reconfigure(encoding="utf-8", errors="replace")
except Exception:
pass
class BPClient:
"""BP API 客户端(只读 + 审计)"""
def __init__(self, app_key: Optional[str] = None, base_url: Optional[str] = None):
_configure_io_encoding()
self.AppKey = app_key or os.getenv("BP_APP_KEY")
self.BaseUrl = base_url or "https://sg-al-cwork-web.mediportal.com.cn/open-api"
if not self.AppKey:
raise ValueError("缺少 appKey,请通过 cms-auth-skills 注入或设置 BP_APP_KEY 环境变量")
def _request(self, method: str, path: str, params: Optional[Dict[str, Any]] = None, data: Any = None) -> Dict[str, Any]:
"""发送 HTTP 请求"""
url = f"{self.BaseUrl}{path}"
headers = {"appKey": self.AppKey, "Content-Type": "application/json"}
if params:
url = f"{url}?{urllib.parse.urlencode(params, encoding='utf-8', errors='strict')}"
try:
if method == "GET":
req = urllib.request.Request(url, headers=headers)
with urllib.request.urlopen(req, timeout=60) as resp:
return json.loads(resp.read().decode("utf-8"))
if method == "POST":
payload = json.dumps(data).encode("utf-8") if data is not None else None
req = urllib.request.Request(url, headers=headers, data=payload, method="POST")
with urllib.request.urlopen(req, timeout=60) as resp:
return json.loads(resp.read().decode("utf-8"))
raise ValueError(f"不支持的 HTTP 方法:{method}")
except Exception as exc:
return {"resultCode": 0, "resultMsg": str(exc), "data": None}
# ==================== 周期管理 ====================
def ListPeriods(self, name: Optional[str] = None) -> Dict[str, Any]:
"""查询周期列表"""
params = {"name": name} if name else None
return self._request("GET", "/bp/period/list", params=params)
# ==================== 分组管理 ====================
def ListGroups(self, period_id: str, only_personal: bool = False) -> Dict[str, Any]:
"""获取分组树"""
params = {"periodId": period_id, "onlyPersonal": str(only_personal).lower()}
return self._request("GET", "/bp/group/list", params=params)
def GetPersonalGroupIds(self, employee_ids: List[str]) -> Dict[str, Any]:
"""批量查询员工个人类型分组 ID"""
payload: List[Any] = []
for emp_id in employee_ids:
s = str(emp_id).strip()
if s.isdigit():
try:
payload.append(int(s))
continue
except Exception:
pass
payload.append(s)
return self._request("POST", "/bp/group/getPersonalGroupIds", data=payload)
def SearchGroups(self, period_id: str, name: str) -> Dict[str, Any]:
"""按名称搜索分组"""
return self._request("GET", "/bp/group/searchByName", params={"periodId": period_id, "name": name})
def GetGroupMarkdown(self, group_id: str) -> Dict[str, Any]:
"""获取分组完整 BP 的 Markdown"""
return self._request("GET", "/bp/group/markdown", params={"groupId": group_id})
def BatchGetKeyPositionMarkdown(self, group_ids: List[str]) -> Dict[str, Any]:
"""批量获取关键岗位详情 Markdown"""
return self._request("POST", "/bp/group/batchGetKeyPositionMarkdown", data=group_ids)
# ==================== 任务管理 ====================
def GetSimpleTree(self, group_id: str) -> Dict[str, Any]:
"""查询 BP 任务树(简要信息)"""
return self._request("GET", "/bp/task/v2/getSimpleTree", params={"groupId": group_id})
def SearchTasks(self, group_id: str, name: str) -> Dict[str, Any]:
"""按名称搜索任务"""
return self._request("GET", "/bp/task/v2/searchByName", params={"groupId": group_id, "name": name})
def GetTaskChildren(self, parent_id: str) -> Dict[str, Any]:
"""获取任务子树骨架"""
return self._request("GET", "/bp/task/children", params={"parentId": parent_id})
# ==================== 目标管理 ====================
def ListGoals(self, group_id: str) -> Dict[str, Any]:
"""获取目标列表"""
return self._request("GET", "/bp/goal/list", params={"groupId": group_id})
def GetGoalDetail(self, goal_id: str) -> Dict[str, Any]:
"""获取目标详情"""
return self._request("GET", f"/bp/goal/{goal_id}/detail")
# ==================== 关键成果管理 ====================
def ListKeyResults(self, goal_id: str) -> Dict[str, Any]:
"""获取关键成果列表"""
return self._request("GET", "/bp/keyResult/list", params={"goalId": goal_id})
def GetKeyResultDetail(self, key_result_id: str) -> Dict[str, Any]:
"""获取关键成果详情"""
return self._request("GET", f"/bp/keyResult/{key_result_id}/detail")
# ==================== 关键举措管理 ====================
def ListActions(self, key_result_id: str) -> Dict[str, Any]:
"""获取关键举措列表"""
return self._request("GET", "/bp/action/list", params={"keyResultId": key_result_id})
def GetActionDetail(self, action_id: str) -> Dict[str, Any]:
"""获取关键举措详情"""
return self._request("GET", f"/bp/action/{action_id}/detail")
# ==================== 汇报管理 ====================
def ListTaskReports(self, task_id: str, page_index: int = 1, page_size: int = 10,
keyword: Optional[str] = None, sort_by: str = "relation_time",
sort_order: str = "desc") -> Dict[str, Any]:
"""分页查询所有汇报"""
data: Dict[str, Any] = {
"taskId": task_id,
"sortBy": sort_by,
"sortOrder": sort_order,
"pageIndex": page_index,
"pageSize": page_size,
}
if keyword:
data["keyword"] = keyword
return self._request("POST", "/bp/task/relation/pageAllReports", data=data)
def ListTaskReportsWithTimeRange(
self,
task_id: str,
page_index: int = 1,
page_size: int = 10,
business_time_start: Optional[str] = None,
business_time_end: Optional[str] = None,
relation_time_start: Optional[str] = None,
relation_time_end: Optional[str] = None,
sort_by: str = "relation_time",
sort_order: str = "desc",
) -> Dict[str, Any]:
"""分页查询所有汇报(支持时间范围过滤)"""
data: Dict[str, Any] = {
"taskId": task_id,
"pageIndex": page_index,
"pageSize": page_size,
"sortBy": sort_by,
"sortOrder": sort_order,
}
if business_time_start:
data["businessTimeStart"] = business_time_start
if business_time_end:
data["businessTimeEnd"] = business_time_end
if relation_time_start:
data["relationTimeStart"] = relation_time_start
if relation_time_end:
data["relationTimeEnd"] = relation_time_end
return self._request("POST", "/bp/task/relation/pageAllReports", data=data)
# ==================== 延期提醒(只读:查询历史) ====================
def ListDelayReports(self, receiver_emp_id: str) -> Dict[str, Any]:
"""查询延期提醒汇报历史"""
return self._request("GET", "/bp/delayReport/list", params={"receiverEmpId": receiver_emp_id})
# ==================== 月度汇报 ====================
def GetMonthlyReportByMonth(self, group_id: str, report_month: str) -> Dict[str, Any]:
"""根据分组和月份获取月度汇报(GET /bp/monthly/report/getByMonth)"""
return self._request("GET", "/bp/monthly/report/getByMonth", params={"groupId": group_id, "reportMonth": report_month})
# ==================== 便捷函数 ====================
def GetCurrentPeriod(client: BPClient) -> Optional[Dict[str, Any]]:
"""获取当前启用的周期"""
result = client.ListPeriods()
if result.get("resultCode") != 1:
return None
for period in (result.get("data") or []):
if period.get("status") == 1:
return period
return None
def FindMyGroup(client: BPClient, period_id: str, employee_id: str) -> Optional[str]:
"""找到员工在指定周期下的个人分组 ID"""
result = client.GetPersonalGroupIds([employee_id])
if result.get("resultCode") != 1:
return None
data = result.get("data") or {}
group_id = data.get(employee_id)
if not group_id:
try:
group_id = data.get(int(employee_id))
except Exception:
group_id = None
return group_id
if __name__ == "__main__":
client = BPClient()
print("测试周期列表:")
result = client.ListPeriods()
print(json.dumps(result, indent=2, ensure_ascii=False))
FILE:scripts/commands.py
#!/usr/bin/env python3
"""
BP Manager 命令实现(只读 + 审计)
基于原版 bp-manager/scripts/commands.py 重建,移除写入命令(归属 cms-bp-manager-write)。
增强:时间范围过滤、月度汇报查询、UTF-8 终端兼容。
"""
import json
import argparse
import os
import sys
import subprocess
import urllib.error
import urllib.request
from pathlib import Path
from typing import Dict, List, Optional, Any
from bp_client import BPClient, GetCurrentPeriod, FindMyGroup
def _configure_io_encoding() -> None:
try:
sys.stdout.reconfigure(encoding="utf-8", errors="replace")
except Exception:
pass
try:
sys.stderr.reconfigure(encoding="utf-8", errors="replace")
except Exception:
pass
def _print(obj: Dict[str, Any]) -> None:
print(json.dumps(obj, ensure_ascii=False, indent=2))
def _is_truthy(value: Optional[str]) -> bool:
if value is None:
return False
return value.strip().lower() in {"1", "true", "yes", "y", "on"}
def _parse_semver(version: str) -> Optional[List[int]]:
s = (version or "").strip()
if not s:
return None
if s.startswith("v") or s.startswith("V"):
s = s[1:]
parts = s.split(".")
nums: List[int] = []
for p in parts:
p = p.strip()
if not p.isdigit():
return None
nums.append(int(p))
if not nums:
return None
while len(nums) < 3:
nums.append(0)
return nums[:3]
def _compare_semver(a: str, b: str) -> Optional[int]:
"""
比较 a 与 b(语义版本号,支持 v 前缀)。
返回:1 表示 a>b;0 表示相等;-1 表示 a<b;None 表示无法比较。
"""
aa = _parse_semver(a)
bb = _parse_semver(b)
if aa is None or bb is None:
return None
if aa == bb:
return 0
return 1 if aa > bb else -1
def _read_local_skill_version() -> Optional[str]:
"""
从本技能的 SKILL.md 头部读取 metadata.version(不引入第三方 YAML 依赖)。
"""
try:
skill_md = Path(__file__).resolve().parents[1] / "SKILL.md"
text = skill_md.read_text(encoding="utf-8")
except Exception:
return None
in_front_matter = False
for line in text.splitlines():
if line.strip() == "---" and not in_front_matter:
in_front_matter = True
continue
if line.strip() == "---" and in_front_matter:
break
if not in_front_matter:
continue
# 只匹配顶层/二级缩进的 version 字段(metadata.version)
# 例如: version: v2.0.1
if line.lstrip().startswith("version:"):
_, v = line.split(":", 1)
return v.strip().strip("\"'") or None
return None
def _fetch_latest_release_tag(timeout_seconds: float = 3.0) -> Optional[str]:
"""
从 GitHub Release 拉取最新 tag(无鉴权;失败则返回 None,不影响主业务)。
"""
url = "https://api.github.com/repos/xgjk/bp-skills/releases/latest"
req = urllib.request.Request(
url,
headers={
"Accept": "application/vnd.github+json",
"User-Agent": "cms-bp-manager-update-check",
},
method="GET",
)
try:
with urllib.request.urlopen(req, timeout=timeout_seconds) as resp:
data = json.loads(resp.read().decode("utf-8"))
tag = (data or {}).get("tag_name")
if isinstance(tag, str) and tag.strip():
return tag.strip()
return None
except Exception:
return None
def _maybe_check_and_update(allow_auto_update: bool, allow_prompt: bool) -> Optional[Dict[str, Any]]:
"""
- 默认只做检查,不阻断主命令执行
- 检测到更新:返回 updateInfo;必要时自动更新或提示更新
"""
local_version = _read_local_skill_version()
latest_tag = _fetch_latest_release_tag()
if not local_version or not latest_tag:
return None
cmp = _compare_semver(latest_tag, local_version)
if cmp is None or cmp <= 0:
return None
install_cmd = "npx clawhub@latest install cms-bp-manager --force"
info: Dict[str, Any] = {
"hasUpdate": True,
"currentVersion": local_version,
"latestVersion": latest_tag,
"installCommand": install_cmd,
}
if allow_auto_update:
try:
proc = subprocess.run(install_cmd, shell=True, check=False, capture_output=True, text=True)
info["autoUpdateAttempted"] = True
info["autoUpdateExitCode"] = proc.returncode
if proc.returncode == 0:
info["autoUpdateSuccess"] = True
else:
info["autoUpdateSuccess"] = False
info["autoUpdateError"] = (proc.stderr or proc.stdout or "").strip()[:2000]
except Exception as exc:
info["autoUpdateAttempted"] = True
info["autoUpdateSuccess"] = False
info["autoUpdateError"] = str(exc)
return info
if allow_prompt and sys.stdin.isatty():
try:
sys.stderr.write(
f\"检测到 cms-bp-manager 有新版本:{local_version} → {latest_tag}\\n是否现在更新?(yes/no):\"
)
sys.stderr.flush()
ans = (sys.stdin.readline() or \"\").strip().lower()
if ans in {\"yes\", \"y\"}:
proc = subprocess.run(install_cmd, shell=True, check=False, capture_output=True, text=True)
info[\"prompted\"] = True
info[\"userAccepted\"] = True
info[\"updateExitCode\"] = proc.returncode
if proc.returncode != 0:
info[\"updateError\"] = (proc.stderr or proc.stdout or \"\").strip()[:2000]
else:
info[\"prompted\"] = True
info[\"userAccepted\"] = False
except Exception as exc:
info[\"prompted\"] = True
info[\"promptError\"] = str(exc)
return info
info["prompted"] = False
info["message"] = "检测到新版本,可手动执行 installCommand 更新。"
return info
def _ensure_employee_id(employee_id: Optional[str]) -> Optional[str]:
return employee_id or os.getenv("BP_EMPLOYEE_ID") or os.getenv("EMPLOYEE_ID")
# ==================== 查看 BP 命令 ====================
def CmdViewMyBp(client: BPClient, employee_id: Optional[str] = None) -> Dict[str, Any]:
"""查看我的 BP"""
emp_id = _ensure_employee_id(employee_id)
if not emp_id:
return {"success": False, "error": "缺少 employeeId,请通过参数 --employee-id 或环境变量 BP_EMPLOYEE_ID/EMPLOYEE_ID 提供"}
period = GetCurrentPeriod(client)
if not period:
return {"success": False, "error": "未找到启用的周期"}
period_id = period["id"]
group_id = FindMyGroup(client, period_id, emp_id)
if not group_id:
return {"success": False, "error": "未找到该员工的个人分组"}
result = client.GetGroupMarkdown(group_id)
if result.get("resultCode") != 1:
return {"success": False, "error": result.get("resultMsg") or "获取 BP 失败"}
return {
"success": True,
"period": {"id": period.get("id"), "name": period.get("name")},
"groupId": group_id,
"markdown": result["data"],
}
def CmdViewGroupBp(client: BPClient, group_id: str) -> Dict[str, Any]:
"""查看指定分组的 BP"""
result = client.GetGroupMarkdown(group_id)
if result.get("resultCode") != 1:
return {"success": False, "error": result.get("resultMsg") or "获取 BP 失败"}
return {"success": True, "groupId": group_id, "markdown": result["data"]}
def CmdViewSubordinateBp(client: BPClient, subordinate_name: str, period_id: Optional[str] = None) -> Dict[str, Any]:
"""查看下属的 BP"""
if not period_id:
period = GetCurrentPeriod(client)
if not period:
return {"success": False, "error": "未找到启用的周期"}
period_id = period["id"]
result = client.SearchGroups(period_id, subordinate_name)
if result.get("resultCode") != 1 or not result.get("data"):
return {"success": False, "error": f"未找到名为 '{subordinate_name}' 的分组"}
groups = result["data"]
target_group = None
for g in groups:
if g.get("type") == "personal":
target_group = g
break
if not target_group:
target_group = groups[0]
return CmdViewGroupBp(client, target_group["id"])
# ==================== 查看汇报历史命令 ====================
def CmdViewReports(
client: BPClient,
task_id: str,
page_index: int = 1,
page_size: int = 10,
business_time_start: Optional[str] = None,
business_time_end: Optional[str] = None,
relation_time_start: Optional[str] = None,
relation_time_end: Optional[str] = None,
) -> Dict[str, Any]:
"""查看任务的汇报历史(支持时间范围过滤)"""
result = client.ListTaskReportsWithTimeRange(
task_id,
page_index=page_index,
page_size=page_size,
business_time_start=business_time_start,
business_time_end=business_time_end,
relation_time_start=relation_time_start,
relation_time_end=relation_time_end,
)
if result.get("resultCode") != 1:
return {"success": False, "error": result.get("resultMsg") or "获取汇报历史失败"}
data = result["data"]
return {"success": True, "total": data.get("total", 0), "reports": data.get("list", [])}
# ==================== 月度汇报命令 ====================
def CmdGetMonthlyReport(client: BPClient, group_id: str, report_month: str) -> Dict[str, Any]:
"""按分组和月份查询月度汇报"""
result = client.GetMonthlyReportByMonth(group_id, report_month)
if result.get("resultCode") != 1:
return {"success": False, "error": result.get("resultMsg") or "查询月度汇报失败"}
return {"success": True, "data": result.get("data")}
# ==================== 搜索命令 ====================
def CmdSearchTasks(client: BPClient, group_id: str, keyword: str) -> Dict[str, Any]:
"""搜索任务"""
result = client.SearchTasks(group_id, keyword)
if result.get("resultCode") != 1:
return {"success": False, "error": result.get("resultMsg") or "搜索任务失败"}
return {"success": True, "tasks": result.get("data") or []}
def CmdSearchGroups(client: BPClient, period_id: str, keyword: str) -> Dict[str, Any]:
"""搜索分组"""
result = client.SearchGroups(period_id, keyword)
if result.get("resultCode") != 1:
return {"success": False, "error": result.get("resultMsg") or "搜索分组失败"}
return {"success": True, "groups": result.get("data") or []}
# ==================== AI 检查命令 ====================
def CmdCheckBp(client: BPClient, group_id: str) -> Dict[str, Any]:
"""
AI 检查 BP 质量(基于康哲规则)
检查项包括:
1. 结构完整性:是否按 G-R-A 三层拆解
2. 衡量标准:所有关键成果是否有合格的衡量标准
3. 承接关系:是否正确承接上级任务
4. 举措可执行性:关键举措是否具体可执行
5. 时间合理性:时间范围是否合理
"""
result = client.GetGroupMarkdown(group_id)
if result.get("resultCode") != 1:
return {"success": False, "error": "获取 BP 失败"}
return {
"success": True,
"markdown": result["data"],
"message": "BP 数据已获取,请结合 references/kangzhe-rules.md 中的康哲规则进行 AI 深度分析",
}
# ==================== 周期列表命令 ====================
def CmdListPeriods(client: BPClient, name: Optional[str] = None) -> Dict[str, Any]:
"""列出周期列表"""
result = client.ListPeriods(name)
if result.get("resultCode") != 1:
return {"success": False, "error": result.get("resultMsg") or "查询周期列表失败"}
return {"success": True, "periods": result.get("data") or []}
# ==================== 主函数 ====================
def main() -> None:
_configure_io_encoding()
parser = argparse.ArgumentParser(description="BP Manager 命令行工具(只读 + 审计)")
parser.add_argument("--skip-update-check", action="store_true", help="跳过版本更新检查")
parser.add_argument("--auto-update", action="store_true", help="发现新版本时自动执行更新安装命令")
parser.add_argument("--prompt-update", action="store_true", help="发现新版本时提示是否更新(仅在 TTY 下生效)")
sub = parser.add_subparsers(dest="command", required=True, help="可用命令")
p_view_my = sub.add_parser("view-my", help="查看我的 BP")
p_view_my.add_argument("--employee-id", help="员工ID(可选;也可通过环境变量提供)")
p_view_group = sub.add_parser("view-group", help="查看指定分组的 BP")
p_view_group.add_argument("--group-id", required=True, help="分组ID")
p_view_subordinate = sub.add_parser("view-subordinate", help="查看下属的 BP")
p_view_subordinate.add_argument("--name", required=True, help="下属姓名")
p_view_subordinate.add_argument("--period-id", help="周期ID(可选)")
p_reports = sub.add_parser("reports", help="查看任务关联汇报列表")
p_reports.add_argument("--task-id", required=True, help="任务ID")
p_reports.add_argument("--page-index", type=int, default=1, help="页码")
p_reports.add_argument("--page-size", type=int, default=10, help="每页条数")
p_reports.add_argument("--business-time-start", help="业务时间开始(yyyy-MM-dd HH:mm:ss,可选)")
p_reports.add_argument("--business-time-end", help="业务时间结束(yyyy-MM-dd HH:mm:ss,可选)")
p_reports.add_argument("--relation-time-start", help="关联时间开始(yyyy-MM-dd HH:mm:ss,可选)")
p_reports.add_argument("--relation-time-end", help="关联时间结束(yyyy-MM-dd HH:mm:ss,可选)")
p_monthly = sub.add_parser("monthly-report", help="按分组和月份查询月度汇报")
p_monthly.add_argument("--group-id", required=True, help="分组ID(个人分组)")
p_monthly.add_argument("--report-month", required=True, help="汇报月份(YYYY-MM)")
p_search_tasks = sub.add_parser("search-tasks", help="搜索任务")
p_search_tasks.add_argument("--group-id", required=True, help="分组ID")
p_search_tasks.add_argument("--keyword", required=True, help="搜索关键词")
p_search_groups = sub.add_parser("search-groups", help="搜索分组")
p_search_groups.add_argument("--period-id", required=True, help="周期ID")
p_search_groups.add_argument("--keyword", required=True, help="搜索关键词")
p_check = sub.add_parser("check-bp", help="AI 检查 BP 质量(基于康哲规则)")
p_check.add_argument("--group-id", required=True, help="分组ID")
p_periods = sub.add_parser("list-periods", help="列出周期列表(可选按名称模糊搜索)")
p_periods.add_argument("--name", help="周期名称关键词(可选)")
args = parser.parse_args()
# 默认策略:检查更新但不打断业务;仅输出提示信息。
# 可通过参数或环境变量切换为自动更新/提示更新。
update_info: Optional[Dict[str, Any]] = None
if not args.skip_update_check and not _is_truthy(os.getenv("BP_MANAGER_SKIP_UPDATE_CHECK")):
allow_auto = args.auto_update or _is_truthy(os.getenv("BP_MANAGER_AUTO_UPDATE"))
allow_prompt = args.prompt_update or _is_truthy(os.getenv("BP_MANAGER_PROMPT_UPDATE"))
update_info = _maybe_check_and_update(allow_auto_update=allow_auto, allow_prompt=allow_prompt)
client = BPClient()
if args.command == "view-my":
res = CmdViewMyBp(client, args.employee_id)
if update_info:
res["updateInfo"] = update_info
_print(res)
elif args.command == "view-group":
res = CmdViewGroupBp(client, args.group_id)
if update_info:
res["updateInfo"] = update_info
_print(res)
elif args.command == "view-subordinate":
res = CmdViewSubordinateBp(client, args.name, args.period_id)
if update_info:
res["updateInfo"] = update_info
_print(res)
elif args.command == "reports":
res = CmdViewReports(
client, args.task_id, args.page_index, args.page_size,
args.business_time_start, args.business_time_end,
args.relation_time_start, args.relation_time_end,
)
if update_info:
res["updateInfo"] = update_info
_print(res)
elif args.command == "monthly-report":
res = CmdGetMonthlyReport(client, args.group_id, args.report_month)
if update_info:
res["updateInfo"] = update_info
_print(res)
elif args.command == "search-tasks":
res = CmdSearchTasks(client, args.group_id, args.keyword)
if update_info:
res["updateInfo"] = update_info
_print(res)
elif args.command == "search-groups":
res = CmdSearchGroups(client, args.period_id, args.keyword)
if update_info:
res["updateInfo"] = update_info
_print(res)
elif args.command == "check-bp":
res = CmdCheckBp(client, args.group_id)
if update_info:
res["updateInfo"] = update_info
_print(res)
elif args.command == "list-periods":
res = CmdListPeriods(client, args.name)
if update_info:
res["updateInfo"] = update_info
_print(res)
else:
res = {"success": False, "error": f"未知命令:{args.command}"}
if update_info:
res["updateInfo"] = update_info
_print(res)
if __name__ == "__main__":
main()
FILE:setup.md
# BP Manager 安装说明
## 前置条件
1. **Python 3.8+**
2. **BP API 访问权限**(需要 BP_APP_KEY)
3. **网络访问**(能访问 `https://sg-al-cwork-web.mediportal.com.cn/open-api`)
---
## 安装步骤
### 1. 设置环境变量
```bash
# 在 ~/.zshrc 或 ~/.bashrc 中添加
export BP_APP_KEY="your-app-key-here"
```
### 2. 验证安装
```bash
# 测试 API 连接
python3 scripts/bp_client.py
```
---
## 配置说明
### 环境变量
| 变量名 | 必需 | 说明 |
|--------|------|------|
| BP_APP_KEY | ✅ | BP API 的 appKey,从玄关开放平台获取 |
### 配置文件(可选)
可以创建 `.env` 文件存储环境变量:
```bash
BP_APP_KEY=your-app-key-here
```
---
## 使用方式
### 在对话中使用
在对话中直接说:
- "查看我的 BP"
- "查看张三的 BP"
- "检查我的 BP"
- "查看目标 A4-1 的汇报历史"
- "搜索关于客户拜访的任务"
### 命令行使用
```bash
# 查看我的 BP
python3 scripts/commands.py view-my
# 查看指定分组的 BP
python3 scripts/commands.py view-group --group-id <group-id>
# 查看下属的 BP
python3 scripts/commands.py view-subordinate --name "张三"
# AI 检查 BP 质量
python3 scripts/commands.py check-bp --group-id <group-id>
# 查看汇报历史(支持时间过滤)
python3 scripts/commands.py reports --task-id <task-id>
# 按分组和月份查询月度汇报
python3 scripts/commands.py monthly-report --group-id <group-id> --report-month 2026-03
# 搜索任务
python3 scripts/commands.py search-tasks --group-id <group-id> --keyword "关键词"
# 搜索分组
python3 scripts/commands.py search-groups --period-id <period-id> --keyword "关键词"
# 列出周期列表
python3 scripts/commands.py list-periods
```
> **写入操作(新增KR/新增举措/延期提醒)请使用 `cms-bp-manager-write`**
---
## 故障排查
### 问题 1:提示缺少 BP_APP_KEY
**解决方案**:确保已设置环境变量
```bash
echo $BP_APP_KEY # 检查是否输出 appKey
```
### 问题 2:API 调用失败(resultCode: 610002)
**解决方案**:BP_APP_KEY 无效,请从玄关开放平台获取正确的 appKey
### 问题 3:找不到分组
**解决方案**:
1. 确认姓名拼写正确
2. 确认有权限访问该分组
3. 确认周期正确(可能不是当前周期)
### 问题 4:无访问权限(resultCode: 610015)
**解决方案**:联系管理员开通相应权限
---
## 更新日志
| 版本 | 日期 | 变更 |
|------|------|------|
| v1.0.0 | 2026-04-04 | 初版发布(原 bp-manager) |
| v2.0.0 | 2026-04-08 | 基于原版重建:写入能力拆分至 cms-bp-manager-write;新增月度汇报查询、时间范围过滤、UTF-8 兼容 |
TPR(Think / Probe / Review)统一工作方法。 用于把复杂问题从模糊需求转化为可验证、可执行、可复盘的结果。 当遇到以下场景时激活: - 需要结构化分析复杂问题 - 启动项目、起草方案、审查方案 - 用户提到 TPR / 三省 / GRV / Battle / DISCOVERY - 需要做...
---
name: tpr-framework
description: >
TPR(Think / Probe / Review)统一工作方法。
用于把复杂问题从模糊需求转化为可验证、可执行、可复盘的结果。
当遇到以下场景时激活:
- 需要结构化分析复杂问题
- 启动项目、起草方案、审查方案
- 用户提到 TPR / 三省 / GRV / Battle / DISCOVERY
- 需要做决策前的系统性思考
---
> **📌 来源与反馈 (Origin & Feedback)**
>
> 本 Skill 由 [tpr-framework](https://github.com/evan-zhang/tpr-framework) 开源项目持续维护。
>
> 如果你在使用中遇到 **Bug、功能需求、改进建议** 或有任何 **反馈意见**,欢迎前往 GitHub 提交 Issue:
>
> 👉 https://github.com/evan-zhang/tpr-framework/issues
# TPR Framework v2.0
## TPR 是什么
**TPR = Think / Probe / Review 认知闭环 + 三省四阶段执行框架。**
一套从认知到执行的完整工作方法,用于把复杂问题从模糊需求转化为可验证、可执行、可复盘的结果。
### 核心理念
1. **契约是唯一基准** — 所有工作以 GRV 为准,不凭印象
2. **编排只调度不动手** — 编排者不执行业务逻辑
3. **没有记录没有发生** — 一切以文件记录为唯一事实溯源
---
## 两种使用模式
### 判定矩阵
接到任务后,检查以下四项决定进入哪种模式:
| # | 判定项 |
|---|--------|
| A | 是否需要正式交付物(DISCOVERY.md / GRV.md / 报告等) |
| B | 是否需要多角色审查(门下省审 / Battle) |
| C | 是否需要阶段流转(DISCOVERY → GRV → Battle → Implementation) |
| D | agent 是否具备 sub-agent 能力(can_spawn = true) |
**判定规则**:
- A/B/C 中满足 ≥ 2 项 → **TPR 全流程**
- A/B/C 中满足 < 2 项 → **TPR 思维**
- D = false → 强制 **TPR 思维**,禁止伪装全流程
- 用户明确说"走 GRV / Battle / 三省" → 强制 **TPR 全流程**(仍受 D 约束)
**⚠️ 进入全流程前必须自检**:在宣布进入 TPR 全流程之前,先确认自己是否具备 sub-agent 能力(can_spawn)。如果不具备,必须降级为 TPR 思维,并向用户说明原因。不得跳过此检查。
### TPR 思维(任何 agent 可用)
不需要 sub-agent,不需要三省角色。遇到复杂问题时,按 T → P → R 顺序思考。
**速记模板**:
```
T: 我们正在解决 _______________
成功标准是 _______________
关键假设是 _______________
P: 已确认 _______________
未确认 _______________
主要风险 _______________
R: 结论是 _______________
不做 _______________
下一步 _______________
```
### TPR 全流程(编排型 agent 可用)
需要 can_spawn = true。按三省四阶段执行完整项目:
| 阶段 | 认知重心 | 产出 |
|------|---------|------|
| DISCOVERY | T + P | DISCOVERY.md |
| GRV | R | GRV.md |
| Battle | P + R | BATTLE-*.md |
| Implementation | 微型 T/P/R | output/* |
---
## 三省角色表
| 角色 | 职责 | T/P/R 映射 |
|------|------|-----------|
| 编排者 | 维护节奏,协调流转,不替代任何省 | 流程管理 |
| 中书省 | 洞察需求,起草 GRV | Think → Review |
| 门下省 | 挑战假设,暴露盲点 | Probe |
| 尚书省 | 制定方案,执行交付 | 微型 T/P/R |
---
## 核心红线(Layer 1 — 任何模式都必须遵守)
| # | 红线 |
|---|------|
| C1 | **不签署** — 不代替用户签署任何文件、合同、审批单 |
| C2 | **不审批** — 决策权永远在用户,agent 只有建议权 |
| C3 | **不私聊** — 不代替用户与任何人私聊或单独联系 |
| C4 | **不越权决策** — 超出范围的判断必须回传用户 |
| C5 | **没有记录没有发生** — 所有工作以文件记录为唯一事实溯源 |
| C6 | **先建议再执行** — 给出判断和理由,供用户拍板 |
> 编排者防线(Layer 2)详见 `references/orchestrator-ops.md`
> Battle 规则(Layer 3)详见 `references/battle-protocol.md`
---
## GRV 必含要素
| # | 要素 |
|---|------|
| 1 | 目标(G)— 要解决什么问题 |
| 2 | 成果(R)— 可衡量的交付物 + 验收标准 |
| 3 | 举措(V)— 具体但可再拆的工作项 |
| 4 | 约束条件 |
| 5 | 风险 |
| 6 | 里程碑 |
| 7 | 验收标准 |
---
## 安装后配置(可选)
如果你是编排型 agent 且需要跑 TPR 全流程,建议在 AGENTS.md 中声明:
| 声明项 | 说明 | 示例 |
|--------|------|------|
| tpr_mode | 使用模式 | cognitive / full |
| can_spawn | 是否能派生 sub-agent | true / false |
| model_config | 模型配置文件路径 | config/tpr-model-config.md |
---
## 按需加载指引
| 场景 | 读取 | 模式 |
|------|------|------|
| 理解 TPR 完整定义 | references/definition.md | 通用 |
| 用 T/P/R 分析问题 | references/tpr-cognitive.md | TPR 思维 |
| 启动新项目 / DISCOVERY | references/tpr-execution.md § DISCOVERY | 全流程 |
| 起草 GRV | references/grv-standard.md | 全流程 |
| 执行 Battle | references/battle-protocol.md | 全流程 |
| 评估项目分级 | references/project-grading.md | 全流程 |
| 初始化项目目录 | references/templates/ | 全流程 |
| 编排操作 / 派遣 sub-agent | references/orchestrator-ops.md | 全流程 |
| 设计多 Agent 架构 | references/multi-agent-pattern.md | 全流程 |
| Implementation 阶段 | references/tpr-execution.md § Implementation | 全流程 |
FILE:README.md
# TPR Framework (Think / Probe / Review)
<div align="center">
<img src="https://img.shields.io/badge/OpenClaw-Skill-blue.svg" alt="OpenClaw Skill">
<img src="https://img.shields.io/badge/version-2.1.0-green.svg" alt="Version 2.1.0">
<img src="https://img.shields.io/badge/Architecture-Single%_Source_of_Truth-orange" alt="SSOT">
</div>
> **“将模糊的战略狂想,收敛为一行行坚不可摧的代码和可被量化的结果。”**
TPR Framework 是专为 **OpenClaw** 在 Multi-Agent 协作场景下设计的方法论插件(Skill)。它摒弃了单体大模型时代“你问我全自动写”的盲目黑盒作业,引入了基于中国古代“三省六部制”启发的**多层分发、对抗审计与量化演进**架构。
本技能通过硬性拦截规则,彻底杜绝了大模型在长线任务中的“装死”、“假成功”与“注意力骚扰”等弊端。
---
## 🌟 核心特性 (v2.1.0 满血版)
* **🛡️ “三省”结构化防线**
* **编排者 (Orchestrator)**:大脑中枢。遵循 *Yield-after-spawn* 和 *Announce-then-act* 原则,只调度,绝不写脏代码。
* **中书省 (Discovery & Planning)**:负责前端需求采集,运用 5 Why 洞察真实痛点,并起草极其严苛的量化 GRV(Goal-Result-Variables)契约。
* **门下省 (Review & Battle)**:制度化挑刺官(Probe)。客观违规直接拦截,主观分歧发回重申,绝不和稀泥。
* **尚书省 (Execution)**:纯粹的执行机器。
* **📏 强制量化基线 (Metrics Baseline)**
所有下游交付不再使用“这是一份好报告”的伪成功标准。要求代码、报告必须含有明确的字数、空字段断言与量化指标,未达标直接触发重构。
* **🔁 执行层自验证 (Self-Verification)**
尚书省在出活并上交前,被加入了“死卡阻断器”。必须先过本地验证脚本或字数格式盲测,未过直接原地自动重跑(Auto-Fix,最大3次)。
* **🧠 知识自驱飞轮 (Knowledge Flywheel)**
引入“强制复盘钩子”。一旦系统结项或接收到 `/reset` 内存清空指令,主编排会被拦截并强制抽出卡刀经验,冷凝至 `patterns.md` 成为久期资产。
* **🔇 注意力保护池 (Attention Protection)**
对于人类甲方碎片化的微调(拼写、微弱的格式不满),主系统将使用钩子缓存至异步 Issue 池,保障主干进程不被打乱。
---
## 📂 核心文档导读 (Directory Structure)
采用渐进式加载(Progressive Disclosure)原则,根目录轻量化,深层规则在 `references/` 按需加载。
```text
tpr-framework/
├── SKILL.md # 150行以内的核心语义触发器
├── _meta.json # 版本控制信息 v2.1.0
├── design/ # 架构蓝图与设计历史记录
├── references/ # 【核心大脑区】运行时加载文档
│ ├── definition.md # TPR 本质是什么
│ ├── tpr-cognitive.md # 认知与思考逻辑
│ ├── tpr-execution.md # 三省流转机制与自查前置规则
│ ├── battle-protocol.md # 主客分离的争辩审计准则
│ ├── orchestrator-ops.md # 注意力保护与知识飞轮守护协议
│ ├── grv-standard.md # 契约的量化 Metrics 与交付基线强制约束
│ ├── project-grading.md # 项目代号及风险分级
│ └── templates/ # 各场景标准交付模板
```
---
## 🚀 部署与升级指南 (给 AI Agent 的系统级指令)
> **⚠️ ATTENTION AI AGENT**: 如果人类用户要求你安装、升级或在其配置中挂载此 Skill,请严格执行以下标准操作规程。
### 1. 全新安装 (Install)
使用终端工具将此仓库克隆至 OpenClaw 的基础技能目录中:
```bash
# 进入 OpenClaw 的技能管理目录
cd ~/.openclaw/skills/
# 下载最新的稳定版框架
git clone https://github.com/evan-zhang/tpr-framework.git
```
### 2. 检查更新或升级 (Upgrade)
如果用户要求更新至最新版本,进入本地现存的目录进行主干更新:
```bash
cd ~/.openclaw/skills/tpr-framework/
git pull origin main
```
### 3. Agent 技能挂载 (Mount)
为了让目标 Agent 拥有 TPR 的思考与执行能力,请在它的核心配置文件(如 `AGENTS.md` 或 `SOUL.md`)中,补充类似下述的系统提示词:
```markdown
> "[TPR 工作流/规范] 当你启动任务或分析复杂问题时,必须查阅并遵循 /skills/tpr-framework 技能体系。"
```
一旦写入配置,该 Agent 的认知链路将被接管。
---
## 📖 使用指南
在您的主控面板或者与 Orchestrator Agent 的对话流中,随口触发以下黑话即可调起整个重装旅:
* *"我们来开始一个新的项目构思,走 TPR 流程。"*
* *"我有个想法,帮我做一份 GRV 出来看看。"*
* *"让下头开始 Battle 吧。"*
* *执行 `/reset` 或 `/clear` 触发大复盘飞轮沉淀。*
FILE:_meta.json
{
"ownerId": "kn7epc2bwekj28w4kr7q4tpdyn82jswd",
"slug": "tpr-framework",
"version": "2.1.0",
"publishedAt": 1775396611017
}
FILE:design/DESIGN.md
# tpr-framework 设计档案
## 产品概述
- **Slug**:tpr-framework
- **当前版本**:v2.0.0
- **定位**:TPR(Think / Probe / Review)统一工作方法。认知闭环 + 三省四阶段执行框架。
## 核心设计决策
### D-01:为什么要三省分离
AI 在单 Agent 场景下很容易"自问自答"——起草了 GRV 又自己审查,立场天然趋同。三省制强制把起草/审查/执行拆给不同 sub-agent,批判性审查才有意义。
### D-02:Battle 为什么必须用真实 sub-agent
自己扮演 Menxi 和 Shangshu 时,两个角色共享同一个上下文窗口,审查会无意识偏向起草立场。真实 sub-agent 有独立上下文,立场更中立。
### D-03:Orchestrator "Brain Only, No Hands" 原则
一旦 Orchestrator 开始亲手执行,角色边界彻底崩溃,后续所有角色分工都名存实亡。429/失败 → 重派,不自己动手。
### D-04:T/P/R 认知内核(v2.0 新增)
v1 的 TPR 本质是组织架构(三省分工),只在编排型 agent 上有意义。v2 引入 Think/Probe/Review 认知方法,使任何 agent 都可以使用 TPR(作为思维方法),编排型 agent 可以使用完整流程。这解锁了 Skill 的可移植性。
### D-05:两种使用模式(v2.0 新增)
TPR 思维(任何 agent)和 TPR 全流程(编排型 agent)的分离,通过四项判定矩阵硬切换,避免模糊判断导致使用分裂。
### D-06:红线分层(v2.0 新增)
将规则分为三层:Core Redlines(通用)、Orchestrator Guardrails(全流程)、Battle Rules(Battle 阶段)。避免规则混写和重复。
## 版本历史
| 版本 | 日期 | 摘要 |
|------|------|------|
| v1.0.0 | 2026-03 | 初版:四阶段流程 + 三省角色表 + Critical Rules |
| v1.0.1 | 2026-04-01 | SKILL.md 重构(189→57行),新增 references/,补 design/ 档案 |
| v1.1.0 | 2026-04-05 | SKILL.md 扩展(445行),新增 Hermes 原则、上下文管理等 |
| v2.0.0 | 2026-04-07 | 重大升级:引入 T/P/R 认知内核,两种使用模式,红线三层分类,references/ 全面重构(7文件+templates),SKILL.md 精简为入口(~160行) |
FILE:design/DISCUSSION-LOG.md
# tpr-framework Discussion Log
## 2026-04-01 — 质检驱动重构
**背景**:xgjk-skill-auditor 审计 D1 得 4/10(189行超标),D3 大量 NEVER 无解释,D5 design/ 缺失。
**决策**:
- Bindings Management + Sub-agent Spawning 两章推入 references/
- Critical Rules 补"为什么"说明
- 补建 design/ 档案
- 发布到 ClawHub(tpr-framework)
**产出**:SKILL.md 189→57行,references/ 新建 spawning-guide.md + bindings-guide.md
FILE:design/LEARNING-LOOP.md
# tpr-framework 学习复盘
## 2026-04-01
### Problem → Rule
**问题**:Critical Rules 用 NEVER/ALWAYS/Critical 大写,但未解释原因,AI 遵守规则的可靠性低于理解原因。
**规则**:每条强制规则必须附带"为什么",让 AI 理解背后的逻辑,而非只能记忆死规则。
### Problem → Rule
**问题**:操作手册类内容(Bindings/Spawning)混入框架规则 SKILL.md,导致文件臃肿,触发时加载大量无关内容。
**规则**:SKILL.md 只放框架规则和角色边界。操作手册推入 references/,按需加载。
FILE:design/V1-MIGRATION.md
# TPR Skill v1 to v2 迁移映射表
本表记录了 v1 版本中所有分散的旧规则在 v2 中的新归属地。供查阅和追溯使用。
| 原归属地 (v1) | 内容 / 概念 | 现归属地 (v2) | 处理方式 |
|--------------|------------|--------------|----------|
| `skills/tpr-framework/SKILL.md` (旧入门) | 三省分工定义 | `SKILL.md` | 精简并重归纳为入口的三省角色表 |
| `skills/tpr-framework/SKILL.md` (旧入门) | Battle 机制(部分) | `references/battle-protocol.md` | 重构,强化门下省作为 Probe 角色的定义 |
| `gateways/.../spawning-guide.md` | Sub-agent 派遣流 | `references/orchestrator-ops.md` | 合并至 Orchestrator Guardrails |
| `gateways/.../best-practices.md` | 编排防线 | `references/orchestrator-ops.md` | 合并,作为 Layer 2 防线 |
| `gateways/.../TPR-framework.md` | 完整执行流程纲要 | `references/tpr-execution.md` | 按四阶段重构 |
| `gateways/.../TPR-framework.md` | 洞察阶段、各种工具包 | `references/tpr-cognitive.md` | 拆出思维方式,并强化为 T/P/R 认知模型 |
| `gateways/.../TPR-pattern.md` | 多 Agent 协作模式 16 条 | `references/multi-agent-pattern.md` | 精简保留核心规则 |
| `gateways/.../methodology/V1/manifest` | 项目分级方案 (Simple/Standard/Complex) | `references/project-grading.md` 和 `references/templates/` | 分割:判断逻辑归 grading,模板归 templates |
| `gateways/.../AGENTS.md` (旧项目级) | 方法论防线及编排者职责 | `SKILL.md` 的 Layer 1 红线及 `orchestrator-ops.md` | 从项目 workspace 踢出,收敛为 Skill 防线 |
| 各子 agent `SOUL.md` | 各省细化工作说明 | `references/tpr-cognitive.md` 及相关流程文件 | 从项目 workspace 踢出,由 Orchestrator 直接通过 Spawn 指令传递或指令自取 |
## 遗弃的概念
- 部分无用或不再推进的临时协同尝试文件(如 `bindings-guide.md` 中属于 OpenClaw 平台配置层面的琐碎细节)。
## 给使用者的建议
如果是第一次接触 TPR,请**不要**按本表去反向找旧文件。直接从 v2 的 `SKILL.md` 入口开始,根据自身的任务类型由判断矩阵决定进入“TPR 思维”或者“TPR 全流程”,然后通过指令自动或手动加载相应的文件即可。
FILE:references/battle-protocol.md
# Battle 机制与状态机
> 本文档只回答一个问题:**Battle 怎么跑。**
> 包含状态机定义、规则、触发条件和 Battle 专属红线。
---
## Battle 的认知定位
Battle 是 TPR 中 **Probe + Review 强化** 的制度化实现。
- **门下省**:承担 Probe 角色,主动挑战、证伪、补证,暴露方案盲点
- **尚书省**:接受挑战,回应、修订,用事实答复
- **编排者**:维护流程节奏,不参与 Battle 内容
Battle 的目标不是"通不通过",而是"有没有看到盲区"。
---
## Battle 触发条件
以下场景需要进入 Battle:
| # | 场景 | 说明 |
|---|------|------|
| 1 | GRV 起草完成 | 中书省 → 门下省审 GRV |
| 2 | 方案制定完成 | 尚书省方案 → 门下省审方案(白头 Battle)|
| 3 | 执行成果完成 | 尚书省执行结果 → 门下省审核 |
**不需要 Battle 的场景**:
- 极简模式项目(项目分级为 Simple)跳过 Battle
- DISCOVERY 阶段不需要 Battle
---
## Battle 状态机
```
GRV_DRAFT
↓ 提交审查
MENXI_REVIEWING
↓ 门下省给出结论
├── APPROVE → GRV_APPROVED
├── CONDITIONAL → PENDING_APPROVAL(需甲方介入)
└── REJECT → SHANGSHU_REVISING
SHANGSHU_REVISING(对应轮次 +1)
↓ 修订完成
MENXI_REVIEWING(重新审核)
若3轮内无法达成一致
→ 编排者汇总争议提交甲方裁决
```
---
## 编排者行为约束
| 当前状态 | 编排者禁止行为 |
|---------|--------------|
| MENXI_REVIEWING | 禁止向甲方汇报门下省结论 |
| SHANGSHU_REVISING | 禁止跳过尚书省修订直接汇报 |
| GRV_APPROVED | 才能向甲方汇报 Battle 结果 |
**REJECT 处理流程**:门下省 REJECT 后,编排者不得在尚书省完成修订并提交门下省重审之前,向甲方汇报门下省的结论。必须等待完整的"修订 → 重审"循环完成。
---
## 并行限制
门下省和尚书省 **不得并行运行**,必须串行:
1. 门下省先审 → 出结论
2. 若 REJECT → 尚书省修订(不并行)
3. 尚书省修订完成 → 提交门下省重审
4. 重复直到 APPROVE 或达轮次上限
---
## Battle 规则
### 轮次限制
- 最多 3 轮 Battle
- 3 轮内无法达成一致 → 汇总争议点交甲方决策
- 甲方决定:继续 Battle 3 轮,或强制推进
### 门下省行为规范
作为 Probe 的制度化承担者,门下省在 Battle 中必须严格执行**主客观分流审查 (Objective vs Strategic Judgement)**:
1. **客观类错误(确定性 Issue)**:如接口报错、交付物缺失、格式违背模板要求。查出此类问题直接将其记入项目根目录 `issues.md` 待办池并给尚书省抛出 REJECT 自动发回重写,**严禁将此类琐碎错误发给甲方请示**。
2. **主观类争议(方向性 Issue)**:如模型选型可能超支、核心架构过于超前。只有这类依靠直觉经验的争议,才允许输出在 BATTLE 审查报告中,并引发甲方的介入裁定。
3. **提出 3-5 个实质性异议**,不是仅做拼写检查的形式审查。
4. **每个异议必须**:引用具体章节、说明为什么有问题、提出具体修改建议。
5. **给出明确结论**:APPROVE / REJECT / CONDITIONAL。
6. **不做和稀泥式审查** — "总体不错但有些地方可以改进"不算审查,必须非黑即白。
### 尚书省行为规范
1. **逐条回应门下省异议**:明确接受/拒绝,给出理由
2. **接受的修改必须体现在修订版 GRV 中**
3. **拒绝的理由必须有事实/证据支撑**
### CONDITIONAL 处理
门下省给出 CONDITIONAL 时:
- 进入 PENDING_APPROVAL 状态
- 编排者将条件清单发给甲方
- 甲方决定:接受条件继续推进,或要求修订
---
## 用户介入条件
以下情况必须通知用户介入:
| 条件 | 用户行为 |
|------|---------|
| Battle 3 轮未达成一致 | 决定继续 Battle 或强制推进 |
| 门下省给出 CONDITIONAL | 决定接受条件或要求修订 |
| GRV 通过 Battle | 做最终确认 |
| 尚书省执行完成 | 确认或打回 |
---
## Battle 专属红线(Layer 3)
| # | 规则 |
|---|------|
| B1 | 门下省 REJECT 后,编排者不得在修订-重审循环完成前向甲方汇报 |
| B2 | 门下省和尚书省不得并行运行,必须串行 |
| B3 | 最多 3 轮,超过交甲方裁决 |
| B4 | CONDITIONAL 需甲方介入决策 |
| B5 | 只有 GRV_APPROVED 后才能向甲方汇报 Battle 结果 |
---
## Sub-agent 派遣模板
### 门下省(审查方)
```
task: You are 门下省 (Menxi), the critical reviewer in a TPR Battle.
Your role is Probe: actively challenge assumptions, find evidence gaps, expose blind spots.
Review the GRV document at {project}/GRV.md.
Raise 3-5 substantive objections. For each:
- Cite the specific GRV section
- Explain why it is problematic
- Propose a concrete fix
After presenting objections, report your verdict: APPROVE / REJECT / CONDITIONAL.
Write your review to {project}/battle/BATTLE-R{round}-MENXI.md
```
### 尚书省(应答方)
```
task: You are 尚书省 (Shangshu), the implementer and defender in a TPR Battle.
The GRV is at {project}/GRV.md.
Menxi has raised these objections (read {project}/battle/BATTLE-R{round}-MENXI.md).
Respond to each objection with:
- Accept (with what will change) or Reject (with evidence-based rationale)
Write your response to {project}/battle/BATTLE-R{round}-SHANGSHU.md
If you accepted changes, also update GRV.md accordingly.
```
---
*版本:2.0.0*
*创建:2026-04-07*
FILE:references/definition.md
# TPR 统一定义
> 本文档是 TPR 的正式定义,是 TPR Skill 的最高权威来源。
> 只回答一个问题:**TPR 是什么。**
---
## TPR 是什么
TPR 是一套从认知到执行的完整工作方法。
- 以 `Think / Probe / Review` 为认知引擎
- 以"三省分治 + 四阶段推进"为执行机制
- 用于把复杂问题从模糊需求转化为可验证、可执行、可复盘的结果
一句话概括:
> **TPR = T/P/R 认知闭环 + 三省四阶段执行框架**
---
## T / P / R 的定义
### T = Think — 定义问题
不是"拍脑袋想方案",而是先定义问题。
**关注点**:
- 这个问题本质上是什么
- 目标是什么,不是什么
- 成功标准是什么
- 约束和边界是什么
- 当前关键假设是什么
**产出**:
- 问题定义
- 目标与边界
- 成功标准
- 假设清单
### P = Probe — 探索验证
不是泛泛调研,而是针对关键假设做探索和验证。
**关注点**:
- 事实和证据是什么
- 缺什么信息
- 有哪些依赖、约束、风险
- 哪些地方只是猜测,哪些已经被验证
**产出**:
- 调研证据
- 风险清单
- 待确认问题
- 事实/假设区分
### R = Review — 决策收敛
不是简单复盘,而是基于 T 和 P 做决策收敛。
**关注点**:
- 哪些判断成立,哪些不成立
- 哪条路径最可行
- 需要明确放弃什么
- 最终结论如何进入执行
**产出**:
- 决策结论
- 取舍说明
- 最终建议
- 执行要求
- 变更记录
---
## TPR 的两层结构
### 认知层:T/P/R 闭环
解决"怎么想清楚"。
任何 agent 都可以使用,不需要 sub-agent 能力,不需要三省角色分工。遇到复杂问题时,按 T → P → R 顺序思考并产出结论。
### 执行层:三省四阶段
解决"怎么把想清楚的东西组织起来并做出来"。
需要编排型 agent(能 spawn sub-agent),通过三省分工(中书/门下/尚书)和四阶段流转(DISCOVERY → GRV → Battle → Implementation)完成从需求到交付的全过程。
### 两层的关系
- T/P/R 是认知内核,三省四阶段是执行外壳
- 执行层的每个阶段都嵌入了 T/P/R
- 没有执行层,T/P/R 依然有效(作为思维方法)
- 没有认知层,三省四阶段只是机械流程
---
## T/P/R 如何嵌入四阶段
| 阶段 | 认知重心 | 说明 |
|------|---------|------|
| DISCOVERY | T + P | 定义问题 + 摸清事实 |
| GRV | R | 收敛成契约化方案 |
| Battle | P + R | 暴露盲点 + 修订结论 |
| Implementation | 微型 T/P/R | 边做边验证边修正 |
---
## 角色与 T/P/R 的映射
| 角色 | 职责 | 与 T/P/R 的关系 |
|------|------|----------------|
| 编排者 | 维护 T/P/R 节奏,协调三省流转 | 不替代任何省级职责 |
| 中书省 | 把 Think 的问题定义与 Review 的决策收敛文档化 | 重点体现在 GRV 形成 |
| 门下省 | 承担 Probe 的制度化角色 | 挑战、证伪、补证,暴露方案漏洞 |
| 尚书省 | 在实现中运行微型 T/P/R | 将 Review 结论变成实际交付物 |
门下省不再只是"挑刺者",而是 TPR 中 Probe 的正式承担者。
---
## TPR 的五条产品原则
1. **TPR 只有一个定义源:Skill**
2. **TPR Agent 不能再单独维护一套平行理论**
3. **Agent 只保留本地运行规则,不保留通用 TPR 方法论**
4. **所有正式 TPR 交付物都必须能追溯到 T / P / R**
5. **三省四阶段是 TPR 的执行机制,不再被当作 TPR 的全部**
---
*版本:2.0.0*
*创建:2026-04-07*
FILE:references/grv-standard.md
# GRV 契约格式标准
> 本文档只回答一个问题:**GRV 怎么写。**
---
## GRV 是什么
GRV = Goal / Result / Variables
- **G(Goal)**:基于洞察报告的"真正问题"来定义目标
- **R(Result)**:可量化、可验收的成果
- **V(Variables)**:真实存在的工作举措
GRV 是项目的契约化方案。DISCOVERY 是"想清楚",GRV 是"写下来"。
---
## GRV 必含要素
| # | 要素 | 说明 |
|---|------|------|
| 1 | 目标(G) | 要解决什么问题 / 方向是什么 |
| 2 | 成果(R) | 可衡量的交付物 + 验收标准 |
| 3 | 举措(V) | 具体但可再拆的工作项 |
| 4 | 约束条件 | 时间/资源/技术/合规限制 |
| 5 | 风险 | 已识别风险 + 等级 + 应对措施 |
| 6 | 里程碑 | 关键节点和时间预期 |
| 7 | 验收标准 | 怎么判断每个成果做好了 |
---
## GRV 完整格式
```markdown
# 项目契约(GRV)
- 项目编号:TPR-YYYYMMDD-NNN
- 文档编号:P-GRV-01
- 创建时间:[ISO 时间]
- 责任 Agent:中书省
## G (Goal) - 目标
[基于 DISCOVERY.md 的"真正问题"来定义]
[一句话说清楚要解决什么]
## R (Result) - 成果
### 成果 1
- 成果描述:[要得到什么结果]
- 验收标准:
- 数量维度:[具体指标]
- 质量维度:[具体标准]
- 进度维度:[时间要求]
### 成果 2
...
## V (Variables) - 关键举措
| 编号 | 举措名称 | 对应成果 | 责任 Agent |
|------|---------|---------|-----------|
| A001 | [举措 1] | R001 | [Agent] |
| A002 | [举措 2] | R001 | [Agent] |
## 约束条件
- 时间约束:[描述]
- 资源约束:[描述]
- 技术约束:[描述]
## 风险
| 风险 | 等级 | 应对措施 |
|------|------|---------|
| [风险 1] | [高/中/低] | [措施] |
## 里程碑
| 里程碑 | 预期时间 | 标志 |
|--------|---------|------|
| [里程碑 1] | [时间] | [完成标志] |
```
---
## 写作规则
### G(目标)
- ✅ 基于洞察报告的"真正问题"而非甲方原话
- ✅ 一句话说清楚方向
- ❌ 不写成"建设一个系统"(这是手段不是目标)
- ❌ 不写成口号(如"提升效率")
### R(成果)
- ✅ 关键成果与衡量标准不可拆分
- ✅ 每个成果是可验收的交付物
- ✅ **强制量化基线(Baseline)**:验收标准必须包含执行层可自行检测的客观 Metrics(例如:必须输出的固定字段、是否包含图表、文件类型约束等),以此作为自查基准,彻底斩断大模型的"假成功"。
- ❌ 严禁写成"完成调研"或"提交一份高质量报告"等无法用代码通过/打回的模糊主观词汇
- ❌ 成果不能超过甲方确认的范围
### V(举措)
- ✅ 必须像"一件真实存在的工作"
- ✅ 具体但可再拆
- ❌ 不写口号式表述(如"优化流程")
- ❌ 不写成 SOP 或项目计划
- ❌ 不写成"每日复盘"(这是管理动作,不是交付工作)
---
## 好坏对比
### 目标(G)
```
❌ 坏:建设一套量化交易系统
✅ 好:让甲方在不盯盘的情况下,自动捕捉交易信号并执行,降低因"看到但没操作"造成的机会损失
```
### 成果(R)
```
❌ 坏:完成交易策略调研评级(太主观无法自动验)
✅ 好:产出《交易策略可行性评估.md》,文本字数要求 > 500字,且必须包含结构化的【回报对照表】。表中必须强制包含年化收益率、最大回撤、夏普率等量化字段,如出现任何空缺项将直接触发检验失败并打回重做。
```
### 举措(V)
```
❌ 坏:优化交易流程
✅ 好:接入 XX 券商 API,实现限价单自动下单功能,延迟 < 500ms
```
---
## GRV 与 DISCOVERY 的关系
- DISCOVERY 产出的是"问题定义 + 事实 + 假设"
- GRV 产出的是"目标 + 成果 + 举措"
- GRV 的 G 必须能追溯到 DISCOVERY 的"问题重建"
- GRV 的 R 必须能追溯到 DISCOVERY 的"成功标志"
- 不允许 GRV 出现 DISCOVERY 中没有提到的新目标
---
*版本:2.0.0*
*创建:2026-04-07*
FILE:references/multi-agent-pattern.md
# 多 Agent 协作模式
> 本文档只回答一个问题:**如何设计多 Agent 协作项目的架构。**
> 来源:TPR 多 Agent 协作模式模板,基于实际项目经验总结。
---
## 架构原则
### 1. 两层架构
```
调度层(Orchestrator) → 执行层(Business Agents)
```
**调度层**:
- 永远是编排 Agent
- 只动脑,不动手
- 负责协调、调度、路由
- 不执行业务逻辑
**执行层**:
- 1-N 个业务 Agent
- 负责具体业务执行
- 接受编排 Agent 调度
- 不得直接与用户交互
### 2. 协作协议
| 阶段 | 动作 | 结果 |
|------|------|------|
| 派发 | 编排 Agent → 执行 Agent | 下发任务 |
| 执行 | 执行 Agent 处理 | 产出结果 |
| 回报 | 执行 Agent → 编排 Agent | 返回结果 |
| 确认 | 编排 Agent → 用户 | 汇报进展 |
### 3. 命名规范
```
{项目前缀}-{角色}-{功能}
```
示例:
- `tpr-orchestrator`(调度中枢)
- `quant-signal-agent`(信号评估)
- `quant-execution-agent`(执行)
---
## 固定规则(16条)
### 基础规则
1. **没有记录没有发生** — 一切工作以文件记录为唯一事实溯源
2. **两层架构** — 调度层 + 执行层,不可混合
3. **Battle 门禁** — 审核最多 3 轮,3 轮不成交用户裁决
4. **发现先于规划** — 先 DISCOVERY 再 GRV,不能跳步
5. **主动轮询兜底** — 不能干等推送通知,必须主动查询子 Agent 状态
### 交接规则
6. **文件交接与跨界检索** — Agent 之间交接以文件路径为准;鼓励启用 `enableMemorySearch` 允许下游 Agent 自动检索上游空间的上下文,杜绝把大量上下文粗暴塞进 prompt。
7. **自动流转 (Post Approval Distribution)** — 当上游节点文件获得系统或人工 Approval 后,编排者应**自动打通并派遣**下游 Agent 启动,同步在公共日志中通报流向,剥离人类作为"传话筒"的角色。
8. **接收确认** — 交接必须有明确的"接收确认"
9. **版本管理** — 文件更新必须记录版本历史
10. **输出物规范** — 每个任务完成后必须产出完成报告
### 安全规则
10. **API Key 不明文** — 使用环境变量或 .env 存储
11. **通道隔离** — 每个调度 Agent 必须有独立通道,不与其他调度 Agent 共享
### 生命周期规则
12. **Agent 生命周期** — 创建 → 激活 → 运行 → 停用
13. **SOUL.md 必备** — 每个 Agent 必须有 SOUL.md
14. **验收标准** — 每个阶段必须有明确的完成标准
### 错误处理规则
15. **错误恢复** — 执行失败记录错误返回状态,超时主动轮询
16. **消息格式统一** — task/input/output/status/agent/timestamp
---
## Agent 类型定义
| 类型 | 描述 | 示例 |
|------|------|------|
| dispatcher | 调度中枢,只协调不执行 | orchestrator |
| command | 指令触发,执行具体业务 | drafting-agent |
| always_on | 持续运行,监控或监听 | review-agent |
| internal | 内部调用,不直接交互 | 子任务 |
---
## SOUL.md 模板
```markdown
# SOUL.md — {Agent名称}
## 基本信息
- 项目:[项目名称]
- 角色:[dispatcher / command / always_on / internal]
## 职责
- 主要职责:[描述]
- 边界:[不做什么]
## 协作协议
- 接收来自:[谁]
- 发给:[谁]
- 触发条件:[什么时候启动]
## TPR 模式
- 遵循 tpr-framework skill 规范
- 层级:[调度层 / 执行层]
```
---
## 交接规范
```markdown
## 交接单
- 任务:[任务描述]
- 输入:[输入内容/文件路径]
- 产出文件:[输出文件路径]
- 状态:[pending / done / failed]
- 接收确认:[ ] 已接收
```
---
## GRV 中的 Agent 架构章节模板
在多 Agent 项目的 GRV 中,必须包含以下内容:
```markdown
## Agent 体系架构
### 架构总览
[调度层 + 执行层架构图]
### Agent 定义
| Agent | 类型 | 职责 | 触发 |
|-------|------|------|------|
| {name} | dispatcher | 调度中枢 | always_on |
| {name} | command | 具体执行 | command |
### 通信矩阵
| 源 | 目标 | 流向 | 格式 |
|----|------|------|------|
| ... | ... | ... | ... |
```
---
## 设计检查清单
设计多 Agent 项目时,逐项检查:
- [ ] 主入口是编排 Agent(dispatcher)?
- [ ] 编排 Agent 只动脑不动手?
- [ ] 执行 Agent 不直接与用户交互?
- [ ] Agent 名字加了项目前缀?
- [ ] 调度层和执行层分离?
- [ ] 协作流程清晰?
- [ ] Battle 机制已定义?
- [ ] 交接规范已定义?
- [ ] 错误处理协议已定义?
- [ ] 安全规范已定义?
- [ ] 先 DISCOVERY 再 GRV?
- [ ] 验收标准已定义?
- [ ] SOUL.md 已包含?
- [ ] 通道隔离已确认?
---
## 与 TPR 角色的映射
| TPR 角色 | 多 Agent 项目角色 |
|----------|-----------------|
| 编排 Agent | 调度中枢(Orchestrator) |
| 中书省 | 方案起草 Agent |
| 门下省 | 审核/Battle Agent |
| 尚书省 | 执行 Agent |
---
*版本:2.0.0(基于 v1.1 升级)*
*创建:2026-04-07*
FILE:references/orchestrator-ops.md
# 编排操作手册
> 本文档只回答一个问题:**编排者在 TPR 全流程中怎么操作。**
> 包含执行透明规则、模型策略、上下文管理、sub-agent 派遣、自改进规范。
> 这是 Layer 2(Orchestrator Guardrails)的详细版。
---
## Orchestrator Guardrails(编排防线)
以下规则仅当 agent 作为编排者运行 TPR 全流程时适用。
| # | 防线 | 说明 |
|---|------|------|
| G1 | 只调度不动手 | 编排者不执行业务逻辑(代码/文档/分析) |
| G2 | 不冒充省级角色 | 编排者不是中书/门下/尚书,不代替它们工作 |
| G3 | 不替代省级回答问题 | 如果问题属于某省,spawn 该省来回答 |
| G4 | Announce-then-act | 说"创建X"的消息必须包含实际 tool call |
| G5 | yield after spawn | spawn 后立即 yield,不同步等待 |
| G6 | 文件锁 | 不并行 spawn 写同一文件的 sub-agent |
| G7 | 文件必须发送 | 写完文件后必须通过消息发送给用户 |
| G8 | 模型降级不接手 | model 429 时用备选模型重派 sub-agent,不自己做 |
---
## 执行透明规则
防止最常见的失败模式:编排者宣布了一个动作但从未真正执行。
### 必须遵守
1. **Announce-then-act(同消息)**:说"spawning X"的消息必须包含实际 spawn tool call,不允许"先说下一条再做"
2. **Notify on spawn**:spawn 成功后立即通知用户:"Started: [任务名]([模型]),预计 X 分钟"
3. **yield after spawn**:spawn 后立即调用 `sessions_yield`,关闭当前 turn
4. **Report on result**:sub-agent 返回结果后,立即用自然语言汇报:做了什么、产出在哪里、发现了什么问题。**⚠️ 核心规则:如果产出的是正式交付物(如 DISCOVERY.md、GRV.md、REVIEW.md),编排者在汇报时必须调用发文件工具将实际文档发送给用户。严禁在聊天消息中大段粘贴或"完整打印"文件内容!聊天窗口只用来写简短介绍,看全文必须通过系统下发真实的文件附件。**
### 反面案例
```
❌ 错误:
Turn 1: "I will now spawn Shangshu to implement X."
[no tool call]
Turn 2: [waiting for user to say something]
✅ 正确:
Turn 1: "Starting: X implementation (MiniMax, ~2 min). Will notify you when done."
[spawn tool call]
[sessions_yield]
```
### 注意力保护 (Attention Protection) 与 异步反馈池
人类甲方的注意力是整个系统中**最昂贵的资源**。编排者必须贯彻以下原则:
1. **拦截琐碎反馈**:如果甲方在聊天单向抛出“这个格式似乎不统一”、“标题名拼错了”等细碎微调意见,编排者**严禁当场打断主线工作流立刻挂起重跑**。
2. **异步记入待办池**:编排者应回复一句简短的“收到,已记录”,并将这些碎片的 Feedback 缓存至业务工作区的 `issues.md` 或全局 `Task Board` 中。
3. **集中清理**:等到当前阶段执行自然结束走入清理环节,或者准备向审查方交货时,再统一打包让尚书省去消化。这真正实现了将协作模式从“同步微操响应”跃迁至“保护老板心智”。
---
## 模型策略(Hermes 原则)
### 模型选择顺序
`preferred` > `primary` > `fallback` > `provider default`
### 429 处理
- 不要自己接手执行
- 立即用 Tier-2 模型重试
- 每个 sub-agent 理想情况下应预定义 fallback 模型
### 具体模型配置
模型配置不属于 skill,属于 workspace。
各 agent 在自己的 `config/` 或 `AGENTS.md` 中声明模型配置。
---
## 上下文管理
### 核心原则
**不要把长上下文塞进 task 参数。用文件传递,sub-agent 按需读取。**
### 正确做法
1. `enableMemorySearch` 寻址:直接在 prompt 里给 Agent 指明:“你去上游/中书省的脑区搜索并读取最近的工作方案”。
2. 将上下文写入文件(如 `temp/context-{id}.md`)
3. 在 task 里只写文件路径和读取指令
4. 告诉 sub-agent 什么时候读、为什么读
```
✅ 正确:
task: You are 门下省.
Read the GRV at {path}/GRV.md before starting.
Raise objections and write to {path}/battle/BATTLE-R1-MENXI.md.
❌ 错误:
task: You are 门下省. Here is the full GRV: [粘贴 200 行文档...]
```
### Pitch File Reads
在 task 描述中明确告诉 sub-agent 什么时候读哪个文件:
- "开始前先读取 {path}/GRV.md"
- "需要时读取 self-improving/patterns.md"
---
## Sub-agent 派遣标准
### 派遣前检查清单
每次 spawn 前必须执行:
**Step 1:读取修正记录**
```
读取 self-improving/corrections.md 最后 3 条。
如有最近 24h 内的修正,在 spawn 消息中注明。
```
**Step 2:检查成功模式**
```
读取 self-improving/patterns.md 是否有相关模式可用。
```
**Step 3:文件预创建**
```
派遣会写文件的 sub-agent 前,先用 write 工具创建带占位符的目标文件。
原因:sub-agent 的 edit 工具要求文件已存在。
```
**Step 4:目录存在性**
```
验证目标目录存在,不存在则 mkdir -p。
```
**Step 5:工具声明**
```
sub-agent 任务 prompt 中必须明确说明:
- 创建新文件 → 用 write
- 修改已有文件 → 用 edit(文件已预创建时注明路径)
```
### 错误恢复
- sub-agent 报"Edit failed" → 说明 edit 作用于不存在的文件,重新派遣并修正工具说明
- 429 错误 → 立即用 Tier-2 模型重试,不自己接手
### 常规并行与拆解提速 (Multi-Subagent)
面对耗时长(如20分钟串行)的长线任务,严禁将其全塞给一个执行节点去抗:
- **主动拆解**:主 Agent 应主动将大问题分解成多个子模块
- **并行发射**:启用 `enableMultiSubagent`,同时派发 2~4 个互不阻塞、拥有独立 Sessions/记忆的 Subagent 去执行
- **限制安全锁**:最多 4 个并发,超过时等待其中一个完成以保安全
### 自驱流转 (Post Approval Distribution)
- **拒绝手动等待**:一旦上游节点输出的结果(如中书省草拟的 GRV)收到甲方通过 Approval 后,无需等甲方再发话,**编排者立刻自动启动并调度下游角色(门下/尚书省)开工**。
- **透明广播**:触发下游任务同时向群内广播进程(例如:"甲方已通过,自动流转至门下省开始审核..."),将人类纯粹置于监督位,彻底剥离"传话筒"身份。
### Read-Only First 原则
复杂任务分两个阶段:
1. **Read-only 阶段**:先派只读 agent 收集上下文
2. **Write/Execute 阶段**:再派执行 agent
避免上下文冲突。
---
## 自改进规范
### 必须维护的文件
| 文件 | 位置 | 更新频率 | 内容 |
|------|------|---------|------|
| corrections.md | workspace/self-improving/ | 每次犯错时 | 错误、修正、预防 |
| patterns.md | workspace/self-improving/ | 每周复盘 | 成功模式、失败模式 |
**这些文件属于 workspace 运行时数据,不属于 skill。**
### 触发自我反省的条件
- 编排者自己干了 sub-agent 该干的事(越界)
- sub-agent 因为上下文问题需要重新执行
- 用户明确指出任务管理有问题
### 格式
```markdown
## [日期] [问题简述]
- 错误:[发生了什么]
- 原因:[为什么发生]
- 修正:[怎么做]
- 预防:[下次怎么避免]
```
---
## Session 状态管理与知识飞轮 (Knowledge Flywheel)
系统智商能否自我攀升的核心取决于**经验写入率**。作为大脑,编排者必须执行严格的“打扫战场”协议:
- **日常写入**:任何由自身发现或甲方指出的错误,必须在自恢复完成后记录至 `self-improving/corrections.md`。
- **强制复盘钩子 (The `/reset` Hook)**:当长线项目完成,或接获甲方主动下达的 `/reset`、`/clear` 等上下文重置指令时,**编排者必须优先拦截重置操作**。用剩余的内存强制撰写一份微型复盘(什么策略被证明有效、什么坑导致了阻断),并追加进 `self-improving/patterns.md`。确保在清除对话气泡前,将经验冷凝成永久资产,彻底转动系统知识飞轮。
---
*版本:2.0.0*
*创建:2026-04-07*
FILE:references/project-grading.md
# 项目分级与流程裁剪
> 本文档只回答一个问题:**不同复杂度的项目应该怎么裁剪 TPR 流程。**
> 来源:TPR Governance V1 项目分级方案。
---
## 核心思路
不是所有项目都需要完整的三省四阶段流程。在 DISCOVERY 阶段结束后,对项目进行复杂度评估,根据评估结果选择三种执行模式之一。
---
## 评估维度
| 维度 | 简单项目 | 中等项目 | 复杂项目 |
|------|---------|---------|---------|
| 交付物数量 | ≤3 个 | 4-10 个 | >10 个 |
| 跨部门协作 | 单一团队 | 2-3 个团队 | >3 个团队或外部依赖 |
| 技术复杂度 | 成熟技术 | 部分新技术 | 大量创新/未知 |
| 时间跨度 | ≤2 周 | 2 周-2 个月 | >2 个月 |
| 风险等级 | 低风险 | 中等风险 | 高风险/合规要求 |
| 变更频率 | 需求稳定 | 可能调整 | 频繁变更 |
## 分级规则
- 6 个维度中 ≥4 个为"简单" → 极简模式
- 6 个维度中 ≥4 个为"复杂" → 完整模式
- 其他情况 → 标准模式
---
## 三种执行模式
### 完整模式(复杂项目)
| 特性 | 值 |
|------|-----|
| WBS 层级 | P-G-R-A(四层) |
| 阶段 | DISCOVERY → GRV → Battle → Implementation → Closure(5个) |
| Sub-agent | 中书省 + 门下省 + 尚书省(3个) |
| 文档数量 | 15+ |
```
01-discovery → 02-planning(完整 WBS)→ 03-battle(门下省评审)
→ 04-execution(按 WBS 执行)→ 05-closure
```
### 标准模式(中等项目)
| 特性 | 值 |
|------|-----|
| WBS 层级 | P-R-A(省略 G 层) |
| 阶段 | DISCOVERY → GRV → Review → Implementation → Closure(5个,03 简化) |
| Sub-agent | 中书省 + 尚书省(2个) |
| 文档数量 | 8-10 |
```
01-discovery → 02-planning(只到 R 层)→ 03-review(编排内部评审)
→ 04-execution → 05-closure
```
差异:
- 省略 G 层,直接 P → R → A
- R-DEF 和 R-PLAN 可以合并
- 03-battle 简化为编排 Agent 内部 review,不派生门下省
### 极简模式(简单项目)
| 特性 | 值 |
|------|-----|
| WBS 层级 | P-A(只有项目和举措) |
| 阶段 | DISCOVERY → GRV → Implementation → Closure(4个,跳过 Battle) |
| Sub-agent | 尚书省(1个) |
| 文档数量 | 3-5 |
```
01-discovery → 02-planning(一份 GRV)→ 04-execution → 05-closure
```
差异:
- 只有一份 GRV.md(包含 G、R、A)
- 跳过 03-battle 阶段
- 编排 Agent 自己写 GRV,不派生中书省
---
## 模式对比
| 特性 | 完整模式 | 标准模式 | 极简模式 |
|------|---------|---------|---------|
| WBS 层级 | P-G-R-A | P-R-A | P-A |
| 阶段数 | 5 | 5(03简化) | 4(跳过03) |
| Sub-agent 数 | 3 | 2 | 1 |
| 文档数量 | 15+ | 8-10 | 3-5 |
| 管理精度 | 高 | 中 | 低 |
| 适用项目 | 复杂 | 中等 | 简单 |
---
## 分级决策流程
### 时机
在 DISCOVERY 阶段结束后,编排 Agent 必须输出项目分级建议。
### 建议书格式
```markdown
# 项目分级建议书
## 项目基本信息
- 项目编号:TPR-YYYYMMDD-NNN
- 项目名称:[名称]
## 复杂度评估
| 维度 | 评估 | 说明 |
|------|------|------|
| 交付物数量 | [简单/中等/复杂] | [说明] |
| 跨部门协作 | [简单/中等/复杂] | [说明] |
| 技术复杂度 | [简单/中等/复杂] | [说明] |
| 时间跨度 | [简单/中等/复杂] | [说明] |
| 风险等级 | [简单/中等/复杂] | [说明] |
| 变更频率 | [简单/中等/复杂] | [说明] |
## 分级结论
建议采用:[完整/标准/极简] 模式
## 理由
1. [理由]
2. [理由]
```
### 用户确认
用户可以:同意 / 升级(改用更复杂模式)/ 降级(改用更简单模式)。
---
## 动态调整
项目执行中如果复杂度发生变化:
1. 编排 Agent 识别变化
2. 输出调整建议
3. 用户确认
4. 记录调整原因和影响范围
---
## 目录结构模板
完整的目录结构模板见 `templates/` 目录:
- `template-complex.md` — 完整模式
- `template-standard.md` — 标准模式
- `template-simple.md` — 极简模式
- `manifest.json` — 模式配置(WBS层级、阶段、必要文档)
---
*版本:2.0.0(基于 TPR Governance V1)*
*创建:2026-04-07*
FILE:references/templates/manifest.json
{
"version": "V1",
"name": "TPR Governance V1",
"description": "The first version of the TPR and TSOR integrated methodology.",
"modes": {
"complex": {
"name": "Complex Mode",
"wbs_layers": ["P", "G", "R", "A"],
"stages": ["01-discovery", "02-planning", "03-battle", "04-execution", "05-closure"],
"sub_agents": ["中书省", "门下省", "尚书省"],
"required_docs": [
"P-IDX-01_项目主索引.md",
"P-IDX-02_任务与状态索引.md",
"P-IDX-03_用户确认与决策索引.md",
"01-discovery/P-REQ-01_原始需求.md",
"01-discovery/TRANSCRIPT.md",
"01-discovery/DISCOVERY.md",
"02-planning/GRV.md",
"03-battle/BATTLE-R1-MENXI.md",
"03-battle/BATTLE-R1-SHANGSHU.md",
"03-battle/DECISION.md",
"04-execution/A-LOG-01_执行日志.md",
"04-execution/A-OUT-01_产出交付.md",
"05-closure/P-LOG-01_项目日志.md",
"05-closure/P-ACPT-01_项目验收.md"
]
},
"standard": {
"name": "Standard Mode",
"wbs_layers": ["P", "R", "A"],
"stages": ["01-discovery", "02-planning", "03-review", "04-execution", "05-closure"],
"sub_agents": ["中书省", "尚书省"],
"required_docs": [
"P-IDX-01_项目主索引.md",
"01-discovery/DISCOVERY.md",
"02-planning/GRV.md",
"03-review/REVIEW-01_内部评审记录.md",
"04-execution/A-LOG-01.md",
"04-execution/A-OUT-01.md",
"05-closure/P-ACPT-01.md"
]
},
"simple": {
"name": "Simple Mode",
"wbs_layers": ["P", "A"],
"stages": ["01-discovery", "02-planning", "04-execution", "05-closure"],
"sub_agents": ["尚书省"],
"required_docs": [
"01-discovery/DISCOVERY.md",
"02-planning/GRV.md",
"04-execution/A-OUT-01.md",
"05-closure/P-ACPT-01.md"
]
}
},
"gate_conditions": {
"planning_complete": {
"complex": "Requires GRV.md to have G, R, A definitions and Battle approval.",
"standard": "Requires GRV.md to have P and R definitions and Orchestrator review.",
"simple": "Requires GRV.md to have P, R, and A definitions."
},
"execution_complete": {
"all": "Requires A-OUT-01 to have evidence of delivery."
}
}
}
FILE:references/templates/template-complex.md
# 模式一:完整版项目模板
**版本**:V1
**适用场景**:复杂项目(多目标、多成果、高风险、长周期)
---
## 目录结构
```
TPR-YYYYMMDD-NNN/
├── P-IDX-01_项目主索引.md
├── P-IDX-02_任务与状态索引.md
├── P-IDX-03_用户确认与决策索引.md
├── 01-discovery/
│ ├── P-REQ-01_原始需求.md
│ ├── TRANSCRIPT.md
│ └── DISCOVERY.md
├── 02-planning/
│ ├── GRV.md
│ ├── G001/
│ │ ├── G001-DEF-01_目标定义.md
│ │ └── G001-PLAN-01_目标规划.md
│ └── G001/R001/
│ ├── R001-DEF-01_成果定义与验收标准.md
│ └── R001-PLAN-01_成果达成规划.md
├── 03-battle/
│ ├── BATTLE-R1-MENXI.md
│ ├── BATTLE-R1-SHANGSHU.md
│ └── DECISION.md
├── 04-execution/
│ └── G001/R001/A001/
│ ├── A001-DEF-01_举措定义.md
│ ├── A001-PLAN-01_举措执行计划.md
│ ├── A001-LOG-01_执行过程记录.md
│ └── A001-OUT-01_执行结果与交付.md
└── 05-closure/
├── P-LOG-01_项目阶段记录.md
└── P-ACPT-01_项目最终验收.md
```
---
## 核心特征
- **WBS 层级**:P-G-R-A(四层完整)
- **阶段数**:5 个(完整流程)
- **子 Agent**:3 个(中书省、门下省、尚书省)
- **文档数量**:15+ 份
- **管理精度**:高
---
## 文档模板
### P-IDX-01_项目主索引.md
```markdown
# 项目主索引
- 项目编号:TPR-YYYYMMDD-NNN
- 项目名称:[项目名称]
- 项目状态:[INIT/RUNNING/BLOCKED/DONE]
- 创建时间:[ISO 时间]
- 最近更新:[ISO 时间]
## 当前状态
- 当前阶段:[01-discovery/02-planning/03-battle/04-execution/05-closure]
- 当前节点:[具体节点编号]
- 进度:[百分比]
## 目标清单
| 编号 | 名称 | 状态 | 责任 Agent | 最近更新 |
|------|------|------|-----------|---------|
| G001 | [目标名称] | [状态] | [Agent] | [时间] |
## 关键里程碑
| 里程碑 | 计划时间 | 实际时间 | 状态 |
|--------|---------|---------|------|
| 洞察完成 | [时间] | [时间] | [完成/进行中] |
| 规划完成 | [时间] | [时间] | [完成/进行中] |
## 当前阻塞项
- [阻塞项 1]
## 当前待确认项
- [待确认项 1]
## 下一步计划
[下一步动作描述]
```
### DISCOVERY.md
```markdown
# 洞察报告
- 项目编号:TPR-YYYYMMDD-NNN
- 文档编号:P-DISCOVERY-01
- 创建时间:[ISO 时间]
- 责任 Agent:中书省
## 甲方画像
- 背景:[背景描述]
- 痛点 Top3:
1. [痛点 1]
2. [痛点 2]
3. [痛点 3]
- 说出来的诉求:[甲方原话]
- 真正的诉求:[洞察后的真实需求]
## 问题重建
- 甲方最初说的是:[原话]
- 真正要解决的是:[本质问题]
- 这个问题为什么重要:[对甲方意味着什么]
## 机会窗口
- 系统解决:[清晰定义]
- 系统不解决:[明确边界]
- 成功的标志:[如何衡量成功]
## 甲方心智模型
- 对这件事的理解程度:[评估]
- 愿意付出的代价:[评估]
- 决策风格:[评估]
```
### GRV.md
```markdown
# 项目契约(GRV)
- 项目编号:TPR-YYYYMMDD-NNN
- 文档编号:P-GRV-01
- 创建时间:[ISO 时间]
- 责任 Agent:中书省
## G (Goal) - 目标
[项目总目标描述]
## R (Result) - 成果
| 编号 | 成果名称 | 验收标准 | 责任 Agent |
|------|---------|---------|-----------|
| R001 | [成果 1] | [标准] | [Agent] |
| R002 | [成果 2] | [标准] | [Agent] |
## V (Variables) - 关键变量/举措
| 编号 | 举措名称 | 对应成果 | 责任 Agent |
|------|---------|---------|-----------|
| A001 | [举措 1] | R001 | [Agent] |
| A002 | [举措 2] | R001 | [Agent] |
## 约束条件
- 时间约束:[描述]
- 资源约束:[描述]
- 技术约束:[描述]
## 风险
| 风险 | 等级 | 应对措施 |
|------|------|---------|
| [风险 1] | [高/中/低] | [措施] |
```
---
## 使用说明
1. **初始化项目时**:按此模板创建完整目录结构
2. **填写文档时**:按模板结构填写,保留所有必填字段
3. **新增节点时**:按编号规则创建新目录和文档
4. **文档命名**:严格按照 `[节点编号]-[文档类型]-[序号]_[描述].md` 格式
---
**模板版本**:V1
**最后更新**:2026-03-26
FILE:references/templates/template-simple.md
# 模式三:极简版项目模板
**版本**:V1
**适用场景**:简单项目(单一成果、1-3 天完成、低风险、无外部依赖)
---
## 目录结构
```
TPR-YYYYMMDD-NNN/
├── 01-discovery/
│ └── DISCOVERY.md
├── 02-planning/
│ └── GRV.md
├── 04-execution/
│ └── A-OUT-01_最终交付物.md
└── 05-closure/
└── P-ACPT-01.md
```
---
## 核心特征
- **WBS 层级**:P-A(极简)
- **阶段数**:4 个(跳过 03-battle)
- **子 Agent**:1 个(仅尚书省执行)
- **文档数量**:3-5 份
- **管理精度**:低
---
## 文档模板
### DISCOVERY.md
```markdown
# 洞察报告(极简版)
- 项目编号:TPR-YYYYMMDD-NNN
- 创建时间:[ISO 时间]
## 用户诉求
[一句话描述用户真正想要什么]
## 成功标准
[如何判断完成]
```
### GRV.md
```markdown
# 项目契约(GRV)
- 项目编号:TPR-YYYYMMDD-NNN
- 创建时间:[ISO 时间]
## 目标
[项目目标]
## 成果
[要交付什么]
## 举措
1. [步骤 1]
2. [步骤 2]
3. [步骤 3]
## 时间
- 开始:[时间]
- 完成:[时间]
```
### A-OUT-01_最终交付物.md
```markdown
# 最终交付物
- 项目编号:TPR-YYYYMMDD-NNN
- 交付时间:[ISO 时间]
## 交付内容
[具体交付了什么]
## 使用说明
[如何使用]
## 备注
[其他说明]
```
### P-ACPT-01.md
```markdown
# 项目验收
- 项目编号:TPR-YYYYMMDD-NNN
- 验收时间:[ISO 时间]
## 验收结论
[通过/不通过]
## 验收说明
[简要说明]
```
---
## 使用说明
1. **跳过 03-battle**:不需要评审阶段。
2. **最少文档**:只保留 4 份核心文档。
3. **快速交付**:适合 1-3 天的小任务。
4. **编排 Agent 自己完成**:不派生子 Agent(除了执行阶段的尚书省)。
---
**模板版本**:V1
**最后更新**:2026-03-26
FILE:references/templates/template-standard.md
# 模式二:简化版项目模板
**版本**:V1
**适用场景**:中等项目(单一目标、3-5 个成果、中等风险)
---
## 目录结构
```
TPR-YYYYMMDD-NNN/
├── P-IDX-01_项目主索引.md
├── 01-discovery/
│ └── DISCOVERY.md
├── 02-planning/
│ ├── GRV.md
│ └── R001/
│ └── R001-PLAN-01_成果达成规划.md
├── 03-review/
│ └── REVIEW-01_内部评审记录.md
├── 04-execution/
│ └── R001/
│ ├── A001-LOG-01.md
│ └── A001-OUT-01.md
└── 05-closure/
└── P-ACPT-01.md
```
---
## 核心特征
- **WBS 层级**:P-R-A(省略 G 层)
- **阶段数**:5 个(03 简化为内部评审)
- **子 Agent**:2 个(中书省、尚书省)
- **文档数量**:8-10 份
- **管理精度**:中
---
## 文档模板
### P-IDX-01_项目主索引.md
```markdown
# 项目主索引
- 项目编号:TPR-YYYYMMDD-NNN
- 项目名称:[项目名称]
- 项目状态:[INIT/RUNNING/BLOCKED/DONE]
- 创建时间:[ISO 时间]
## 当前状态
- 当前阶段:[阶段]
- 进度:[百分比]
## 成果清单
| 编号 | 名称 | 状态 | 最近更新 |
|------|------|------|---------|
| R001 | [成果名称] | [状态] | [时间] |
## 下一步计划
[下一步动作描述]
```
### DISCOVERY.md
```markdown
# 洞察报告(简化版)
- 项目编号:TPR-YYYYMMDD-NNN
- 创建时间:[ISO 时间]
## 核心诉求
[用户真正想要什么]
## 成功标准
[如何判断项目成功]
## 边界
- 做什么:[范围]
- 不做什么:[边界]
```
### GRV.md
```markdown
# 项目契约(GRV)
- 项目编号:TPR-YYYYMMDD-NNN
- 创建时间:[ISO 时间]
## 目标
[项目总目标]
## 成果清单
| 编号 | 成果名称 | 验收标准 |
|------|---------|---------|
| R001 | [成果 1] | [标准] |
| R002 | [成果 2] | [标准] |
## 时间计划
- 开始时间:[时间]
- 完成时间:[时间]
```
### R001-PLAN-01_成果达成规划.md
```markdown
# 成果达成规划
- 成果编号:R001
- 成果名称:[名称]
- 创建时间:[ISO 时间]
## 验收标准
[具体标准]
## 举措清单
| 编号 | 举措名称 | 预计时间 |
|------|---------|---------|
| A001 | [举措 1] | [时间] |
| A002 | [举措 2] | [时间] |
## 风险
- [风险 1]
- [风险 2]
```
### REVIEW-01_内部评审记录.md
```markdown
# 内部评审记录
- 项目编号:TPR-YYYYMMDD-NNN
- 评审时间:[ISO 时间]
- 评审人:编排 Agent
## 评审结论
[通过/需修改/不通过]
## 评审意见
- [意见 1]
- [意见 2]
## 修改要求
- [要求 1]
- [要求 2]
```
---
## 使用说明
1. **省略 G 层**:直接从 P 到 R
2. **合并文档**:R-DEF 和 R-PLAN 合并为一份
3. **简化评审**:03-review 由编排 Agent 内部完成,不派生门下省
4. **快速交付**:适合 2 周到 1 个月的项目
---
**模板版本**:V1
**最后更新**:2026-03-26
FILE:references/tpr-cognitive.md
# T/P/R 认知方法
> 本文档只回答一个问题:**怎么做 T/P/R。**
> 不是理念介绍,而是可执行协议。每一环都有最小产出格式。
---
## 速记模板
任何场景下,T/P/R 的最小产出格式:
```
T: 我们正在解决 _______________(一句话问题定义)
成功标准是 _______________
关键假设是 _______________
P: 已确认 _______________
未确认 _______________
主要风险 _______________
R: 结论是 _______________
不做 _______________
下一步 _______________
```
**没有产出,就没有做过。**
---
## Think — 定义问题
### 核心问题清单
做 Think 时,必须回答以下问题:
1. **这个问题本质上是什么?**
- 不是甲方说的第一句话,而是挖掘背后的真正问题
- 甲方说"我要一个量化系统",真正问题可能是"我需要一个不用盯盘就能执行交易的系统"
2. **目标是什么?**
- 清晰、可衡量、有时间边界
3. **非目标是什么?**
- 明确不做什么,防止范围蔓延
4. **边界和约束是什么?**
- 时间约束、资源约束、技术约束、合规约束
5. **成功标准是什么?**
- 怎么判断做完了、做好了
6. **关键假设是什么?**
- 列出所有"我们认为是对的但还没验证"的判断
### Think 的最小产出
| 字段 | 必须/可选 | 说明 |
|------|----------|------|
| 问题定义 | 必须 | 一句话说清楚本质问题 |
| 目标 | 必须 | 要达成什么 |
| 非目标 | 必须 | 明确不做什么 |
| 边界与约束 | 必须 | 时间/资源/技术/合规等限制 |
| 成功标准 | 必须 | 怎么判断做完了、做好了 |
| 关键假设 | 必须 | 列出未验证但影响决策的假设 |
### Think 的常见陷阱
- ❌ 跳过problem definition直接给solution
- ❌ 目标写成"做一个系统"而不是"解决什么问题"
- ❌ 把甲方说的第一句话当作问题定义
- ❌ 忘记列假设,导致 Probe 阶段不知道该验证什么
---
## Probe — 探索验证
### 探索工具箱
Probe 不是泛泛搜索,而是**针对 Think 阶段列出的假设进行验证**。以下工具按场景选用:
#### 5 Why(连续追问)
**用途**:当问题定义还不够深入时,用来挖掘根因。
```
甲方:我想做量化交易
你:为什么想做量化交易?
甲方:因为工作忙没时间看盘
你:为什么没时间看盘会让你困扰?
甲方:因为错过买卖点会亏钱
你:亏钱这件事最让你难受的是什么?
甲方:不是亏钱本身,是事后后悔"明明看到了为什么没操作"
→ 根因:需要的不是量化系统,是把"看到的机会变成行动"的系统
```
**注意**:5 Why 跨越 Think 和 Probe 的边界 — 它既帮助定义问题(Think),又帮助验证假设(Probe)。在 DISCOVERY 阶段通常同时扮演两个角色。
#### 同理心地图
**用途**:理解甲方/用户的真实处境和情绪。
```
甲方在经历什么?
- 看盘时:焦虑、犹豫、错过
- 持仓时:担心、不确定
- 止损时:后悔、不甘
甲方在想什么?
- "我知道这支股票会涨,但没买"
- "我早该卖掉的"
甲方真正想要的是:
- 安心(不用时刻盯着)
- 自信(有人/系统帮他做决策)
- 后悔最小化
```
#### 第一性原理拆解
**用途**:把复杂问题拆解到最基本的组成要素。
```
甲方目标:赚钱
→ 赚钱的障碍是什么?
→ 错误决策
→ 原因:信息不完备 + 情绪干扰 + 执行不及时
→ 解决路径:
→ 信息完备 → 数据系统
→ 减少情绪 → 规则化交易
→ 及时执行 → 自动化下单
```
#### 事实/假设分离
**用途**:把已知事实和未验证假设明确分开。
```
事实(有证据):
- 甲方每天只有30分钟看盘
- 过去3个月因错过信号亏损约15%
假设(待验证):
- 自动化交易能降低亏损
- 甲方愿意接受模型决策而非自己判断
- 现有 API 能满足延迟要求
```
### Probe 的最小产出
| 字段 | 必须/可选 | 说明 |
|------|----------|------|
| 已确认事实 | 必须 | 有证据支撑的结论 |
| 已否定假设 | 可选 | Think 阶段的假设被证伪的部分 |
| 未确认项 | 必须 | 还没有证据的关键问题 |
| 风险清单 | 必须 | 已识别的风险 + 等级 |
| 依赖项 | 可选 | 外部依赖、前置条件 |
| 证据来源 | 必须 | 每个事实的来源(文档/数据/访谈/实验) |
### Probe 的常见陷阱
- ❌ 只确认了支持自己结论的证据(确认偏误)
- ❌ 把假设当事实用
- ❌ 没有记录证据来源
- ❌ 搜集了大量信息但没有回应 Think 阶段的假设清单
---
## Review — 决策收敛
### 决策检查清单
做 Review 时,必须回答以下问题:
1. **Think 阶段的假设,哪些成立了?哪些不成立?**
2. **基于 Probe 的事实,最可行的路径是什么?**
3. **需要明确放弃什么?为什么?**
4. **结论如何进入执行?谁做、做什么、交付什么、什么时候交付?**
5. **什么条件下需要重新 Review?**
### Review 的最小产出
| 字段 | 必须/可选 | 说明 |
|------|----------|------|
| 决策结论 | 必须 | 最终结论是什么 |
| 取舍说明 | 必须 | 为什么选这条路,放弃了什么 |
| 不做什么 | 必须 | 明确排除项(防范围蔓延) |
| 执行要求 | 必须 | 结论如何落地 |
| 复查点 | 可选 | 什么条件下需要重新 Review |
| 变更记录 | 可选 | 相比上一版 Review 改了什么 |
### Review 的常见陷阱
- ❌ 只给结论不给取舍理由
- ❌ 不明确"不做什么",导致范围蔓延
- ❌ 结论无法落地(没有执行要求)
- ❌ Review 完没有人/系统跟进执行
---
## 微型 T/P/R 协议
### 用途
Implementation 阶段的持续校正机制。避免"只执行,不校正"。
### 触发时机
满足任一即触发:
1. **关键里程碑完成后**
2. **sub-agent 返回结果与预期不一致时**
3. **进入下一阶段前**
4. **出现风险升级或范围变更时**
### 不触发的场景
- 常规任务按预期完成
- 简单查询或信息获取
- 已在当日做过微型 T/P/R 且无新情况
### 产出格式(限 3 行)
```
T: 本轮解决的核心问题是 _______________
P: 新发现的事实/偏差是 _______________
R: 是否调整后续动作 _______________(是→具体调整 / 否→理由)
```
### 产出位置
写入执行日志或 `self-improving/corrections.md`。
---
*版本:2.0.0*
*创建:2026-04-07*
FILE:references/tpr-execution.md
# 三省四阶段执行框架
> 本文档只回答一个问题:**怎么把 T/P/R 组织成三省四阶段来执行。**
> 不重复 T/P/R 的定义(见 `tpr-cognitive.md`),不重复 TPR 是什么(见 `definition.md`)。
---
## 四阶段概览
| 阶段 | 认知重心 | 产出 | 核心角色 |
|------|---------|------|---------|
| DISCOVERY | T + P | DISCOVERY.md | 编排 + 中书省 |
| GRV | R | GRV.md | 中书省 |
| Battle | P + R | BATTLE-*.md + DECISION.md | 门下省 + 尚书省 |
| Implementation | 微型 T/P/R | output/* | 尚书省 |
---
## 完整 12 步流程
```
第一步:编排 Agent 与用户深度访谈
↓
产出 TRANSCRIPT.md + DISCOVERY.md(洞察报告)
↓
第二步:中书省起草 GRV
↓
第三步:门下省 Battle(审 GRV)
↓
第四步:用户决策(通过/打回)
↓
第五步:尚书省制定方案
↓
第六步:门下省审方案(白头 Battle)
↓
第七步:用户确认方案
↓
【如果需要实施,进入第八步】
第八步:尚书省真实执行
↓
第九步:门下省审核(白头 Battle)
↓
第十步:中书省复核
↓
第十一步:编排 Agent 最终确认 + 给建议
↓
第十二步:用户最终确认/打回(可打回到任意节点)
```
---
## DISCOVERY 阶段(T + P)
### 目标
把问题定义清楚,把事实摸清楚。不急着给方案。
### 认知映射
- **Think**:定义问题本质、目标、边界、假设
- **Probe**:用洞察工具(5 Why、同理心地图、第一性原理)验证假设、摸清事实
### 流程
```
甲方描述痛点/愿景
↓
编排 Agent 与甲方深度对话(主 session,持续)
↓
对话完成 → 编排 Agent 整理聊天记录 → TRANSCRIPT.md
↓
编排 Agent 将 TRANSCRIPT.md 发给甲方确认
↓
编排 Agent 将 TRANSCRIPT.md 发给中书省(子 Agent)
↓
中书省基于完整 TRANSCRIPT.md 输出 DISCOVERY.md
↓
编排 Agent 将 DISCOVERY.md 发给甲方确认
↓
甲方确认洞察报告 → 进入 GRV 阶段
```
### DISCOVERY.md 必含内容
1. **甲方画像** — 背景、痛点 Top3、说出的诉求 vs 真正的诉求
2. **问题重建** — 甲方最初说的是什么?真正要解决的是什么?为什么重要?
3. **机会窗口** — 系统解决什么?不解决什么?成功的标志是什么?
4. **甲方心智模型** — 理解程度、付出意愿、决策风格
### 洞察原则
1. **先听,再问,不急着给方案** — 第一个需求往往不是真需求
2. **用"为什么"层层深入** — 连续 5 次 Why 挖根因
3. **找痛点,不是找需求** — 痛点是情绪,需求是方案
4. **敢于否定甲方** — 好顾问帮甲方想清楚该做什么
5. **超出预期是底线** — 甲方没想到的,你要想到
### 验收标准
- 甲方确认洞察报告准确反映真实想法
- 甲方认可"问题重建"的表述
- 洞察报告被甲方认可后,才能进入 GRV 阶段
---
## GRV 阶段(R)
### 目标
把 DISCOVERY 的结果收敛成契约化方案。
### 认知映射
- **Review**:基于 DISCOVERY 的 T/P 产出,做决策收敛,形成可执行契约
### 流程
```
中书省基于 DISCOVERY.md 起草 GRV
↓
中书省与甲方深度交流(每次一个话题,不一次性抛问题)
↓
交流原则:先诊断再开方、深度讨论、超出预期、敢于否定
↓
所有话题讨论完毕
↓
中书省整合讨论结果,产出 GRV.md
↓
甲方最终确认 GRV → 进入 Battle
```
### GRV 格式标准
见 `grv-standard.md`。
### 中书省交流原则
1. **先诊断,再开方** — 不急着给选项,先通过提问理解真实处境
2. **深度讨论,每次一个问题** — 不一次性抛 6 个问题
3. **超出预期** — 甲方说想炒股赚钱,你给完整方案
4. **敢于否定甲方** — 不切实际的想法直接说
5. **保持开放** — 不预设结论
---
## Battle 阶段(P + R 强化)
### 目标
用门下省挑战暴露盲点,用尚书省回应完成修订。让 GRV 更稳,而不是更花哨。
### 认知映射
- **Probe**:门下省作为 Probe 的制度化承担者,主动寻找证据、挑战假设
- **Review**:每轮 Battle 后的修订都是一次 Review 收敛
### 流程与规则
见 `battle-protocol.md`。
---
## Implementation 阶段(微型 T/P/R)
### 目标
避免"只执行,不校正"。每个关键节点都能边做边验证边修正。
### 认知映射
- **微型 T/P/R**:在关键节点触发,持续校正执行方向
### 尚书省的两层含义
| 阶段 | 尚书省职责 |
|------|----------|
| 方案阶段 | 制定方案(调研/设计/文档) |
| 实施阶段 | 真实执行(代码/脚本/部署) |
有些项目只需要方案,不需要实施。
### 微型 T/P/R 触发时机
见 `tpr-cognitive.md` § 微型 T/P/R 协议。
### 执行节点自检 (Self-Verification)
作为最后一道业务承重墙,尚书省在真实执行完毕后,**严禁未验证即单方面宣布“竣工”并击鼓传花**。交出文件前必须执行自查闭环:
1. **调取 Baseline尺子**:尚书省强制回溯至 GRV 的 R 节点,提取出核心的定量/客观 Metrics。
2. **强制自验证 (Auto-Detect)**:自行用脚手架比对自身产出(例如:表格占位符是否真的填入了数、字数是否超过 500、代码脚本能跑通的 Assert 结果)。
3. **自我修复 (Auto-Fix)**:若自检项标红,尚书省必须立刻触发打回重构,在内部形成闭环,最多自我重试 3 次。只有经过了坚固的 Metrics 考验,才允许把成果发往外转接。
### 执行后跨省审核
- 门下省 vs 尚书省白头 Battle(进行更高级别的主观/方向审核)
- 中书省复核(检查流程合规、文档完整)
- 编排 Agent 最终确认 + 给建议
- 用户最终确认/打回(可打回到任意节点)
---
## 节点交接机制
1. A 节点完成后,输出写到自己的文件里(A 终止)
2. 编排 Agent 把 A 的输出物发给 B
3. B 消化后判断:能不能开始工作?
4. **B 认为可以** → 编排 Agent 通知甲方确认 → 甲方确认 → B 开始
5. **B 有疑问** → 编排 Agent 让 A 继续工作或给 B 做解释
6. **3轮后仍无法达成一致** → 编排 Agent 总结讨论,给出建议,通知甲方决策
---
## 甲方介入时机
| 时机 | 说明 |
|------|------|
| 交流阶段 | 回应中书省的问题,逐项确认/选择 |
| GRV 确认 | 中书省起草完成后,甲方最终确认 |
| Battle 裁决 | 3轮无法达成一致时,甲方决定继续还是强制推进 |
| 执行确认 | 尚书省每阶段完成后的确认(可选打回) |
| 最终确认 | 用户做最终决策,可打回到任意节点 |
**甲方只做选择题,不做填空题。**
---
## 状态定义
| 状态 | 含义 |
|------|------|
| 立项 | 项目刚创建,待启动 |
| 中书省起草 | 起草阶段进行中 |
| 门下省评审 | 评审阶段进行中 |
| 尚书省执行 | 执行阶段进行中 |
| 待放行 | 节点完成,等甲方确认 |
| 已完成 | 终态,项目结束 |
| 已终止 | 终态,项目取消 |
---
## 关键机制
**契约确认与调整**:尚书省收到任务时先确认契约,确认没问题才执行。执行中发现契约问题可随时提出,由甲方决策。
**评审轮次**:每打回一次,review_round + 1。
**超时处理**:子 Agent 超过 stallThresholdSec(默认600秒)未响应,算超时。超时后自动重试,最多 maxRetry 次。
**错误恢复**:执行失败 → 重试2次 → 仍失败 → 通知编排 Agent → 汇报甲方。
**流转记录**:每个状态变更都要记录在 flow_log 里。
**多项目管理**:项目靠编号切换(TPR-YYYYMMDD-NNN),同时只运行一个项目。
---
*版本:2.0.0*
*创建:2026-04-07*
管理工作协同中的员工查询、汇报处理、待办闭环和任务协作流程。触发词:cwork/CWork/工作协同/发送汇报/发汇报/汇报/申请/周报/待办/任务/催办/搜索员工/查收件箱。
---
name: cms-cwork-workflow
description: 管理工作协同中的员工查询、汇报处理、待办闭环和任务协作流程。触发词:cwork/CWork/工作协同/发送汇报/发汇报/汇报/申请/周报/待办/任务/催办/搜索员工/查收件箱。
skillcode: cms-cwork-workflow
github: https://github.com/xgjk/cwork-skills/tree/main/cwork-skills/cms-cwork-workflow
dependencies:
- cms-auth-skills
version: 1.0.5
tools_provided:
- name: cwork_client
category: exec
risk_level: medium
permission: exec
description: CWork API共享客户端,封装HTTP请求、认证和所有业务API方法
status: active
- name: cwork-search-emp
category: exec
risk_level: low
permission: read
description: 搜索员工信息(支持模糊查询)
status: active
- name: cwork-send-report
category: exec
risk_level: medium
permission: write
description: 发送工作协同汇报(支持附件)
status: active
- name: cwork-query-report
category: exec
risk_level: low
permission: read
description: 查询汇报(收件箱/发件箱/详情/历史)
status: active
- name: cwork-create-task
category: exec
risk_level: medium
permission: write
description: 创建工作计划/任务
status: active
- name: cwork-review-report
category: exec
risk_level: medium
permission: write
description: 审阅汇报(回复/标记已读)
status: active
- name: cwork-query-tasks
category: exec
risk_level: low
permission: read
description: 查询任务(我的/创建的/团队/详情)
status: active
- name: cwork-nudge-report
category: exec
risk_level: medium
permission: write
description: 催办闭环(识别未闭环/生成催办/发送)
status: active
- name: cwork-todo
category: exec
risk_level: medium
permission: write
description: 待办管理(查询/完成决策/建议/反馈)
status: active
- name: cwork-templates
category: exec
risk_level: low
permission: read
description: 查询汇报模板列表
status: active
- name: cwork-report-issue
category: exec
risk_level: low
permission: read
description: 自动上报问题到 GitHub Issues(需环境变量 GITHUB_TOKEN)
status: active
---
# cms-cwork-workflow
## 概述
本 Skill 将 CWork(工作协同平台)的完整 API 能力封装为 **9 个意图级编排脚本**,每个脚本独立可执行,Agent 通过 `exec python3 scripts/<name>.py` 调用,JSON 输出到 stdout、错误到 stderr。
**设计原则**:
- **Agent-First**:脚本负责 API 编排,Agent 负责 LLM 推理和用户交互
- **幂等安全**:所有写操作支持 `--dry-run` / `--preview-only`
- **零外部依赖**:纯 Python 3.9+,仅需标准库
- **强制封装**:所有 API 调用必须通过脚本,**禁止直接 HTTP/curl 调用**(脚本已内置 URL 编码、参数验证、错误转换、重试机制)
## 快速开始
### 发送汇报(标准 3 步流程)
```
1. 搜索接收人,确认 empId
python3 scripts/cwork-search-emp.py --name "张三"
2. 预览草稿(--preview-only 仅保存草稿,不发送)
python3 scripts/cwork-send-report.py \
--title "周报" --content-html "<p>内容</p>" \
--receivers "张三" --preview-only
3. 确认发送
python3 scripts/cwork-send-report.py \
--title "周报" --content-html "<p>内容</p>" \
--receivers "张三"
```
### 其他常用命令
```bash
# 查看未读汇报
python3 scripts/cwork-query-report.py --mode unread
# 查看待办列表
python3 scripts/cwork-todo.py list --page-size 20
# 查询我的任务
python3 scripts/cwork-query-tasks.py --mode my
```
---
## 9 个编排命令
### 0. 搜索员工 — `cwork-search-emp.py` ✨ 新增
**意图**:根据姓名/关键词搜索员工 ID 和详细信息
**使用场景**:
1. ✅ **发送汇报前确认接收人** - 确保姓名和 empId 准确
2. ✅ **处理待办时确认发件人** - 查看发件人部门/职位
3. ✅ **创建任务时确认责任人** - 避免姓名错误(重名/错别字)
4. ✅ **催办时确认责任人信息** - 获取完整的员工信息
```bash
# 基础搜索(模糊匹配)
python3 scripts/cwork-search-emp.py --name "张"
# 精确搜索
python3 scripts/cwork-search-emp.py --name "成伟"
# 详细模式(包含 personId、dingUserId 等)
python3 scripts/cwork-search-emp.py --name "刘丽华" --verbose
# 更多结果
python3 scripts/cwork-search-emp.py --name "刘" --max-results 10
# 原始 API 响应(调试用)
python3 scripts/cwork-search-emp.py --name "张" --output-raw
```
| 参数 | 必填 | 说明 |
|------|------|------|
| `--name` / `-n` | ✅ | 员工姓名或关键词(支持模糊匹配) |
| `--max-results` / `-m` | ❌ | 每个类别最多返回数量(默认 5) |
| `--verbose` / `-v` | ❌ | 包含额外信息(personId、dingUserId、corpId) |
| `--output-raw` | ❌ | 输出原始 API 响应(调试用) |
**输出格式**:
```json
{
"success": true,
"searchKey": "成伟",
"inside": [
{
"empId": "1514822118611259394",
"name": "成伟",
"title": "首席架构师",
"mainDept": "技术部",
"status": "在职"
}
],
"outside": [
{
"empId": "1897870576398327809",
"name": "成伟",
"title": "",
"mainDept": "其他",
"status": "在职",
"company": "德镁医药"
}
],
"totalInside": 1,
"totalOutside": 1
}
```
**注意事项**:
- ✅ **URL 编码已自动处理**(支持中文参数)
- ✅ **模糊匹配**:搜索"刘"会返回所有姓刘的员工
- ✅ **内外部区分**:`inside`(玄关健康员工)+ `outside`(外部联系人/其他公司)
- ⚠️ **重名问题**:可能返回多个同名员工,需要根据部门/职位区分
- 💡 **推荐用法**:发送汇报前先搜索确认 empId
---
### 1. 发送汇报 — `cwork-send-report.py`
**意图**:先**全量**保存/更新草稿(5.23,更新前会拉 5.25 详情合并,避免覆盖丢字段)→ 输出接口返回的**完整**草稿(`draftDetail`)供用户过目 → 仅在用户明确同意后加 `--confirm-send` 调用 **5.27**(`draftBox/submit/{汇报id}`)发出。
**汇报 id 与 `draftId` 字段(避免歧义)**
| 概念 | 含义 | 出现位置 |
|------|------|----------|
| **汇报 id** | 草稿对应的汇报记录主键 | 5.23 返回 `data.id`、5.25 路径与 `draftDetail.id`、5.27 路径 `{id}` |
| **草稿箱记录 id** | 草稿箱列表里一行的主键,**仅用于 5.26 删除** | 5.24 列表项的 `id`(勿与汇报 id 混用) |
**删除草稿(`cwork_client`)**:`delete_draft` 的参数必须是 **5.24 列表项的 `id`**。若只有汇报 id(与列表里的 `businessId` 相同),须调用 **`delete_draft_by_report_id(汇报id)`**;误把汇报 id 传给 `delete_draft` 时,接口可能仍返回 `true` 但列表中草稿未删(见开放 API 5.26 与 5.24 参数说明)。
脚本 stdout 里同时有根字段 **`reportId`** 与 **`draftId`**:二者**不是**两种 id,而是**同一汇报 id 的重复输出**——`draftId` **并非**开放平台文档里的字段名,而是本脚本为衔接历史参数 `--draft-id` 而保留的 JSON 键名,容易让人误以为是「草稿箱 id」。**以 `reportId` / `draftDetail.id` 为准即可**;后续步骤一律传该汇报 id(`--draft-id <汇报id>` 中的值也是它)。
```bash
# 第一步:保存草稿并输出完整预览(默认不会发出)
python3 scripts/cwork-send-report.py \
--title "周报标题" \
--content-html "<p>汇报内容</p>" \
--receivers "张三,李四" \
--grade "一般"
# 第二步:用户确认 draftDetail 全文后,仅发出(无需再传标题正文)
python3 scripts/cwork-send-report.py --draft-id "<汇报id>" --confirm-send
# 一步保存并发出(仍须显式 --confirm-send,表示已确认预览)
python3 scripts/cwork-send-report.py \
--title "周报标题" \
--content-html "<p>汇报内容</p>" \
--receivers "张三" \
--confirm-send
```
| 参数 | 必填 | 说明 |
|------|------|------|
| `--title` / `-t` | 保存草稿时 ✅ | 汇报标题(与 `--draft-id --confirm-send` 单独发出时勿传) |
| `--content-html` / `-c` | 保存草稿时 ✅ | 正文 HTML(同上) |
| `--receivers` / `-r` | ❌ | 接收人姓名;更新时若省略则沿用草稿详情中的接收人。**若本次传了姓名**且草稿已有 `reportLevelList`(且未使用 `--report-level-json`),脚本会把解析后的 empId **写回**对应节点的 `levelUserList`,与开放 API「接收人以 `reportLevelList` 为准」一致,避免仅 `summary` 显示新人而 `draftDetail` 仍为旧人 |
| `--cc` | ❌ | 抄送;更新时若省略则沿用草稿中的抄送 |
| `--grade` | ❌ | 优先级:`一般`(默认)/ `紧急` |
| `--type-id` | ❌ | 汇报类型 ID(默认 9999) |
| `--file-paths` | ❌ | 本地附件;**未传且为更新**时沿用草稿已有附件 |
| `--file-names` | ❌ | 附件显示名称 |
| `--plan-id` | ❌ | 关联任务 ID |
| `--report-level-json` | ❌ | JSON 文件路径,`reportLevelList` 数组,覆盖流程节点 |
| `--preview-only` | ❌ | 仅保存+预览;**即使带 `--confirm-send` 也不会发出** |
| `--draft-id` | ❌ | **值为汇报 id**(参数名历史沿用):更新草稿或配合 `--confirm-send` 仅执行 5.27 |
| `--confirm-send` | ❌ | **必须**在用户确认完整 `draftDetail` 后再加,才会调用 5.27 |
**流程步骤**:
1. **Resolve** — 按姓名搜索员工;本轮回填的姓名参与合并,未填则沿用 5.25 详情中的接收人/抄送
2. **Validate** — 姓名未找到或多匹配时报错终止
3. **Upload** — 若传了 `--file-paths` 则上传并作为附件;否则更新时保留原附件列表
4. **Detail(更新时)** — 若有 `--draft-id`,先 `get_draft_detail` 再与本次参数合并
5. **Draft(5.23)** — 全量 `saveOrUpdate`,返回汇报 id
6. **Preview** — 再次 `get_draft_detail`,stdout 含完整 `draftDetail`(含全文 `contentHtml`)。`summary` 含 `contentPlainText`(全文去标签)、`contentPreview`(极长纯文本时最多约 4000 字截断)、`contentPlainLength`;去标签后不足 50 字时有 `previewWarnings`。`confirmPrompt` 以纯文本展示正文(≤2000 字全文,更长截断),完整 HTML 始终以 `draftDetail.contentHtml` 为准
7. **Submit(5.27)** — 仅当 `--confirm-send` 且非 `--preview-only` 时 `submit`;**不要**再用 5.1 无 id 提交,以免产生重复汇报与孤儿草稿
---
### 2. 查询汇报 — `cwork-query-report.py`
**意图**:收件箱 / 发件箱 / 未读 / 汇报详情 / 节点详情 / **历史上下文检索** ✨ 新增
```bash
# 收件箱(默认)
python3 scripts/cwork-query-report.py --mode inbox --page-size 20
# 未读汇报
python3 scripts/cwork-query-report.py --mode unread --page-size 20
# 发件箱
python3 scripts/cwork-query-report.py --mode outbox
# 单条汇报详情(含回复链)
python3 scripts/cwork-query-report.py --mode detail --report-id <id>
# 节点详情(含审批/建议/反馈状态与处理意见)✨ v3.1.0 新增
python3 scripts/cwork-query-report.py --mode node-detail --report-id <id>
# 历史上下文检索(审批决策支持)✨ v3.1.1 新增
# 查询发件人历史汇报
python3 scripts/cwork-query-report.py --mode sender-history \
--sender-emp-id <empId> \
--days 90
# 关键字搜索汇报(客户端过滤)
python3 scripts/cwork-query-report.py --mode keyword-search \
--keyword "公章" \
--days 90
```
| 参数 | 说明 |
|------|------|
| `--mode` | `inbox` / `outbox` / `unread` / `detail` / `node-detail` / `sender-history` / `keyword-search` / `pending` / `my-sent` |
| `--page-size` | 分页大小(默认 20) |
| `--page-index` | 页码(默认 1) |
| `--report-id` | 汇报 ID(detail / node-detail 必填) |
| `--sender-emp-id` | 发件人员工 ID(sender-history 必填) |
| `--keyword` | 搜索关键词(keyword-search 必填) |
| `--days` | 回溯天数(sender-history / keyword-search,默认 90) |
| `--report-type` | 汇报类型:1-工作交流 / 2-工作指引 / 3-文件签批 / 4-AI汇报 / 5-工作汇报 |
| `--status` | 已读状态:0=未读 / 1=已读 |
| `--start-date` / `--end-date` | 时间范围(YYYY-MM-DD) |
**输出格式**(sender-history):
```json
{
"success": true,
"data": {
"senderEmpId": "1514822194347806721",
"totalReports": 15,
"recentReports": [
{
"id": "2039993163862765570",
"main": "互联网公司公章借出申请",
"createTime": "2026-04-03 17:11:48",
"reportRecordType": 5
}
]
}
}
```
**输出格式**(keyword-search):
```json
{
"success": true,
"data": {
"keyword": "公章",
"total": 5,
"reports": [
{
"id": "2039993163862765570",
"main": "互联网公司公章借出申请",
"content": "用于办理工商变更...",
"createTime": "2026-04-03 17:11:48",
"sendEmpName": "刘丽华"
}
]
}
}
```
**输出格式**(node-detail):
```json
{
"success": true,
"data": {
"id": 汇报ID,
"main": "汇报标题",
"content": "汇报正文",
"writeEmpName": "汇报人",
"createTime": "发起时间",
"nodeList": [
{
"nodeName": "建议人",
"type": "建议",
"status": "已完成",
"level": 1,
"userList": [
{
"empId": 员工ID,
"name": "张三",
"status": "已处理",
"operate": "建议",
"content": "建议增加异常处理",
"finishTime": "2026-04-03 11:00:00"
}
]
}
]
}
}
```
---
### 3. 创建任务 — `cwork-create-task.py`
**意图**:解析人员姓名 → 创建工作计划/任务
```bash
python3 scripts/cwork-create-task.py \
--task-main "完成XXX功能" \
--content "详细描述" \
--assignee "张三" \
--deadline 2026-05-01
```
| 参数 | 必填 | 说明 |
|------|------|------|
| `--task-main` | ✅ | 任务标题 |
| `--content` | ✅ | 任务描述 |
| `--target` | ❌ | 预期目标(默认 = content) |
| `--assignee` | ❌ | 责任人姓名(自动解析 empId) |
| `--report-to` | ❌ | 汇报人姓名(不传则自动取 `--assignee` 的值;API 要求必填) |
| `--assistant` | ❌ | 协办人姓名(逗号分隔多人) |
| `--supervisor` | ❌ | 监督人姓名 |
| `--copy` | ❌ | 抄送人姓名(逗号分隔多人) |
| `--observer` | ❌ | 观察员姓名(逗号分隔多人) |
| `--deadline` | ❌ | 截止时间(YYYY-MM-DD 或 Unix ms,默认 7 天后) |
| `--push-now` | ❌ | 是否立即推送(true/false,默认 true)。开放 API 文档未说明 `pushNow=0` 时的额外字段;若服务端报「待办发送时间未设置」等错误,需向接口提供方确认是否另有未文档化参数或是否暂不支持延迟推送 |
| `--dry-run` | ❌ | 仅验证+解析,不创建 |
**流程步骤**:
1. 解析所有人员姓名 → empId
2. 校验必填项(task-main、content)
3. 汇总所有未匹配姓名 → 报错
4. `--dry-run` 时输出解析结果,不调用创建 API
5. 调用 `createPlan` API 创建任务
---
### 4. 审阅汇报 — `cwork-review-report.py`
**意图**:回复汇报 / 标记已读 / 查询待审汇报
```bash
# 标记已读
python3 scripts/cwork-review-report.py --mode mark-read --report-id <id>
# 回复(默认 markdown,可发内部链接;需纯 HTML 段落可加 --content-type html)
python3 scripts/cwork-review-report.py --mode reply \
--report-id <id> --reply "回复内容"
# 查询待审汇报列表
python3 scripts/cwork-review-report.py --mode pending --page-size 20
```
| 参数 | 说明 |
|------|------|
| `--mode` | `reply` / `mark-read` / `pending` |
| `--report-id` | 汇报记录 ID(reply / mark-read 必填) |
| `--reply` | 回复正文(reply 必填;默认按 markdown 原样提交,支持内部链接语法) |
| `--content-type` | `markdown`(默认)或 `html`;`html` 时将正文包成 `<p>…</p>` |
| `--at` | 回复中 @的人姓名(自动解析 empId) |
| `--page-index` | 页码(pending 模式,默认 1) |
| `--page-size` | 每页大小(pending 模式,默认 20) |
| `--report-type` | 汇报类型筛选 1-5(pending 模式可选) |
| `--dry-run` | 仅预览,不调用 API |
---
### 5. 查询任务 — `cwork-query-tasks.py`
**意图**:我的任务 / 我创建的 / 团队任务 / 任务详情(含汇报链)/ 识别逾期和未闭环
```bash
# 分配给我的任务
python3 scripts/cwork-query-tasks.py --mode my --status 1
# 我创建的任务
python3 scripts/cwork-query-tasks.py --mode created
# 下属任务
python3 scripts/cwork-query-tasks.py --mode manager --subordinate-ids "id1,id2"
# 任务详情(含汇报链路)
python3 scripts/cwork-query-tasks.py --mode detail --task-id <planId>
# 识别逾期任务
python3 scripts/cwork-query-tasks.py --mode blocked --days-threshold 7
```
| 参数 | 说明 |
|------|------|
| `--mode` | `my` / `created` / `team` / `assigned` / `detail` / `chain` / `blocked` / `unclosed` / `manager` / `nudge` |
| `--task-id` | 任务/计划 ID(detail / chain 必填) |
| `--subordinate-ids` | 下属 empId 列表(逗号分隔,manager 模式必填) |
| `--assignee` | 责任人姓名(my / assigned 模式可选,自动解析 empId) |
| `--status` | 任务状态:0=已关闭 / 1=进行中 / 2=未启动 |
| `--report-status` | 汇报状态:0=关闭 / 1=待汇报 / 2=已汇报 / 3=逾期 |
| `--key-word` | 关键词搜索 |
| `--days-threshold` | 逾期天数阈值(blocked 模式,默认 7) |
| `--page-index` | 页码(默认 1) |
| `--page-size` | 每页大小(默认 20) |
| `--dry-run` | 仅预览,不调用 API |
---
### 6. 催办闭环 — `cwork-nudge-report.py`
**意图**:列出逾期未闭环任务 / 向责任人发送催办通知
```bash
# 列出逾期未闭环任务(超过阈值天数)
python3 scripts/cwork-nudge-report.py --mode list --days-threshold 7
# 向责任人发送催办(通过 empId)
python3 scripts/cwork-nudge-report.py --mode nudge \
--emp-id <empId> \
--task-main "完成XXX功能" \
--deadline 2026-05-01 \
--content "请尽快处理"
# 通过姓名自动解析 empId 并催办
python3 scripts/cwork-nudge-report.py --mode nudge \
--assignee "张三" \
--task-main "完成XXX功能" \
--remind-style normal
```
| 参数 | 说明 |
|------|------|
| `--mode` | `list`=列出未闭环 / `nudge`=发送催办 |
| `--days-threshold` | 逾期天数阈值(list 模式,默认 7) |
| `--page-index` | 页码(list 模式,默认 1) |
| `--page-size` | 每页大小(list 模式,默认 50) |
| `--emp-id` | 催办对象 empId(nudge 必填,与 `--assignee` 二选一) |
| `--assignee` | 责任人姓名(nudge 模式,自动解析 empId) |
| `--task-main` | 任务名称(nudge 必填) |
| `--deadline` | 截止日期(YYYY-MM-DD 或 Unix ms) |
| `--content` | 催办内容描述(脚本自动构建 HTML 正文) |
| `--target` | 目标描述 |
| `--remind-style` | 催办风格:`polite`(默认,含礼貌用语)/ `normal`(简洁) |
| `--dry-run` | 仅预览,不调用 API |
---
### 7. 待办管理 — `cwork-todo.py`
**意图**:查询待办列表 / 完成待办(支持决策/建议/反馈三种类型)
**支持的三种待办类型**:
| 类型 | 英文标识 | 必填参数 | 说明 |
|------|---------|---------|------|
| **决策** | `decide` | `--operate agree/disagree` | 决策人必须明确同意或不同意 |
| **建议** | `suggest` | `--content` | 建议人提供意见或建议 |
| **反馈** | `feedback` | `--content` | 反馈人回复评论或补充信息 |
```bash
# 查询待办列表(5.15 分页结构为 PageInfo,条目见 items,字段含 todoId、reportId、main、todoType 等)
python3 scripts/cwork-todo.py list --page-size 20 --status pending
# 完成决策待办(必须指定 operate)
python3 scripts/cwork-todo.py complete \
--todo-id <id> \
--content "同意该方案,建议增加异常处理" \
--operate agree
# 完成建议待办
python3 scripts/cwork-todo.py complete \
--todo-id <id> \
--content "从技术角度看,建议采用微服务架构"
# 完成反馈待办
python3 scripts/cwork-todo.py complete \
--todo-id <id> \
--content "已补充相关数据,详见附件"
# 查看汇报详情(含节点与处理意见)
python3 scripts/cwork-query-report.py --mode node-detail --report-id <id>
```
| 参数 | 说明 |
|------|------|
| `action` | `list` / `complete` |
| `--page-index` | 页码(默认 1) |
| `--page-size` | 每页数量(默认 20) |
| `--status` | 状态筛选 |
| `--todo-id` | 待办 ID(complete 必填) |
| `--content` | 完成说明(所有类型必填) |
| `--operate` | 决策操作:`agree`(同意)/ `disagree`(不同意)(仅决策类待办需要,不传则不发送此字段) |
| `--dry-run` | 仅预览(complete 可用) |
**输出格式**(complete):
```json
{
"success": true,
"action": "complete",
"todoId": "12345",
"result": {}
}
```
---
### 8. 模板管理 — `cwork-templates.py`
**意图**:查询汇报模板列表
```bash
# 查询模板列表
python3 scripts/cwork-templates.py list --limit 50
# 带时间范围
python3 scripts/cwork-templates.py list --begin-time 1710000000000 --end-time 1712000000000
```
| 参数 | 说明 |
|------|------|
| `action` | `list` |
| `--limit` | 返回数量限制(默认 50) |
| `--begin-time` | 开始时间戳(毫秒) |
| `--end-time` | 结束时间戳(毫秒) |
| `--output-raw` | 输出原始 API 响应 |
**输出字段**:
- `id` — 模板 ID
- `name` — 模板名称
- `type` — 类型 ID
- `typeName` — 类型名称
- `grade` — 优先级
---
## 辅助工具
### 问题自动上报 — `cwork-report-issue.py`
**意图**:当脚本报错或 API 异常时,自动将问题提交为 GitHub Issue,便于追踪和修复。
**前置条件**:设置环境变量 `GITHUB_TOKEN`(详见 `references/maintenance.md`)。
```bash
# 上报一个脚本报错
python3 scripts/cwork-report-issue.py \
--title "bug: cwork-send-report.py 发送失败" \
--script cwork-send-report.py \
--error '{"success": false, "error": "API Error (200003): 流程节点类型不正确"}' \
--body "发送汇报时传入 reportLevelList,type 字段使用了中文导致报错"
# 先预览,不实际提交
python3 scripts/cwork-report-issue.py \
--title "bug: ..." \
--script cwork-query-report.py \
--error "..." \
--dry-run
```
| 参数 | 必填 | 说明 |
|------|------|------|
| `--title` / `-T` | ✅ | Issue 标题 |
| `--script` / `-s` | ❌ | 出错的脚本名称 |
| `--error` / `-e` | ❌ | 错误信息(脚本 stderr 的 JSON 输出) |
| `--body` / `-b` | ❌ | 问题描述(复现步骤等) |
| `--extra` | ❌ | 附加信息(环境、版本等) |
| `--labels` | ❌ | 额外标签(逗号分隔,默认已含 `bug` 和 `cms-cwork-workflow`) |
| `--dry-run` | ❌ | 预览将提交的内容,不实际创建 |
| `--token` | ❌ | GitHub Token(仅调试用,生产环境请用环境变量 `GITHUB_TOKEN`) |
**输出格式**:
```json
{
"success": true,
"issue_number": 42,
"issue_url": "https://github.com/xgjk/cwork-skills/issues/42",
"title": "bug: cwork-send-report.py 发送失败"
}
```
**Agent 调用建议**:
- 脚本返回 `"success": false` 且错误类型为 API 异常(非参数错误)时,询问用户是否上报
- 上报前使用 `--dry-run` 预览内容,确认无敏感信息(如 appKey、empId 等)后再提交
- `--error` 传入脚本 stderr 的原始 JSON 输出即可,脚本会自动格式化
---
## `reportLevelList` 字段格式
`cwork-send-report.py` 可通过 **`--report-level-json`** 指向 UTF-8 JSON 文件(根节点为数组),内容对应 API 字段 `reportLevelList`,用于指定建议人/决策人/传阅等节点;不传时新建草稿可为空,**更新**时默认从 5.25 详情中的 `reportLevelList` 原样转换后写回,避免全量更新被清空。每个节点结构如下:
```python
report_level_list = [
{
"level": 1, # 节点序号(从1开始)
"nodeName": "建议人", # 节点显示名称
"type": "suggest", # suggest=建议 | decide=决策 | read=传阅
"levelUserList": [
{"empId": 1512393035869810694}, # empId 必须是整数(非字符串)
],
}
]
```
> ⚠️ `type` 只接受英文小写 `suggest` / `decide` / `read`,不接受中文。`levelUserList` 是必填字段,不可为 `null` 或空列表。
---
## Agent 调用模式
### 模式 A:简单查询(单次 exec)
```
用户:「帮我看看今天有没有未读汇报」
Agent → exec: python3 scripts/cwork-query-report.py --mode unread --page-size 10
Agent ← JSON → 摘要呈现给用户
```
### 模式 B:多步编排(Agent 协调多次 exec)
```
用户:「给张三发一份周报,内容是XXX」
Agent → exec: python3 scripts/cwork-send-report.py \
--title "周报" --content-html "..." --receivers "张三"
Agent ← JSON(含完整 draftDetail、confirmPrompt;默认不会发出)
Agent → 向用户展示 **draftDetail 全文**(尤其 contentHtml、附件、reportLevelList)
用户:「确认」
Agent → exec: python3 scripts/cwork-send-report.py \
--draft-id "<上一步的 reportId(与 draftId 同值)>" --confirm-send
Agent ← JSON(success、已通过 5.27 发出)
Agent → 告知发送成功
```
### 模式 C:催办闭环(3步分离)
```
Agent → exec: python3 scripts/cwork-nudge-report.py identify --days-threshold 7
Agent ← JSON(未闭环列表)
Agent → (LLM 推理)筛选需要催办的事项
Agent → exec: python3 scripts/cwork-nudge-report.py reminder \
--item-id <id> --recipient "张三" --days-unresolved 14 --style polite
Agent ← JSON(催办文案)
Agent → (可选 LLM 优化文案)
Agent → exec: python3 scripts/cwork-nudge-report.py nudge \
--report-id <id> --content-html "..."
```
---
## 错误处理
所有脚本遵循统一错误约定:
- **成功**:JSON 到 stdout,含 `"success": true`
- **失败**:JSON 到 stderr,含 `"success": false` 和 `"error"` 字段,exit code ≠ 0
- **Agent 应同时检查 stdout 和 stderr**
遇到 API 异常(如 `API Error (2xxxxx)`)时,可调用 `cwork-report-issue.py` 上报问题:
```bash
python3 scripts/cwork-report-issue.py \
--title "bug: <出错脚本> <简短描述>" \
--script "<出错脚本>.py" \
--error '<stderr JSON>' \
--body "<复现步骤>"
```
> ⚠️ 上报前确认 `--error` 和 `--body` 中不含 appKey、empId 等敏感信息。
### 通用参数
所有脚本均支持以下通用参数:
| 参数 | 说明 |
|------|------|
| `--params-file <path>` | 从 UTF-8 JSON 文件读取参数,key 与命令行参数名一致(连字符格式)。用于解决 Windows PowerShell 中文编码问题。 |
**用法示例**:
```json
{
"title": "周报标题",
"content-html": "<p>汇报内容</p>",
"receivers": "张三"
}
```
```bash
python3 scripts/cwork-send-report.py --params-file params.json
```
> 文件参数与命令行参数可混用,命令行参数优先级更高。文件必须为 UTF-8 编码(带或不带 BOM 均支持)。
---
## 目录结构
```
cms-cwork-workflow/
├── SKILL.md ← 本文件(意图级接口文档)
├── scripts/
│ ├── cwork_client.py ← 共享 API 客户端(HTTP 封装 + 所有 API 方法)
│ ├── cwork-search-emp.py ← 0. 搜索员工 ✨ v3.2.0 新增
│ ├── cwork-send-report.py ← 1. 发送汇报
│ ├── cwork-query-report.py ← 2. 查询汇报
│ ├── cwork-create-task.py ← 3. 创建任务
│ ├── cwork-review-report.py ← 4. 审阅汇报
│ ├── cwork-query-tasks.py ← 5. 查询任务
│ ├── cwork-nudge-report.py ← 6. 催办闭环
│ ├── cwork-todo.py ← 7. 待办管理
│ └── cwork-templates.py ← 8. 模板管理
├── design/
│ └── DESIGN.md ← 架构设计文档
└── references/
└── maintenance.md ← 维护操作说明
```
---
## 参考资料
> 正常调用只需按本文档使用 CLI 脚本,不必直接查阅 API 文档。
> 遇到 API 错误码或需要扩展脚本时,可查阅以下官方文档:
- **工作协同 Open API 接口文档**:[工作协同API说明.md](https://github.com/xgjk/dev-guide/blob/main/02.%E4%BA%A7%E5%93%81%E4%B8%9A%E5%8A%A1AI%E6%96%87%E6%A1%A3/%E5%B7%A5%E4%BD%9C%E5%8D%8F%E5%90%8C/%E5%B7%A5%E4%BD%9C%E5%8D%8F%E5%90%8CAPI%E8%AF%B4%E6%98%8E.md)
FILE:design/DESIGN.md
# cms-cwork-workflow — Agent-First 重构设计文档
## 设计概述
将 cms-cwork-workflow 从 TypeScript 源码模式重构为 Agent-First 架构,通过 Python 编排脚本提供可组合、可复用的能力层。
## 核心设计理念
### Agent-First 架构
- **设计原则**: Agent 负责业务逻辑和 LLM 推理,脚本负责 API 编排和数据处理
- **协作模式**: Agent → JSON(工具输出)→ Agent(进一步处理)
- **边界清晰**: 脚本只做标准化的 API 调用,不做业务决策
### 编排脚本规范
- **JSON 输出**: 所有脚本输出结构化 JSON 到 stdout
- **错误处理**: stderr 输出调试信息,成功/failure 统一格式
- **参数校验**: 强制参数验证,可选参数支持默认值
- **干跑模式**: `--dry-run` / `--preview-only` 支持
## 文件结构设计
```
cms-cwork-workflow/
├── SKILL.md ← 产品级接口文档
├── scripts/
│ ├── cwork_client.py ← 共享 API 客户端(HTTP 封装 + 所有 API 方法)
│ ├── cwork-report-issue.py ← 辅助:问题自动上报到 GitHub
│ ├── cwork-search-emp.py ← 搜索员工
│ ├── cwork-send-report.py ← 发送汇报
│ ├── cwork-query-report.py ← 查询汇报
│ ├── cwork-create-task.py ← 创建任务
│ ├── cwork-review-report.py ← 审阅汇报
│ ├── cwork-query-tasks.py ← 查询任务
│ ├── cwork-nudge-report.py ← 催办闭环
│ ├── cwork-todo.py ← 待办管理
│ └── cwork-templates.py ← 模板管理
├── design/
│ └── DESIGN.md ← 本文件
└── references/
└── maintenance.md ← 维护说明
```
## 编排脚本架构
### 客户端层 (`cwork_client.py`)
- 封装 HTTP 请求逻辑(含 307 重定向处理)
- 统一错误处理和重试
- 参数编码和响应解析
- 所有业务 API 方法(`submit_report`、`save_draft` 等)
- 共享工具函数(`resolve_names_to_empids`、`parse_deadline`、`make_client` 等)
### 编排脚本层 (`.py`)
- argparse 命令行参数处理
- 业务逻辑编排
- 结构化 JSON 输出
## cwork_client.py API 方法索引
| API 端点 | 方法 |
|----------|------|
| `/open-api/cwork-user/searchEmpByName` | `search_emp_by_name()` |
| `/open-api/work-report/report/record/inbox` | `get_inbox_list()` |
| `/open-api/work-report/report/record/outbox` | `get_outbox_list()` |
| `/open-api/work-report/report/info` | `get_report_info()` |
| `/open-api/work-report/report/record/submit` | `submit_report()` |
| `/open-api/work-report/report/record/reply` | `reply_report()` |
| `/open-api/work-report/reportInfoOpenQuery/unreadList` | `get_unread_list()` |
| `/open-api/work-report/open-platform/report/readReport` | `mark_report_read()` |
| `/open-api/work-report/report/plan/searchPage` | `search_task_page()` |
| `/open-api/work-report/report/plan/getSimplePlanAndReportInfo` | `get_simple_plan_and_report_info()` |
| `/open-api/work-report/open-platform/report/plan/create` | `create_plan()` |
| `/open-api/work-report/draftBox/saveOrUpdate` | `save_draft()` |
| `/open-api/work-report/draftBox/listByPage` | `list_drafts()` |
| `/open-api/work-report/draftBox/detail/{id}` | `get_draft_detail()` |
| `/open-api/work-report/draftBox/delete/{id}` | `delete_draft()`(路径 id = 5.24 列表项 `id`);`delete_draft_by_report_id()`(按 `businessId` 查列表再删) |
| `/open-api/work-report/draftBox/submit/{id}` | `submit_draft()`(5.27,路径 id 为汇报 id) |
| `/open-api/cwork-file/uploadWholeFile` | `upload_file()` |
| `/open-api/work-report/template/listTemplates` | `list_templates()` |
| `/open-api/work-report/reportInfoOpenQuery/todoList` | `get_todo_list()` |
| `/open-api/work-report/open-platform/todo/completeTodo` | `complete_todo()` |
| `/open-api/work-report/report/getReportNodeDetail` | `get_report_node_detail()` |
| — | `get_sender_history()` |
| — | `search_reports_by_keyword()` |
## API 覆盖设计
| 功能域 | 脚本 | 主要 API | 输出格式 |
|--------|------|----------|----------|
| 汇报查询 | `cwork-query-report.py` | `get_inbox_list()`, `get_outbox_list()`, `get_unread_list()` | `{"success": true, "items": [...]}` |
| 任务查询 | `cwork-query-tasks.py` | `search_task_page()`, `get_simple_plan_and_report_info()` | `{"success": true, "tasks": [...]}` |
| 审阅回复 | `cwork-review-report.py` | `reply_report()`, `mark_report_read()` | `{"success": true, "result": {...}}` |
| 催办闭环 | `cwork-nudge-report.py` | `submit_report(type=12)`, `complete_todo()` | `{"success": true, "action": "nudge"}` |
| 创建任务 | `cwork-create-task.py` | `create_plan()`, `search_emp_by_name()` | `{"success": true, "planId": "..."}` |
| 发送汇报 | `cwork-send-report.py` | `save_draft()`, `get_draft_detail()`, `submit_draft()`(5.27), `upload_file()` | `{"success": true, "reportId": "..."}` |
| 待办管理 | `cwork-todo.py` | `get_todo_list()`, `complete_todo()` | `{"success": true, "todos": [...]}` |
| 模板管理 | `cwork-templates.py` | `list_templates()` | `{"success": true, "templates": [...]}` |
## 错误处理设计
### 成功响应
```json
{
"success": true,
"action": "list",
"total": 20,
"items": []
}
```
### 错误响应
```json
{
"success": false,
"error": "缺少必填参数: --report-id"
}
```
### 交互模式
```bash
# 错误信息输出到 stderr
$ python3 cwork-send-report.py --to 张三 --content "测试" --dry-run
{"success": false, "error": "缺少必填参数: --task-id"}
# 干跑模式输出到 stdout
$ python3 cwork-send-report.py --to 张三 --content "测试" --dry-run --preview-only
{"success": true, "preview": {...}, "validation": {...}}
```
## 与 Agent 协作模式
### Agent → Script
```python
Agent: "帮我查询张三的待办列表"
Agent → exec: python3 cwork-todo.py list --status pending --page-size 10
Agent ← stdout: {"success": true, "items": [...]}
```
### Script → Agent (可选)
```python
# Agent 可进一步处理脚本输出
Agent: "我发现你有3个逾期待办,需要催办吗?"
Agent → exec: python3 cwork-nudge-report.py identify --days-threshold 7
```
## 扩展性设计
### 新功能添加流程
1. 在 `cwork_client.py` 添加新的 API 方法
2. 在对应脚本中调用新 API
3. 更新 `SKILL.md` 文档
4. 按需更新 `DESIGN.md` 和 `SKILL.md` 记录设计变更
### 版本管理
- 主版本号:重大架构变更
- 次版本号:新功能增加
- 修订版本号:Bug 修复
---
## 实现状态 (v3.1.0)
### ✅ 已完成
- [x] 8个编排脚本完整实现
- [x] 共享客户端封装(含 23 个 API 方法)
- [x] JSON 输出规范
- [x] 参数校验和错误处理
- [x] Agent-First 架构设计
- [x] 文档体系完善
- [x] **决策/建议/反馈待办完整支持**(v3.1.0 新增)
- [x] **汇报节点详情查询**(`get_report_node_detail()`)
### 🆕 v3.1.0 新增功能(2026-04-03)
| 功能 | 说明 | API 接口 |
|------|------|----------|
| **决策待办** | 支持同意/不同意操作 | `complete_todo(operate="agree/disagree")` |
| **建议待办** | 支持建议内容提交 | `complete_todo(content="...")` |
| **反馈待办** | 支持反馈回复 | `complete_todo(content="...")` |
| **节点详情** | 查询汇报的审批节点与处理意见 | `get_report_node_detail()` |
### 🔄 进行中
- [ ] 单元测试覆盖
- [ ] 性能优化
- [ ] 更多汇报类型支持
### 📋 待优化
- [ ] 错误码标准化
- [ ] 重试机制完善
- [ ] 缓存策略优化
---
*最后更新: 2026-04-03*
FILE:references/maintenance.md
# 维护信息
## 基本信息
- 版本:见 `_meta.json`
- ClawHub slug:`cms-cwork-workflow`
## GitHub 地址
- 仓库:https://github.com/xgjk/cwork-skills
- Skill 目录:`cwork-skills/cms-cwork-workflow/`
## 官方 API 文档
- [工作协同 Open API 接口文档](https://github.com/xgjk/dev-guide/blob/main/02.%E4%BA%A7%E5%93%81%E4%B8%9A%E5%8A%A1AI%E6%96%87%E6%A1%A3/%E5%B7%A5%E4%BD%9C%E5%8D%8F%E5%90%8C/%E5%B7%A5%E4%BD%9C%E5%8D%8F%E5%90%8CAPI%E8%AF%B4%E6%98%8E.md)
> 脚本已封装所有 API 调用,正常使用无需查阅。排查 API 错误码或新增 API 支持时参考。
## 如何提 Issue
### 方式一:自动上报(推荐)
通过 `cwork-report-issue.py` 脚本直接创建 Issue,需提前配置 `GITHUB_TOKEN`:
`cwork-report-issue.py` 已内置共享 token,所有用户无需任何配置,直接调用即可。
> 维护者如需更换 token,修改 `scripts/cwork-report-issue.py` 中的 `_BUILTIN_TOKEN` 常量。
**调用示例**:
```bash
python3 scripts/cwork-report-issue.py \
--title "bug: cwork-send-report.py 发送失败" \
--script cwork-send-report.py \
--error '{"success": false, "error": "API Error (200003)"}' \
--body "复现步骤:..."
```
### 方式二:手动提交
1. 访问 https://github.com/xgjk/cwork-skills/issues/new
2. 选择 Label:`cms-cwork-workflow`
3. 填写问题描述 + 复现步骤
## 如何更新
**工厂内部开发:**
1. 修改 Skill 内容
2. 更新 `_meta.json` 版本号
3. 执行 `clawhub publish`
**ClawHub 用户:**
```bash
clawhub update cms-cwork-workflow
```
---
*最后更新:2026-04-05*
FILE:scripts/cwork-create-task.py
#!/usr/bin/env python3
"""
CWork Create Task - Agent-First
Usage:
python3 scripts/cwork-create-task.py --task-main "name" --content "desc" --assignee "person" --deadline 2026-04-10
"""
import sys
import os
import json
import argparse
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
from cwork_client import make_client, CWorkError, parse_deadline, resolve_names_to_empids, apply_params_file_pre_parse
_DEFAULT_MS = int(__import__("datetime").datetime.now().timestamp() * 1000) + 7 * 86400000
def parse_args(argv=None):
p = argparse.ArgumentParser(description="CWork create task (Agent-First)")
p.add_argument("--task-main", required=True, help="Task name")
p.add_argument("--deadline", help="Deadline YYYY-MM-DD or ms timestamp (default 7d)")
p.add_argument("--content", required=True, help="Task description")
p.add_argument("--target", help="Target description")
p.add_argument("--assignee", help="Owner name")
p.add_argument("--assistant", help="Assistant names (comma-separated)")
p.add_argument("--supervisor", help="Supervisor name")
p.add_argument("--copy", help="CC names (comma-separated)")
p.add_argument("--observer", help="Observer names (comma-separated)")
p.add_argument("--report-to", help="Report-to name")
p.add_argument("--push-now", type=lambda x: x.lower() == "true", default=True)
p.add_argument("--dry-run", action="store_true")
p.add_argument("--params-file", dest="params_file", default=None,
help="UTF-8 JSON 文件路径,从文件读取参数")
return p.parse_args(argv)
def _comma(val):
if not val:
return None
return [v.strip() for v in val.split(",") if v.strip()]
def _die(msg):
print(json.dumps({"success": False, "error": msg}, ensure_ascii=False), file=sys.stderr)
sys.exit(1)
def main():
apply_params_file_pre_parse()
args = parse_args()
try:
client = make_client()
except CWorkError as e:
_die(str(e))
def _resolve(name_or_list):
names = _comma(name_or_list) if isinstance(name_or_list, str) else name_or_list
if not names:
return None
return resolve_names_to_empids(client, names)
dl = parse_deadline(args.deadline) if args.deadline else _DEFAULT_MS
report_to = args.report_to or args.assignee
if not report_to:
_die("--report-to 或 --assignee 至少需要提供一个(API 要求 reportEmpIdList 必填)")
info = dict(assignee=args.assignee, assistant=args.assistant, supervisor=args.supervisor,
copy=args.copy, observer=args.observer, reportTo=report_to, deadlineMs=dl)
if args.dry_run:
print(json.dumps({"success": True, "dryRun": True,
"task": dict(
main=args.task_main,
content=args.content,
target=args.target or args.content,
deadline=args.deadline,
deadlineMs=dl,
pushNow=args.push_now,
),
"resolved": info}, ensure_ascii=False, indent=2))
return
try:
pid = client.create_plan(
main=args.task_main,
needful=args.content,
target=args.target or args.content,
end_time=dl,
owner_emp_id_list=_resolve(args.assignee), assist_emp_id_list=_resolve(args.assistant),
supervisor_emp_id_list=_resolve(args.supervisor), copy_emp_id_list=_resolve(args.copy),
observer_emp_id_list=_resolve(args.observer), report_emp_id_list=_resolve(report_to),
push_now=args.push_now,
)
print(json.dumps({"success": True, "planId": pid,
"task": dict(main=args.task_main, deadline=args.deadline, deadlineMs=dl),
"resolved": info}, ensure_ascii=False, indent=2))
except CWorkError as e:
_die(str(e))
if __name__ == "__main__":
main()
FILE:scripts/cwork-nudge-report.py
#!/usr/bin/env python3
"""
CWork Nudge Report — 催办通知脚本
Modes:
list — 列出未闭环事项清单(用于催收)
nudge — 发送催办通知
Usage:
python cwork-nudge-report.py --mode list [--days-threshold 7]
python cwork-nudge-report.py --mode nudge --emp-id <empId> --task-main "任务名" --deadline 2026-04-10 --content "催办内容"
Output: JSON to stdout, error JSON to stderr + exit 1
"""
import sys
import os
import json
import argparse
from datetime import datetime
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
from cwork_client import (CWorkClient, make_client, CWorkError,
output_json, output_error, parse_deadline,
resolve_names_to_empids, apply_params_file_pre_parse)
REPORT_TYPE_ID = 12 # 催收汇报
def parse_args(argv=None):
parser = argparse.ArgumentParser(description="CWork 催办通知")
parser.add_argument("--mode", required=True, choices=["list", "nudge"],
help="操作模式: list=列出未闭环, nudge=发送催办")
parser.add_argument("--emp-id", help="催办对象 empId(nudge模式必填)")
parser.add_argument("--task-main", help="任务名称(nudge模式必填)")
parser.add_argument("--deadline", help="截止日期 YYYY-MM-DD 或毫秒时间戳")
parser.add_argument("--content", help="催办内容描述")
parser.add_argument("--target", help="目标描述")
parser.add_argument("--remind-style", choices=["polite", "normal"], default="polite",
help="催办风格(默认polite)")
parser.add_argument("--days-threshold", type=int, default=7,
help="未闭环天数阈值(默认7天)")
parser.add_argument("--assignee", help="责任人姓名(用于解析empId)")
parser.add_argument("--page-index", type=int, default=1, help="页码(默认1)")
parser.add_argument("--page-size", type=int, default=50, help="每页大小(默认50)")
parser.add_argument("--interactive", action="store_true", help="交互模式")
parser.add_argument("--dry-run", action="store_true", help="干跑模式")
parser.add_argument("--params-file", dest="params_file", default=None,
help="UTF-8 JSON 文件路径,从文件读取参数")
return parser.parse_args(argv)
def build_nudge_content(task_main: str, deadline: str | None, content: str | None,
style: str) -> str:
"""Build HTML nudge content based on style."""
if style == "polite":
body = f"""<p>您好,您有任务需要关注:</p>
<p><strong>📌 任务名称:</strong>{task_main}</p>"""
if deadline:
body += f"<p><strong>⏰ 截止日期:</strong>{deadline}</p>"
if content:
body += f"<p><strong>📝 详情:</strong>{content}</p>"
body += "<p>请及时处理,如有疑问请联系我。谢谢!</p>"
else:
body = f"""<p>【催办】任务:{task_main}</p>"""
if deadline:
body += f"<p>截止日期:{deadline}</p>"
if content:
body += f"<p>详情:{content}</p>"
body += "<p>请尽快处理。</p>"
return body
def main():
apply_params_file_pre_parse()
args = parse_args()
if args.dry_run:
preview = {
"mode": args.mode,
"empId": args.emp_id,
"taskMain": args.task_main,
"deadline": args.deadline,
"content": args.content,
"target": args.target,
"remindStyle": args.remind_style,
"daysThreshold": args.days_threshold,
"assignee": args.assignee,
}
print("=== DRY RUN PREVIEW ===", file=sys.stderr)
print(json.dumps(preview, ensure_ascii=False, indent=2), file=sys.stderr)
output_json({"success": True, "message": "Dry run — no actual API call made"})
return
if args.interactive:
from cwork_client import interactive_confirm
desc = f"催办任务 (mode={args.mode}, empId={args.emp_id})"
if not interactive_confirm(f"nudge_{args.mode}", desc):
output_json({"success": True, "message": "Skipped by user"})
return
try:
client = make_client()
except CWorkError as e:
output_error(str(e))
try:
if args.mode == "list":
threshold_ms = args.days_threshold * 24 * 60 * 60 * 1000
now_ms = int(datetime.now().timestamp() * 1000)
result = client.search_task_page(
page_size=args.page_size,
page_index=args.page_index,
report_status=1,
)
items = result if isinstance(result, list) else result.get("list", [])
unclosed = [
item for item in items
if item.get("endTime") and (now_ms - item.get("endTime", 0)) > threshold_ms
]
output_json({
"success": True,
"data": unclosed,
"total": len(unclosed),
"daysThreshold": args.days_threshold,
"message": f"Found {len(unclosed)} unclosed items older than {args.days_threshold} days"
})
elif args.mode == "nudge":
if not args.emp_id and not args.assignee:
output_error("--emp-id or --assignee is required for nudge mode")
if not args.task_main:
output_error("--task-main is required for nudge mode")
emp_id = args.emp_id
if args.assignee and not args.emp_id:
emp_ids = resolve_names_to_empids(client, [args.assignee])
emp_id = emp_ids[0]
deadline_str = args.deadline if args.deadline else None
nudge_content = build_nudge_content(
args.task_main,
deadline_str,
args.content,
args.remind_style
)
result = client.submit_report(
main=f"【催办】{args.task_main}",
content_html=nudge_content,
type_id=REPORT_TYPE_ID,
accept_emp_id_list=[emp_id],
)
output_json({
"success": True,
"reportId": result.get("id"),
"empId": emp_id,
"message": f"Nudge sent to empId={emp_id} for task: {args.task_main}"
})
except CWorkError as e:
output_error(str(e))
except Exception as e:
output_error(f"Unexpected error: {e}")
if __name__ == "__main__":
main()
FILE:scripts/cwork-query-report.py
#!/usr/bin/env python3
"""
CWork Query Reports - Agent-First
Modes: inbox / outbox / unread / detail / node-detail / sender-history / keyword-search / pending / my-sent
Usage:
python3 scripts/cwork-query-report.py --mode inbox [--page-size 20]
python3 scripts/cwork-query-report.py --mode detail --report-id <id>
python3 scripts/cwork-query-report.py --mode node-detail --report-id <id>
python3 scripts/cwork-query-report.py --mode sender-history --sender-emp-id <empId>
python3 scripts/cwork-query-report.py --mode keyword-search --keyword "公章"
python3 scripts/cwork-query-report.py --mode pending
python3 scripts/cwork-query-report.py --mode unread
"""
import sys
import os
import json
import argparse
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
from cwork_client import CWorkClient, make_client, CWorkError, apply_params_file_pre_parse
def parse_args(argv=None):
parser = argparse.ArgumentParser(description="CWork query reports (Agent-First)")
parser.add_argument("--mode", required=True,
choices=["inbox", "outbox", "unread", "detail", "node-detail", "sender-history", "keyword-search", "pending", "my-sent"])
parser.add_argument("--page-index", type=int, default=1)
parser.add_argument("--page-size", type=int, default=20)
parser.add_argument("--report-id", help="Report ID (required for detail/node-detail)")
parser.add_argument("--sender-emp-id", help="Sender employee ID (required for sender-history)")
parser.add_argument("--keyword", help="Search keyword (required for keyword-search)")
parser.add_argument("--days", type=int, default=90, help="Days to look back (default 90)")
parser.add_argument("--report-type", type=int, choices=[1, 2, 3, 4, 5])
parser.add_argument("--status", type=int, help="Read status: 0=unread 1=read")
parser.add_argument("--keyword-filter", help="Legacy: Keyword filter")
parser.add_argument("--start-date", help="Start date YYYY-MM-DD")
parser.add_argument("--end-date", help="End date YYYY-MM-DD")
parser.add_argument("--params-file", dest="params_file", default=None,
help="UTF-8 JSON 文件路径,从文件读取参数")
return parser.parse_args(argv)
def _parse_date(value, end_of_day: bool = False):
"""Convert YYYY-MM-DD to millisecond timestamp (always interpreted as UTC+8).
Binds the date to UTC+8 explicitly so the result is identical regardless of
the system timezone where the script is executed.
end_of_day=True: returns 23:59:59.999 CST of that day so --end-date covers
the full Beijing calendar day (e.g. 2026-04-07 → 2026-04-07T23:59:59.999+08:00).
"""
if value is None:
return None
from datetime import datetime, timedelta, timezone
_CST = timezone(timedelta(hours=8))
try:
dt = datetime.strptime(value, "%Y-%m-%d").replace(tzinfo=_CST)
if end_of_day:
dt = dt + timedelta(days=1) - timedelta(milliseconds=1)
return int(dt.timestamp() * 1000)
except ValueError:
return None
def _die(msg):
print(json.dumps({"success": False, "error": msg}, ensure_ascii=False), file=sys.stderr)
sys.exit(1)
def main():
try:
sys.stdout.reconfigure(encoding="utf-8")
except AttributeError:
pass
apply_params_file_pre_parse()
args = parse_args()
try:
client = make_client()
except CWorkError as e:
_die(str(e))
try:
if args.mode == "detail":
if not args.report_id:
_die("--report-id is required for detail mode")
data = client.get_report_info(args.report_id)
print(json.dumps({"success": True, "data": data}, ensure_ascii=False, indent=2))
return
if args.mode == "node-detail":
if not args.report_id:
_die("--report-id is required for node-detail mode")
data = client.get_report_node_detail(args.report_id)
print(json.dumps({"success": True, "data": data}, ensure_ascii=False, indent=2))
return
if args.mode == "sender-history":
if not args.sender_emp_id:
_die("--sender-emp-id is required for sender-history mode")
data = client.get_sender_history(
sender_emp_id=args.sender_emp_id,
days=args.days,
max_count=args.page_size
)
print(json.dumps({"success": True, "data": data}, ensure_ascii=False, indent=2))
return
if args.mode == "keyword-search":
if not args.keyword:
_die("--keyword is required for keyword-search mode")
data = client.search_reports_by_keyword(
keyword=args.keyword,
days=args.days,
max_count=args.page_size
)
print(json.dumps({"success": True, "data": data}, ensure_ascii=False, indent=2))
return
if args.mode == "unread":
data = client.get_unread_list(args.page_index, args.page_size)
print(json.dumps({"success": True, "data": data}, ensure_ascii=False, indent=2))
return
read_status = args.status
if args.mode == "pending":
read_status = 0
if args.mode in ("inbox", "pending"):
data = client.get_inbox_list(
page_size=args.page_size, page_index=args.page_index,
report_record_type=args.report_type, read_status=read_status,
begin_time=_parse_date(args.start_date), end_time=_parse_date(args.end_date, end_of_day=True))
else:
data = client.get_outbox_list(
page_size=args.page_size, page_index=args.page_index,
report_record_type=args.report_type,
begin_time=_parse_date(args.start_date), end_time=_parse_date(args.end_date, end_of_day=True))
print(json.dumps({"success": True, "data": data}, ensure_ascii=False, indent=2))
except CWorkError as e:
_die(str(e))
if __name__ == "__main__":
main()
FILE:scripts/cwork-query-tasks.py
#!/usr/bin/env python3
"""
CWork Query Tasks — 任务查询脚本
Modes:
my — 查询我负责的任务(进行中 status=1)
created — 查询我创建的任务(未启动 status=0)
team — 查询团队任务(进行中 status=1)
assigned — 查询分配给我的任务
detail — 查看任务详情(需 --task-id)
chain — 查看任务→汇报链路(需 --task-id)
blocked — 识别卡点/逾期任务
unclosed — 识别未闭环事项
manager — 管理者仪表盘(需 --subordinate-ids)
Usage:
python cwork-query-tasks.py --mode my [--page-index 1] [--page-size 20]
python cwork-query-tasks.py --mode detail --task-id <id>
python cwork-query-tasks.py --mode blocked [--days-threshold 7]
python cwork-query-tasks.py --mode manager --subordinate-ids emp001,emp002
Output: JSON to stdout, error JSON to stderr + exit 1
"""
import sys
import os
import json
import argparse
from datetime import datetime, timedelta
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
from cwork_client import CWorkClient, make_client, CWorkError, output_json, output_error, resolve_names_to_empids, apply_params_file_pre_parse
def parse_args(argv=None):
parser = argparse.ArgumentParser(description="CWork 任务查询")
parser.add_argument("--mode", required=True,
choices=["my", "created", "team", "assigned", "detail", "chain",
"blocked", "unclosed", "manager", "nudge"],
help="查询模式")
parser.add_argument("--task-id", help="任务ID(detail/chain模式必填)")
parser.add_argument("--page-index", type=int, default=1, help="页码(默认1)")
parser.add_argument("--page-size", type=int, default=20, help="每页大小(默认20)")
parser.add_argument("--status", type=int, choices=[0, 1, 2],
help="任务状态: 0=已关闭, 1=进行中, 2=未启动")
parser.add_argument("--task-status", type=int, choices=[0, 1, 2, 3],
help="汇报状态: 0=关闭, 1=待汇报, 2=已汇报, 3=逾期")
parser.add_argument("--report-status", type=int, choices=[0, 1, 2, 3],
help="汇报状态(同task-status): 0=关闭, 1=待汇报, 2=已汇报, 3=逾期")
parser.add_argument("--key-word", help="关键词搜索")
parser.add_argument("--assignee", help="责任人姓名")
parser.add_argument("--subordinate-ids", help="下属empId列表(逗号分隔,manager模式用)")
parser.add_argument("--days-threshold", type=int, default=7,
help="未闭环天数阈值(默认7)")
parser.add_argument("--interactive", action="store_true", help="交互模式")
parser.add_argument("--dry-run", action="store_true", help="干跑模式")
parser.add_argument("--params-file", dest="params_file", default=None,
help="UTF-8 JSON 文件路径,从文件读取参数")
return parser.parse_args(argv)
def main():
apply_params_file_pre_parse()
args = parse_args()
if args.dry_run:
preview = {
"mode": args.mode,
"params": {
"pageIndex": args.page_index,
"pageSize": args.page_size,
"status": args.status,
"taskStatus": args.task_status,
"reportStatus": args.report_status,
"keyWord": args.key_word,
"assignee": args.assignee,
"subordinateIds": args.subordinate_ids,
"daysThreshold": args.days_threshold,
}
}
print("=== DRY RUN PREVIEW ===", file=sys.stderr)
print(json.dumps(preview, ensure_ascii=False, indent=2), file=sys.stderr)
output_json({"success": True, "message": "Dry run — no actual API call made"})
return
if args.interactive:
from cwork_client import interactive_confirm
desc = f"查询任务 (mode={args.mode}, page={args.page_index})"
if not interactive_confirm(f"query_task_{args.mode}", desc):
output_json({"success": True, "message": "Skipped by user"})
return
try:
client = make_client()
except CWorkError as e:
output_error(str(e))
try:
if args.mode in ("detail", "chain"):
if not args.task_id:
output_error("--task-id is required for detail/chain mode")
result = client.get_simple_plan_and_report_info(args.task_id)
output_json({"success": True, "data": result})
elif args.mode == "blocked":
threshold_ms = args.days_threshold * 24 * 60 * 60 * 1000
now_ms = int(datetime.now().timestamp() * 1000)
result = client.search_task_page(
page_size=args.page_size,
page_index=args.page_index,
report_status=3,
key_word=args.key_word,
)
items = result if isinstance(result, list) else result.get("list", [])
blocked = [
item for item in items
if item.get("endTime") and (now_ms - item.get("endTime", 0)) > threshold_ms
]
output_json({"success": True, "data": blocked, "total": len(blocked)})
elif args.mode == "unclosed":
result = client.search_task_page(
page_size=args.page_size,
page_index=args.page_index,
report_status=1,
key_word=args.key_word,
)
output_json({"success": True, "data": result})
elif args.mode == "manager":
if not args.subordinate_ids:
output_error("--subordinate-ids is required for manager mode")
emp_ids = [e.strip() for e in args.subordinate_ids.split(",") if e.strip()]
result = client.search_task_page(
page_size=args.page_size,
page_index=args.page_index,
emp_id_list=emp_ids,
status=args.status,
key_word=args.key_word,
)
output_json({"success": True, "data": result})
elif args.mode in ("my", "assigned"):
status = args.status if args.status is not None else 1
if args.assignee:
emp_ids = resolve_names_to_empids(client, [args.assignee])
else:
emp_ids = None
result = client.search_task_page(
page_size=args.page_size,
page_index=args.page_index,
status=status,
emp_id_list=emp_ids,
key_word=args.key_word,
)
output_json({"success": True, "data": result})
elif args.mode == "created":
status = args.status if args.status is not None else 0
result = client.search_task_page(
page_size=args.page_size,
page_index=args.page_index,
status=status,
key_word=args.key_word,
)
output_json({"success": True, "data": result})
elif args.mode == "team":
status = args.status if args.status is not None else 1
result = client.search_task_page(
page_size=args.page_size,
page_index=args.page_index,
status=status,
key_word=args.key_word,
)
output_json({"success": True, "data": result})
elif args.mode == "nudge":
result = client.search_task_page(
page_size=args.page_size,
page_index=args.page_index,
report_status=3,
key_word=args.key_word,
)
output_json({"success": True, "data": result, "message": "Use --emp-id with cwork-nudge-report.py to send nudge"})
except CWorkError as e:
output_error(str(e))
except Exception as e:
output_error(f"Unexpected error: {e}")
if __name__ == "__main__":
main()
FILE:scripts/cwork-report-issue.py
#!/usr/bin/env python3
"""cwork-report-issue.py — 自动上报问题到 GitHub Issues。
使用场景:
Agent 遇到脚本报错或 API 异常时,调用本脚本将问题自动提交为 GitHub Issue。
认证方式(优先级从高到低):
1. 环境变量 GITHUB_TOKEN
2. --token 参数(仅调试用,不要在 CI/生产环境使用)
用法示例:
python3 scripts/cwork-report-issue.py \\
--title "bug: cwork-send-report.py 发送失败" \\
--script cwork-send-report.py \\
--error '{"success": false, "error": "API Error (200003)"}' \\
--body "复现步骤:python3 scripts/cwork-send-report.py --title 测试"
"""
from __future__ import annotations
import sys
import json
import os
import argparse
import urllib.request
import urllib.error
from pathlib import Path
sys.stdout.reconfigure(encoding="utf-8", errors="replace")
sys.stderr.reconfigure(encoding="utf-8", errors="replace")
GITHUB_REPO = "xgjk/cwork-skills"
GITHUB_API_URL = f"https://api.github.com/repos/{GITHUB_REPO}/issues"
DEFAULT_LABELS = ["bug", "cms-cwork-workflow"]
# Fine-grained token,仅限 xgjk/cwork-skills 仓库的 Issues: Read and Write 权限。
# 无法读写代码、推送提交或访问其他仓库。所有安装此 skill 的用户共享此 token。
# 如需使用自己的 token,设置环境变量 GITHUB_TOKEN 即可覆盖。
_BUILTIN_TOKEN = "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"
def output_json(data: dict, *, to_stderr: bool = False) -> None:
out = sys.stderr if to_stderr else sys.stdout
print(json.dumps(data, ensure_ascii=False, indent=2), file=out)
def create_github_issue(token: str, title: str, body: str, labels: list[str]) -> dict:
payload = json.dumps({
"title": title,
"body": body,
"labels": labels,
}).encode("utf-8")
req = urllib.request.Request(
GITHUB_API_URL,
data=payload,
headers={
"Authorization": f"Bearer {token}",
"Accept": "application/vnd.github.v3+json",
"Content-Type": "application/json",
"X-GitHub-Api-Version": "2022-11-28",
},
method="POST",
)
try:
with urllib.request.urlopen(req) as resp:
result = json.loads(resp.read().decode("utf-8"))
return {
"success": True,
"issue_number": result["number"],
"issue_url": result["html_url"],
"title": result["title"],
}
except urllib.error.HTTPError as e:
error_body = e.read().decode("utf-8", errors="replace")
try:
detail = json.loads(error_body).get("message", error_body)
except Exception:
detail = error_body
return {
"success": False,
"error": f"GitHub API HTTP {e.code}: {detail}",
}
except urllib.error.URLError as e:
return {
"success": False,
"error": f"网络错误: {e.reason}",
}
def build_issue_body(
script: str | None,
error: str | None,
body: str | None,
extra: str | None,
) -> str:
parts: list[str] = []
if script:
parts.append(f"## 出错脚本\n\n`{script}`")
if error:
# 尝试格式化 JSON 错误输出
try:
parsed = json.loads(error)
formatted = json.dumps(parsed, ensure_ascii=False, indent=2)
except Exception:
formatted = error
parts.append(f"## 错误信息\n\n```json\n{formatted}\n```")
if body:
parts.append(f"## 问题描述\n\n{body}")
if extra:
parts.append(f"## 附加信息\n\n{extra}")
parts.append("## Skill\n\n`cms-cwork-workflow`")
return "\n\n---\n\n".join(parts)
def apply_params_file_pre_parse() -> None:
"""从 --params-file 读取参数并注入 sys.argv(与其他脚本保持一致)。"""
if "--params-file" not in sys.argv:
return
idx = sys.argv.index("--params-file")
if idx + 1 >= len(sys.argv):
return
path = sys.argv[idx + 1]
try:
raw = Path(path).read_bytes()
# 处理 UTF-8 BOM
if raw.startswith(b"\xef\xbb\xbf"):
raw = raw[3:]
params = json.loads(raw.decode("utf-8"))
injected: list[str] = []
for key, value in params.items():
arg_key = f"--{key}" if not key.startswith("--") else key
injected.extend([arg_key, str(value)])
# 插入到 --params-file 之前
sys.argv = sys.argv[:idx] + injected + sys.argv[idx + 2:]
except Exception as exc:
print(
json.dumps({"success": False, "error": f"读取 --params-file 失败: {exc}"}, ensure_ascii=False),
file=sys.stderr,
)
sys.exit(1)
def main() -> None:
sys.path.insert(0, str(Path(__file__).parent))
apply_params_file_pre_parse()
parser = argparse.ArgumentParser(
description="自动上报 cms-cwork-workflow 问题到 GitHub Issues",
formatter_class=argparse.RawDescriptionHelpFormatter,
epilog=__doc__,
)
parser.add_argument("--title", "-T", required=True, help="Issue 标题(必填)")
parser.add_argument("--script", "-s", help="出错的脚本名称,如 cwork-send-report.py")
parser.add_argument("--error", "-e", help="错误信息(脚本 stderr 的 JSON 输出)")
parser.add_argument("--body", "-b", help="问题描述(补充说明、复现步骤等)")
parser.add_argument("--extra", help="附加信息(环境、版本等)")
parser.add_argument(
"--labels",
default="",
help="额外标签(逗号分隔,默认已含 bug 和 cms-cwork-workflow)",
)
parser.add_argument(
"--token",
help="GitHub Token(优先从环境变量 GITHUB_TOKEN 读取,此参数仅供调试)",
)
parser.add_argument(
"--dry-run",
action="store_true",
help="预览将要提交的 Issue 内容,不实际创建",
)
parser.add_argument("--params-file", help="从 UTF-8 JSON 文件读取参数")
args = parser.parse_args()
# 解析 token:环境变量 > --token 参数 > 内置共享 token
token = os.environ.get("GITHUB_TOKEN") or args.token or _BUILTIN_TOKEN
if not token and not args.dry_run:
output_json(
{
"success": False,
"error": "缺少 GitHub Token,且内置 token 不可用。",
"hint": "设置环境变量 GITHUB_TOKEN 或使用 --token 参数。",
},
to_stderr=True,
)
sys.exit(1)
# 构建标签
labels = list(DEFAULT_LABELS)
if args.labels:
labels.extend(lbl.strip() for lbl in args.labels.split(",") if lbl.strip())
# 构建正文
issue_body = build_issue_body(
script=args.script,
error=args.error,
body=args.body,
extra=args.extra,
)
# --dry-run:只展示,不提交
if args.dry_run:
output_json(
{
"success": True,
"dry_run": True,
"would_create": {
"repo": GITHUB_REPO,
"title": args.title,
"labels": labels,
"body": issue_body,
},
}
)
return
result = create_github_issue(token, args.title, issue_body, labels)
if result["success"]:
output_json(result)
else:
output_json(result, to_stderr=True)
sys.exit(1)
if __name__ == "__main__":
main()
FILE:scripts/cwork-review-report.py
#!/usr/bin/env python3
"""
CWork Review Report — 审阅/回复汇报脚本
Modes:
reply — 回复/点评汇报
mark-read — 标记已读
pending — 查询待审汇报
Usage:
python cwork-review-report.py --mode reply --report-id <id> --reply "内容"
python cwork-review-report.py --mode reply --report-id <id> --reply "内容" --content-type html
python cwork-review-report.py --mode reply --report-id <id> --reply "内容" --at "张三"
python cwork-review-report.py --mode mark-read --report-id <id>
python cwork-review-report.py --mode pending [--page-size 20]
Output: JSON to stdout, error JSON to stderr + exit 1
"""
import sys
import os
import json
import argparse
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
from cwork_client import (CWorkClient, make_client, CWorkError,
output_json, output_error, resolve_names_to_empids,
apply_params_file_pre_parse)
def parse_args(argv=None):
parser = argparse.ArgumentParser(description="CWork 审阅汇报")
parser.add_argument("--mode", required=True,
choices=["reply", "mark-read", "pending"],
help="操作模式")
parser.add_argument("--report-id", help="汇报ID(reply/mark-read模式必填)")
parser.add_argument("--reply", help="回复内容(reply模式必填)")
parser.add_argument(
"--content-type",
choices=["html", "markdown"],
default="markdown",
help="回复内容类型:markdown 支持 [@标题](reportId=…&linkType=report) 等内部链接(默认);html 时包裹为 <p>…</p>",
)
parser.add_argument("--at", help="被@人的姓名(reply模式可选)")
parser.add_argument("--page-index", type=int, default=1, help="页码(默认1)")
parser.add_argument("--page-size", type=int, default=20, help="每页大小(默认20)")
parser.add_argument("--report-type", type=int, choices=[1, 2, 3, 4, 5],
help="汇报类型筛选")
parser.add_argument("--interactive", action="store_true", help="交互模式")
parser.add_argument("--dry-run", action="store_true", help="干跑模式")
parser.add_argument("--params-file", dest="params_file", default=None,
help="UTF-8 JSON 文件路径,从文件读取参数")
return parser.parse_args(argv)
def main():
apply_params_file_pre_parse()
args = parse_args()
if args.dry_run:
preview = {
"mode": args.mode,
"reportId": args.report_id,
"reply": args.reply,
"contentType": args.content_type,
"at": args.at,
}
print("=== DRY RUN PREVIEW ===", file=sys.stderr)
print(json.dumps(preview, ensure_ascii=False, indent=2), file=sys.stderr)
output_json({"success": True, "message": "Dry run — no actual API call made"})
return
if args.interactive:
from cwork_client import interactive_confirm
desc = f"{args.mode} 汇报 (report_id={args.report_id})"
if not interactive_confirm(f"review_{args.mode}", desc):
output_json({"success": True, "message": "Skipped by user"})
return
try:
client = make_client()
except CWorkError as e:
output_error(str(e))
try:
if args.mode == "reply":
if not args.report_id:
output_error("--report-id is required for reply mode")
if not args.reply:
output_error("--reply is required for reply mode")
at_emp_ids = None
if args.at:
at_emp_ids = resolve_names_to_empids(client, [args.at])
if args.content_type == "html":
content_body = f"<p>{args.reply}</p>"
else:
content_body = args.reply
reply_id = client.reply_report(
report_record_id=args.report_id,
content_html=content_body,
content_type=args.content_type,
add_emp_id_list=at_emp_ids,
send_msg=True,
)
output_json({"success": True, "replyId": reply_id, "message": "Reply submitted successfully"})
elif args.mode == "mark-read":
if not args.report_id:
output_error("--report-id is required for mark-read mode")
client.mark_report_read(args.report_id)
output_json({"success": True, "message": f"Report {args.report_id} marked as read"})
elif args.mode == "pending":
result = client.get_inbox_list(
page_size=args.page_size,
page_index=args.page_index,
report_record_type=args.report_type,
read_status=0,
)
output_json({"success": True, "data": result})
except CWorkError as e:
output_error(str(e))
except Exception as e:
output_error(f"Unexpected error: {e}")
if __name__ == "__main__":
main()
FILE:scripts/cwork-search-emp.py
#!/usr/bin/env python3
"""
CWork 员工搜索工具
用途:根据姓名搜索员工 ID 和详细信息
场景:发送汇报前确认接收人、处理待办时确认发件人、创建任务时确认责任人
用法:
python3 cwork-search-emp.py --name "张"
python3 cwork-search-emp.py --name "成伟" --verbose
python3 cwork-search-emp.py --name "刘" --max-results 10
"""
import argparse
import json
import sys
import os
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
from cwork_client import CWorkClient, CWorkError, make_client, apply_params_file_pre_parse
def search_employees(client: CWorkClient, search_key: str, max_results: int = 5, verbose: bool = False) -> dict:
"""
Search employees by name keyword.
Args:
client: CWork API client
search_key: Search keyword (supports fuzzy matching)
max_results: Maximum results to return per category (inside/outside)
verbose: Include additional details
Returns:
{
"success": true,
"searchKey": "张",
"inside": [...],
"outside": [...],
"totalInside": 10,
"totalOutside": 2
}
"""
try:
result = client.search_emp_by_name(search_key)
# Parse inside employees
inside_list = []
inside_data = result.get("inside", {})
if inside_data:
company = inside_data.get("companyVO", {})
emp_list = inside_data.get("empList", [])
for emp in emp_list[:max_results]:
emp_info = {
"empId": emp.get("id"),
"name": emp.get("name"),
"title": emp.get("title", ""),
"mainDept": emp.get("mainDept", ""),
"status": "在职" if emp.get("status") == 1 else "离职"
}
if verbose:
emp_info.update({
"personId": emp.get("personId"),
"dingUserId": emp.get("dingUserId"),
"corpId": emp.get("corpId"),
"company": company.get("name", "")
})
inside_list.append(emp_info)
# Parse outside contacts
outside_list = []
outside_data = result.get("outside", [])
if outside_data:
for item in outside_data:
company = item.get("companyVO", {})
emp_list = item.get("empList", [])
for emp in emp_list[:max_results]:
emp_info = {
"empId": emp.get("id"),
"name": emp.get("name"),
"title": emp.get("title", ""),
"mainDept": emp.get("mainDept", ""),
"status": "在职" if emp.get("status") == 1 else "离职",
"company": company.get("name", "")
}
outside_list.append(emp_info)
return {
"success": True,
"searchKey": search_key,
"inside": inside_list,
"outside": outside_list,
"totalInside": len(result.get("inside", {}).get("empList", [])) if result.get("inside") else 0,
"totalOutside": sum(len(item.get("empList", [])) for item in result.get("outside", [])) if result.get("outside") else 0
}
except CWorkError as e:
return {
"success": False,
"error": str(e),
"searchKey": search_key
}
def main():
parser = argparse.ArgumentParser(
description="Search CWork employees by name",
formatter_class=argparse.RawDescriptionHelpFormatter,
epilog="""
Examples:
# Basic search
python3 cwork-search-emp.py --name "张"
# Verbose mode (includes personId, dingUserId, etc.)
python3 cwork-search-emp.py --name "成伟" --verbose
# More results
python3 cwork-search-emp.py --name "刘" --max-results 10
"""
)
parser.add_argument(
"--name", "-n",
required=True,
help="Employee name or keyword to search (supports fuzzy matching)"
)
parser.add_argument(
"--max-results", "-m",
type=int,
default=5,
help="Maximum results to return per category (default: 5)"
)
parser.add_argument(
"--verbose", "-v",
action="store_true",
help="Include additional details (personId, dingUserId, etc.)"
)
parser.add_argument(
"--output-raw",
action="store_true",
help="Output raw API response"
)
parser.add_argument(
"--params-file",
help="从 UTF-8 JSON 文件读取参数(用于 Windows 下传递中文内容)"
)
apply_params_file_pre_parse()
args = parser.parse_args()
try:
client = make_client()
# Output raw response if requested
if args.output_raw:
result = client.search_emp_by_name(args.name)
print(json.dumps(result, ensure_ascii=False, indent=2))
return
# Normal search
result = search_employees(
client,
args.name,
max_results=args.max_results,
verbose=args.verbose
)
# Output JSON to stdout
print(json.dumps(result, ensure_ascii=False, indent=2))
# Exit with error code if failed
if not result.get("success"):
sys.exit(1)
except CWorkError as e:
error_output = {
"success": False,
"error": str(e),
"searchKey": args.name
}
print(json.dumps(error_output, ensure_ascii=False, indent=2), file=sys.stderr)
sys.exit(1)
except Exception as e:
error_output = {
"success": False,
"error": f"Unexpected error: {e}",
"searchKey": args.name
}
print(json.dumps(error_output, ensure_ascii=False, indent=2), file=sys.stderr)
sys.exit(1)
if __name__ == "__main__":
main()
FILE:scripts/cwork-send-report.py
#!/usr/bin/env python3
"""
cwork-send-report.py
发送汇报:解析接收人 → 全量保存/更新草稿(5.23)→ 输出完整草稿详情(5.25)供用户确认
→ 仅在 --confirm-send 后调用 5.27 将草稿转为正式汇报。
更新草稿前会先拉取详情,与本次参数合并后整包提交,避免全量覆盖导致字段丢失。
Usage:
# 1) 仅存草稿 + 输出完整预览(默认,不发送)
python3 scripts/cwork-send-report.py --title "..." --content-html "<p>...</p>" --receivers "张三"
# 2) 用户确认后,仅凭汇报 id 发出(5.27)
python3 scripts/cwork-send-report.py --draft-id <汇报id> --confirm-send
# 3) 一步:保存并发出(需显式确认)
python3 scripts/cwork-send-report.py --title "..." --content-html "..." --receivers "张三" --confirm-send
Environment:
CWORK_APP_KEY (required)
CWORK_BASE_URL (optional)
"""
from __future__ import annotations
import sys
import json
import copy
import argparse
from pathlib import Path
sys.path.insert(0, str(Path(__file__).parent))
from cwork_client import (
make_client,
CWorkError,
apply_params_file_pre_parse,
flatten_emp_search_bucket,
)
# ---------------------------------------------------------------------------
# CLI
# ---------------------------------------------------------------------------
def parse_args():
p = argparse.ArgumentParser(description="Send a CWork report (draft-first, 5.27 to publish)")
p.add_argument("--title", "-t", default=None, help="汇报标题(发送-only 模式可不填)")
p.add_argument("--content-html", "-c", default=None, help="正文 HTML(发送-only 模式可不填)")
p.add_argument(
"--receivers", "-r", default="",
help="Comma-separated receiver names (will be resolved to empId)",
)
p.add_argument(
"--cc", dest="cc_names", default="",
help="Comma-separated CC recipient names",
)
p.add_argument(
"--grade", "-g", default="一般",
choices=["一般", "紧急"],
help="Report urgency",
)
p.add_argument(
"--type-id", dest="type_id", type=int, default=9999,
help="Report type ID (default 9999)",
)
p.add_argument(
"--file-paths", nargs="*", dest="file_paths", default=[],
help="Local file paths to attach (up to 10)",
)
p.add_argument(
"--file-names", nargs="*", dest="file_names", default=[],
help="File names for attachments (same order as --file-paths)",
)
p.add_argument(
"--plan-id", dest="plan_id", default=None,
help="Linked plan/task ID",
)
p.add_argument(
"--preview-only", dest="preview_only", action="store_true",
help="仅保存草稿并输出完整预览(与默认不发送行为一致,便于显式调用)",
)
p.add_argument(
"--draft-id", dest="draft_id", default=None,
help="汇报 id:更新已有草稿;与 --confirm-send 单独使用时仅执行 5.27 发出",
)
p.add_argument(
"--confirm-send", dest="confirm_send", action="store_true",
help="用户已预览完整草稿并同意发出后,再指定此参数才会调用 5.27",
)
p.add_argument(
"--report-level-json", dest="report_level_json", default=None,
help="UTF-8 JSON 文件路径,内容为 reportLevelList 数组(覆盖详情中的流程节点)",
)
p.add_argument(
"--params-file", dest="params_file", default=None,
help="UTF-8 JSON 文件路径,从文件读取参数(用于 Windows 下传递中文内容)",
)
return p.parse_args()
# ---------------------------------------------------------------------------
# Resolve / validate names
# ---------------------------------------------------------------------------
def resolve_receivers(client, names: list[str]) -> dict:
results = {}
for name in names:
if not name.strip():
continue
try:
data = client.search_emp_by_name(name.strip())
except CWorkError as e:
results[name] = {"status": "error", "message": str(e)}
continue
inside = flatten_emp_search_bucket(data.get("inside"))
if inside:
candidates = inside
else:
candidates = flatten_emp_search_bucket(data.get("outside"))
if len(candidates) == 0:
results[name] = {"status": "not_found"}
elif len(candidates) == 1:
emp = candidates[0]
results[name] = {
"status": "found",
"empId": emp["id"],
"name": emp["name"],
"title": emp.get("title", ""),
"dept": emp.get("mainDept", ""),
}
else:
results[name] = {
"status": "multiple",
"employees": [
{"empId": e["id"], "name": e["name"],
"title": e.get("title", ""), "dept": e.get("mainDept", "")}
for e in candidates
],
}
return results
def validate_receivers(resolved: dict) -> tuple[list[dict], list[dict]]:
confirmed = []
errors = []
for name, info in resolved.items():
if info["status"] == "not_found":
errors.append({"name": name, "reason": "not_found"})
elif info["status"] == "multiple":
errors.append({
"name": name,
"reason": "multiple_matches",
"candidates": [
{"empId": e["empId"], "name": e["name"],
"title": e.get("title", ""), "dept": e.get("dept", "")}
for e in info["employees"]
],
})
elif info["status"] == "found":
confirmed.append({"empId": info["empId"], "name": info["name"]})
return confirmed, errors
# ---------------------------------------------------------------------------
# Draft detail → saveOrUpdate 全量字段
# ---------------------------------------------------------------------------
def _emp_ids_from_detail(emp_list: list | None) -> list[str]:
if not emp_list:
return []
out: list[str] = []
for e in emp_list:
if isinstance(e, dict) and e.get("id") is not None:
out.append(str(e["id"]))
return out
def _file_vos_from_detail(file_list: list | None) -> list[dict]:
if not file_list:
return []
out: list[dict] = []
for f in file_list:
if not isinstance(f, dict):
continue
fid = f.get("fileId")
if not fid:
continue
out.append({
"fileId": str(fid),
"name": f.get("name") or "",
"type": f.get("type") or "file",
})
return out
def _report_level_param_from_detail(nodes: list | None) -> list[dict] | None:
if not nodes:
return None
result: list[dict] = []
for node in nodes:
if not isinstance(node, dict):
continue
emp_list = node.get("empList") or []
level_users = []
for e in emp_list:
if not isinstance(e, dict):
continue
eid = e.get("empId", e.get("id"))
if eid is not None:
level_users.append({"empId": eid})
entry = {
"type": node.get("type"),
"level": node.get("level"),
"nodeCode": node.get("nodeCode"),
"nodeName": node.get("nodeName"),
"levelUserList": level_users,
"groupIdList": node.get("groupIdList"),
"requirement": node.get("requirement"),
}
result.append({k: v for k, v in entry.items() if v is not None})
return result or None
def _receiver_node_index_for_cli_sync(nodes: list[dict]) -> int:
"""选择应将 ``--receivers`` 写入 ``levelUserList`` 的节点(与 5.1/5.23 一致:接收人以 reportLevelList 为准)。"""
for i, n in enumerate(nodes):
if isinstance(n, dict) and isinstance(n.get("type"), str) and n["type"].lower() == "read":
return i
for i, n in enumerate(nodes):
if isinstance(n, dict) and n.get("levelUserList"):
return i
return 0
def _apply_cli_receivers_to_report_level_list(
report_level_list: list[dict],
accept_emp_ids: list[str],
) -> list[dict]:
"""用本次 CLI 解析出的接收人覆盖目标节点 ``levelUserList``,避免仅更新 acceptEmpIdList 时详情仍显示旧人。"""
if not report_level_list or not accept_emp_ids:
return report_level_list
out = copy.deepcopy(report_level_list)
idx = _receiver_node_index_for_cli_sync(out)
if idx < 0 or idx >= len(out):
return report_level_list
level_users: list[dict] = []
for eid in accept_emp_ids:
try:
level_users.append({"empId": int(str(eid))})
except (TypeError, ValueError):
level_users.append({"empId": eid})
node = dict(out[idx])
node["levelUserList"] = level_users
node.pop("groupIdList", None)
out[idx] = {k: v for k, v in node.items() if v is not None}
return out
def load_report_level_json(path: str) -> list[dict]:
raw = Path(path).read_text(encoding="utf-8-sig")
data = json.loads(raw)
if not isinstance(data, list):
raise ValueError("report-level-json 根节点须为 JSON 数组")
return data
# ---------------------------------------------------------------------------
# Upload files
# ---------------------------------------------------------------------------
def upload_files(client, file_paths: list[str], file_names: list[str]) -> list[dict]:
if not file_paths:
return []
file_vos = []
for i, path in enumerate(file_paths):
fname = file_names[i] if i < len(file_names) else Path(path).name
try:
result = client.upload_file(path)
file_id = result.get("fileId", "")
file_vos.append({
"fileId": str(file_id) if file_id is not None else "",
"name": fname,
"type": "file",
})
except CWorkError as e:
print(json.dumps({
"step": "upload",
"file": fname,
"error": str(e),
}, ensure_ascii=False), file=sys.stderr)
return file_vos
# ---------------------------------------------------------------------------
# Merge + save draft (5.23 全量)
# ---------------------------------------------------------------------------
def build_save_draft_kwargs(
args,
*,
detail: dict | None,
accept_emp_ids: list[str],
cc_emp_ids: list[str],
file_vos: list[dict],
receiver_names_nonempty: bool,
cc_names_nonempty: bool,
new_uploads: bool,
) -> dict:
"""构造 save_draft 的完整参数,避免更新时省略字段导致服务端清空。"""
if args.report_level_json:
report_level_list = load_report_level_json(args.report_level_json)
elif detail is not None:
rll = detail.get("reportLevelList")
if rll:
converted = _report_level_param_from_detail(rll)
report_level_list = converted if converted is not None else []
else:
report_level_list = []
else:
report_level_list = None
if detail:
if receiver_names_nonempty:
final_accept = accept_emp_ids
else:
final_accept = _emp_ids_from_detail(detail.get("acceptEmployeeList"))
if cc_names_nonempty:
final_cc = cc_emp_ids
else:
final_cc = _emp_ids_from_detail(detail.get("copyEmployeeList"))
if new_uploads:
final_files = file_vos
else:
final_files = _file_vos_from_detail(detail.get("fileList"))
privacy_level = detail.get("privacyLevel") or "非涉密"
template_raw = detail.get("templateId")
template_id = str(template_raw) if template_raw is not None else None
plan_raw = args.plan_id if args.plan_id is not None else detail.get("planId")
plan_id = str(plan_raw) if plan_raw is not None else None
# CLI 约定正文为 HTML,与 --content-html 一致,避免沿用详情里 markdown 与 HTML 混用
content_type = "html"
else:
final_accept = accept_emp_ids
final_cc = cc_emp_ids
final_files = file_vos
privacy_level = "非涉密"
template_id = None
plan_id = str(args.plan_id) if args.plan_id is not None else None
content_type = "html"
# 5.1/5.23:接收人以 reportLevelList 为准;acceptEmpIdList 仅在 reportLevelList 为空时兜底。
# 更新草稿且用户显式传 --receivers 时,必须把新人写入 reportLevelList,否则详情仍显示旧 empList。
if (
receiver_names_nonempty
and not args.report_level_json
and isinstance(report_level_list, list)
and len(report_level_list) > 0
):
report_level_list = _apply_cli_receivers_to_report_level_list(
report_level_list, final_accept
)
return {
"main": args.title,
"content_html": args.content_html,
"content_type": content_type,
"type_id": args.type_id,
"grade": args.grade,
"privacy_level": privacy_level,
"plan_id": plan_id,
"template_id": template_id,
"accept_emp_id_list": final_accept,
"copy_emp_id_list": final_cc,
"report_level_list": report_level_list,
"file_vo_list": final_files,
"draft_id": args.draft_id,
}
def save_draft_full(client, kwargs: dict) -> str | None:
draft_id = kwargs.pop("draft_id", None)
try:
result = client.save_draft(draft_id=draft_id, **kwargs)
rid = result.get("id")
return str(rid) if rid is not None else None
except CWorkError as e:
print(json.dumps({"step": "save_draft", "error": str(e)}, ensure_ascii=False), file=sys.stderr)
return None
# ---------------------------------------------------------------------------
# Preview output(完整草稿,来自 5.25)
# ---------------------------------------------------------------------------
def build_preview_shell(args, confirmed: list[dict], cc_confirmed: list[dict],
file_vos: list[dict], *, from_api_detail: dict) -> dict:
import re
html = from_api_detail.get("contentHtml") or ""
plain = re.sub(r"<[^>]+>", "", html)
plain_stripped = plain.strip()
plain_len = len(plain_stripped)
# summary.contentPreview:极短正文不截断,避免占位符被「摘要」误伤;仅超长纯文本才截断
preview_cap = 4000
if len(plain) <= preview_cap:
content_preview = plain
else:
content_preview = plain[:preview_cap] + "…"
# confirmPrompt 内嵌正文:用纯文本预览,避免重复塞入整段 HTML;短正文全文展示便于审核
prompt_body_cap = 2000
prompt_plain = plain if len(plain) <= prompt_body_cap else plain[:prompt_body_cap] + "…"
preview_warnings: list[str] = []
if plain_len < 50:
preview_warnings.append(
f"正文去标签后仅 {plain_len} 个字符,可能为过短或占位内容,发送前请与用户确认"
)
accept_names = [e["name"] for e in confirmed]
cc_names = [e["name"] for e in cc_confirmed]
summary: dict = {
"title": from_api_detail.get("main"),
"grade": from_api_detail.get("grade"),
"typeId": from_api_detail.get("typeId"),
"planId": from_api_detail.get("planId"),
"contentType": from_api_detail.get("contentType"),
"receiversResolved": accept_names,
"ccResolved": cc_names,
"attachmentsThisRun": [{"name": f["name"]} for f in file_vos],
"contentPlainText": plain,
"contentPreview": content_preview,
"contentPlainLength": plain_len,
}
if preview_warnings:
summary["previewWarnings"] = preview_warnings
warn_prefix = ("⚠ " + preview_warnings[0] + "\n\n") if preview_warnings else ""
confirm_prompt = (
warn_prefix
+ "【请用户确认以下完整草稿后再发送】\n"
f"标题:{from_api_detail.get('main')}\n"
f"优先级:{from_api_detail.get('grade')}\n"
"正文(纯文本预览;完整富文本见 draftDetail.contentHtml):\n"
f"{prompt_plain}\n"
f"接收人(解析结果):{', '.join(accept_names) or '(沿用草稿详情)'}\n"
f"抄送:{', '.join(cc_names) or '(沿用草稿详情)'}\n"
f"附件:{json.dumps(from_api_detail.get('fileList') or [], ensure_ascii=False)}\n"
f"流程节点 reportLevelList:{json.dumps(from_api_detail.get('reportLevelList') or [], ensure_ascii=False)}\n"
"\n用户同意后,执行:--draft-id <汇报id> --confirm-send"
)
return {
"reportId": from_api_detail.get("id"),
"note": (
"以下为接口 5.25 返回的完整草稿数据(draftDetail),请向用户展示全文后再发送。"
"确认无误后使用 --draft-id <汇报id> --confirm-send。"
),
"draftDetail": from_api_detail,
"summary": summary,
"confirmPrompt": confirm_prompt,
}
# ---------------------------------------------------------------------------
# Main
# ---------------------------------------------------------------------------
def main():
try:
sys.stdout.reconfigure(encoding="utf-8")
except AttributeError:
pass
apply_params_file_pre_parse()
args = parse_args()
send_only = bool(args.confirm_send and args.draft_id and not args.preview_only)
if send_only:
if args.title is not None or args.content_html is not None:
print(json.dumps({
"success": False,
"error": "发送-only 模式请勿再传 --title/--content-html;仅需 --draft-id 与 --confirm-send",
}, ensure_ascii=False), file=sys.stderr)
sys.exit(1)
else:
if not args.title or args.content_html is None:
print(json.dumps({
"success": False,
"error": "保存草稿需要 --title 与 --content-html(或使用 --draft-id + --confirm-send 仅发出)",
}, ensure_ascii=False), file=sys.stderr)
sys.exit(1)
client = make_client()
if send_only:
try:
ok = client.submit_draft(args.draft_id)
print(json.dumps({
"success": bool(ok),
"reportId": args.draft_id,
"submitted": bool(ok),
"message": "已通过 5.27 将草稿转为正式汇报" if ok else "5.27 返回未成功",
}, ensure_ascii=False, indent=2))
sys.exit(0 if ok else 1)
except CWorkError as e:
print(json.dumps({
"success": False,
"error": str(e),
"reportId": args.draft_id,
}, ensure_ascii=False), file=sys.stderr)
sys.exit(1)
receiver_names = [n.strip() for n in args.receivers.split(",") if n.strip()]
cc_names = [n.strip() for n in args.cc_names.split(",") if n.strip()]
receiver_nonempty = bool(receiver_names)
cc_nonempty = bool(cc_names)
resolved = resolve_receivers(client, receiver_names)
confirmed, errors = validate_receivers(resolved)
cc_resolved = resolve_receivers(client, cc_names)
cc_confirmed, cc_errors = validate_receivers(cc_resolved)
all_errors = []
if errors:
all_errors.append({"field": "receivers", "errors": errors})
if cc_errors:
all_errors.append({"field": "cc", "errors": cc_errors})
if all_errors:
print(json.dumps({
"success": False,
"step": "validate_names",
"message": "部分姓名未能唯一匹配,请确认后重试",
"details": all_errors,
}, ensure_ascii=False, indent=2))
sys.exit(1)
accept_emp_ids = [str(e["empId"]) for e in confirmed]
cc_emp_ids = [str(e["empId"]) for e in cc_confirmed]
detail: dict | None = None
if args.draft_id:
try:
detail = client.get_draft_detail(args.draft_id)
except CWorkError as e:
print(json.dumps({
"success": False,
"step": "get_draft_detail",
"error": str(e),
}, ensure_ascii=False), file=sys.stderr)
sys.exit(1)
file_vos = upload_files(client, args.file_paths, args.file_names)
new_uploads = bool(args.file_paths)
kwargs = build_save_draft_kwargs(
args,
detail=detail,
accept_emp_ids=accept_emp_ids,
cc_emp_ids=cc_emp_ids,
file_vos=file_vos,
receiver_names_nonempty=receiver_nonempty,
cc_names_nonempty=cc_nonempty,
new_uploads=new_uploads,
)
saved_report_id = save_draft_full(client, kwargs)
if not saved_report_id:
print(json.dumps({
"success": False,
"step": "save_draft",
"message": "草稿保存失败",
}, ensure_ascii=False, indent=2))
sys.exit(1)
try:
fresh_detail = client.get_draft_detail(saved_report_id)
except CWorkError as e:
print(json.dumps({
"success": False,
"step": "get_draft_detail",
"error": str(e),
"reportId": saved_report_id,
}, ensure_ascii=False), file=sys.stderr)
sys.exit(1)
preview = build_preview_shell(
args, confirmed, cc_confirmed, file_vos, from_api_detail=fresh_detail,
)
preview["success"] = True
# draftId:历史 JSON 键名,值为汇报 id,与 reportId / draftDetail.id 相同(非草稿箱记录 id)
preview["draftId"] = saved_report_id
if not args.confirm_send or args.preview_only:
preview["nextStep"] = (
"已向用户展示完整草稿(draftDetail)并确认无误后,再执行:"
f"--draft-id {saved_report_id} --confirm-send"
)
if args.preview_only and args.confirm_send:
preview["noteOnFlags"] = "已指定 --preview-only,不会发出;忽略 --confirm-send"
print(json.dumps(preview, ensure_ascii=False, indent=2))
return
try:
ok = client.submit_draft(saved_report_id)
except CWorkError as e:
print(json.dumps({
"success": False,
"step": "submit_draft_5_27",
"error": str(e),
"draftId": saved_report_id,
}, ensure_ascii=False), file=sys.stderr)
sys.exit(1)
if not ok:
print(json.dumps({
"success": False,
"step": "submit_draft_5_27",
"draftId": saved_report_id,
"message": "5.27 返回未成功",
}, ensure_ascii=False, indent=2))
sys.exit(1)
print(json.dumps({
"success": True,
"reportId": saved_report_id,
"submitted": True,
"receivers": accept_emp_ids,
"cc": cc_emp_ids,
"attachmentsThisRun": len(file_vos),
"message": "已通过 5.27 将草稿转为正式汇报",
}, ensure_ascii=False, indent=2))
if __name__ == "__main__":
main()
FILE:scripts/cwork-templates.py
#!/usr/bin/env python3
"""
cwork-templates.py — 模板管理
功能:
1. 查询汇报模板列表
用法:
python3 cwork-templates.py list --limit 50
"""
import argparse
import json
import sys
import os
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
from cwork_client import make_client, apply_params_file_pre_parse
def list_templates(args):
"""查询模板列表"""
client = make_client()
result = client.list_templates(
limit=args.limit,
begin_time=getattr(args, "begin_time", None),
end_time=getattr(args, "end_time", None),
)
if args.output_raw:
print(json.dumps(result, ensure_ascii=False, indent=2))
return
# 结构化输出(API 返回 recentOperateTemplates 或 rows 或直接是列表)
if isinstance(result, list):
templates = result
else:
templates = (
result.get("recentOperateTemplates")
or result.get("rows")
or []
)
output = {
"success": True,
"action": "list",
"total": len(templates),
"items": [
{
"id": t.get("id") or t.get("templateId"),
"name": t.get("name") or t.get("templateName") or t.get("main"),
"type": t.get("type"),
"typeName": t.get("typeName"),
"grade": t.get("grade"),
}
for t in templates
]
}
print(json.dumps(output, ensure_ascii=False, indent=2))
def main():
parser = argparse.ArgumentParser(
description="CWork 模板管理",
formatter_class=argparse.RawDescriptionHelpFormatter
)
subparsers = parser.add_subparsers(dest="action", help="操作类型")
# list 子命令
list_parser = subparsers.add_parser("list", help="查询模板列表")
list_parser.add_argument("--limit", type=int, default=50, help="返回数量限制")
list_parser.add_argument("--begin-time", type=int, help="开始时间戳")
list_parser.add_argument("--end-time", type=int, help="结束时间戳")
list_parser.add_argument("--output-raw", action="store_true", help="输出原始响应")
parser.add_argument("--params-file", dest="params_file", default=None,
help="UTF-8 JSON 文件路径,从文件读取参数")
apply_params_file_pre_parse()
args = parser.parse_args()
if not args.action:
parser.print_help()
sys.exit(1)
if args.action == "list":
list_templates(args)
if __name__ == "__main__":
main()
FILE:scripts/cwork-todo.py
#!/usr/bin/env python3
"""
cwork-todo.py — 待办管理
功能:
1. 查询待办列表
2. 完成待办
用法:
python3 cwork-todo.py list --page-size 20 --status pending
python3 cwork-todo.py complete --todo-id <id> --content "已完成"
"""
import argparse
import json
import sys
import os
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
from cwork_client import make_client, apply_params_file_pre_parse
def list_todos(args):
"""查询待办列表"""
client = make_client()
result = client.get_todo_list(
page_index=args.page_index,
page_size=args.page_size
)
if args.output_raw:
print(json.dumps(result, ensure_ascii=False, indent=2))
return
# 5.15 返回 PageInfo:列表在 ``list``(见开放 API 6.3),非 ``rows``
rows = result.get("list") or result.get("rows") or []
total = result.get("total", len(rows))
output = {
"success": True,
"action": "list",
"total": total,
"items": [
{
"todoId": item.get("todoId"),
"reportId": item.get("reportId"),
"id": item.get("todoId"),
"title": item.get("main") or item.get("title"),
"type": item.get("todoType") or item.get("type"),
"status": item.get("status"),
"createTime": item.get("createTime"),
"creator": item.get("writeEmpName") or item.get("creatorName"),
}
for item in rows
],
}
print(json.dumps(output, ensure_ascii=False, indent=2))
def complete_todo(args):
"""完成待办"""
client = make_client()
if not args.todo_id:
print(json.dumps({
"success": False,
"error": "缺少必填参数: --todo-id"
}, ensure_ascii=False), file=sys.stderr)
sys.exit(1)
if not args.content:
print(json.dumps({
"success": False,
"error": "缺少必填参数: --content"
}, ensure_ascii=False), file=sys.stderr)
sys.exit(1)
if args.dry_run:
print(json.dumps({
"success": True,
"action": "complete",
"dryRun": True,
"todoId": args.todo_id,
"content": args.content
}, ensure_ascii=False, indent=2))
return
result = client.complete_todo(
todo_id=args.todo_id,
content=args.content,
operate=args.operate
)
output = {
"success": True,
"action": "complete",
"todoId": args.todo_id,
"result": result
}
print(json.dumps(output, ensure_ascii=False, indent=2))
def main():
parser = argparse.ArgumentParser(
description="CWork 待办管理",
formatter_class=argparse.RawDescriptionHelpFormatter
)
parser.add_argument("--params-file", dest="params_file", default=None,
help="UTF-8 JSON 文件路径,从文件读取参数")
subparsers = parser.add_subparsers(dest="action", help="操作类型")
# list 子命令
list_parser = subparsers.add_parser("list", help="查询待办列表")
list_parser.add_argument("--page-index", type=int, default=1, help="页码")
list_parser.add_argument("--page-size", type=int, default=20, help="每页数量")
list_parser.add_argument("--status", type=str, help="状态筛选")
list_parser.add_argument("--output-raw", action="store_true", help="输出原始响应")
# complete 子命令
complete_parser = subparsers.add_parser("complete", help="完成待办")
complete_parser.add_argument("--todo-id", type=str, required=True, help="待办 ID")
complete_parser.add_argument("--content", type=str, required=True, help="完成说明")
complete_parser.add_argument("--operate", type=str, default=None,
choices=["agree", "disagree"],
help="决策操作: agree=同意, disagree=不同意(仅决策类待办需要)")
complete_parser.add_argument("--dry-run", action="store_true", help="仅预览")
apply_params_file_pre_parse()
args = parser.parse_args()
if not args.action:
parser.print_help()
sys.exit(1)
if args.action == "list":
list_todos(args)
elif args.action == "complete":
complete_todo(args)
if __name__ == "__main__":
main()
FILE:scripts/cwork_client.py
#!/usr/bin/env python3
"""
CWork API Client — 共享 API 封装
由所有编排脚本 import 使用
环境变量:
CWORK_BASE_URL (default: https://sg-al-cwork-web.mediportal.com.cn)
CWORK_APP_KEY (required)
"""
from __future__ import annotations
import os
import json
import sys
import urllib.request
import urllib.parse
import urllib.error
import argparse
from datetime import datetime
# 在模块加载时强制 stdout/stderr 使用 UTF-8,避免在 LANG=en_US 等环境下
# argparse --help 或任何 print() 输出中文时崩溃
try:
sys.stdout.reconfigure(encoding="utf-8", errors="replace")
sys.stderr.reconfigure(encoding="utf-8", errors="replace")
except AttributeError:
pass
# ---------------------------------------------------------------------------
# HTTP redirect handler — preserves method on 307/308
# ---------------------------------------------------------------------------
class _MethodPreservingRedirectHandler(urllib.request.HTTPRedirectHandler):
"""urllib 默认不跟随 307 的 POST 请求;此 handler 保留原始 method 和 body。"""
def redirect_request(self, req, fp, code, msg, headers, newurl):
new_headers = {k: v for k, v in req.header_items()
if k.lower() not in ("host", "content-length")}
return urllib.request.Request(
newurl,
data=req.data,
headers=new_headers,
method=req.method,
origin_req_host=req.origin_req_host,
unverifiable=True,
)
def http_error_307(self, req, fp, code, msg, headers):
return self.http_error_302(req, fp, code, msg, headers)
def http_error_308(self, req, fp, code, msg, headers):
return self.http_error_302(req, fp, code, msg, headers)
# ---------------------------------------------------------------------------
# Exceptions
# ---------------------------------------------------------------------------
class CWorkError(Exception):
"""Raised on API errors (non-1 resultCode or HTTP non-OK)."""
pass
# ---------------------------------------------------------------------------
# Client
# ---------------------------------------------------------------------------
class CWorkClient:
BASE_URL = os.environ.get(
"CWORK_BASE_URL",
"https://sg-al-cwork-web.mediportal.com.cn"
)
def __init__(self, app_key: str | None = None):
self.app_key = app_key or os.environ.get("CWORK_APP_KEY")
if not self.app_key:
raise CWorkError("CWORK_APP_KEY environment variable is required")
# ---- HTTP helpers -------------------------------------------------------
def _headers(self, json_body: bool = False) -> dict:
h = {"appKey": self.app_key}
if json_body:
h["Content-Type"] = "application/json"
return h
def _get(self, path: str, params: dict | None = None) -> dict:
url = f"{self.BASE_URL}{path}"
if params:
q = urllib.parse.urlencode({k: v for k, v in params.items() if v is not None})
url = f"{url}?{q}&appKey={self.app_key}"
else:
url = f"{url}?appKey={self.app_key}"
req = urllib.request.Request(url, headers=self._headers(), method="GET")
return self._request(req)
def _post(self, path: str, body: dict | None = None) -> dict:
url = f"{self.BASE_URL}{path}"
data = json.dumps(body).encode() if body else None
req = urllib.request.Request(
url,
data=data,
headers=self._headers(json_body=body is not None),
method="POST",
)
return self._request(req)
def _request(self, req: urllib.request.Request) -> dict:
try:
opener = urllib.request.build_opener(_MethodPreservingRedirectHandler)
with opener.open(req, timeout=30) as resp:
raw = resp.read()
charset = resp.headers.get_content_charset() or "utf-8"
result = json.loads(raw.decode(charset))
except urllib.error.HTTPError as e:
raise CWorkError(f"HTTP {e.code}: {e.reason}")
except urllib.error.URLError as e:
raise CWorkError(f"URL error: {e.reason}")
if result.get("resultCode", -1) != 1:
raise CWorkError(
f"API Error ({result.get('resultCode')}): {result.get('resultMsg', 'unknown')}"
)
return result.get("data", {})
# -------------------------------------------------------------------------
# Employee
# -------------------------------------------------------------------------
def search_emp_by_name(self, search_key: str) -> dict:
"""Returns {inside: {empList:[]}, outside: {empList:[]}}"""
return self._get("/open-api/cwork-user/searchEmpByName", {"searchKey": search_key})
# -------------------------------------------------------------------------
# Reports — inbox / outbox / detail
# -------------------------------------------------------------------------
def get_inbox_list(
self,
page_size: int,
page_index: int = 1,
*,
report_record_type: int | None = None,
emp_id_list: list[str] | None = None,
begin_time: int | None = None,
end_time: int | None = None,
read_status: int | None = None,
order_column: str | None = None,
grade: str | None = None,
template_id: int | None = None,
) -> dict:
"""PaginatedResult<ReportListItem>"""
params = {
"pageSize": page_size,
"pageIndex": page_index,
"reportRecordType": report_record_type,
"empIdList": emp_id_list,
"beginTime": begin_time,
"endTime": end_time,
"readStatus": read_status,
"orderColumn": order_column,
"grade": grade,
"templateId": template_id,
}
return self._post("/open-api/work-report/report/record/inbox", params)
def get_outbox_list(
self,
page_size: int,
page_index: int = 1,
*,
report_record_type: int | None = None,
emp_id_list: list[str] | None = None,
begin_time: int | None = None,
end_time: int | None = None,
grade: str | None = None,
template_id: int | None = None,
) -> dict:
params = {
"pageSize": page_size,
"pageIndex": page_index,
"reportRecordType": report_record_type,
"empIdList": emp_id_list,
"beginTime": begin_time,
"endTime": end_time,
"grade": grade,
"templateId": template_id,
}
return self._post("/open-api/work-report/report/record/outbox", params)
def get_report_info(self, report_id: str) -> dict:
"""ReportDetail"""
return self._get("/open-api/work-report/report/info", {"reportId": report_id})
def get_report_node_detail(self, report_id: str | int) -> dict:
"""
获取汇报详情(含节点与处理意见)
API: 5.33 /work-report/report/getReportNodeDetail
Returns:
{
"id": 汇报ID,
"main": 标题,
"content": 正文,
"writeEmpId": 汇报人ID,
"writeEmpName": 汇报人姓名,
"createTime": 发起时间,
"nodeList": [
{
"nodeName": "建议人",
"type": "建议/决策/传阅",
"status": "未开始/已完成/进行中/已取消",
"level": 1,
"userList": [
{
"empId": 员工ID,
"name": 姓名,
"status": "待处理/已处理",
"operate": "同意/不同意/建议",
"content": "处理意见",
"finishTime": "完成时间"
}
]
}
]
}
"""
return self._get("/open-api/work-report/report/getReportNodeDetail", {"reportId": report_id})
def get_unread_list(self, page_index: int, page_size: int) -> dict:
return self._post("/open-api/work-report/reportInfoOpenQuery/unreadList", {
"pageIndex": page_index,
"pageSize": page_size,
})
def is_report_read(self, report_id: str | int, employee_id: str | int) -> bool:
return self._get(
"/open-api/work-report/reportInfoOpenQuery/isReportRead",
{"reportId": str(report_id), "employeeId": str(employee_id)},
)
def mark_report_read(self, report_id: str | int) -> None:
self._get(
"/open-api/work-report/open-platform/report/readReport",
{"reportId": str(report_id)},
)
# -------------------------------------------------------------------------
# Report — reply / submit / remind
# -------------------------------------------------------------------------
def submit_report(
self,
main: str,
content_html: str,
*,
report_id: str | None = None,
content_type: str = "html",
type_id: int = 9999,
grade: str = "一般",
privacy_level: str = "非涉密",
plan_id: str | None = None,
template_id: int | None = None,
accept_emp_id_list: list[str] | None = None,
copy_emp_id_list: list[str] | None = None,
report_level_list: list[dict] | None = None,
file_vo_list: list[dict] | None = None,
) -> dict:
"""Submit a report. Pass report_id to promote an existing draft."""
payload = {
"appKey": self.app_key,
"main": main,
"contentHtml": content_html,
"contentType": content_type,
"typeId": type_id,
"grade": grade,
"privacyLevel": privacy_level,
"planId": plan_id,
"templateId": template_id,
"acceptEmpIdList": accept_emp_id_list,
"copyEmpIdList": copy_emp_id_list,
"reportLevelList": report_level_list,
"fileVOList": file_vo_list,
}
if report_id is not None:
payload["id"] = report_id
return self._post("/open-api/work-report/report/record/submit", payload)
def reply_report(
self,
report_record_id: str,
content_html: str,
*,
content_type: str = "html",
add_emp_id_list: list[str] | None = None,
send_msg: bool = True,
) -> int:
"""Returns reply ID"""
return self._post("/open-api/work-report/report/record/reply", {
"appKey": self.app_key,
"reportRecordId": report_record_id,
"contentHtml": content_html,
"contentType": content_type,
"addEmpIdList": add_emp_id_list,
"sendMsg": send_msg,
})
# -------------------------------------------------------------------------
# Draft box
# -------------------------------------------------------------------------
def save_draft(
self,
main: str,
content_html: str,
*,
draft_id: str | None = None,
id: str | None = None, # noqa: A002 — 兼容误用 save_draft(id=汇报id) 的调用方
content_type: str = "markdown",
type_id: int = 9999,
grade: str = "一般",
privacy_level: str = "非涉密",
plan_id: str | None = None,
template_id: str | None = None,
accept_emp_id_list: list[str] | None = None,
copy_emp_id_list: list[str] | None = None,
report_level_list: list[dict] | None = None,
file_vo_list: list[dict] | None = None,
) -> dict:
effective_draft_id = draft_id if draft_id is not None else id
if draft_id is not None and id is not None and str(draft_id) != str(id):
raise ValueError("save_draft: pass only one of draft_id and id")
payload = {
"appKey": self.app_key,
"main": main,
"contentHtml": content_html,
"contentType": content_type,
"typeId": type_id,
"grade": grade,
"privacyLevel": privacy_level,
"planId": plan_id,
"templateId": template_id,
"acceptEmpIdList": accept_emp_id_list,
"copyEmpIdList": copy_emp_id_list,
"reportLevelList": report_level_list,
"fileVOList": file_vo_list,
}
if effective_draft_id is not None:
payload["id"] = effective_draft_id
return self._post("/open-api/work-report/draftBox/saveOrUpdate", payload)
def list_drafts(self, page_index: int, page_size: int) -> dict:
"""5.24 草稿箱分页。``data.list[]`` 中 ``id`` 为草稿箱记录 id(用于 5.26 删除);``businessId`` 为汇报 id(``bizType=report`` 时)。"""
return self._post("/open-api/work-report/draftBox/listByPage", {
"pageIndex": page_index,
"pageSize": page_size,
})
def get_draft_detail(self, report_record_id: str) -> dict:
return self._get(f"/open-api/work-report/draftBox/detail/{report_record_id}")
def submit_draft(self, report_id: str) -> bool:
"""API 5.27: 将草稿转为正式汇报发出。路径参数 id 为汇报 id(与 saveOrUpdate 返回的 id 一致)。"""
rid = urllib.parse.quote(str(report_id), safe="")
return bool(self._post(f"/open-api/work-report/draftBox/submit/{rid}"))
def delete_draft(self, draft_id: str) -> bool:
"""5.26 删除草稿。路径 ``id`` 必须是 **5.24 列表项的 ``id``**(草稿箱记录主键)。
**不是** ``businessId``,也**不是** 汇报 id(与 5.25/5.27、``--draft-id`` 所用相同的那份汇报 id 不同)。
误传汇报 id 时,部分环境仍可能返回 ``data: true``,但草稿仍在列表中。
若只有汇报 id,请用 ``delete_draft_by_report_id``。
"""
did = urllib.parse.quote(str(draft_id), safe="")
return bool(self._post(f"/open-api/work-report/draftBox/delete/{did}"))
def delete_draft_by_report_id(
self,
report_id: str | int,
*,
page_size: int = 50,
max_pages: int = 20,
) -> bool:
"""按汇报 id 删除草稿:先 5.24 查找 ``bizType=report`` 且 ``businessId`` 匹配的行,再 5.26。
未在列表中找到对应行时返回 ``False``(不调用删除接口)。
"""
rid = str(report_id)
for page in range(1, max_pages + 1):
data = self.list_drafts(page_index=page, page_size=page_size)
items = data.get("list") or []
for row in items:
if not isinstance(row, dict):
continue
if row.get("bizType") != "report":
continue
if str(row.get("businessId")) != rid:
continue
box_id = row.get("id")
if box_id is None:
continue
return self.delete_draft(str(box_id))
if len(items) < page_size:
break
return False
# -------------------------------------------------------------------------
# Tasks
# -------------------------------------------------------------------------
def search_task_page(
self,
page_size: int,
page_index: int = 1,
*,
key_word: str | None = None,
status: int | None = None,
report_status: int | None = None,
emp_id_list: list[str] | None = None,
grades: list[str] | None = None,
label_list: list[str] | None = None,
is_read: int | None = None,
) -> dict:
params = {
"pageSize": page_size,
"pageIndex": page_index,
"keyWord": key_word,
"status": status,
"reportStatus": report_status,
"empIdList": emp_id_list,
"grades": grades,
"labelList": label_list,
"isRead": is_read,
}
return self._post("/open-api/work-report/report/plan/searchPage", params)
def get_simple_plan_and_report_info(self, plan_id: str) -> dict:
return self._get(
"/open-api/work-report/report/plan/getSimplePlanAndReportInfo",
{"planId": plan_id},
)
def create_plan(
self,
main: str,
needful: str,
target: str,
end_time: int,
type_id: int = 9999,
*,
report_emp_id_list: list[str] | None = None,
owner_emp_id_list: list[str] | None = None,
assist_emp_id_list: list[str] | None = None,
supervisor_emp_id_list: list[str] | None = None,
copy_emp_id_list: list[str] | None = None,
observer_emp_id_list: list[str] | None = None,
push_now: bool = True,
) -> str:
"""Returns plan ID string."""
result = self._post("/open-api/work-report/open-platform/report/plan/create", {
"appKey": self.app_key,
"main": main,
"needful": needful,
"target": target,
"typeId": type_id,
"reportEmpIdList": report_emp_id_list,
"ownerEmpIdList": owner_emp_id_list,
"assistEmpIdList": assist_emp_id_list,
"supervisorEmpIdList": supervisor_emp_id_list,
"copyEmpIdList": copy_emp_id_list,
"observerEmpIdList": observer_emp_id_list,
"pushNow": 1 if push_now else 0,
"endTime": end_time,
})
return str(result)
# -------------------------------------------------------------------------
# Todo / feedback
# -------------------------------------------------------------------------
def list_created_feedbacks(self, emp_id: str | None = None) -> list:
"""API 5.12: GET, optional empId filter."""
params = {}
if emp_id is not None:
params["empId"] = emp_id
return self._get(
"/open-api/work-report/todoTask/listCreatedFeedbacks", params
)
def get_todo_list(self, page_index: int, page_size: int, *, status: str | None = None) -> dict:
"""5.15 待办列表。成功时 ``data`` 为 PageInfo(``total`` + ``list``,见开放 API 6.3 / 6.18)。"""
params = {"pageIndex": page_index, "pageSize": page_size}
if status:
params["status"] = status
return self._post("/open-api/work-report/reportInfoOpenQuery/todoList", params)
def complete_todo(
self,
todo_id: str,
content: str,
*,
content_type: str = "html",
operate: str | None = None,
) -> bool:
payload: dict = {
"appKey": self.app_key,
"todoId": todo_id,
"content": content,
"contentType": content_type,
}
if operate is not None:
payload["operate"] = operate
return self._post("/open-api/work-report/open-platform/todo/completeTodo", payload)
# -------------------------------------------------------------------------
# File upload
# -------------------------------------------------------------------------
def upload_file(self, file_path: str) -> dict:
import mimetypes
boundary = "----FormBoundary7MA4YWxkTrZu0gW"
filename = os.path.basename(file_path)
mime_type = mimetypes.guess_type(file_path)[0] or "application/octet-stream"
with open(file_path, "rb") as f:
file_data = f.read()
body_parts = [
f"--{boundary}\r\n".encode(),
f'Content-Disposition: form-data; name="file"; filename="{filename}"\r\n'.encode(),
f"Content-Type: {mime_type}\r\n\r\n".encode(),
file_data,
f"\r\n--{boundary}--\r\n".encode(),
]
body = b"".join(body_parts)
req = urllib.request.Request(
f"{self.BASE_URL}/open-api/cwork-file/uploadWholeFile",
data=body,
headers={
"appKey": self.app_key,
"Content-Type": f"multipart/form-data; boundary={boundary}",
},
method="POST",
)
result = self._request(req)
if isinstance(result, str):
return {"fileId": result}
return result
# -------------------------------------------------------------------------
# Templates
# -------------------------------------------------------------------------
def list_templates(
self, begin_time: int | None = None, end_time: int | None = None, limit: int | None = None
) -> dict:
return self._post("/open-api/work-report/template/listTemplates", {
"appKey": self.app_key,
"beginTime": begin_time,
"endTime": end_time,
"limit": limit,
})
# -------------------------------------------------------------------------
# History context retrieval (for approval decision support)
# -------------------------------------------------------------------------
def get_sender_history(
self,
sender_emp_id: str | int,
days: int = 90,
max_count: int = 20
) -> dict:
"""
Get sender's historical reports (for approval context)
Args:
sender_emp_id: Sender employee ID
days: Days to look back (default 90)
max_count: Max results (default 20)
Returns:
{
"senderEmpId": "xxx",
"totalReports": 15,
"recentReports": [...]
}
"""
import time
end_time = int(time.time() * 1000)
begin_time = end_time - (days * 24 * 60 * 60 * 1000)
# Query inbox for sender's reports
inbox = self.get_inbox_list(
page_size=max_count,
emp_id_list=[str(sender_emp_id)],
begin_time=begin_time,
end_time=end_time
)
return {
"senderEmpId": str(sender_emp_id),
"totalReports": inbox.get("total", 0),
"recentReports": inbox.get("list", [])[:max_count]
}
def search_reports_by_keyword(
self,
keyword: str,
days: int = 90,
max_count: int = 100
) -> dict:
"""
Search reports by keyword (client-side filtering)
Args:
keyword: Search keyword
days: Days to look back (default 90)
max_count: Max results to fetch (default 100)
Returns:
{
"keyword": "公章",
"total": 5,
"reports": [...]
}
"""
import time
end_time = int(time.time() * 1000)
begin_time = end_time - (days * 24 * 60 * 60 * 1000)
# Fetch inbox
inbox = self.get_inbox_list(
page_size=max_count,
begin_time=begin_time,
end_time=end_time
)
# Client-side filtering
all_reports = inbox.get("list", [])
matched = [
r for r in all_reports
if keyword in r.get("main", "") or keyword in r.get("content", "")
]
return {
"keyword": keyword,
"total": len(matched),
"reports": matched[:20] # Return top 20
}
# ---------------------------------------------------------------------------
# Convenience factory
# ---------------------------------------------------------------------------
def make_client() -> CWorkClient:
app_key = os.environ.get("CWORK_APP_KEY")
if not app_key:
raise CWorkError(
"CWORK_APP_KEY environment variable is not set. "
"Run: export CWORK_APP_KEY='your-key'"
)
return CWorkClient(app_key)
# ---------------------------------------------------------------------------
# CLI argument helpers
# ---------------------------------------------------------------------------
def flatten_emp_search_bucket(raw) -> list:
"""Normalize ``inside`` / ``outside`` from searchEmpByName to a flat emp dict list.
API may return a dict with ``empList``, a list of group dicts (each with
``empList``), or a flat list of employee records.
"""
if raw is None:
return []
if isinstance(raw, dict):
emp_list = raw.get("empList")
return list(emp_list) if isinstance(emp_list, list) else []
if isinstance(raw, list):
out: list = []
for item in raw:
if not isinstance(item, dict):
continue
nested = item.get("empList")
if isinstance(nested, list) and nested:
out.extend(nested)
elif item.get("id") is not None or item.get("empId") is not None or item.get("empid") is not None:
out.append(item)
return out
return []
def parse_deadline(value: str | None) -> int | None:
"""Parse deadline string to milliseconds timestamp."""
if value is None:
return None
try:
return int(value)
except ValueError:
pass
try:
dt = datetime.strptime(value, "%Y-%m-%d")
return int(dt.timestamp() * 1000)
except ValueError:
pass
raise argparse.ArgumentTypeError(f"Invalid deadline format: {value}. Use YYYY-MM-DD or milliseconds.")
def resolve_names_to_empids(client: CWorkClient, names: list[str]) -> list[str]:
"""Resolve a list of names to empIds via search API.
Priority: internal employees (inside) > external contacts (outside).
If inside has any matches, outside is ignored entirely.
Raises CWorkError if any name is not found or matches more than one employee
within the same category (inside or outside).
"""
empids = []
for name in names:
result = client.search_emp_by_name(name)
inside_list = flatten_emp_search_bucket(result.get("inside"))
# Only fall back to outside when inside has no match at all
if inside_list:
all_emps = inside_list
else:
all_emps = flatten_emp_search_bucket(result.get("outside"))
if not all_emps:
raise CWorkError(
f'未找到姓名为"{name}"的员工,请确认姓名或直接提供员工 ID'
)
if len(all_emps) > 1:
candidates = [
{
"empId": e.get("id") or e.get("empId") or e.get("empid"),
"name": e.get("name", ""),
"title": e.get("title", ""),
"dept": e.get("mainDept", ""),
}
for e in all_emps
]
raise CWorkError(
f'"{name}" 匹配到多名员工,请指定唯一员工后重试:'
+ json.dumps(candidates, ensure_ascii=False)
)
empids.append(
all_emps[0].get("id") or all_emps[0].get("empId") or all_emps[0].get("empid")
)
return empids
def apply_params_file(args) -> None:
"""[Deprecated: use apply_params_file_pre_parse() instead]
Post-parse merge — cannot satisfy required argparse args from file.
"""
apply_params_file_pre_parse()
def apply_params_file_pre_parse() -> None:
"""Pre-scan sys.argv for --params-file, load JSON, and inject missing flags
back into sys.argv BEFORE argparse.parse_args() is called.
This allows required arguments (e.g. --mode) to be provided from a file,
which is the primary workaround for Windows PowerShell encoding issues when
passing Chinese content on the command line.
Call this at the very start of main(), before parse_args():
def main():
apply_params_file_pre_parse()
args = parse_args()
...
File format example (params.json, UTF-8):
{
"mode": "inbox",
"content": "本周工作进展",
"receivers": "张三,李四"
}
CLI args always take precedence over file values.
"""
# Step 1: find --params-file in sys.argv without a full parse
params_file = None
argv = sys.argv[1:]
for i, arg in enumerate(argv):
if arg == "--params-file" and i + 1 < len(argv):
params_file = argv[i + 1]
break
if arg.startswith("--params-file="):
params_file = arg.split("=", 1)[1]
break
if not params_file:
return
# Step 2: load the JSON file
try:
# utf-8-sig strips the UTF-8 BOM that PowerShell Out-File adds by default
with open(params_file, "r", encoding="utf-8-sig") as f:
file_params = json.load(f)
except (OSError, json.JSONDecodeError) as exc:
print(
json.dumps({"success": False, "error": f"--params-file: {exc}"},
ensure_ascii=False),
file=sys.stderr,
)
sys.exit(1)
# Step 3: build set of flags already present in sys.argv (CLI wins)
existing_flags: set[str] = set()
for arg in sys.argv[1:]:
if arg.startswith("--"):
existing_flags.add(arg.split("=")[0])
# Step 4: append missing flags to sys.argv
extra: list[str] = []
for key, value in file_params.items():
flag = f"--{key}"
if flag in existing_flags:
continue
if isinstance(value, bool):
if value:
extra.append(flag)
elif isinstance(value, list):
for v in value:
extra.extend([flag, str(v)])
else:
extra.extend([flag, str(value)])
if extra:
sys.argv.extend(extra)
def _write_utf8(text: str, stream=None) -> None:
"""Write text as UTF-8 to stream's binary buffer, falling back to print."""
target = stream or sys.stdout
try:
target.buffer.write((text + "\n").encode("utf-8"))
target.buffer.flush()
except AttributeError:
print(text, file=target)
def output_json(data: dict) -> None:
"""Output JSON to stdout (UTF-8, regardless of terminal codepage)."""
_write_utf8(json.dumps(data, ensure_ascii=False, indent=2))
def output_error(message: str) -> None:
"""Output error JSON to stderr (UTF-8) and exit."""
_write_utf8(
json.dumps({"success": False, "message": message}, ensure_ascii=False, indent=2),
sys.stderr,
)
sys.exit(1)
def interactive_confirm(step_name: str, description: str) -> bool:
"""Print step description and wait for 'confirm' from stdin."""
print(f"\n[STEP] {step_name}: {description}", file=sys.stderr)
print("Type 'confirm' to proceed (or press Enter to skip): ", file=sys.stderr, end="")
sys.stderr.flush()
try:
user_input = input().strip().lower()
return user_input == "confirm"
except (EOFError, KeyboardInterrupt):
return False
FILE:version.json
{
"skillcode": "cms-cwork-workflow",
"version": "1.0.5"
}用于"反馈问题 / 报 bug / 上报错误 / 提交 issue / 查看 Skill 问题列表 / 标记问题已解决 / 关闭问题"。处理 Skill 使用过程中遇到的报错、异常、改进建议;支持 stdin 管道接收错误输出。是 cms-create-skill 与 cms-push-skill 的统一问题反馈入口
---
name: cms-report-issue
description: 用于"反馈问题 / 报 bug / 上报错误 / 提交 issue / 查看 Skill 问题列表 / 标记问题已解决 / 关闭问题"。处理 Skill 使用过程中遇到的报错、异常、改进建议;支持 stdin 管道接收错误输出。是 cms-create-skill 与 cms-push-skill 的统一问题反馈入口
skillCode: cms-report-issue
dependencies:
- cms-auth-skills
---
# CMS Skill 问题上报工具
**当前版本**: v1.0.1
`cms-report-issue` 只负责问题闭环:
1. 提交问题。
2. 查看问题列表和统计。
3. 解决或关闭问题。
`cms-create-skill` 和 `cms-push-skill` 如果遇到问题反馈场景,统一转交到这里处理。
## 能力总览
| # | 能力 | 脚本 | 需要登录 |
|---|---|---|---|
| 1 | 提交问题 | `scripts/issue_report/report_issue.py` | 否 |
| 2 | 查看问题列表 | `scripts/issue_report/list_issues.py` | 否 |
| 3 | 更新问题状态 | `scripts/issue_report/update_issue.py` | 是 |
## 路由
- 上报问题:`python3 cms-report-issue/scripts/issue_report/report_issue.py --skill-code my-skill --version 1.0.0 --error "..."`
- 管道上报:`python3 some_script.py 2>&1 | python3 cms-report-issue/scripts/issue_report/report_issue.py --skill-code my-skill --stdin`
- 查看问题:`python3 cms-report-issue/scripts/issue_report/list_issues.py --skill-code my-skill`
- 查看统计:`python3 cms-report-issue/scripts/issue_report/list_issues.py --stats`
- 解决问题:`python3 cms-report-issue/scripts/issue_report/update_issue.py --issue-id abc123 --status resolved --resolution "已修复"`
- 关闭问题:`python3 cms-report-issue/scripts/issue_report/update_issue.py --issue-id abc123 --status closed`
## 规则
1. 问题提交、查看、关闭统一使用 `scripts/issue_report/` 下的脚本。
2. 需要鉴权的动作只有问题状态更新;鉴权统一通过 `cms-auth-skills` 准备 `access-token`。
3. 问题上报失败不应阻塞原始业务流程;装饰器模式下异常仍需重新抛出。
4. 所有说明文档统一使用 Markdown,不维护旧接口文档目录。
## 能力树
```text
cms-report-issue/
├── SKILL.md
├── references/
│ └── issue-report/
│ └── README.md
├── github-issue-templates/
│ ├── config.yml
│ ├── bug_report.yml
│ ├── feature_request.yml
│ └── ...
└── scripts/
└── issue_report/
├── README.md
├── list_issues.py
├── report_issue.py
└── update_issue.py
```
FILE:_meta.json
{
"ownerId": "kn75s45s478x9t53qv91jrmb7h8208xm",
"slug": "cms-report-issue",
"version": "1.0.1",
"publishedAt": 1775570310374
}
FILE:github-issue-templates/bug_report.yml
name: "🐞 Bug Report"
description: "提交缺陷问题(触发异常、鉴权异常、功能错误等)"
title: "[Bug] "
labels: ["type:bug", "status:triage"]
body:
- type: dropdown
id: severity
attributes:
label: 严重度
options: [critical, major, minor]
validations:
required: true
- type: dropdown
id: priority
attributes:
label: 优先级
options: [P0, P1, P2, P3]
validations:
required: true
- type: input
id: skill_main
attributes:
label: 主 Skill
placeholder: "cms-auth-skills"
validations:
required: true
- type: input
id: skill_related
attributes:
label: 关联 Skill(可选)
placeholder: "notex-skills"
- type: textarea
id: expected
attributes:
label: 期望行为
validations:
required: true
- type: textarea
id: actual
attributes:
label: 实际行为
validations:
required: true
- type: textarea
id: repro
attributes:
label: 最小复现步骤
placeholder: "1. 前置条件\n2. 输入/操作\n3. 实际结果\n4. 期望结果"
validations:
required: true
- type: textarea
id: impact
attributes:
label: 影响范围
placeholder: "影响用户、流程、频率"
validations:
required: true
- type: textarea
id: dod
attributes:
label: 验收标准(DoD)
placeholder: "- [ ] 验收点1"
validations:
required: true
FILE:github-issue-templates/config.yml
blank_issues_enabled: false
contact_links:
- name: "💬 Question / Support"
url: "https://github.com/xgjk/xg-skills/discussions"
about: "不确定是 Bug、需求还是用法问题,先在 Discussions 交流。"
FILE:github-issue-templates/documentation_issue.yml
name: "📚 Documentation Issue"
description: "文档不一致、缺失、错误"
title: "[Docs] "
labels: ["type:docs", "status:triage"]
body:
- type: input
id: doc_path
attributes: { label: 文档路径/链接 }
validations: { required: true }
- type: textarea
id: issue
attributes: { label: 问题描述 }
validations: { required: true }
- type: textarea
id: expected
attributes: { label: 建议修正文案/结构 }
validations: { required: true }
FILE:github-issue-templates/feature_request.yml
name: "✨ Feature Request"
description: "提交新需求 / 功能增强 / 未来规划"
title: "[Feature] "
labels: ["type:feature", "status:triage"]
body:
- type: dropdown
id: subtype
attributes:
label: 类型
options: [feature, enhancement, roadmap]
validations:
required: true
- type: dropdown
id: priority
attributes:
label: 优先级
options: [P0, P1, P2, P3]
validations:
required: true
- type: input
id: skill_main
attributes:
label: 主 Skill
placeholder: "notex-skills"
validations:
required: true
- type: input
id: skill_related
attributes:
label: 关联 Skill(可选)
- type: textarea
id: background
attributes:
label: 需求背景
validations:
required: true
- type: textarea
id: proposal
attributes:
label: 方案描述
validations:
required: true
- type: textarea
id: value
attributes:
label: 价值与收益
validations:
required: true
- type: textarea
id: dod
attributes:
label: 验收标准(DoD)
validations:
required: true
FILE:github-issue-templates/incident_report.yml
name: "🚨 Incident Report"
description: "线上事故/业务中断"
title: "[Incident] "
labels: ["type:incident", "priority:P0", "severity:critical", "status:triage"]
body:
- type: input
id: started
attributes: { label: 发生时间 }
validations: { required: true }
- type: textarea
id: symptoms
attributes: { label: 现象与影响 }
validations: { required: true }
- type: textarea
id: mitigation
attributes: { label: 临时止血措施 }
- type: textarea
id: next
attributes: { label: 后续修复计划 }
FILE:github-issue-templates/integration_issue.yml
name: "🔗 Integration Issue"
description: "跨 Skill 集成 / 鉴权链路问题"
title: "[Integration] "
labels: ["type:integration", "scope:cross-skill", "status:triage"]
body:
- type: input
id: skills
attributes: { label: 涉及 Skill 列表, placeholder: "cms-auth-skills, notex-skills" }
validations: { required: true }
- type: textarea
id: chain
attributes: { label: 调用链路(谁调用谁) }
validations: { required: true }
- type: textarea
id: failure
attributes: { label: 失败点与错误表现 }
validations: { required: true }
FILE:github-issue-templates/performance_issue.yml
name: "⚡ Performance Issue"
description: "性能 / 延迟 / 稳定性问题"
title: "[Perf] "
labels: ["type:perf", "status:triage"]
body:
- type: input
id: skill
attributes: { label: 主 Skill, placeholder: "skill-name" }
validations: { required: true }
- type: textarea
id: symptom
attributes: { label: 性能现象(慢/超时/抖动) }
validations: { required: true }
- type: textarea
id: metrics
attributes: { label: 指标与数据(响应时间、失败率等) }
validations: { required: true }
- type: textarea
id: repro
attributes: { label: 最小复现 }
validations: { required: true }
FILE:github-issue-templates/question_support.yml
name: "❓ Question / Support"
description: "用法咨询、排查求助"
title: "[Question] "
labels: ["type:question", "status:needs-info"]
body:
- type: input
id: skill
attributes: { label: 涉及 Skill }
- type: textarea
id: question
attributes: { label: 问题描述 }
validations: { required: true }
- type: textarea
id: tried
attributes: { label: 已尝试方案 }
FILE:github-issue-templates/refactor_proposal.yml
name: "🛠️ Refactor Proposal"
description: "技术债与重构建议"
title: "[Refactor] "
labels: ["type:refactor", "status:triage"]
body:
- type: input
id: scope
attributes: { label: 重构范围 }
validations: { required: true }
- type: textarea
id: problem
attributes: { label: 当前技术债问题 }
validations: { required: true }
- type: textarea
id: plan
attributes: { label: 重构方案 }
validations: { required: true }
FILE:github-issue-templates/regression_report.yml
name: "♻️ Regression Report"
description: "回归问题(之前正常,现在异常)"
title: "[Regression] "
labels: ["type:bug", "area:regression", "status:triage"]
body:
- type: input
id: skill
attributes: { label: 主 Skill }
validations: { required: true }
- type: input
id: good_version
attributes: { label: 上一个正常版本/时间 }
- type: input
id: bad_version
attributes: { label: 当前异常版本/时间 }
- type: textarea
id: repro
attributes: { label: 最小复现步骤 }
validations: { required: true }
FILE:github-issue-templates/roadmap_proposal.yml
name: "🗺️ Roadmap Proposal"
description: "未来规划、阶段目标、里程碑建议"
title: "[Roadmap] "
labels: ["type:roadmap", "status:triage"]
body:
- type: textarea
id: vision
attributes: { label: 目标与愿景 }
validations: { required: true }
- type: textarea
id: milestones
attributes: { label: 里程碑(M1/M2/M3) }
validations: { required: true }
- type: textarea
id: risks
attributes: { label: 风险与依赖 }
validations: { required: true }
FILE:github-issue-templates/security_issue.yml
name: "🔒 Security Issue"
description: "安全问题(请勿公开敏感细节)"
title: "[Security] "
labels: ["type:security", "priority:P0", "status:triage"]
body:
- type: markdown
attributes:
value: "⚠️ 请勿在公开 issue 中披露可利用细节、密钥、token。"
- type: textarea
id: summary
attributes: { label: 安全问题概要 }
validations: { required: true }
- type: textarea
id: impact
attributes: { label: 影响范围 }
validations: { required: true }
FILE:references/issue-report/README.md
# issue-report 模块说明
## 模块职责
`cms-report-issue` 负责 Skill 问题闭环:
1. 提交问题。
2. 查看问题列表和统计。
3. 解决或关闭问题。
## 脚本清单
| 脚本 | 说明 | 需要鉴权 |
|---|---|---|
| `report_issue.py` | 提交问题,支持命令行和 stdin 管道输入 | 否 |
| `list_issues.py` | 查看问题列表、按条件筛选、查看统计 | 否 |
| `update_issue.py` | 将问题更新为 `resolved` 或 `closed` | 是 |
## 状态流转
```text
open -> resolved -> closed
```
## 常见场景
| 场景 | 脚本 |
|---|---|
| Skill 运行异常,需要快速提交问题 | `report_issue.py` |
| 想看某个 Skill 还有哪些未处理问题 | `list_issues.py --skill-code ...` |
| 问题已修复,需要标记解决 | `update_issue.py --status resolved` |
| 问题确认结案 | `update_issue.py --status closed` |
## GitHub 模板
`github-issue-templates/` 下提供标准化的 GitHub Issue 模板,可按需复制到目标仓库的 `.github/ISSUE_TEMPLATE/` 中使用。
## 鉴权边界
- 问题提交和查看默认可直接调用。
- 问题状态更新前,统一通过 `cms-auth-skills` 准备 `access-token`。
- 本模块不自己处理登录或换取 token。
FILE:scripts/issue_report/README.md
# issue_report 脚本清单
## 模块定位
`issue_report` 负责问题提交、查看和关闭,对应说明统一见 `../../references/issue-report/README.md`。
## 脚本列表
| 脚本 | 说明 | 需要鉴权 |
|---|---|---|
| `report_issue.py` | 提交问题 | 否 |
| `list_issues.py` | 查看问题列表、筛选、统计 | 否 |
| `update_issue.py` | 更新问题状态 | 是 |
## 说明
- 关闭问题前,统一通过 `cms-auth-skills` 准备 `access-token`。
- `github-issue-templates/` 可按需复制到目标仓库使用。
FILE:scripts/issue_report/list_issues.py
#!/usr/bin/env python3
"""
查看已上报的 Skill 问题列表。
使用方式:
python3 cms-report-issue/scripts/issue_report/list_issues.py
python3 cms-report-issue/scripts/issue_report/list_issues.py --skill-code "work-collaboration"
python3 cms-report-issue/scripts/issue_report/list_issues.py --severity error
python3 cms-report-issue/scripts/issue_report/list_issues.py --status open
"""
import argparse
import json
import os
import ssl
import sys
import urllib.request
DEFAULT_API_BASE = 'https://skills.mediportal.com.cn'
API_BASE = DEFAULT_API_BASE
def _ssl_context():
ctx = ssl.create_default_context()
ctx.check_hostname = False
ctx.verify_mode = ssl.CERT_NONE
return ctx
def _get_auth_headers():
headers = {'Content-Type': 'application/json'}
token = (
os.environ.get('XG_USER_TOKEN')
or os.environ.get('access-token')
or os.environ.get('ACCESS_TOKEN')
or ''
)
if token:
headers['access-token'] = token
return headers
def list_issues(skill_code: str = '', status: str = '', severity: str = '', api_base: str = '') -> list:
base_url = (api_base or API_BASE).rstrip('/')
url = f'{base_url}/api/skill/issues/list'
payload = {}
if skill_code:
payload['skillCode'] = skill_code
if status:
payload['status'] = status
if severity:
payload['severity'] = severity
body = json.dumps(payload, ensure_ascii=False).encode('utf-8')
ctx = _ssl_context()
req = urllib.request.Request(url, data=body, headers=_get_auth_headers(), method='POST')
with urllib.request.urlopen(req, context=ctx, timeout=30) as resp:
data = json.loads(resp.read().decode('utf-8'))
if data.get('resultCode') not in (None, 1):
raise RuntimeError(f"查询失败: {data.get('resultMsg', '未知错误')}")
return data.get('data', [])
def get_issue_stats(api_base: str = '') -> dict:
base_url = (api_base or API_BASE).rstrip('/')
url = f'{base_url}/api/skill/issues/stats'
body = json.dumps({}).encode('utf-8')
ctx = _ssl_context()
req = urllib.request.Request(url, data=body, headers=_get_auth_headers(), method='POST')
with urllib.request.urlopen(req, context=ctx, timeout=30) as resp:
data = json.loads(resp.read().decode('utf-8'))
if data.get('resultCode') not in (None, 1):
raise RuntimeError(f"查询统计失败: {data.get('resultMsg', '未知错误')}")
return data.get('data', {})
def format_issues(issues: list) -> str:
if not issues:
return '(暂无问题记录)'
lines = []
lines.append(f"{'#':<4} {'级别':<8} {'Skill':<22} {'状态':<10} {'上报时间':<22} {'问题摘要'}")
lines.append('-' * 112)
for index, issue in enumerate(issues, 1):
severity = issue.get('severity', 'error')
skill = (issue.get('skillName') or issue.get('skillCode') or '')[:20]
status = issue.get('status', 'open')
reported_at = (issue.get('reportedAt') or '')[:19]
message = (issue.get('userMessage') or issue.get('errorMessage') or '')[:40]
lines.append(f'{index:<4} {severity:<8} {skill:<22} {status:<10} {reported_at:<22} {message}')
lines.append(f'\n共 {len(issues)} 条记录')
return '\n'.join(lines)
def main():
parser = argparse.ArgumentParser(description='查看已上报的 Skill 问题')
parser.add_argument('--skill-code', '-c', default='', help='按 Skill code 筛选')
parser.add_argument('--status', '-s', default='', choices=['', 'open', 'resolved', 'closed'], help='按状态筛选')
parser.add_argument('--severity', default='', choices=['', 'error', 'warning', 'info'], help='按级别筛选')
parser.add_argument('--stats', action='store_true', help='显示统计概览')
parser.add_argument('--json', action='store_true', help='输出原始 JSON')
parser.add_argument('--api-base', default='', help='后端地址')
args = parser.parse_args()
try:
if args.stats:
stats = get_issue_stats(api_base=args.api_base)
if args.json:
print(json.dumps(stats, ensure_ascii=False, indent=2))
else:
print('📊 问题统计概览\n')
print(f" 总 Skill 数: {stats.get('totalSkills', 0)}")
print(f" 总问题数: {stats.get('totalIssues', 0)}")
by_status = stats.get('byStatus', {})
by_severity = stats.get('bySeverity', {})
print(f" 按状态: open={by_status.get('open', 0)}, resolved={by_status.get('resolved', 0)}, closed={by_status.get('closed', 0)}")
print(f" 按级别: error={by_severity.get('error', 0)}, warning={by_severity.get('warning', 0)}, info={by_severity.get('info', 0)}")
return
issues = list_issues(
skill_code=args.skill_code,
status=args.status,
severity=args.severity,
api_base=args.api_base,
)
if args.json:
print(json.dumps(issues, ensure_ascii=False, indent=2))
else:
print('📋 Skill 问题列表\n')
print(format_issues(issues))
except Exception as error:
print(f'❌ {error}', file=sys.stderr)
sys.exit(1)
if __name__ == '__main__':
main()
FILE:scripts/issue_report/report_issue.py
#!/usr/bin/env python3
"""
Skill 问题反馈。
用途:将 Skill 运行错误、用户反馈的问题整理后上报到技能管理平台。
使用方式:
python3 cms-report-issue/scripts/issue_report/report_issue.py \
--skill-code "work-collaboration" \
--version "1.0.0" \
--error "requests.exceptions.ConnectionError: 连接超时" \
--message "用户调用汇报提交接口时连接超时,重试 3 次均失败" \
--issue-type "bug" \
--severity "critical"
python3 some_script.py 2>&1 | python3 cms-report-issue/scripts/issue_report/report_issue.py \
--skill-code "xxx" --version "1.0.0" --stdin --issue-type "bug"
"""
import argparse
import functools
import json
import os
import ssl
import sys
import time
import traceback
import urllib.error
import urllib.request
DEFAULT_API_BASE = 'https://skills.mediportal.com.cn'
API_BASE = DEFAULT_API_BASE
REPORT_ENDPOINT = '/api/skill/issues/report'
ALLOWED_ISSUE_TYPES = ('bug', 'feature', 'enhancement', 'docs', 'security', 'question')
ALLOWED_SEVERITIES = ('critical', 'major', 'minor')
def _ssl_context():
ctx = ssl.create_default_context()
ctx.check_hostname = False
ctx.verify_mode = ssl.CERT_NONE
return ctx
def _get_auth_headers():
headers = {'Content-Type': 'application/json'}
token = (
os.environ.get('XG_USER_TOKEN')
or os.environ.get('access-token')
or os.environ.get('ACCESS_TOKEN')
or ''
)
if token:
headers['access-token'] = token
return headers
def _signature(skill_code: str, version: str, error_message: str, user_message: str) -> str:
import hashlib
raw = f"{skill_code}|{version}|{error_message.strip()}|{user_message.strip()}"
return hashlib.sha1(raw.encode('utf-8')).hexdigest()
def find_duplicate_open_issue(skill_code: str, signature: str, api_base: str = '') -> dict | None:
"""查询同一 skillCode 下是否已存在等效的 open 问题。失败时静默返回 None。"""
base_url = (api_base or API_BASE).rstrip('/')
url = f'{base_url}/api/skill/issues/list'
payload = {'skillCode': skill_code, 'status': 'open'}
body = json.dumps(payload, ensure_ascii=False).encode('utf-8')
try:
req = urllib.request.Request(url, data=body, headers=_get_auth_headers(), method='POST')
with urllib.request.urlopen(req, context=_ssl_context(), timeout=15) as resp:
data = json.loads(resp.read().decode('utf-8'))
if data.get('resultCode') not in (None, 1):
return None
for issue in data.get('data', []) or []:
existing_sig = _signature(
skill_code,
issue.get('version', ''),
issue.get('errorMessage', '') or '',
issue.get('userMessage', '') or '',
)
if existing_sig == signature:
return issue
except Exception:
return None
return None
def report_issue(
skill_code: str,
version: str = '1.0.0',
skill_name: str = '',
skill_description: str = '',
error_message: str = '',
error_stack: str = '',
user_message: str = '',
context: dict = None,
issue_type: str = 'bug',
severity: str = 'critical',
api_base: str = '',
sync_robot: bool = False,
dedupe: bool = True,
) -> dict:
base_url = (api_base or API_BASE).rstrip('/')
url = f'{base_url}{REPORT_ENDPOINT}'
if dedupe:
sig = _signature(skill_code, version, error_message, user_message)
existing = find_duplicate_open_issue(skill_code, sig, api_base)
if existing:
return {
'deduped': True,
'message': '检测到相同 open 问题,跳过重复上报',
'existing': existing,
}
payload = {
'skillCode': skill_code,
'version': version,
'skillName': skill_name,
'skillDescription': skill_description,
'errorMessage': error_message,
'errorStack': error_stack,
'userMessage': user_message,
'context': context or {},
'issueType': issue_type,
'severity': severity,
'syncRobot': sync_robot,
}
body = json.dumps(payload, ensure_ascii=False).encode('utf-8')
ctx = _ssl_context()
headers = _get_auth_headers()
req = urllib.request.Request(url, data=body, headers=headers, method='POST')
last_error = None
for attempt in range(3):
try:
with urllib.request.urlopen(req, context=ctx, timeout=30) as resp:
result = json.loads(resp.read().decode('utf-8'))
if result.get('resultCode') in (None, 1):
return result.get('data', result)
raise RuntimeError(f"上报失败: {result.get('resultMsg', '未知错误')}")
except (urllib.error.URLError, urllib.error.HTTPError, OSError) as error:
last_error = error
if attempt < 2:
time.sleep(1)
raise RuntimeError(f'上报失败(重试 3 次): {last_error}')
def auto_catch(
skill_code: str,
version: str = '1.0.0',
skill_name: str = '',
skill_description: str = '',
issue_type: str = 'bug',
severity: str = 'critical',
sync_robot: bool = False,
):
def decorator(func):
@functools.wraps(func)
def wrapper(*args, **kwargs):
try:
return func(*args, **kwargs)
except Exception as error:
stack = traceback.format_exc()
try:
report_issue(
skill_code=skill_code,
version=version,
skill_name=skill_name,
skill_description=skill_description,
error_message=str(error),
error_stack=stack,
user_message=f'脚本 {func.__name__} 执行异常',
context={
'function': func.__name__,
'module': func.__module__,
'args_count': len(args),
},
issue_type=issue_type,
severity=severity,
sync_robot=sync_robot,
)
print(
f"⚠️ 问题已自动上报到技能管理平台 {'(已触发机器人)' if sync_robot else ''}",
file=sys.stderr,
)
except Exception as report_error:
print(f'⚠️ 自动上报失败: {report_error}', file=sys.stderr)
raise
return wrapper
return decorator
def main():
parser = argparse.ArgumentParser(description='Skill 问题反馈')
parser.add_argument('--skill-code', '-c', required=True, help='Skill 唯一标识')
parser.add_argument('--version', '-v', default='1.0.0', help='Skill 版本号')
parser.add_argument('--skill-name', '-n', default='', help='Skill 显示名称')
parser.add_argument('--skill-desc', default='', help='Skill 描述')
parser.add_argument('--error', '-e', default='', help='错误信息')
parser.add_argument('--stack', default='', help='错误堆栈')
parser.add_argument('--message', '-m', default='', help='用户描述的问题')
parser.add_argument('--issue-type', '-t', default='bug', choices=list(ALLOWED_ISSUE_TYPES), help='问题类型')
parser.add_argument('--severity', '-s', default='critical', choices=list(ALLOWED_SEVERITIES), help='严重级别')
parser.add_argument('--stdin', action='store_true', help='从 stdin 读取错误信息')
parser.add_argument('--api-base', default='', help='后端地址')
parser.add_argument('--sync-robot', '--sync-github', '--internal', action='store_true', help='是否同步触发机器人任务')
parser.add_argument('--no-dedupe', action='store_true', help='跳过重复检测,强制上报')
args = parser.parse_args()
error_msg = args.error
if args.stdin:
stdin_data = sys.stdin.read().strip()
if stdin_data:
error_msg = f'{error_msg}\n{stdin_data}' if error_msg else stdin_data
if not error_msg and not args.message:
print('错误: 请提供 --error 或 --message(至少一个)', file=sys.stderr)
sys.exit(1)
try:
result = report_issue(
skill_code=args.skill_code,
version=args.version,
skill_name=args.skill_name,
skill_description=args.skill_desc,
error_message=error_msg,
error_stack=args.stack,
user_message=args.message,
issue_type=args.issue_type,
severity=args.severity,
api_base=args.api_base,
sync_robot=args.sync_robot,
dedupe=not args.no_dedupe,
)
if isinstance(result, dict) and result.get('deduped'):
print('ℹ️ 已存在等效 open 问题,跳过重复上报', file=sys.stderr)
else:
print('✅ 问题已上报', file=sys.stderr)
print(json.dumps(result, ensure_ascii=False))
except RuntimeError as error:
print(f'❌ {error}', file=sys.stderr)
sys.exit(1)
if __name__ == '__main__':
main()
FILE:scripts/issue_report/update_issue.py
#!/usr/bin/env python3
"""
更新 Skill 问题状态。
使用方式:
python3 cms-report-issue/scripts/issue_report/update_issue.py \
--issue-id "abc123" \
--status resolved \
--resolution "已修复连接超时问题,增加了重试机制"
python3 cms-report-issue/scripts/issue_report/update_issue.py \
--issue-id "abc123" \
--status closed
"""
import argparse
import json
import os
import ssl
import sys
import urllib.error
import urllib.request
DEFAULT_API_BASE = 'https://skills.mediportal.com.cn'
API_BASE = DEFAULT_API_BASE
def _ssl_context():
ctx = ssl.create_default_context()
ctx.check_hostname = False
ctx.verify_mode = ssl.CERT_NONE
return ctx
def _get_auth_headers():
headers = {'Content-Type': 'application/json'}
token = (
os.environ.get('XG_USER_TOKEN')
or os.environ.get('access-token')
or os.environ.get('ACCESS_TOKEN')
or ''
)
if token:
headers['access-token'] = token
return headers
def update_issue(issue_id: str, status: str = '', resolution: str = '', api_base: str = '') -> dict:
base_url = (api_base or API_BASE).rstrip('/')
url = f'{base_url}/api/skill/issues/update'
payload = {'issueId': issue_id}
if status:
payload['status'] = status
if resolution:
payload['resolution'] = resolution
body = json.dumps(payload, ensure_ascii=False).encode('utf-8')
ctx = _ssl_context()
req = urllib.request.Request(url, data=body, headers=_get_auth_headers(), method='POST')
try:
with urllib.request.urlopen(req, context=ctx, timeout=30) as resp:
data = json.loads(resp.read().decode('utf-8'))
except urllib.error.HTTPError as error:
error_body = error.read().decode('utf-8', errors='replace')
raise RuntimeError(f'更新失败 (HTTP {error.code}): {error_body}')
except urllib.error.URLError as error:
raise RuntimeError(f'连接失败: {error.reason}')
if data.get('resultCode') not in (None, 1):
raise RuntimeError(f"更新失败: {data.get('resultMsg', '未知错误')}")
return data.get('data', data)
def main():
parser = argparse.ArgumentParser(description='更新 Skill 问题状态')
parser.add_argument('--issue-id', '-i', required=True, help='问题 ID')
parser.add_argument('--status', '-s', required=True, choices=['open', 'resolved', 'closed'], help='新状态')
parser.add_argument('--resolution', '-r', default='', help='解决方案描述')
parser.add_argument('--api-base', default='', help='后端地址')
args = parser.parse_args()
token = (
os.environ.get('XG_USER_TOKEN')
or os.environ.get('access-token')
or os.environ.get('ACCESS_TOKEN')
or ''
)
if not token:
print('⚠️ 尚未通过 cms-auth-skills 准备 access-token,可能导致认证失败', file=sys.stderr)
try:
result = update_issue(
issue_id=args.issue_id,
status=args.status,
resolution=args.resolution,
api_base=args.api_base,
)
print(f'✅ 问题状态已更新为 {args.status}', file=sys.stderr)
print(json.dumps(result, ensure_ascii=False))
except RuntimeError as error:
print(f'❌ {error}', file=sys.stderr)
sys.exit(1)
if __name__ == '__main__':
main()
提供【工作协同】全流程执行能力。用户一旦表达“写汇报/发汇报/发周报/提交汇报/查看收件箱/查待办/任务协作/业务单元”等执行意图,必须进入本 Skill 的脚本调用流程;仅当用户明确是纯咨询(如问规则、问怎么做)时,才允许先文字说明并二次确认是否执行。本 Skill 通过依赖 `cms-auth-skills`...
---
name: cms-cwork-workflow
description: 提供【工作协同】全流程执行能力。用户一旦表达“写汇报/发汇报/发周报/提交汇报/查看收件箱/查待办/任务协作/业务单元”等执行意图,必须进入本 Skill 的脚本调用流程;仅当用户明确是纯咨询(如问规则、问怎么做)时,才允许先文字说明并二次确认是否执行。本 Skill 通过依赖 `cms-auth-skills` 获取 `AppKey` 并完成鉴权后,才允许进入脚本调用链路。
skillcode: cms-cwork-workflow
github: https://github.com/xgjk/cwork-skills
dependencies:
- cms-auth-skills
# bump 时须同步修改同目录下 version.json 的 version 字段
version: 1.0.12
tools_provided:
- name: cwork_client
category: exec
risk_level: medium
permission: exec
description: CWork API共享客户端,封装HTTP请求、认证和所有业务API方法
status: active
- name: cwork-search-emp
category: exec
risk_level: low
permission: read
description: 搜索员工信息(支持模糊查询)
status: active
- name: cwork-send-report
category: exec
risk_level: medium
permission: write
description: 发送工作协同汇报(🚨限制:当你明确知道确切的人员姓名时才可使用;如果你不知道发给谁,绝对不要猜,立即停止使用此工具!)
status: active
- name: cwork-query-report
category: exec
risk_level: low
permission: read
description: 查询汇报(收件箱/发件箱/详情/历史)
status: active
- name: cwork-create-task
category: exec
risk_level: medium
permission: write
description: 创建工作计划/任务
status: active
- name: cwork-review-report
category: exec
risk_level: medium
permission: write
description: 审阅汇报(回复/标记已读)
status: active
- name: cwork-query-tasks
category: exec
risk_level: low
permission: read
description: 查询任务(我的/创建的/团队/详情)
status: active
- name: cwork-nudge-report
category: exec
risk_level: medium
permission: write
description: 催办闭环(识别未闭环/生成催办/发送)
status: active
- name: cwork-todo
category: exec
risk_level: medium
permission: write
description: 待办管理(查询/完成决策/建议/反馈)
status: active
- name: cwork-templates
category: exec
risk_level: low
permission: read
description: 查询汇报模板列表
status: active
- name: cwork-draft-box
category: exec
risk_level: high
permission: write
description: 草稿箱列表(5.24)与批量删除草稿(5.28)
status: active
- name: cwork-business-unit
category: exec
risk_level: medium
permission: write
description: 业务单元管理(创建/更新/查询/删除)
status: active
- name: cms-match-businessunit
category: exec
risk_level: medium
permission: write
description: 自动根据正文智能选择业务单元去发送(🚨前置要求:如果不清楚具体该发给谁,必须优先执行这个工具!哪怕报错未匹配,也要先调!)
status: active
- name: cwork-virtual-employee
category: exec
risk_level: medium
permission: write
description: 虚拟员工管理(创建/列表/修改/删除),并在汇报/回复/任务中透传 virtualEmpId
status: active
---
# cms-cwork-workflow
## 核心定位
本 Skill 只做一件事:根据用户执行意图,读取对应 `references/*.md`,再执行 `scripts/*.py`。
参数、边界、分支逻辑都以 `references` 为准,`SKILL.md` 只负责入口和流程约束。
## 强制前置(保持不变)
调用任何脚本前,必须先通过依赖 Skill `cms-auth-skills` 获取有效 `AppKey`。
未鉴权时,不允许执行本 Skill 的任何 Python 脚本。
本 Skill 发起的所有 CWork API 请求均基于该 `AppKey` 鉴权。
AppKey 的获取与传递方式必须为:由上游 `cms-auth-skills` 注入/传递到本 Skill 执行命令中(`--app-key`)。
## 标准执行流程(必须遵循)
1. 识别用户是“执行动作”还是“纯咨询”。
2. 若是执行动作:先定位目标脚本。
3. 先读取 `references/auth.md`,确保 AppKey 获取与注入规则满足(未读不得进入鉴权相关链路)。
4. 再读取该脚本对应的 `references/*.md`(未读不得执行)。
5. 按文档组装参数并执行 `python3 scripts/<name>.py`。
6. 如一轮调用多个脚本,每个脚本的 reference 都要先读再执行。
7. 查询汇报/任务时,若结果可识别业务 ID,应优先返回可点击 `shareLink`;如补链失败,不得阻断主查询结果。
8. 用户表达“发汇报/写汇报/提交汇报”且提到“建议人/决策人/节点/流程/reportLevelList”时,必须走汇报链路(`cwork-send-report.py` 或 `cms-match-businessunit.py`),禁止误路由到 `cwork-create-task.py`。
## 常用命令与必读文档
| 脚本 | 必读 reference | 用途 |
|------|----------------|------|
| `cwork-search-emp.py` | `references/cwork-search-emp.md` | 搜索员工 |
| `cwork-send-report.py` | `references/cwork-send-report.md` | 发送汇报(草稿 -> 确认) |
| `cms-match-businessunit.py` | `references/cms-match-businessunit.md` | 正文匹配业务单元并发送 |
| `cwork-query-report.py` | `references/cwork-query-report.md` | 查询汇报 |
| `cwork-create-task.py` | `references/cwork-create-task.md` | 创建任务 |
| `cwork-review-report.py` | `references/cwork-review-report.md` | 审阅汇报 |
| `cwork-query-tasks.py` | `references/cwork-query-tasks.md` | 查询任务 |
| `cwork-nudge-report.py` | `references/cwork-nudge-report.md` | 催办闭环 |
| `cwork-todo.py` | `references/cwork-todo.md` | 待办管理 |
| `cwork-templates.py` | `references/cwork-templates.md` | 模板查询 |
| `cwork-draft-box.py` | `references/cwork-draft-box.md` | 草稿箱 |
| `cwork-business-unit.py` | `references/cwork-business-unit.md` | 业务单元管理 |
| `cwork-virtual-employee.py` | `references/cwork-virtual-employee.md` | 虚拟员工管理 |
补充:写/发汇报场景,还需先读 `references/report-virtual-identity.md`。
## 测试示例(推荐)
### 示例 1:查未读汇报
```bash
# 第一步:先读 references/cwork-query-report.md
# 第二步:执行脚本
python3 scripts/cwork-query-report.py --app-key "<AppKey>" --mode unread --page-size 10
```
### 示例 2:标准发送汇报(两步)
```bash
# 第一步:先读 references/cwork-send-report.md
python3 scripts/cwork-send-report.py \
--app-key "<AppKey>" \
--title "周报" \
--content "<p>本周完成联调</p>" \
--receivers "张三" \
--confirm-save-draft
# 第二步:用户明确确认后再发出
python3 scripts/cwork-send-report.py --app-key "<AppKey>" --draft-id "<reportId>" --confirm-send
```
> 字段说明:上述 `--title` / `--content` 是脚本参数名;脚本调用开放接口时会自动映射为请求体字段 `main` / `contentHtml`。
### 示例 3:收件人不明确时先匹配业务单元
```bash
# 第一步:先读 references/cms-match-businessunit.md
python3 scripts/cms-match-businessunit.py \
--app-key "<AppKey>" \
--title "周报" \
--content "<p>本周完成 API 联调</p>" \
--content-type html \
--dry-run
```
## 反向示例(不要这样做)
- 未获取 `AppKey` 就直接执行 `scripts/*.py`。
- 没读对应 `references/*.md` 就起调脚本。
- 发送汇报时一次性带 `--confirm-send` 直接发出(缺少草稿确认步骤)。
- 保存/更新草稿时不带 `--confirm-save-draft`(未获用户确认即落草稿)。
- `cms-match-businessunit.py` 返回未匹配后,擅自猜测接收人继续发送。
- 测试/调试汇报默认发给无关同事(应优先 `--test-mode` + 当前用户本人或测试账号)。
- 用户明确在配置汇报节点(如建议/决策)却调用 `cwork-create-task.py` 创建任务。
## 错误处理与通用参数
通用错误格式、特殊字符处理、`--params-file` 用法请查看 `references/common-params.md`。
---
## 目录结构
```
cms-cwork-workflow/
├── SKILL.md ← 本文件(意图级接口文档)
├── scripts/
│ ├── cwork_client.py ← 共享 API 客户端(HTTP 封装 + 所有 API 方法)
│ ├── cwork-search-emp.py ← 0. 搜索员工
│ ├── cwork-send-report.py ← 1. 发送汇报
│ ├── cwork-query-report.py ← 2. 查询汇报
│ ├── cwork-create-task.py ← 3. 创建任务
│ ├── cwork-review-report.py ← 4. 审阅汇报
│ ├── cwork-query-tasks.py ← 5. 查询任务
│ ├── cwork-nudge-report.py ← 6. 催办闭环
│ ├── cwork-todo.py ← 7. 待办管理
│ ├── cwork-templates.py ← 8. 模板管理
│ ├── cwork-draft-box.py ← 9. 草稿箱列表 / 批量删除(API 5.24 / 5.28)
│ ├── cwork-business-unit.py ← 10. 业务单元管理
│ ├── cms-match-businessunit.py ← 11. 正文匹配业务单元并发汇报
│ └── cwork-virtual-employee.py ← 12. 虚拟员工管理
└── references/
├── auth.md
├── cwork-search-emp.md
├── cwork-send-report.md
├── cwork-query-report.md
├── cwork-create-task.md
├── cwork-review-report.md
├── cwork-query-tasks.md
├── cwork-nudge-report.md
├── cwork-todo.md
├── cwork-templates.md
├── cwork-draft-box.md
├── cwork-business-unit.md
├── cms-match-businessunit.md
├── cwork-virtual-employee.md
├── report-virtual-identity.md
├── edge-cases.md
├── agent-patterns.md
├── common-params.md
└── maintenance.md ← Skill 维护/发布参考(非 Cursor 规则)
```
FILE:version.json
{
"skillcode": "cms-cwork-workflow",
"version": "1.0.12"
}
FILE:scripts/cwork-search-emp.py
#!/usr/bin/env python3
"""
CWork 员工搜索工具
用途:根据姓名搜索员工 ID 和详细信息
场景:发送汇报前确认接收人、处理待办时确认发件人、创建任务时确认责任人
用法:
python3 cwork-search-emp.py --name "张"
python3 cwork-search-emp.py --name "成伟" --verbose
python3 cwork-search-emp.py --name "刘" --max-results 10
"""
import argparse
import json
import sys
import os
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
from cwork_client import CWorkClient, CWorkError, make_client, apply_params_file_pre_parse
def search_employees(client: CWorkClient, search_key: str, max_results: int = 5, verbose: bool = False) -> dict:
"""
Search employees by name keyword.
Args:
client: CWork API client
search_key: Search keyword (supports fuzzy matching)
max_results: Maximum results to return per category (inside/outside)
verbose: Include additional details
Returns:
{
"success": true,
"searchKey": "张",
"inside": [...],
"outside": [...],
"totalInside": 10,
"totalOutside": 2
}
"""
try:
result = client.search_emp_by_name(search_key)
# Parse inside employees
inside_list = []
inside_data = result.get("inside", {})
if inside_data:
company = inside_data.get("companyVO", {})
emp_list = inside_data.get("empList", [])
for emp in emp_list[:max_results]:
emp_info = {
"empId": emp.get("id"),
"name": emp.get("name"),
"title": emp.get("title", ""),
"mainDept": emp.get("mainDept", ""),
"status": "在职" if emp.get("status") == 1 else "离职"
}
if verbose:
emp_info.update({
"personId": emp.get("personId"),
"dingUserId": emp.get("dingUserId"),
"corpId": emp.get("corpId"),
"company": company.get("name", "")
})
inside_list.append(emp_info)
# Parse outside contacts
outside_list = []
outside_data = result.get("outside", [])
if outside_data:
for item in outside_data:
company = item.get("companyVO", {})
emp_list = item.get("empList", [])
for emp in emp_list[:max_results]:
emp_info = {
"empId": emp.get("id"),
"name": emp.get("name"),
"title": emp.get("title", ""),
"mainDept": emp.get("mainDept", ""),
"status": "在职" if emp.get("status") == 1 else "离职",
"company": company.get("name", "")
}
outside_list.append(emp_info)
return {
"success": True,
"searchKey": search_key,
"inside": inside_list,
"outside": outside_list,
"totalInside": len(result.get("inside", {}).get("empList", [])) if result.get("inside") else 0,
"totalOutside": sum(len(item.get("empList", [])) for item in result.get("outside", [])) if result.get("outside") else 0
}
except CWorkError as e:
return {
"success": False,
"error": str(e),
"searchKey": search_key
}
def main():
parser = argparse.ArgumentParser(
description="Search CWork employees by name",
formatter_class=argparse.RawDescriptionHelpFormatter,
epilog="""
Examples:
# Basic search
python3 cwork-search-emp.py --name "张"
# Verbose mode (includes personId, dingUserId, etc.)
python3 cwork-search-emp.py --name "成伟" --verbose
# More results
python3 cwork-search-emp.py --name "刘" --max-results 10
"""
)
parser.add_argument(
"--name", "-n",
required=True,
help="Employee name or keyword to search (supports fuzzy matching)"
)
parser.add_argument(
"--max-results", "-m",
type=int,
default=5,
help="Maximum results to return per category (default: 5)"
)
parser.add_argument(
"--verbose", "-v",
action="store_true",
help="Include additional details (personId, dingUserId, etc.)"
)
parser.add_argument(
"--output-raw",
action="store_true",
help="Output raw API response"
)
parser.add_argument(
"--params-file",
help="从 UTF-8 JSON 文件读取参数(用于 Windows 下传递中文内容)"
)
apply_params_file_pre_parse()
args = parser.parse_args()
try:
client = make_client()
# Output raw response if requested
if args.output_raw:
result = client.search_emp_by_name(args.name)
print(json.dumps(result, ensure_ascii=False, indent=2))
return
# Normal search
result = search_employees(
client,
args.name,
max_results=args.max_results,
verbose=args.verbose
)
# Output JSON to stdout
print(json.dumps(result, ensure_ascii=False, indent=2))
# Exit with error code if failed
if not result.get("success"):
sys.exit(1)
except CWorkError as e:
error_output = {
"success": False,
"error": str(e),
"searchKey": args.name
}
print(json.dumps(error_output, ensure_ascii=False, indent=2), file=sys.stderr)
sys.exit(1)
except Exception as e:
error_output = {
"success": False,
"error": f"Unexpected error: {e}",
"searchKey": args.name
}
print(json.dumps(error_output, ensure_ascii=False, indent=2), file=sys.stderr)
sys.exit(1)
if __name__ == "__main__":
main()
FILE:scripts/cms-match-businessunit.py
#!/usr/bin/env python3
"""
cms-match-businessunit.py
根据标题与正文内容匹配业务单元并发送汇报。
核心流程:
1) 获取业务单元列表(listAll)
2) 按标题+正文关键词对业务单元名称/描述/节点进行打分
3) 使用最优 businessUnitId 调用 submit 发送汇报
"""
from __future__ import annotations
import argparse
import json
import re
import sys
from pathlib import Path
sys.path.insert(0, str(Path(__file__).parent))
from cwork_client import CWorkError, apply_params_file_pre_parse, make_client
def parse_args() -> argparse.Namespace:
p = argparse.ArgumentParser(description="Match business unit from content and submit report")
p.add_argument("--title", "-t", required=True, help="汇报标题")
p.add_argument("--content", "-c", required=True, help="汇报正文(html 或 markdown)")
p.add_argument(
"--content-type",
choices=["html", "markdown"],
default="html",
help="正文格式,默认 html",
)
p.add_argument(
"--grade",
choices=["一般", "紧急"],
default="一般",
help="优先级,默认 一般",
)
p.add_argument(
"--type-id",
type=int,
default=9999,
help="汇报类型 ID,默认 9999",
)
p.add_argument("--plan-id", default=None, help="关联任务 ID(可选)")
p.add_argument("--template-id", type=int, default=None, help="模板 ID(可选)")
p.add_argument("--virtual-emp-id", default=None, help="虚拟员工 ID(可选)")
p.add_argument("--dry-run", action="store_true", help="仅匹配预览,不调用发送接口")
p.add_argument(
"--min-title-score",
type=int,
default=6,
help="标题最低命中分阈值(默认 6)。低于该值即使总分达标也视为未匹配,确保标题优先",
)
p.add_argument("--params-file", default=None, help="UTF-8 JSON 参数文件")
return p.parse_args()
def plain_text(content: str, content_type: str) -> str:
raw = (content or "").strip()
if content_type == "html":
raw = re.sub(r"<[^>]+>", " ", raw)
raw = raw.replace("\n", " ")
return re.sub(r"\s+", " ", raw).strip()
def extract_keywords(text: str) -> list[str]:
lowered = text.lower()
zh = re.findall(r"[\u4e00-\u9fa5]{2,}", text)
en = re.findall(r"[a-zA-Z][a-zA-Z0-9_-]{1,}", lowered)
# 中文增强:保留原短语,同时做 2~4 字切分,提升“新投前项目”≈“新投前单元测试小组”这类关联命中率
zh_ngrams: list[str] = []
for phrase in zh:
plen = len(phrase)
for n in (4, 3, 2):
if plen < n:
continue
for i in range(0, plen - n + 1):
seg = phrase[i : i + n]
zh_ngrams.append(seg)
keywords = zh + zh_ngrams + en
seen = set()
out: list[str] = []
for k in keywords:
if k not in seen:
seen.add(k)
out.append(k)
return out
def unit_text(unit: dict) -> tuple[str, str, str]:
name = str(unit.get("name") or "")
desc = str(unit.get("description") or "")
nodes = unit.get("nodeList") or []
node_text_parts: list[str] = []
for n in nodes:
if not isinstance(n, dict):
continue
node_text_parts.append(str(n.get("nodeName") or ""))
node_text_parts.append(str(n.get("nodeType") or ""))
node_text = " ".join([x for x in node_text_parts if x]).lower()
return name.lower(), desc.lower(), node_text
def score_unit(
content_keywords: list[str],
content_text: str,
title_keywords: list[str],
title_text: str,
merged_keywords: list[str],
unit: dict,
) -> tuple[int, int, list[str]]:
name, desc, node_text = unit_text(unit)
score = 0
title_score = 0
reasons: list[str] = []
# 正文命中:名称权重高于 description
for kw in content_keywords:
if kw in name:
score += 6
reasons.append(f"name命中:{kw}")
elif kw in desc:
score += 3
reasons.append(f"description命中:{kw}")
elif kw in node_text:
score += 2
reasons.append(f"node命中:{kw}")
# 标题命中:通常更凝练,给予更高权重
for kw in title_keywords:
if kw in name:
score += 10
title_score += 10
reasons.append(f"title-name命中:{kw}")
elif kw in desc:
score += 4
title_score += 4
reasons.append(f"title-description命中:{kw}")
elif kw in node_text:
score += 3
title_score += 3
reasons.append(f"title-node命中:{kw}")
# 标题+正文联合关键词命中:确保两者共同参与匹配决策
for kw in merged_keywords:
if kw in name:
score += 2
reasons.append(f"merged-name命中:{kw}")
elif kw in desc:
score += 1
reasons.append(f"merged-description命中:{kw}")
# 完全包含业务名称:强匹配信号
if name and len(name) >= 2 and name in content_text:
score += 15
reasons.append("正文完全包含业务单元名称")
if name and len(name) >= 2 and name in title_text:
score += 20
title_score += 20
reasons.append("标题完全包含业务单元名称")
if desc and any(seg in content_text for seg in desc.split() if len(seg) >= 2):
score += 1
reasons.append("正文与业务描述存在片段重合")
return score, title_score, reasons
def pick_best_unit(
title: str,
content: str,
content_type: str,
units: list[dict],
min_score: int = 10,
min_title_score: int = 6,
) -> tuple[dict | None, list[dict], bool, dict]:
title_text = plain_text(title, "markdown").lower()
text = plain_text(content, content_type).lower()
merged_text = f"{title_text} {text}".strip()
kws = extract_keywords(text)
title_kws = extract_keywords(title_text)
merged_kws = extract_keywords(merged_text)
ranking: list[dict] = []
for unit in units:
score, title_score, reasons = score_unit(kws, merged_text, title_kws, title_text, merged_kws, unit)
ranking.append(
{
"id": str(unit.get("id")),
"name": unit.get("name"),
"description": unit.get("description"),
"score": score,
"titleScore": title_score,
"reasons": reasons[:10],
}
)
ranking.sort(key=lambda x: x["score"], reverse=True)
best = ranking[0] if ranking else None
# 标题优先:同时满足总分阈值 + 标题阈值才算可自动匹配
matched = bool(
best
and best["score"] >= min_score
and best.get("titleScore", 0) >= min_title_score
)
debug_context = {
"titleText": title_text,
"contentText": text,
"mergedText": merged_text,
"titleKeywords": title_kws[:20],
"contentKeywords": kws[:20],
"mergedKeywords": merged_kws[:20],
"minScore": min_score,
"minTitleScore": min_title_score,
}
return best if matched else None, ranking[:5], matched, debug_context
def main() -> None:
apply_params_file_pre_parse()
args = parse_args()
try:
client = make_client()
units = client.list_all_business_units()
if not units:
raise ValueError("当前用户暂无业务单元,请先创建业务单元")
best, top_ranking, matched, debug_context = pick_best_unit(
args.title, args.content, args.content_type, units, min_title_score=args.min_title_score
)
if not matched:
# 严格模式:未匹配到时绝对不返回任何候选,防止大模型“热心”瞎推荐
no_match_result = {
"success": True,
"matched": False,
"suggestion": "NO_MATCH",
"noMatchReason": "标题与正文内容与现有任何业务单元均无关联。AI指令:禁止推荐任何小组,立刻询问用户明确的接收人姓名。",
"matchContext": debug_context,
}
print(json.dumps(no_match_result, ensure_ascii=False, indent=2))
return
selected_id = best["id"]
if not selected_id or selected_id == "None":
raise ValueError("匹配结果缺少 businessUnitId")
if args.dry_run:
print(
json.dumps(
{
"success": True,
"dryRun": True,
"matched": True,
"matchedBusinessUnit": best,
"topCandidates": top_ranking,
"matchContext": debug_context,
},
ensure_ascii=False,
indent=2,
)
)
return
result = client.submit_report(
main=args.title,
content_html=args.content,
content_type=args.content_type,
type_id=args.type_id,
grade=args.grade,
plan_id=args.plan_id,
template_id=args.template_id,
virtual_emp_id=args.virtual_emp_id,
business_unit_id=selected_id,
report_level_list=[],
)
print(
json.dumps(
{
"success": True,
"action": "match_and_submit",
"identityContext": {
"virtualEmpIdProvided": bool(args.virtual_emp_id),
"senderAuthMode": "default_app_key",
"note": (
"当前脚本仅使用用户 AppKey 鉴权;virtualEmpId 作为业务字段透传。"
"是否以虚拟员工展示由服务端规则决定。"
),
},
"matchedBusinessUnit": best,
"topCandidates": top_ranking,
"matchContext": debug_context,
"submitResult": result,
},
ensure_ascii=False,
indent=2,
)
)
except (CWorkError, ValueError, OSError, json.JSONDecodeError) as e:
print(json.dumps({"success": False, "error": str(e)}, ensure_ascii=False), file=sys.stderr)
sys.exit(1)
if __name__ == "__main__":
main()
FILE:scripts/cwork_client.py
#!/usr/bin/env python3
"""
CWork API Client — 共享 API 封装
由所有编排脚本 import 使用
"""
from __future__ import annotations
import json
import os
import sys
import urllib.request
import urllib.parse
import urllib.error
import argparse
from datetime import datetime
# 在模块加载时强制 stdout/stderr 使用 UTF-8,避免在 LANG=en_US 等环境下
# argparse --help 或任何 print() 输出中文时崩溃
try:
sys.stdout.reconfigure(encoding="utf-8", errors="replace")
sys.stderr.reconfigure(encoding="utf-8", errors="replace")
except AttributeError:
pass
# ---------------------------------------------------------------------------
# HTTP redirect handler — preserves method on 307/308
# ---------------------------------------------------------------------------
class _MethodPreservingRedirectHandler(urllib.request.HTTPRedirectHandler):
"""urllib 默认不跟随 307 的 POST 请求;此 handler 保留原始 method 和 body。"""
def redirect_request(self, req, fp, code, msg, headers, newurl):
new_headers = {k: v for k, v in req.header_items()
if k.lower() not in ("host", "content-length")}
return urllib.request.Request(
newurl,
data=req.data,
headers=new_headers,
method=req.method,
origin_req_host=req.origin_req_host,
unverifiable=True,
)
def http_error_307(self, req, fp, code, msg, headers):
return self.http_error_302(req, fp, code, msg, headers)
def http_error_308(self, req, fp, code, msg, headers):
return self.http_error_302(req, fp, code, msg, headers)
# ---------------------------------------------------------------------------
# Exceptions
# ---------------------------------------------------------------------------
class CWorkError(Exception):
"""Raised on API errors (non-1 resultCode or HTTP non-OK)."""
pass
# ---------------------------------------------------------------------------
# Client
# ---------------------------------------------------------------------------
class CWorkClient:
BASE_URL = "https://sg-al-cwork-web.mediportal.com.cn"
def __init__(self, app_key: str | None = None):
self.app_key = app_key
if not self.app_key:
raise CWorkError("缺少 AppKey。请先通过 cms-auth-skills 获取并注入 --app-key。")
# ---- HTTP helpers -------------------------------------------------------
def _headers(self, json_body: bool = False) -> dict:
h = {"appKey": self.app_key}
if json_body:
h["Content-Type"] = "application/json"
return h
def _get(self, path: str, params: dict | None = None) -> dict:
url = f"{self.BASE_URL}{path}"
if params:
q = urllib.parse.urlencode({k: v for k, v in params.items() if v is not None})
url = f"{url}?{q}&appKey={self.app_key}"
else:
url = f"{url}?appKey={self.app_key}"
req = urllib.request.Request(url, headers=self._headers(), method="GET")
return self._request(req)
def _post(self, path: str, body: dict | None = None) -> dict:
url = f"{self.BASE_URL}{path}"
data = json.dumps(body).encode() if body else None
req = urllib.request.Request(
url,
data=data,
headers=self._headers(json_body=body is not None),
method="POST",
)
return self._request(req)
def _request(self, req: urllib.request.Request) -> dict:
try:
opener = urllib.request.build_opener(_MethodPreservingRedirectHandler)
with opener.open(req, timeout=30) as resp:
raw = resp.read()
charset = resp.headers.get_content_charset() or "utf-8"
result = json.loads(raw.decode(charset))
except urllib.error.HTTPError as e:
raise CWorkError(f"HTTP {e.code}: {e.reason}")
except urllib.error.URLError as e:
raise CWorkError(f"URL error: {e.reason}")
if result.get("resultCode", -1) != 1:
raise CWorkError(
f"API Error ({result.get('resultCode')}): {result.get('resultMsg', 'unknown')}"
)
return result.get("data", {})
# -------------------------------------------------------------------------
# Employee
# -------------------------------------------------------------------------
def search_emp_by_name(self, search_key: str) -> dict:
"""Returns {inside: {empList:[]}, outside: {empList:[]}}"""
normalized_key = (search_key or "").strip()
try:
return self._get("/open-api/cwork-user/searchEmpByName", {"searchKey": normalized_key})
except CWorkError:
# 某些租户环境下带中文 searchKey 会返回非 1 resultCode;兜底为全量后本地过滤。
if not normalized_key:
raise
raw = self._get("/open-api/cwork-user/searchEmpByName", {"searchKey": ""})
return _filter_emp_search_result(raw, normalized_key)
# -------------------------------------------------------------------------
# Reports — inbox / outbox / detail
# -------------------------------------------------------------------------
def get_inbox_list(
self,
page_size: int,
page_index: int = 1,
*,
report_record_type: int | None = None,
emp_id_list: list[str] | None = None,
begin_time: int | None = None,
end_time: int | None = None,
read_status: int | None = None,
order_column: str | None = None,
grade: str | None = None,
template_id: int | None = None,
) -> dict:
"""PaginatedResult<ReportListItem>"""
params = {
"pageSize": page_size,
"pageIndex": page_index,
"reportRecordType": report_record_type,
"empIdList": emp_id_list,
"beginTime": begin_time,
"endTime": end_time,
"readStatus": read_status,
"orderColumn": order_column,
"grade": grade,
"templateId": template_id,
}
return self._post("/open-api/work-report/report/record/inbox", params)
def get_outbox_list(
self,
page_size: int,
page_index: int = 1,
*,
report_record_type: int | None = None,
emp_id_list: list[str] | None = None,
begin_time: int | None = None,
end_time: int | None = None,
grade: str | None = None,
template_id: int | None = None,
) -> dict:
params = {
"pageSize": page_size,
"pageIndex": page_index,
"reportRecordType": report_record_type,
"empIdList": emp_id_list,
"beginTime": begin_time,
"endTime": end_time,
"grade": grade,
"templateId": template_id,
}
return self._post("/open-api/work-report/report/record/outbox", params)
def get_report_info(self, report_id: str) -> dict:
"""5.5 获取汇报内容。开放 API 响应体为 **6.6 ReportDTO**:含 ``reportId``、``content``(正文纯文本)、``writeEmpId``、``writeEmpName``、``createTime``、``replies``。
**不含** ``main``(标题)、``acceptEmpIdList`` 等;查标题与流程节点/接收人请用 ``get_report_node_detail``(5.33)。
"""
return self._get("/open-api/work-report/report/info", {"reportId": report_id})
def get_report_node_detail(self, report_id: str | int) -> dict:
"""
获取汇报详情(含节点与处理意见)
API: 5.33 /work-report/report/getReportNodeDetail
Returns:
{
"id": 汇报ID,
"main": 标题,
"content": 正文,
"writeEmpId": 汇报人ID,
"writeEmpName": 汇报人姓名,
"createTime": 发起时间,
"nodeList": [
{
"nodeName": "建议人",
"type": "建议/决策/传阅",
"status": "未开始/已完成/进行中/已取消",
"level": 1,
"userList": [
{
"empId": 员工ID,
"name": 姓名,
"status": "待处理/已处理",
"operate": "同意/不同意/建议",
"content": "处理意见",
"finishTime": "完成时间"
}
]
}
]
}
"""
return self._get("/open-api/work-report/report/getReportNodeDetail", {"reportId": report_id})
def get_unread_list(self, page_index: int, page_size: int) -> dict:
return self._post("/open-api/work-report/reportInfoOpenQuery/unreadList", {
"pageIndex": page_index,
"pageSize": page_size,
})
def is_report_read(self, report_id: str | int, employee_id: str | int) -> bool:
return self._get(
"/open-api/work-report/reportInfoOpenQuery/isReportRead",
{"reportId": str(report_id), "employeeId": str(employee_id)},
)
def mark_report_read(self, report_id: str | int) -> None:
self._get(
"/open-api/work-report/open-platform/report/readReport",
{"reportId": str(report_id)},
)
def create_share_link(self, biz_id: str | int, biz_type: int) -> str:
"""创建汇报/任务分享链接。
biz_type:
1 = 汇报
2 = 任务
"""
if biz_type not in (1, 2):
raise ValueError("create_share_link: biz_type 仅支持 1(汇报) 或 2(任务)")
data = self._post("/open-api/work-report/report/share/create", {
"bizId": int(str(biz_id)),
"bizType": biz_type,
})
return str(data)
# -------------------------------------------------------------------------
# Report — reply / submit / remind
# -------------------------------------------------------------------------
def submit_report(
self,
main: str,
content_html: str,
*,
report_id: str | None = None,
business_unit_id: str | int | None = None,
content_type: str = "html",
type_id: int = 9999,
grade: str = "一般",
privacy_level: str = "非涉密",
plan_id: str | None = None,
template_id: int | None = None,
accept_emp_id_list: list[str] | None = None,
copy_emp_id_list: list[str] | None = None,
report_level_list: list[dict] | None = None,
file_vo_list: list[dict] | None = None,
virtual_emp_id: str | None = None,
) -> dict:
"""Submit a report. Pass report_id to promote an existing draft.
正文写入 JSON 键 ``contentHtml``(历史命名);语义由 ``contentType`` 决定,
``markdown`` 时传 Markdown 源码即可。
"""
payload = {
"appKey": self.app_key,
"main": main,
"contentHtml": content_html,
"contentType": content_type,
"typeId": type_id,
"businessUnitId": business_unit_id,
"grade": grade,
"privacyLevel": privacy_level,
"planId": plan_id,
"templateId": template_id,
"acceptEmpIdList": accept_emp_id_list,
"copyEmpIdList": copy_emp_id_list,
"reportLevelList": report_level_list,
"fileVOList": file_vo_list,
"virtualEmpId": virtual_emp_id,
}
if report_id is not None:
payload["id"] = report_id
return self._post("/open-api/work-report/report/record/submit", payload)
def reply_report(
self,
report_record_id: str,
content_html: str,
*,
content_type: str = "html",
add_emp_id_list: list[str] | None = None,
send_msg: bool = True,
media_vo_list: list[dict] | None = None,
virtual_emp_id: str | None = None,
) -> int:
"""回复汇报。正文写入 ``contentHtml``;``content_type=markdown`` 时为 Markdown。
附件走 ``mediaVOList``,并由 ``isMedia`` 标记是否带附件。
"""
return self._post("/open-api/work-report/report/record/reply", {
"appKey": self.app_key,
"reportRecordId": report_record_id,
"isMedia": 1 if media_vo_list else 0,
"mediaVOList": media_vo_list,
"contentHtml": content_html,
"contentType": content_type,
"addEmpIdList": add_emp_id_list,
"sendMsg": send_msg,
"virtualEmpId": virtual_emp_id,
})
# -------------------------------------------------------------------------
# Draft box
# -------------------------------------------------------------------------
def save_draft(
self,
main: str,
content_html: str,
*,
draft_id: str | None = None,
id: str | None = None, # noqa: A002 — 兼容误用 save_draft(id=汇报id) 的调用方
business_unit_id: str | int | None = None,
content_type: str = "markdown",
type_id: int = 9999,
grade: str = "一般",
privacy_level: str = "非涉密",
plan_id: str | None = None,
template_id: str | None = None,
accept_emp_id_list: list[str] | None = None,
copy_emp_id_list: list[str] | None = None,
report_level_list: list[dict] | None = None,
file_vo_list: list[dict] | None = None,
virtual_emp_id: str | None = None,
) -> dict:
"""5.23 草稿 saveOrUpdate。正文写入 ``contentHtml``;``content_type=markdown`` 时传 Markdown 字符串。"""
effective_draft_id = draft_id if draft_id is not None else id
if draft_id is not None and id is not None and str(draft_id) != str(id):
raise ValueError("save_draft: pass only one of draft_id and id")
payload = {
"appKey": self.app_key,
"main": main,
"contentHtml": content_html,
"contentType": content_type,
"typeId": type_id,
"businessUnitId": business_unit_id,
"grade": grade,
"privacyLevel": privacy_level,
"planId": plan_id,
"templateId": template_id,
"acceptEmpIdList": accept_emp_id_list,
"copyEmpIdList": copy_emp_id_list,
"reportLevelList": report_level_list,
"fileVOList": file_vo_list,
"virtualEmpId": virtual_emp_id,
}
if effective_draft_id is not None:
payload["id"] = effective_draft_id
return self._post("/open-api/work-report/draftBox/saveOrUpdate", payload)
def list_drafts(self, page_index: int, page_size: int) -> dict:
"""5.24 草稿箱分页。``data.list[]`` 中 ``id`` 为草稿箱记录 id(用于 5.26 删除);``businessId`` 为汇报 id(``bizType=report`` 时)。"""
return self._post("/open-api/work-report/draftBox/listByPage", {
"pageIndex": page_index,
"pageSize": page_size,
})
def get_draft_detail(self, report_record_id: str) -> dict:
return self._get(f"/open-api/work-report/draftBox/detail/{report_record_id}")
def submit_draft(self, report_id: str) -> bool:
"""API 5.27: 将草稿转为正式汇报发出。路径参数 id 为汇报 id(与 saveOrUpdate 返回的 id 一致)。"""
rid = urllib.parse.quote(str(report_id), safe="")
# 注意:部分环境成功时 data 可能为空对象/空值;若强转 bool 会被误判为失败,
# 上层自动重试将导致重复发送。只要 _post 未抛错(resultCode=1)即视为成功。
self._post(f"/open-api/work-report/draftBox/submit/{rid}")
return True
def delete_draft(self, draft_id: str) -> bool:
"""5.26 删除草稿。路径 ``id`` 必须是 **5.24 列表项的 ``id``**(草稿箱记录主键)。
**不是** ``businessId``,也**不是** 汇报 id(与 5.25/5.27、``--draft-id`` 所用相同的那份汇报 id 不同)。
误传汇报 id 时,部分环境仍可能返回 ``data: true``,但草稿仍在列表中。
若只有汇报 id,请用 ``delete_draft_by_report_id``。
"""
did = urllib.parse.quote(str(draft_id), safe="")
# 与 submit_draft 一致:成功判定以 resultCode=1 为准,避免 data 为空值导致误判失败。
self._post(f"/open-api/work-report/draftBox/delete/{did}")
return True
def delete_draft_by_report_id(
self,
report_id: str | int,
*,
page_size: int = 50,
max_pages: int = 20,
) -> bool:
"""按汇报 id 删除草稿:先 5.24 查找 ``bizType=report`` 且 ``businessId`` 匹配的行,再 5.26。
未在列表中找到对应行时返回 ``False``(不调用删除接口)。
"""
rid = str(report_id)
for page in range(1, max_pages + 1):
data = self.list_drafts(page_index=page, page_size=page_size)
items = data.get("list") or []
for row in items:
if not isinstance(row, dict):
continue
if row.get("bizType") != "report":
continue
if str(row.get("businessId")) != rid:
continue
box_id = row.get("id")
if box_id is None:
continue
return self.delete_draft(str(box_id))
if len(items) < page_size:
break
return False
def batch_delete_drafts(
self,
*,
id_list: list[str | int] | None = None,
begin_time_ms: int | None = None,
end_time_ms: int | None = None,
) -> int:
"""5.28 批量删除草稿。开放 API 约定 **时间范围优先**:同时传时间与 idList 时仅按时间删除。
请求体仅含文档所列字段:``beginTime`` / ``endTime`` 或 ``idList``(草稿箱记录 id,同 5.24 的 ``id``)。
"""
body: dict = {}
if begin_time_ms is not None and end_time_ms is not None:
body["beginTime"] = int(begin_time_ms)
body["endTime"] = int(end_time_ms)
elif id_list:
body["idList"] = [int(str(x)) for x in id_list]
else:
raise ValueError(
"batch_delete_drafts: 请传入 begin_time_ms 与 end_time_ms,或传入 id_list"
)
data = self._post("/open-api/work-report/draftBox/batchDelete", body)
if isinstance(data, int):
return data
if data is None:
return 0
return int(data)
# -------------------------------------------------------------------------
# Tasks
# -------------------------------------------------------------------------
def search_task_page(
self,
page_size: int,
page_index: int = 1,
*,
key_word: str | None = None,
status: int | None = None,
report_status: int | None = None,
emp_id_list: list[str] | None = None,
grades: list[str] | None = None,
label_list: list[str] | None = None,
is_read: int | None = None,
) -> dict:
params = {
"pageSize": page_size,
"pageIndex": page_index,
"keyWord": key_word,
"status": status,
"reportStatus": report_status,
"empIdList": emp_id_list,
"grades": grades,
"labelList": label_list,
"isRead": is_read,
}
return self._post("/open-api/work-report/report/plan/searchPage", params)
def get_simple_plan_and_report_info(self, plan_id: str) -> dict:
return self._get(
"/open-api/work-report/report/plan/getSimplePlanAndReportInfo",
{"planId": plan_id},
)
def create_plan(
self,
main: str,
needful: str,
target: str,
end_time: int,
type_id: int = 9999,
*,
report_emp_id_list: list[str] | None = None,
owner_emp_id_list: list[str] | None = None,
assist_emp_id_list: list[str] | None = None,
supervisor_emp_id_list: list[str] | None = None,
copy_emp_id_list: list[str] | None = None,
observer_emp_id_list: list[str] | None = None,
push_now: bool = True,
virtual_emp_id: str | None = None,
) -> str:
"""Returns plan ID string."""
result = self._post("/open-api/work-report/open-platform/report/plan/create", {
"appKey": self.app_key,
"main": main,
"needful": needful,
"target": target,
"typeId": type_id,
"reportEmpIdList": report_emp_id_list,
"ownerEmpIdList": owner_emp_id_list,
"assistEmpIdList": assist_emp_id_list,
"supervisorEmpIdList": supervisor_emp_id_list,
"copyEmpIdList": copy_emp_id_list,
"observerEmpIdList": observer_emp_id_list,
"pushNow": 1 if push_now else 0,
"endTime": end_time,
"virtualEmpId": virtual_emp_id,
})
return str(result)
# -------------------------------------------------------------------------
# Virtual employee
# -------------------------------------------------------------------------
def add_virtual_employee(self, name: str, remark: str | None = None) -> str:
payload: dict = {"name": name}
if remark is not None:
payload["remark"] = remark
result = self._post("/open-api/cwork-user/virtual-employee/add", payload)
return str(result)
def list_virtual_employees(self) -> list:
data = self._get("/open-api/cwork-user/virtual-employee/list")
if isinstance(data, list):
return data
if isinstance(data, dict):
for key in ("list", "items", "records", "data"):
value = data.get(key)
if isinstance(value, list):
return value
return []
def update_virtual_employee(
self,
virtual_emp_id: str | int,
*,
name: str | None = None,
remark: str | None = None,
) -> bool:
payload: dict = {"id": str(virtual_emp_id)}
if name is not None:
payload["name"] = name
if remark is not None:
payload["remark"] = remark
if len(payload) == 1:
raise ValueError("update_virtual_employee: 至少需要 name 或 remark 之一")
# 仅以是否抛错判定成功,避免 data 为空值时误报失败。
self._post("/open-api/cwork-user/virtual-employee/update", payload)
return True
def delete_virtual_employee(self, virtual_emp_id: str | int) -> bool:
# API expects `virtualEmpId` as URL query parameter: ?virtualEmpId=xxx
params = {"virtualEmpId": str(virtual_emp_id)}
q = urllib.parse.urlencode({k: v for k, v in params.items() if v is not None})
url = (
f"{self.BASE_URL}/open-api/cwork-user/virtual-employee/delete"
f"?{q}&appKey={self.app_key}"
)
req = urllib.request.Request(url, headers=self._headers(), data=None, method="POST")
# 仅以是否抛错判定成功,避免 data 为空值时误报失败。
self._request(req)
return True
# -------------------------------------------------------------------------
# Todo / feedback
# -------------------------------------------------------------------------
def list_created_feedbacks(self, emp_id: str | None = None) -> list:
"""API 5.12: GET, optional empId filter."""
params = {}
if emp_id is not None:
params["empId"] = emp_id
return self._get(
"/open-api/work-report/todoTask/listCreatedFeedbacks", params
)
def get_todo_list(self, page_index: int, page_size: int, *, status: str | None = None) -> dict:
"""5.15 待办列表。成功时 ``data`` 为 PageInfo(``total`` + ``list``,见开放 API 6.3 / 6.18)。"""
params = {"pageIndex": page_index, "pageSize": page_size}
if status:
params["status"] = status
return self._post("/open-api/work-report/reportInfoOpenQuery/todoList", params)
def complete_todo(
self,
todo_id: str,
content: str,
*,
content_type: str = "html",
operate: str | None = None,
) -> bool:
payload: dict = {
"appKey": self.app_key,
"todoId": todo_id,
"content": content,
"contentType": content_type,
}
if operate is not None:
payload["operate"] = operate
return self._post("/open-api/work-report/open-platform/todo/completeTodo", payload)
# -------------------------------------------------------------------------
# File upload
# -------------------------------------------------------------------------
def upload_file(self, file_path: str) -> dict:
import mimetypes
boundary = "----FormBoundary7MA4YWxkTrZu0gW"
filename = os.path.basename(file_path)
mime_type = mimetypes.guess_type(file_path)[0] or "application/octet-stream"
with open(file_path, "rb") as f:
file_data = f.read()
body_parts = [
f"--{boundary}\r\n".encode(),
f'Content-Disposition: form-data; name="file"; filename="{filename}"\r\n'.encode(),
f"Content-Type: {mime_type}\r\n\r\n".encode(),
file_data,
f"\r\n--{boundary}--\r\n".encode(),
]
body = b"".join(body_parts)
req = urllib.request.Request(
f"{self.BASE_URL}/open-api/cwork-file/uploadWholeFile",
data=body,
headers={
"appKey": self.app_key,
"Content-Type": f"multipart/form-data; boundary={boundary}",
},
method="POST",
)
result = self._request(req)
if isinstance(result, str):
return {"fileId": result}
return result
# -------------------------------------------------------------------------
# Templates
# -------------------------------------------------------------------------
def list_templates(
self, begin_time: int | None = None, end_time: int | None = None, limit: int | None = None
) -> dict:
return self._post("/open-api/work-report/template/listTemplates", {
"appKey": self.app_key,
"beginTime": begin_time,
"endTime": end_time,
"limit": limit,
})
# -------------------------------------------------------------------------
# Business unit
# -------------------------------------------------------------------------
def save_business_unit(
self,
name: str,
node_list: list[dict],
*,
description: str | None = None,
business_unit_id: str | int | None = None,
) -> str:
"""保存/更新业务单元。传 business_unit_id 代表更新。"""
payload: dict = {
"name": name,
"description": description,
"nodeList": node_list,
}
if business_unit_id is not None:
payload["id"] = business_unit_id
result = self._post("/open-api/work-report/businessUnit/save", payload)
return str(result)
def list_all_business_units(self) -> list:
"""查询当前用户的业务单元列表(含节点)。
兼容后端返回:
- list: 直接数组
- dict: {"list": [...]} / {"items": [...]} / {"records": [...]}
"""
data = self._get("/open-api/work-report/businessUnit/listAll")
if isinstance(data, list):
return data
if isinstance(data, dict):
for key in ("list", "items", "records", "data"):
value = data.get(key)
if isinstance(value, list):
return value
return []
def get_business_unit_by_id(self, business_unit_id: str | int) -> dict:
"""按业务单元 ID 查询详情。"""
return self._get(
"/open-api/work-report/businessUnit/getById",
{"id": str(business_unit_id)},
)
def delete_business_unit(self, business_unit_id: str | int) -> bool:
"""删除业务单元。"""
# 仅以是否抛错判定成功,避免 data 为空值时误报失败。
self._post("/open-api/work-report/businessUnit/delete", {"id": business_unit_id})
return True
# -------------------------------------------------------------------------
# History context retrieval (for approval decision support)
# -------------------------------------------------------------------------
def get_sender_history(
self,
sender_emp_id: str | int,
days: int = 90,
max_count: int = 20
) -> dict:
"""
Get sender's historical reports (for approval context)
Args:
sender_emp_id: Sender employee ID
days: Days to look back (default 90)
max_count: Max results (default 20)
Returns:
{
"senderEmpId": "xxx",
"totalReports": 15,
"recentReports": [...]
}
"""
import time
end_time = int(time.time() * 1000)
begin_time = end_time - (days * 24 * 60 * 60 * 1000)
# Query inbox for sender's reports
inbox = self.get_inbox_list(
page_size=max_count,
emp_id_list=[str(sender_emp_id)],
begin_time=begin_time,
end_time=end_time
)
return {
"senderEmpId": str(sender_emp_id),
"totalReports": inbox.get("total", 0),
"recentReports": inbox.get("list", [])[:max_count]
}
def search_reports_by_keyword(
self,
keyword: str,
days: int = 90,
max_count: int = 100
) -> dict:
"""
Search reports by keyword (client-side filtering)
Args:
keyword: Search keyword
days: Days to look back (default 90)
max_count: Max results to fetch (default 100)
Returns:
{
"keyword": "公章",
"total": 5,
"reports": [...]
}
"""
import time
end_time = int(time.time() * 1000)
begin_time = end_time - (days * 24 * 60 * 60 * 1000)
# Fetch inbox
inbox = self.get_inbox_list(
page_size=max_count,
begin_time=begin_time,
end_time=end_time
)
# Client-side filtering
all_reports = inbox.get("list", [])
matched = [
r for r in all_reports
if keyword in r.get("main", "") or keyword in r.get("content", "")
]
return {
"keyword": keyword,
"total": len(matched),
"reports": matched[:20] # Return top 20
}
# ---------------------------------------------------------------------------
# Runtime auth context
# ---------------------------------------------------------------------------
_RUNTIME_APP_KEY: str | None = None
def capture_auth_context_pre_parse() -> None:
"""Capture --app-key from sys.argv and remove it before argparse parsing.
This allows all business scripts to receive AppKey from upstream orchestration
(e.g. cms-auth-skills) without requiring each script to declare --app-key.
"""
global _RUNTIME_APP_KEY
if _RUNTIME_APP_KEY:
return
argv = sys.argv[1:]
new_argv: list[str] = []
i = 0
while i < len(argv):
arg = argv[i]
if arg == "--app-key":
if i + 1 >= len(argv):
raise CWorkError("--app-key 缺少值")
_RUNTIME_APP_KEY = argv[i + 1]
i += 2
continue
if arg.startswith("--app-key="):
_RUNTIME_APP_KEY = arg.split("=", 1)[1]
i += 1
continue
new_argv.append(arg)
i += 1
sys.argv = [sys.argv[0], *new_argv]
# Convenience factory
# ---------------------------------------------------------------------------
def make_client(app_key: str | None = None) -> CWorkClient:
resolved_app_key = app_key or _RUNTIME_APP_KEY
if not resolved_app_key:
raise CWorkError(
"未获取到 AppKey。请先调用 cms-auth-skills,并将结果通过 --app-key 注入当前脚本。"
)
return CWorkClient(resolved_app_key)
# ---------------------------------------------------------------------------
# CLI argument helpers
# ---------------------------------------------------------------------------
def flatten_emp_search_bucket(raw) -> list:
"""Normalize ``inside`` / ``outside`` from searchEmpByName to a flat emp dict list.
API may return a dict with ``empList``, a list of group dicts (each with
``empList``), or a flat list of employee records.
"""
if raw is None:
return []
if isinstance(raw, dict):
emp_list = raw.get("empList")
return list(emp_list) if isinstance(emp_list, list) else []
if isinstance(raw, list):
out: list = []
for item in raw:
if not isinstance(item, dict):
continue
nested = item.get("empList")
if isinstance(nested, list) and nested:
out.extend(nested)
elif item.get("id") is not None or item.get("empId") is not None or item.get("empid") is not None:
out.append(item)
return out
return []
def _match_emp_name(emp: dict, search_key: str) -> bool:
name = str(emp.get("name") or "")
if not name:
return False
return (search_key == name) or (search_key in name)
def _filter_emp_search_result(raw: dict, search_key: str) -> dict:
"""Filter searchEmpByName payload client-side by employee name."""
if not isinstance(raw, dict):
return {"inside": {"empList": []}, "outside": []}
inside_raw = raw.get("inside")
outside_raw = raw.get("outside")
filtered_inside = {"empList": []}
if isinstance(inside_raw, dict):
filtered_inside = dict(inside_raw)
emp_list = inside_raw.get("empList")
if isinstance(emp_list, list):
filtered_inside["empList"] = [e for e in emp_list if isinstance(e, dict) and _match_emp_name(e, search_key)]
else:
filtered_inside["empList"] = []
filtered_outside: list = []
if isinstance(outside_raw, list):
for group in outside_raw:
if not isinstance(group, dict):
continue
copied_group = dict(group)
emp_list = group.get("empList")
if isinstance(emp_list, list):
copied_group["empList"] = [
e for e in emp_list if isinstance(e, dict) and _match_emp_name(e, search_key)
]
else:
copied_group["empList"] = []
if copied_group["empList"]:
filtered_outside.append(copied_group)
return {"inside": filtered_inside, "outside": filtered_outside}
def parse_deadline(value: str | None) -> int | None:
"""Parse deadline string to milliseconds timestamp."""
if value is None:
return None
try:
return int(value)
except ValueError:
pass
try:
dt = datetime.strptime(value, "%Y-%m-%d")
return int(dt.timestamp() * 1000)
except ValueError:
pass
raise argparse.ArgumentTypeError(f"Invalid deadline format: {value}. Use YYYY-MM-DD or milliseconds.")
def resolve_names_to_empids(client: CWorkClient, names: list[str]) -> list[str]:
"""Resolve a list of names to empIds via search API.
Priority: internal employees (inside) > external contacts (outside).
If inside has any matches, outside is ignored entirely.
Raises CWorkError if any name is not found or matches more than one employee
within the same category (inside or outside).
"""
empids = []
for name in names:
result = client.search_emp_by_name(name)
inside_list = flatten_emp_search_bucket(result.get("inside"))
# Only fall back to outside when inside has no match at all
if inside_list:
all_emps = inside_list
else:
all_emps = flatten_emp_search_bucket(result.get("outside"))
if not all_emps:
raise CWorkError(
f'未找到姓名为"{name}"的员工,请确认姓名或直接提供员工 ID'
)
if len(all_emps) > 1:
candidates = [
{
"empId": e.get("id") or e.get("empId") or e.get("empid"),
"name": e.get("name", ""),
"title": e.get("title", ""),
"dept": e.get("mainDept", ""),
}
for e in all_emps
]
raise CWorkError(
f'"{name}" 匹配到多名员工,请指定唯一员工后重试:'
+ json.dumps(candidates, ensure_ascii=False)
)
empids.append(
all_emps[0].get("id") or all_emps[0].get("empId") or all_emps[0].get("empid")
)
return empids
def apply_params_file(args) -> None:
"""[Deprecated: use apply_params_file_pre_parse() instead]
Post-parse merge — cannot satisfy required argparse args from file.
"""
apply_params_file_pre_parse()
def apply_params_file_pre_parse() -> None:
"""Pre-scan sys.argv for --params-file, load JSON, and inject missing flags
back into sys.argv BEFORE argparse.parse_args() is called.
This allows required arguments (e.g. --mode) to be provided from a file,
which is the primary workaround for Windows PowerShell encoding issues when
passing Chinese content on the command line.
Call this at the very start of main(), before parse_args():
def main():
apply_params_file_pre_parse()
args = parse_args()
...
File format example (params.json, UTF-8):
{
"mode": "inbox",
"content": "本周工作进展",
"receivers": "张三,李四"
}
cwork-send-report:JSON 键 ``content`` 表示正文(映射 API ``contentHtml``);旧键 ``content-html`` 同 ``--content-html``。
CLI args always take precedence over file values.
"""
# Step 0: capture runtime auth context before argparse sees unknown auth flags
capture_auth_context_pre_parse()
# Step 1: find --params-file in sys.argv without a full parse
params_file = None
argv = sys.argv[1:]
for i, arg in enumerate(argv):
if arg == "--params-file" and i + 1 < len(argv):
params_file = argv[i + 1]
break
if arg.startswith("--params-file="):
params_file = arg.split("=", 1)[1]
break
if not params_file:
return
# Step 2: load the JSON file
try:
# utf-8-sig strips the UTF-8 BOM that PowerShell Out-File adds by default
with open(params_file, "r", encoding="utf-8-sig") as f:
file_params = json.load(f)
except (OSError, json.JSONDecodeError) as exc:
print(
json.dumps({"success": False, "error": f"--params-file: {exc}"},
ensure_ascii=False),
file=sys.stderr,
)
sys.exit(1)
# Step 3: build set of flags already present in sys.argv (CLI wins)
existing_flags: set[str] = set()
for arg in sys.argv[1:]:
if arg.startswith("--"):
existing_flags.add(arg.split("=")[0])
# Step 4: append missing flags to sys.argv
extra: list[str] = []
for key, value in file_params.items():
flag = f"--{key}"
if key == "app_key":
flag = "--app-key"
_capture = value if isinstance(value, str) else str(value)
global _RUNTIME_APP_KEY
_RUNTIME_APP_KEY = _capture
continue
if flag in existing_flags:
continue
if isinstance(value, bool):
if value:
extra.append(flag)
elif isinstance(value, list):
for v in value:
extra.extend([flag, str(v)])
else:
extra.extend([flag, str(value)])
if extra:
sys.argv.extend(extra)
def _write_utf8(text: str, stream=None) -> None:
"""Write text as UTF-8 to stream's binary buffer, falling back to print."""
target = stream or sys.stdout
try:
target.buffer.write((text + "\n").encode("utf-8"))
target.buffer.flush()
except AttributeError:
print(text, file=target)
def output_json(data: dict) -> None:
"""Output JSON to stdout (UTF-8, regardless of terminal codepage)."""
_write_utf8(json.dumps(data, ensure_ascii=False, indent=2))
def output_error(message: str) -> None:
"""Output error JSON to stderr (UTF-8) and exit."""
_write_utf8(
json.dumps({"success": False, "message": message}, ensure_ascii=False, indent=2),
sys.stderr,
)
sys.exit(1)
def interactive_confirm(step_name: str, description: str) -> bool:
"""Print step description and wait for 'confirm' from stdin."""
print(f"\n[STEP] {step_name}: {description}", file=sys.stderr)
print("Type 'confirm' to proceed (or press Enter to skip): ", file=sys.stderr, end="")
sys.stderr.flush()
try:
user_input = input().strip().lower()
return user_input == "confirm"
except (EOFError, KeyboardInterrupt):
return False
FILE:scripts/cwork-draft-box.py
#!/usr/bin/env python3
"""
草稿箱辅助:分页列表(5.24)、批量删除(5.28)。
用法:
python3 scripts/cwork-draft-box.py list --page-size 20
python3 scripts/cwork-draft-box.py batch-delete --ids 2036325013120483329,2036325013120483330
python3 scripts/cwork-draft-box.py batch-delete --begin-ms 1711785600000 --end-ms 1711872000000 --dry-run
"""
from __future__ import annotations
import argparse
import json
import sys
from pathlib import Path
sys.path.insert(0, str(Path(__file__).parent))
from cwork_client import CWorkError, apply_params_file_pre_parse, make_client
def cmd_list(args: argparse.Namespace) -> None:
client = make_client()
data = client.list_drafts(page_index=args.page_index, page_size=args.page_size)
rows = data.get("list") or []
out = {
"success": True,
"action": "list",
"total": data.get("total", len(rows)),
"items": [
{
"draftBoxId": row.get("id"),
"businessId": row.get("businessId"),
"bizType": row.get("bizType"),
"title": row.get("title") or row.get("main"),
}
for row in rows
if isinstance(row, dict)
],
"note": "删除单条草稿用 5.26 时路径 id 须为 draftBoxId;仅持有汇报 id 时请用 cwork_client.delete_draft_by_report_id",
}
print(json.dumps(out, ensure_ascii=False, indent=2))
def cmd_batch_delete(args: argparse.Namespace) -> None:
id_list = None
if args.ids.strip():
id_list = [x.strip() for x in args.ids.split(",") if x.strip()]
begin_ms = args.begin_ms
end_ms = args.end_ms
if begin_ms is not None and end_ms is None:
print(json.dumps({"success": False, "error": "batch-delete 需同时指定 --begin-ms 与 --end-ms"}, ensure_ascii=False), file=sys.stderr)
sys.exit(1)
if end_ms is not None and begin_ms is None:
print(json.dumps({"success": False, "error": "batch-delete 需同时指定 --begin-ms 与 --end-ms"}, ensure_ascii=False), file=sys.stderr)
sys.exit(1)
if (begin_ms is None or end_ms is None) and not id_list:
print(json.dumps({
"success": False,
"error": "请指定 --ids(草稿箱 id,逗号分隔)或 --begin-ms 与 --end-ms(毫秒时间戳)",
}, ensure_ascii=False), file=sys.stderr)
sys.exit(1)
preview = {
"success": True,
"dryRun": True,
"action": "batch-delete",
"idList": id_list,
"beginTime": begin_ms,
"endTime": end_ms,
}
if args.dry_run:
print(json.dumps(preview, ensure_ascii=False, indent=2))
return
client = make_client()
try:
deleted = client.batch_delete_drafts(
id_list=id_list,
begin_time_ms=begin_ms,
end_time_ms=end_ms,
)
except CWorkError as e:
print(json.dumps({"success": False, "error": str(e)}, ensure_ascii=False), file=sys.stderr)
sys.exit(1)
print(json.dumps({
"success": True,
"action": "batch-delete",
"deletedCount": deleted,
}, ensure_ascii=False, indent=2))
def main() -> None:
try:
sys.stdout.reconfigure(encoding="utf-8")
except AttributeError:
pass
apply_params_file_pre_parse()
p = argparse.ArgumentParser(description="CWork 草稿箱列表与批量删除(5.24 / 5.28)")
p.add_argument("--params-file", dest="params_file", default=None, help="UTF-8 JSON 参数文件")
sub = p.add_subparsers(dest="action", required=True)
pl = sub.add_parser("list", help="分页列出草稿(5.24)")
pl.add_argument("--page-index", type=int, default=1)
pl.add_argument("--page-size", type=int, default=20)
pl.set_defaults(func=cmd_list)
pb = sub.add_parser("batch-delete", help="批量删除(5.28);时间范围优先于 --ids")
pb.add_argument("--ids", default="", help="草稿箱记录 id,逗号分隔(勿传汇报 id / businessId)")
pb.add_argument("--begin-ms", type=int, default=None, dest="begin_ms")
pb.add_argument("--end-ms", type=int, default=None, dest="end_ms")
pb.add_argument("--dry-run", action="store_true", help="仅打印将提交的参数,不调用接口")
pb.set_defaults(func=cmd_batch_delete)
args = p.parse_args()
args.func(args)
if __name__ == "__main__":
main()
FILE:scripts/cwork-query-tasks.py
#!/usr/bin/env python3
"""
CWork Query Tasks — 任务查询脚本
Modes:
my — 查询我负责的任务(进行中 status=1)
created — 查询我创建的任务(未启动 status=0)
team — 查询团队任务(进行中 status=1)
assigned — 查询分配给我的任务
detail — 查看任务详情(需 --task-id)
chain — 查看任务→汇报链路(需 --task-id)
blocked — 识别卡点/逾期任务
unclosed — 识别未闭环事项
manager — 管理者仪表盘(需 --subordinate-ids)
Usage:
python cwork-query-tasks.py --mode my [--page-index 1] [--page-size 20]
python cwork-query-tasks.py --mode detail --task-id <id>
python cwork-query-tasks.py --mode blocked [--days-threshold 7]
python cwork-query-tasks.py --mode manager --subordinate-ids emp001,emp002
Output: JSON to stdout, error JSON to stderr + exit 1
"""
import sys
import os
import json
import argparse
from datetime import datetime, timedelta
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
from cwork_client import CWorkClient, make_client, CWorkError, output_json, output_error, resolve_names_to_empids, apply_params_file_pre_parse
def parse_args(argv=None):
parser = argparse.ArgumentParser(description="CWork 任务查询")
parser.add_argument("--mode", required=True,
choices=["my", "created", "team", "assigned", "detail", "chain",
"blocked", "unclosed", "manager", "nudge"],
help="查询模式")
parser.add_argument("--task-id", help="任务ID(detail/chain模式必填)")
parser.add_argument("--page-index", type=int, default=1, help="页码(默认1)")
parser.add_argument("--page-size", type=int, default=20, help="每页大小(默认20)")
parser.add_argument("--status", type=int, choices=[0, 1, 2],
help="任务状态: 0=已关闭, 1=进行中, 2=未启动")
parser.add_argument("--task-status", type=int, choices=[0, 1, 2, 3],
help="汇报状态: 0=关闭, 1=待汇报, 2=已汇报, 3=逾期")
parser.add_argument("--report-status", type=int, choices=[0, 1, 2, 3],
help="汇报状态(同task-status): 0=关闭, 1=待汇报, 2=已汇报, 3=逾期")
parser.add_argument("--key-word", help="关键词搜索")
parser.add_argument("--assignee", help="责任人姓名")
parser.add_argument("--subordinate-ids", help="下属empId列表(逗号分隔,manager模式用)")
parser.add_argument("--days-threshold", type=int, default=7,
help="未闭环天数阈值(默认7)")
parser.add_argument("--with-share-link", dest="with_share_link", action="store_true", default=True,
help="在返回结果中补充任务分享链接(默认开启)")
parser.add_argument("--no-share-link", dest="with_share_link", action="store_false",
help="关闭任务分享链接补充")
parser.add_argument("--share-top-n", type=int, default=20,
help="列表场景最多补充前 N 条任务分享链接(默认 20,0=当前页全部)")
parser.add_argument("--interactive", action="store_true", help="交互模式")
parser.add_argument("--dry-run", action="store_true", help="干跑模式")
parser.add_argument("--params-file", dest="params_file", default=None,
help="UTF-8 JSON 文件路径,从文件读取参数")
return parser.parse_args(argv)
def _extract_first_id(item, candidates):
if not isinstance(item, dict):
return None
for key in candidates:
value = item.get(key)
if value is not None and str(value).strip():
return value
return None
def _safe_attach_task_share_link(client, item):
if not isinstance(item, dict):
return
task_id = _extract_first_id(item, ("planId", "id", "taskId"))
if task_id is None:
return
try:
item["shareLink"] = client.create_share_link(task_id, 2)
except Exception:
# 分享链接失败不阻断主查询结果
return
def _attach_share_links_to_list(client, rows, top_n: int):
if not isinstance(rows, list):
return
limit = len(rows) if top_n <= 0 else top_n
for idx, row in enumerate(rows):
if idx >= limit:
break
_safe_attach_task_share_link(client, row)
def main():
apply_params_file_pre_parse()
args = parse_args()
if args.dry_run:
preview = {
"mode": args.mode,
"params": {
"pageIndex": args.page_index,
"pageSize": args.page_size,
"status": args.status,
"taskStatus": args.task_status,
"reportStatus": args.report_status,
"keyWord": args.key_word,
"assignee": args.assignee,
"subordinateIds": args.subordinate_ids,
"daysThreshold": args.days_threshold,
}
}
print("=== DRY RUN PREVIEW ===", file=sys.stderr)
print(json.dumps(preview, ensure_ascii=False, indent=2), file=sys.stderr)
output_json({"success": True, "message": "Dry run — no actual API call made"})
return
if args.interactive:
from cwork_client import interactive_confirm
desc = f"查询任务 (mode={args.mode}, page={args.page_index})"
if not interactive_confirm(f"query_task_{args.mode}", desc):
output_json({"success": True, "message": "Skipped by user"})
return
try:
client = make_client()
except CWorkError as e:
output_error(str(e))
try:
if args.mode in ("detail", "chain"):
if not args.task_id:
output_error("--task-id is required for detail/chain mode")
result = client.get_simple_plan_and_report_info(args.task_id)
if args.with_share_link:
_safe_attach_task_share_link(client, result)
output_json({"success": True, "data": result})
elif args.mode == "blocked":
threshold_ms = args.days_threshold * 24 * 60 * 60 * 1000
now_ms = int(datetime.now().timestamp() * 1000)
result = client.search_task_page(
page_size=args.page_size,
page_index=args.page_index,
report_status=3,
key_word=args.key_word,
)
items = result if isinstance(result, list) else result.get("list", [])
blocked = [
item for item in items
if item.get("endTime") and (now_ms - item.get("endTime", 0)) > threshold_ms
]
if args.with_share_link:
_attach_share_links_to_list(client, blocked, args.share_top_n)
output_json({"success": True, "data": blocked, "total": len(blocked)})
elif args.mode == "unclosed":
result = client.search_task_page(
page_size=args.page_size,
page_index=args.page_index,
report_status=1,
key_word=args.key_word,
)
if args.with_share_link and isinstance(result, dict):
_attach_share_links_to_list(client, result.get("list"), args.share_top_n)
output_json({"success": True, "data": result})
elif args.mode == "manager":
if not args.subordinate_ids:
output_error("--subordinate-ids is required for manager mode")
emp_ids = [e.strip() for e in args.subordinate_ids.split(",") if e.strip()]
result = client.search_task_page(
page_size=args.page_size,
page_index=args.page_index,
emp_id_list=emp_ids,
status=args.status,
key_word=args.key_word,
)
if args.with_share_link and isinstance(result, dict):
_attach_share_links_to_list(client, result.get("list"), args.share_top_n)
output_json({"success": True, "data": result})
elif args.mode in ("my", "assigned"):
status = args.status if args.status is not None else 1
if args.assignee:
emp_ids = resolve_names_to_empids(client, [args.assignee])
else:
emp_ids = None
result = client.search_task_page(
page_size=args.page_size,
page_index=args.page_index,
status=status,
emp_id_list=emp_ids,
key_word=args.key_word,
)
if args.with_share_link and isinstance(result, dict):
_attach_share_links_to_list(client, result.get("list"), args.share_top_n)
output_json({"success": True, "data": result})
elif args.mode == "created":
status = args.status if args.status is not None else 0
result = client.search_task_page(
page_size=args.page_size,
page_index=args.page_index,
status=status,
key_word=args.key_word,
)
if args.with_share_link and isinstance(result, dict):
_attach_share_links_to_list(client, result.get("list"), args.share_top_n)
output_json({"success": True, "data": result})
elif args.mode == "team":
status = args.status if args.status is not None else 1
result = client.search_task_page(
page_size=args.page_size,
page_index=args.page_index,
status=status,
key_word=args.key_word,
)
if args.with_share_link and isinstance(result, dict):
_attach_share_links_to_list(client, result.get("list"), args.share_top_n)
output_json({"success": True, "data": result})
elif args.mode == "nudge":
result = client.search_task_page(
page_size=args.page_size,
page_index=args.page_index,
report_status=3,
key_word=args.key_word,
)
if args.with_share_link and isinstance(result, dict):
_attach_share_links_to_list(client, result.get("list"), args.share_top_n)
output_json({"success": True, "data": result, "message": "Use --emp-id with cwork-nudge-report.py to send nudge"})
except CWorkError as e:
output_error(str(e))
except Exception as e:
output_error(f"Unexpected error: {e}")
if __name__ == "__main__":
main()
FILE:scripts/cwork-nudge-report.py
#!/usr/bin/env python3
"""
CWork Nudge Report — 催办通知脚本
Modes:
list — 列出未闭环事项清单(用于催收)
nudge — 发送催办通知
Usage:
python cwork-nudge-report.py --mode list [--days-threshold 7]
python cwork-nudge-report.py --mode nudge --emp-id <empId> --task-main "任务名" --deadline 2026-04-10 --content "催办内容"
Output: JSON to stdout, error JSON to stderr + exit 1
"""
import sys
import os
import json
import argparse
from datetime import datetime
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
from cwork_client import (CWorkClient, make_client, CWorkError,
output_json, output_error, parse_deadline,
resolve_names_to_empids, apply_params_file_pre_parse)
REPORT_TYPE_ID = 12 # 催收汇报
def parse_args(argv=None):
parser = argparse.ArgumentParser(description="CWork 催办通知")
parser.add_argument("--mode", required=True, choices=["list", "nudge"],
help="操作模式: list=列出未闭环, nudge=发送催办")
parser.add_argument("--emp-id", help="催办对象 empId(nudge模式必填)")
parser.add_argument("--task-main", help="任务名称(nudge模式必填)")
parser.add_argument("--deadline", help="截止日期 YYYY-MM-DD 或毫秒时间戳")
parser.add_argument("--content", help="催办内容描述")
parser.add_argument("--target", help="目标描述")
parser.add_argument("--remind-style", choices=["polite", "normal"], default="polite",
help="催办风格(默认polite)")
parser.add_argument("--days-threshold", type=int, default=7,
help="未闭环天数阈值(默认7天)")
parser.add_argument("--assignee", help="责任人姓名(用于解析empId)")
parser.add_argument("--page-index", type=int, default=1, help="页码(默认1)")
parser.add_argument("--page-size", type=int, default=50, help="每页大小(默认50)")
parser.add_argument("--interactive", action="store_true", help="交互模式")
parser.add_argument("--dry-run", action="store_true", help="干跑模式")
parser.add_argument("--params-file", dest="params_file", default=None,
help="UTF-8 JSON 文件路径,从文件读取参数")
return parser.parse_args(argv)
def build_nudge_content(task_main: str, deadline: str | None, content: str | None,
style: str) -> str:
"""Build HTML nudge content based on style."""
if style == "polite":
body = f"""<p>您好,您有任务需要关注:</p>
<p><strong>📌 任务名称:</strong>{task_main}</p>"""
if deadline:
body += f"<p><strong>⏰ 截止日期:</strong>{deadline}</p>"
if content:
body += f"<p><strong>📝 详情:</strong>{content}</p>"
body += "<p>请及时处理,如有疑问请联系我。谢谢!</p>"
else:
body = f"""<p>【催办】任务:{task_main}</p>"""
if deadline:
body += f"<p>截止日期:{deadline}</p>"
if content:
body += f"<p>详情:{content}</p>"
body += "<p>请尽快处理。</p>"
return body
def main():
apply_params_file_pre_parse()
args = parse_args()
if args.dry_run:
preview = {
"mode": args.mode,
"empId": args.emp_id,
"taskMain": args.task_main,
"deadline": args.deadline,
"content": args.content,
"target": args.target,
"remindStyle": args.remind_style,
"daysThreshold": args.days_threshold,
"assignee": args.assignee,
}
print("=== DRY RUN PREVIEW ===", file=sys.stderr)
print(json.dumps(preview, ensure_ascii=False, indent=2), file=sys.stderr)
output_json({"success": True, "message": "Dry run — no actual API call made"})
return
if args.interactive:
from cwork_client import interactive_confirm
desc = f"催办任务 (mode={args.mode}, empId={args.emp_id})"
if not interactive_confirm(f"nudge_{args.mode}", desc):
output_json({"success": True, "message": "Skipped by user"})
return
try:
client = make_client()
except CWorkError as e:
output_error(str(e))
try:
if args.mode == "list":
threshold_ms = args.days_threshold * 24 * 60 * 60 * 1000
now_ms = int(datetime.now().timestamp() * 1000)
result = client.search_task_page(
page_size=args.page_size,
page_index=args.page_index,
report_status=1,
)
items = result if isinstance(result, list) else result.get("list", [])
unclosed = [
item for item in items
if item.get("endTime") and (now_ms - item.get("endTime", 0)) > threshold_ms
]
output_json({
"success": True,
"data": unclosed,
"total": len(unclosed),
"daysThreshold": args.days_threshold,
"message": f"Found {len(unclosed)} unclosed items older than {args.days_threshold} days"
})
elif args.mode == "nudge":
if not args.emp_id and not args.assignee:
output_error("--emp-id or --assignee is required for nudge mode")
if not args.task_main:
output_error("--task-main is required for nudge mode")
emp_id = args.emp_id
if args.assignee and not args.emp_id:
emp_ids = resolve_names_to_empids(client, [args.assignee])
emp_id = emp_ids[0]
deadline_str = args.deadline if args.deadline else None
nudge_content = build_nudge_content(
args.task_main,
deadline_str,
args.content,
args.remind_style
)
result = client.submit_report(
main=f"【催办】{args.task_main}",
content_html=nudge_content,
type_id=REPORT_TYPE_ID,
accept_emp_id_list=[emp_id],
)
output_json({
"success": True,
"reportId": result.get("id"),
"empId": emp_id,
"message": f"Nudge sent to empId={emp_id} for task: {args.task_main}"
})
except CWorkError as e:
output_error(str(e))
except Exception as e:
output_error(f"Unexpected error: {e}")
if __name__ == "__main__":
main()
FILE:scripts/cwork-business-unit.py
#!/usr/bin/env python3
"""
cwork-business-unit.py
业务单元管理:保存/更新、列表、详情、删除
Usage:
# 新增
python3 scripts/cwork-business-unit.py save \
--name "工作协同开发小组" \
--description "研发周报流程" \
--node-list-json ./nodes.json
# 更新(传 --id)
python3 scripts/cwork-business-unit.py save \
--id 2043594941317410818 \
--name "工作协同开发小组(更新)" \
--node-list-json ./nodes.json
# 查询全部
python3 scripts/cwork-business-unit.py list
# 查询详情
python3 scripts/cwork-business-unit.py get --id 2043594941317410818
# 删除
python3 scripts/cwork-business-unit.py delete --id 2043594941317410818
"""
from __future__ import annotations
import argparse
import json
import sys
from pathlib import Path
sys.path.insert(0, str(Path(__file__).parent))
from cwork_client import CWorkError, apply_params_file_pre_parse, make_client
def parse_args() -> argparse.Namespace:
p = argparse.ArgumentParser(description="Manage CWork business units")
p.add_argument(
"--params-file",
dest="params_file",
default=None,
help="UTF-8 JSON 文件路径,从文件读取参数(键名与长参数一致)",
)
sub = p.add_subparsers(dest="action", required=True)
save = sub.add_parser("save", help="保存或更新业务单元")
save.add_argument("--id", dest="business_unit_id", default=None, help="业务单元 ID;传入代表更新")
save.add_argument("--name", required=True, help="方案名称")
save.add_argument("--description", default=None, help="方案说明")
save.add_argument(
"--node-list-json",
required=True,
help="UTF-8 JSON 文件路径,内容为 nodeList 数组",
)
save.add_argument("--dry-run", action="store_true", help="仅校验参数,不调用 API")
list_cmd = sub.add_parser("list", help="查询我的所有业务单元")
list_cmd.add_argument("--dry-run", action="store_true", help="仅输出说明,不调用 API")
get = sub.add_parser("get", help="查询业务单元详情")
get.add_argument("--id", dest="business_unit_id", required=True, help="业务单元 ID")
get.add_argument("--dry-run", action="store_true", help="仅校验参数,不调用 API")
delete = sub.add_parser("delete", help="删除业务单元")
delete.add_argument("--id", dest="business_unit_id", required=True, help="业务单元 ID")
delete.add_argument("--dry-run", action="store_true", help="仅校验参数,不调用 API")
return p.parse_args()
def load_node_list(path: str) -> list[dict]:
raw = Path(path).read_text(encoding="utf-8-sig")
data = json.loads(raw)
if not isinstance(data, list):
raise ValueError("node-list-json 根节点必须是数组")
if not data:
raise ValueError("nodeList 不能为空")
allowed_types = {"read", "suggest", "decide"}
out: list[dict] = []
for idx, node in enumerate(data):
if not isinstance(node, dict):
raise ValueError(f"nodeList[{idx}] 必须是对象")
node_name = node.get("nodeName")
node_type = node.get("nodeType")
emp_list = node.get("empList")
if not node_name:
raise ValueError(f"nodeList[{idx}].nodeName 必填")
if node_type not in allowed_types:
raise ValueError(
f"nodeList[{idx}].nodeType 仅支持 read/suggest/decide,当前为: {node_type}"
)
if not isinstance(emp_list, list) or not emp_list:
raise ValueError(f"nodeList[{idx}].empList 必须为非空数组")
normalized_emp_list: list[dict] = []
for j, emp in enumerate(emp_list):
if not isinstance(emp, dict):
raise ValueError(f"nodeList[{idx}].empList[{j}] 必须是对象")
emp_id = emp.get("id")
emp_name = emp.get("name")
if emp_id is None or str(emp_id).strip() == "":
raise ValueError(f"nodeList[{idx}].empList[{j}].id 必填(empId)")
if not emp_name:
raise ValueError(f"nodeList[{idx}].empList[{j}].name 必填")
normalized_emp_list.append({"id": str(emp_id), "name": str(emp_name)})
out.append(
{
"nodeName": str(node_name),
"nodeType": str(node_type),
"empList": normalized_emp_list,
}
)
return out
def main() -> None:
apply_params_file_pre_parse()
args = parse_args()
try:
if args.action == "save":
node_list = load_node_list(args.node_list_json)
if args.dry_run:
print(
json.dumps(
{
"success": True,
"action": "save",
"dryRun": True,
"payload": {
"id": args.business_unit_id,
"name": args.name,
"description": args.description,
"nodeList": node_list,
},
},
ensure_ascii=False,
indent=2,
)
)
return
client = make_client()
business_unit_id = client.save_business_unit(
name=args.name,
description=args.description,
node_list=node_list,
business_unit_id=args.business_unit_id,
)
print(
json.dumps(
{
"success": True,
"action": "save",
"businessUnitId": business_unit_id,
"mode": "update" if args.business_unit_id else "create",
},
ensure_ascii=False,
indent=2,
)
)
return
if args.action == "list":
if args.dry_run:
print(json.dumps({"success": True, "action": "list", "dryRun": True}, ensure_ascii=False, indent=2))
return
client = make_client()
data = client.list_all_business_units()
print(
json.dumps(
{
"success": True,
"action": "list",
"count": len(data),
"data": data,
"message": (
"未查询到业务单元(可能是未配置,或当前 appKey/access-token 对应的用户下暂无数据)"
if not data
else "查询成功"
),
},
ensure_ascii=False,
indent=2,
)
)
return
if args.action == "get":
if args.dry_run:
print(
json.dumps(
{
"success": True,
"action": "get",
"dryRun": True,
"businessUnitId": str(args.business_unit_id),
},
ensure_ascii=False,
indent=2,
)
)
return
client = make_client()
data = client.get_business_unit_by_id(args.business_unit_id)
print(json.dumps({"success": True, "action": "get", "data": data}, ensure_ascii=False, indent=2))
return
if args.action == "delete":
if args.dry_run:
print(
json.dumps(
{
"success": True,
"action": "delete",
"dryRun": True,
"businessUnitId": str(args.business_unit_id),
},
ensure_ascii=False,
indent=2,
)
)
return
client = make_client()
ok = client.delete_business_unit(args.business_unit_id)
print(
json.dumps(
{
"success": bool(ok),
"action": "delete",
"businessUnitId": str(args.business_unit_id),
"deleted": bool(ok),
},
ensure_ascii=False,
indent=2,
)
)
return
except (CWorkError, OSError, ValueError, json.JSONDecodeError) as e:
print(json.dumps({"success": False, "error": str(e)}, ensure_ascii=False), file=sys.stderr)
sys.exit(1)
if __name__ == "__main__":
main()
FILE:scripts/cwork-virtual-employee.py
#!/usr/bin/env python3
"""
cwork-virtual-employee.py
虚拟员工管理:创建 / 列表 / 修改 / 删除
"""
from __future__ import annotations
import argparse
import json
import sys
from pathlib import Path
sys.path.insert(0, str(Path(__file__).parent))
from cwork_client import CWorkError, apply_params_file_pre_parse, make_client
def parse_args() -> argparse.Namespace:
p = argparse.ArgumentParser(description="CWork virtual employee management")
p.add_argument(
"--mode",
required=True,
choices=["add", "list", "update", "delete"],
help="操作模式",
)
p.add_argument("--id", dest="virtual_emp_id", default=None, help="虚拟员工 ID")
p.add_argument("--name", default=None, help="虚拟员工名称")
p.add_argument("--remark", default=None, help="虚拟员工备注")
p.add_argument("--params-file", default=None, help="UTF-8 JSON 参数文件")
return p.parse_args()
def main() -> None:
apply_params_file_pre_parse()
args = parse_args()
try:
client = make_client()
if args.mode == "add":
if not args.name:
raise ValueError("mode=add 时必须提供 --name")
vid = client.add_virtual_employee(name=args.name, remark=args.remark)
print(
json.dumps(
{
"success": True,
"mode": "add",
"virtualEmpId": vid,
"name": args.name,
"remark": args.remark,
},
ensure_ascii=False,
indent=2,
)
)
return
if args.mode == "list":
rows = client.list_virtual_employees()
print(
json.dumps(
{"success": True, "mode": "list", "count": len(rows), "list": rows},
ensure_ascii=False,
indent=2,
)
)
return
if args.mode == "update":
if not args.virtual_emp_id:
raise ValueError("mode=update 时必须提供 --id")
ok = client.update_virtual_employee(
virtual_emp_id=args.virtual_emp_id, name=args.name, remark=args.remark
)
print(
json.dumps(
{
"success": bool(ok),
"mode": "update",
"virtualEmpId": str(args.virtual_emp_id),
"name": args.name,
"remark": args.remark,
},
ensure_ascii=False,
indent=2,
)
)
return
if not args.virtual_emp_id:
raise ValueError("mode=delete 时必须提供 --id")
ok = client.delete_virtual_employee(args.virtual_emp_id)
print(
json.dumps(
{
"success": bool(ok),
"mode": "delete",
"virtualEmpId": str(args.virtual_emp_id),
},
ensure_ascii=False,
indent=2,
)
)
except (CWorkError, ValueError, OSError, json.JSONDecodeError) as e:
print(json.dumps({"success": False, "error": str(e)}, ensure_ascii=False), file=sys.stderr)
sys.exit(1)
if __name__ == "__main__":
main()
FILE:scripts/cwork-send-report.py
#!/usr/bin/env python3
"""
cwork-send-report.py
发送汇报:解析接收人 → 全量保存/更新草稿(5.23)→ 输出完整草稿详情(5.25)供用户确认
→ 仅在 --confirm-send 后调用 5.27 将草稿转为正式汇报。
更新草稿前会先拉取详情,与本次参数合并后整包提交,避免全量覆盖导致字段丢失。
Usage:
# 1) 仅存草稿 + 输出完整预览(默认,不发送)
python3 scripts/cwork-send-report.py --title "..." --content "<p>...</p>" --receivers "张三"
# Markdown 正文(须指定 --content-type markdown)
python3 scripts/cwork-send-report.py --title "..." --content-type markdown --content "## 标题\n正文" --receivers "张三"
# 2) 用户确认后,仅凭汇报 id 发出(5.27)
python3 scripts/cwork-send-report.py --draft-id <汇报id> --confirm-send
Auth:
--app-key (required, injected by cms-auth-skills)
"""
from __future__ import annotations
import sys
import json
import copy
import re
import argparse
from pathlib import Path
sys.path.insert(0, str(Path(__file__).parent))
from cwork_client import (
make_client,
CWorkError,
apply_params_file_pre_parse,
flatten_emp_search_bucket,
)
# ---------------------------------------------------------------------------
# CLI
# ---------------------------------------------------------------------------
def parse_args():
p = argparse.ArgumentParser(description="Send a CWork report (draft-first, 5.27 to publish)")
p.add_argument("--title", "-t", default=None, help="汇报标题(发送-only 模式可不填)")
p.add_argument(
"--content",
"-c",
default=None,
help=(
"汇报正文(与 Skill / params 键 content 一致)。"
"格式由 --content-type 指定(html / markdown);发送-only 可不填。"
),
)
p.add_argument(
"--content-html",
dest="content_html",
default=None,
help="[兼容] 与 --content 相同,勿与 --content 同时指定",
)
p.add_argument(
"--content-type",
dest="body_content_type",
choices=["html", "markdown"],
default=None,
help=(
"正文格式 html 或 markdown。新建未指定时默认 html;"
"带 --draft-id 更新且未指定时沿用当前草稿详情中的格式。"
),
)
p.add_argument(
"--receivers", "-r", default="",
help="Comma-separated receiver names (will be resolved to empId)",
)
p.add_argument(
"--cc", dest="cc_names", default="",
help="Comma-separated CC recipient names",
)
p.add_argument(
"--grade", "-g", default="一般",
choices=["一般", "紧急"],
help="Report urgency",
)
p.add_argument(
"--type-id", dest="type_id", type=int, default=9999,
help="Report type ID (default 9999)",
)
p.add_argument(
"--file-paths", nargs="*", dest="file_paths", default=[],
help="Local file paths to attach (up to 10)",
)
p.add_argument(
"--file-names", nargs="*", dest="file_names", default=[],
help="File names for attachments (same order as --file-paths)",
)
p.add_argument(
"--plan-id", dest="plan_id", default=None,
help="Linked plan/task ID",
)
p.add_argument(
"--business-unit-id",
dest="business_unit_id",
default=None,
help="业务单元 ID。传入后发汇报按业务单元预设节点流转",
)
p.add_argument(
"--virtual-emp-id",
dest="virtual_emp_id",
default=None,
help="虚拟员工 ID。传入后由虚拟人代发",
)
p.add_argument(
"--preview-only", dest="preview_only", action="store_true",
help="仅保存草稿并输出完整预览(与默认不发送行为一致,便于显式调用)",
)
p.add_argument(
"--draft-id", dest="draft_id", default=None,
help="汇报 id:更新已有草稿;与 --confirm-send 单独使用时仅执行 5.27 发出",
)
p.add_argument(
"--confirm-save-draft",
dest="confirm_save_draft",
action="store_true",
help="显式确认允许保存/更新草稿(安全护栏:未确认不执行 5.23)",
)
p.add_argument(
"--confirm-send", dest="confirm_send", action="store_true",
help="用户已预览完整草稿并同意发出后,再指定此参数才会调用 5.27",
)
p.add_argument(
"--test-mode",
dest="test_mode",
action="store_true",
help="测试/调试模式:默认仅允许接收人为当前用户本人",
)
p.add_argument(
"--current-user-name",
dest="current_user_name",
default=None,
help="当前发起用户姓名(test-mode 下用于默认接收人及校验)",
)
p.add_argument(
"--allow-external-test-receivers",
dest="allow_external_test_receivers",
action="store_true",
help="test-mode 下允许接收人为非当前用户(高风险,需显式开启)",
)
p.add_argument(
"--report-level-json", dest="report_level_json", default=None,
help="UTF-8 JSON 文件路径,内容为 reportLevelList 数组(覆盖详情中的流程节点)",
)
p.add_argument(
"--params-file", dest="params_file", default=None,
help="UTF-8 JSON 文件路径,从文件读取参数(用于 Windows 下传递中文内容)",
)
p.add_argument(
"--allow-minimal-body",
dest="allow_minimal_body",
action="store_true",
help="允许正文过短(默认纯文本长度 ≤10 会拒绝保存草稿;超过 10 字不拦截)",
)
p.add_argument(
"--fail-on-literal-newlines",
dest="fail_on_literal_newlines",
action="store_true",
help=(
"在自动修正前检测:正文若含字面量 \\\\n / \\\\r\\\\n 则直接失败退出(供 CI/自动化)。"
"终端用户无需使用此参数。"
),
)
return p.parse_args()
def merge_report_content_args(args) -> None:
"""--content 与 --content-html 二选一,合并到 args.content。"""
if args.content is not None and args.content_html is not None:
print(json.dumps({
"success": False,
"error": "请勿同时指定 --content 与 --content-html",
}, ensure_ascii=False), file=sys.stderr)
sys.exit(1)
if args.content is None:
args.content = args.content_html
def normalize_markdown_escaped_newlines(content: str | None, content_type: str | None) -> str | None:
"""兼容 AI/CLI 传入的字面量转义(如 '\\n'),还原为真实换行。
仅在 markdown 场景处理,避免影响 html 正文中本就合法的反斜杠字符。
"""
if content is None:
return None
if (content_type or "").lower() != "markdown":
return content
if "\\" not in content:
return content
normalized = content.replace("\\r\\n", "\n").replace("\\n", "\n").replace("\\t", "\t")
return normalized
def body_has_literal_escaped_newlines(content: str | None, content_type: str | None) -> bool:
"""检测正文是否含应被自动修正的字面量换行转义。"""
if content is None:
return False
if (content_type or "").lower() != "markdown":
return False
return ("\\n" in content) or ("\\r\\n" in content)
# ---------------------------------------------------------------------------
# Resolve / validate names
# ---------------------------------------------------------------------------
# 纯文本长度 ≤ 此值则拒绝保存;须 **严格大于** 该值(即至少 11 字)才放行(与 Issue #37 约定一致)
SHORT_BODY_REJECT_IF_LEN_LE = 10
def split_cli_name_list(s: str) -> list[str]:
"""按英文逗号、中文逗号、顿号、分号拆分姓名列表(修复仅用「,」导致整串当一人)。"""
s = (s or "").strip()
if not s:
return []
parts = re.split(r"[,,、;;]", s)
return [p.strip() for p in parts if p.strip()]
def body_plain_length(content: str | None, content_type: str) -> int:
"""与预览一致的「纯文本长度」近似:html 去标签;markdown 按原文字符数。"""
if content is None:
return 0
raw = content.strip()
if not raw:
return 0
ct = (content_type or "html").lower()
if ct == "markdown":
return len(raw)
plain = re.sub(r"<[^>]+>", "", raw)
return len(plain.strip())
def effective_body_content_type(args, detail: dict | None) -> str:
if args.body_content_type is not None:
return args.body_content_type
if detail:
raw_ct = detail.get("contentType")
if isinstance(raw_ct, str) and raw_ct.lower() in ("html", "markdown"):
return raw_ct.lower()
return "html"
def _body_has_literal_escaped_newlines(content: str | None) -> bool:
"""检测正文是否含 JSON/Agent 常见的「字面量 \\n」序列(反斜杠 + n),而非真实换行。"""
text = content or ""
return ("\\n" in text) or ("\\r\\n" in text) or ("\\r" in text)
def normalize_literal_escaped_newlines(content: str | None) -> str:
"""将字面量 \\n / \\r\\n / \\r 转为真实换行(静默;不区分 html/markdown)。
典型来源:params 被二次转义、或非 json.dump 手写 JSON。对真实换行无影响。
"""
if not content:
return content or ""
if "\\" not in content:
return content
s = content.replace("\\r\\n", "\n").replace("\\n", "\n").replace("\\r", "\n")
return s
def resolve_receivers(client, names: list[str]) -> dict:
results = {}
for name in names:
if not name.strip():
continue
try:
data = client.search_emp_by_name(name.strip())
except CWorkError as e:
results[name] = {"status": "error", "message": str(e)}
continue
inside = flatten_emp_search_bucket(data.get("inside"))
if inside:
candidates = inside
else:
candidates = flatten_emp_search_bucket(data.get("outside"))
if len(candidates) == 0:
results[name] = {"status": "not_found"}
elif len(candidates) == 1:
emp = candidates[0]
results[name] = {
"status": "found",
"empId": emp["id"],
"name": emp["name"],
"title": emp.get("title", ""),
"dept": emp.get("mainDept", ""),
}
else:
results[name] = {
"status": "multiple",
"employees": [
{"empId": e["id"], "name": e["name"],
"title": e.get("title", ""), "dept": e.get("mainDept", "")}
for e in candidates
],
}
return results
def validate_receivers(resolved: dict) -> tuple[list[dict], list[dict]]:
confirmed = []
errors = []
for name, info in resolved.items():
if info["status"] == "not_found":
errors.append({"name": name, "reason": "not_found"})
elif info["status"] == "multiple":
errors.append({
"name": name,
"reason": "multiple_matches",
"candidates": [
{"empId": e["empId"], "name": e["name"],
"title": e.get("title", ""), "dept": e.get("dept", "")}
for e in info["employees"]
],
})
elif info["status"] == "found":
confirmed.append({"empId": info["empId"], "name": info["name"]})
return confirmed, errors
# ---------------------------------------------------------------------------
# Draft detail → saveOrUpdate 全量字段
# ---------------------------------------------------------------------------
def _emp_ids_from_detail(emp_list: list | None) -> list[str]:
if not emp_list:
return []
out: list[str] = []
for e in emp_list:
if isinstance(e, dict) and e.get("id") is not None:
out.append(str(e["id"]))
return out
def _file_vos_from_detail(file_list: list | None) -> list[dict]:
if not file_list:
return []
out: list[dict] = []
for f in file_list:
if not isinstance(f, dict):
continue
fid = f.get("fileId")
if not fid:
continue
out.append({
"fileId": str(fid),
"name": f.get("name") or "",
"type": f.get("type") or "file",
})
return out
def _report_level_param_from_detail(nodes: list | None) -> list[dict] | None:
if not nodes:
return None
result: list[dict] = []
for node in nodes:
if not isinstance(node, dict):
continue
emp_list = node.get("empList") or []
level_users = []
for e in emp_list:
if not isinstance(e, dict):
continue
eid = e.get("empId", e.get("id"))
if eid is not None:
level_users.append({"empId": eid})
entry = {
"type": node.get("type"),
"level": node.get("level"),
"nodeCode": node.get("nodeCode"),
"nodeName": node.get("nodeName"),
"levelUserList": level_users,
"groupIdList": node.get("groupIdList"),
"requirement": node.get("requirement"),
}
result.append({k: v for k, v in entry.items() if v is not None})
return result or None
def _receiver_node_index_for_cli_sync(nodes: list[dict]) -> int:
"""选择应将 ``--receivers`` 写入 ``levelUserList`` 的节点(与 5.1/5.23 一致:接收人以 reportLevelList 为准)。"""
for i, n in enumerate(nodes):
if isinstance(n, dict) and isinstance(n.get("type"), str) and n["type"].lower() == "read":
return i
for i, n in enumerate(nodes):
if isinstance(n, dict) and n.get("levelUserList"):
return i
return 0
def _apply_cli_receivers_to_report_level_list(
report_level_list: list[dict],
accept_emp_ids: list[str],
) -> list[dict]:
"""用本次 CLI 解析出的接收人覆盖目标节点 ``levelUserList``,避免仅更新 acceptEmpIdList 时详情仍显示旧人。"""
if not report_level_list or not accept_emp_ids:
return report_level_list
out = copy.deepcopy(report_level_list)
idx = _receiver_node_index_for_cli_sync(out)
if idx < 0 or idx >= len(out):
return report_level_list
level_users: list[dict] = []
for eid in accept_emp_ids:
try:
level_users.append({"empId": int(str(eid))})
except (TypeError, ValueError):
level_users.append({"empId": eid})
node = dict(out[idx])
node["levelUserList"] = level_users
node.pop("groupIdList", None)
out[idx] = {k: v for k, v in node.items() if v is not None}
return out
def load_report_level_json(path: str) -> list[dict]:
raw = Path(path).read_text(encoding="utf-8-sig")
data = json.loads(raw)
if not isinstance(data, list):
raise ValueError("report-level-json 根节点须为 JSON 数组")
return data
def _validate_report_level_list_nodes(report_level_list: list[dict] | None) -> dict | None:
"""校验节点至少包含人员/分组/部门其一,返回首个错误信息。"""
if not isinstance(report_level_list, list):
return None
for idx, node in enumerate(report_level_list, start=1):
if not isinstance(node, dict):
continue
users = node.get("levelUserList")
groups = node.get("groupIdList")
# 后端字段在不同环境可能命名不同,统一兼容常见部门字段。
depts = (
node.get("deptIdList")
or node.get("departmentIdList")
or node.get("deptIds")
or node.get("departmentIds")
)
if users or groups or depts:
continue
node_name = str(node.get("nodeName") or f"节点{idx}")
return {
"index": idx,
"nodeName": node_name,
"message": (
f"{node_name}缺少审批对象:请至少补充人员(levelUserList)、分组(groupIdList)或部门"
"(deptIdList/departmentIdList)其一;若暂不需要该节点,请删除该节点后重试。"
),
}
return None
# ---------------------------------------------------------------------------
# Upload files
# ---------------------------------------------------------------------------
def upload_files(client, file_paths: list[str], file_names: list[str]) -> list[dict]:
if not file_paths:
return []
file_vos = []
for i, path in enumerate(file_paths):
fname = file_names[i] if i < len(file_names) else Path(path).name
try:
result = client.upload_file(path)
file_id = result.get("fileId", "")
file_vos.append({
"fileId": str(file_id) if file_id is not None else "",
"name": fname,
"type": "file",
})
except CWorkError as e:
print(json.dumps({
"step": "upload",
"file": fname,
"error": str(e),
}, ensure_ascii=False), file=sys.stderr)
return file_vos
# ---------------------------------------------------------------------------
# Merge + save draft (5.23 全量)
# ---------------------------------------------------------------------------
def build_save_draft_kwargs(
args,
*,
detail: dict | None,
accept_emp_ids: list[str],
cc_emp_ids: list[str],
file_vos: list[dict],
receiver_names_nonempty: bool,
cc_names_nonempty: bool,
new_uploads: bool,
) -> dict:
"""构造 save_draft 的完整参数,避免更新时省略字段导致服务端清空。"""
if args.report_level_json:
report_level_list = load_report_level_json(args.report_level_json)
elif detail is not None:
rll = detail.get("reportLevelList")
if rll:
converted = _report_level_param_from_detail(rll)
report_level_list = converted if converted is not None else []
else:
report_level_list = []
else:
report_level_list = None
if detail:
if receiver_names_nonempty:
final_accept = accept_emp_ids
else:
final_accept = _emp_ids_from_detail(detail.get("acceptEmployeeList"))
if cc_names_nonempty:
final_cc = cc_emp_ids
else:
final_cc = _emp_ids_from_detail(detail.get("copyEmployeeList"))
if new_uploads:
final_files = file_vos
else:
final_files = _file_vos_from_detail(detail.get("fileList"))
privacy_level = detail.get("privacyLevel") or "非涉密"
template_raw = detail.get("templateId")
template_id = str(template_raw) if template_raw is not None else None
plan_raw = args.plan_id if args.plan_id is not None else detail.get("planId")
plan_id = str(plan_raw) if plan_raw is not None else None
# 接口约定:传 businessUnitId 时会忽略 reportLevelList。
# 因此当用户显式提供 --report-level-json 时,不应再继承草稿中的 businessUnitId,
# 否则会出现“流程节点设置成功但服务端不生效”的假象。
if args.business_unit_id is not None:
business_unit_raw = args.business_unit_id
elif args.report_level_json:
business_unit_raw = None
else:
business_unit_raw = detail.get("businessUnitId")
business_unit_id = str(business_unit_raw) if business_unit_raw is not None else None
virtual_emp_raw = (
args.virtual_emp_id
if args.virtual_emp_id is not None
else detail.get("virtualEmpId")
)
virtual_emp_id = str(virtual_emp_raw) if virtual_emp_raw is not None else None
if args.body_content_type is not None:
content_type = args.body_content_type
else:
raw_ct = detail.get("contentType")
if isinstance(raw_ct, str) and raw_ct.lower() in ("html", "markdown"):
content_type = raw_ct.lower()
else:
content_type = "html"
else:
final_accept = accept_emp_ids
final_cc = cc_emp_ids
final_files = file_vos
privacy_level = "非涉密"
template_id = None
plan_id = str(args.plan_id) if args.plan_id is not None else None
business_unit_id = str(args.business_unit_id) if args.business_unit_id is not None else None
virtual_emp_id = str(args.virtual_emp_id) if args.virtual_emp_id is not None else None
content_type = args.body_content_type if args.body_content_type is not None else "html"
# 5.1/5.23:接收人以 reportLevelList 为准;acceptEmpIdList 仅在 reportLevelList 为空时兜底。
# 更新草稿且用户显式传 --receivers 时,必须把新人写入 reportLevelList,否则详情仍显示旧 empList。
if (
receiver_names_nonempty
and not args.report_level_json
and isinstance(report_level_list, list)
and len(report_level_list) > 0
):
report_level_list = _apply_cli_receivers_to_report_level_list(
report_level_list, final_accept
)
return {
"main": args.title,
"content_html": args.content,
"content_type": content_type,
"type_id": args.type_id,
"grade": args.grade,
"privacy_level": privacy_level,
"plan_id": plan_id,
"business_unit_id": business_unit_id,
"template_id": template_id,
"accept_emp_id_list": final_accept,
"copy_emp_id_list": final_cc,
"report_level_list": report_level_list,
"file_vo_list": final_files,
"virtual_emp_id": virtual_emp_id,
"draft_id": args.draft_id,
}
def save_draft_full(client, kwargs: dict) -> str | None:
draft_id = kwargs.pop("draft_id", None)
try:
result = client.save_draft(draft_id=draft_id, **kwargs)
if isinstance(result, str):
return result if result else None
rid = result.get("id")
return str(rid) if rid is not None else None
except CWorkError as e:
print(json.dumps({"step": "save_draft", "error": str(e)}, ensure_ascii=False), file=sys.stderr)
return None
# ---------------------------------------------------------------------------
# Preview output(完整草稿,来自 5.25)
# ---------------------------------------------------------------------------
def build_preview_shell(args, confirmed: list[dict], cc_confirmed: list[dict],
file_vos: list[dict], *, from_api_detail: dict) -> dict:
import re
html = from_api_detail.get("contentHtml") or ""
plain = re.sub(r"<[^>]+>", "", html)
plain_stripped = plain.strip()
plain_len = len(plain_stripped)
# summary.contentPreview:极短正文不截断,避免占位符被「摘要」误伤;仅超长纯文本才截断
preview_cap = 4000
if len(plain) <= preview_cap:
content_preview = plain
else:
content_preview = plain[:preview_cap] + "…"
# confirmPrompt 内嵌正文:预览用纯文本,避免重复塞入整段 HTML/Markdown
prompt_body_cap = 2000
prompt_plain = plain if len(plain) <= prompt_body_cap else plain[:prompt_body_cap] + "…"
preview_warnings: list[str] = []
if plain_len <= 30:
preview_warnings.append(
f"summary 中的正文预览仅 {plain_len} 个字符(由草稿正文简化得到,"
"一般应与本次 --content 长度接近),可能过短,发送前请与用户确认"
)
accept_names = [e["name"] for e in confirmed]
cc_names = [e["name"] for e in cc_confirmed]
summary: dict = {
"title": from_api_detail.get("main"),
"grade": from_api_detail.get("grade"),
"typeId": from_api_detail.get("typeId"),
"planId": from_api_detail.get("planId"),
"virtualEmpId": from_api_detail.get("virtualEmpId"),
"contentType": from_api_detail.get("contentType"),
"receiversResolved": accept_names,
"ccResolved": cc_names,
"attachmentsThisRun": [{"name": f["name"]} for f in file_vos],
"contentPlainText": plain,
"contentPreview": content_preview,
"contentPlainLength": plain_len,
}
if preview_warnings:
summary["previewWarnings"] = preview_warnings
warn_prefix = ("⚠ " + preview_warnings[0] + "\n\n") if preview_warnings else ""
confirm_prompt = (
warn_prefix
+ "【请用户确认以下完整草稿后再发送】\n"
f"标题:{from_api_detail.get('main')}\n"
f"优先级:{from_api_detail.get('grade')}\n"
"正文(以下为预览;定稿请以 draftDetail 为准,其中正文与本次 --content 对应):\n"
f"{prompt_plain}\n"
f"接收人(解析结果):{', '.join(accept_names) or '(沿用草稿详情)'}\n"
f"抄送:{', '.join(cc_names) or '(沿用草稿详情)'}\n"
f"附件:{json.dumps(from_api_detail.get('fileList') or [], ensure_ascii=False)}\n"
f"流程节点 reportLevelList:{json.dumps(from_api_detail.get('reportLevelList') or [], ensure_ascii=False)}\n"
"\n用户同意后,执行:--draft-id <汇报id> --confirm-send"
)
return {
"reportId": from_api_detail.get("id"),
"note": (
"以下为完整草稿 draftDetail(含接口原始字段)。请向用户展示全文:"
"正文即本次 --content 保存后的结果;确认后再执行 --draft-id <汇报id> --confirm-send。"
),
"draftDetail": from_api_detail,
"summary": summary,
"confirmPrompt": confirm_prompt,
}
# ---------------------------------------------------------------------------
# Main
# ---------------------------------------------------------------------------
def main():
try:
sys.stdout.reconfigure(encoding="utf-8")
except AttributeError:
pass
apply_params_file_pre_parse()
args = parse_args()
merge_report_content_args(args)
if args.fail_on_literal_newlines and body_has_literal_escaped_newlines(
args.content, args.body_content_type
):
print(json.dumps({
"success": False,
"step": "validate_content_newline",
"error": (
"正文含字面量换行转义序列;已按 --fail-on-literal-newlines 拒绝保存。"
),
"contentTypeUsed": args.body_content_type or "html",
}, ensure_ascii=False), file=sys.stderr)
sys.exit(1)
# Markdown 兼容:将字面量 \n/\t 还原,避免页面按普通字符展示导致不换行
args.content = normalize_markdown_escaped_newlines(args.content, args.body_content_type)
# 安全护栏:--confirm-send 必须搭配 --draft-id(强制两步流程)
if args.confirm_send and not args.draft_id:
print(json.dumps({
"success": False,
"error": (
"【安全拦截】--confirm-send 必须搭配 --draft-id 使用。"
"请先不带 --confirm-send 调用一次以保存草稿并获取 reportId,"
"向用户展示完整预览,待用户明确确认后,"
"再执行 --draft-id <reportId> --confirm-send。"
"禁止跳过预览直接发送。"
),
}, ensure_ascii=False), file=sys.stderr)
sys.exit(1)
send_only = bool(args.confirm_send and args.draft_id and not args.preview_only)
if send_only:
if args.title is not None or args.content is not None:
print(json.dumps({
"success": False,
"error": (
"发送-only 模式请勿再传 --title/--content;"
"仅需 --draft-id 与 --confirm-send(可选 --virtual-emp-id 用于发送前覆盖虚拟人)"
),
}, ensure_ascii=False), file=sys.stderr)
sys.exit(1)
else:
if not args.confirm_save_draft:
print(json.dumps({
"success": False,
"error": (
"【安全拦截】保存/更新草稿前必须显式确认。"
"请先征得用户同意后重试,并添加 --confirm-save-draft。"
),
}, ensure_ascii=False), file=sys.stderr)
sys.exit(1)
if not args.title or args.content is None:
print(json.dumps({
"success": False,
"error": "保存草稿需要 --title 与 --content(或仅 --draft-id + --confirm-send);可用 --content-html 代替 --content",
}, ensure_ascii=False), file=sys.stderr)
sys.exit(1)
client = make_client()
if send_only:
try:
detail = client.get_draft_detail(args.draft_id)
except CWorkError as e:
print(json.dumps({
"success": False,
"step": "get_draft_detail",
"error": str(e),
"reportId": args.draft_id,
}, ensure_ascii=False), file=sys.stderr)
sys.exit(1)
effective_virtual_emp_id = (
str(args.virtual_emp_id)
if args.virtual_emp_id is not None
else (
str(detail.get("virtualEmpId"))
if detail.get("virtualEmpId") is not None
else None
)
)
# 虚拟人提交场景:改走 5.1 submit(id=草稿id) 以确保 virtualEmpId 在最终发出时参与判定。
if effective_virtual_emp_id is not None:
main = detail.get("main")
content_html = detail.get("contentHtml")
if not main or content_html is None:
print(json.dumps({
"success": False,
"step": "submit_report_5_1",
"error": "草稿详情缺少 main 或 contentHtml,无法提交",
"reportId": args.draft_id,
}, ensure_ascii=False), file=sys.stderr)
sys.exit(1)
report_level_list = _report_level_param_from_detail(detail.get("reportLevelList")) or []
node_validation_error = _validate_report_level_list_nodes(report_level_list)
if node_validation_error:
print(json.dumps({
"success": False,
"step": "validate_report_level_list",
"error": node_validation_error["message"],
"nodeIndex": node_validation_error["index"],
"nodeName": node_validation_error["nodeName"],
}, ensure_ascii=False, indent=2), file=sys.stderr)
sys.exit(1)
try:
result = client.submit_report(
main=main,
content_html=content_html,
report_id=args.draft_id,
business_unit_id=detail.get("businessUnitId"),
content_type=effective_body_content_type(args, detail),
type_id=int(detail.get("typeId") or 9999),
grade=detail.get("grade") or "一般",
privacy_level=detail.get("privacyLevel") or "非涉密",
plan_id=str(detail.get("planId")) if detail.get("planId") is not None else None,
template_id=detail.get("templateId"),
accept_emp_id_list=_emp_ids_from_detail(detail.get("acceptEmployeeList")),
copy_emp_id_list=_emp_ids_from_detail(detail.get("copyEmployeeList")),
report_level_list=report_level_list,
file_vo_list=_file_vos_from_detail(detail.get("fileList")),
virtual_emp_id=effective_virtual_emp_id,
)
report_id = result.get("id") if isinstance(result, dict) else None
print(json.dumps({
"success": True,
"reportId": str(report_id) if report_id is not None else str(args.draft_id),
"submitted": True,
"virtualEmpId": effective_virtual_emp_id,
"submitApi": "report_record_submit_5_1",
"message": "已通过 5.1 提交草稿并按 virtualEmpId 发出",
}, ensure_ascii=False, indent=2))
sys.exit(0)
except CWorkError as e:
print(json.dumps({
"success": False,
"step": "submit_report_5_1",
"error": str(e),
"reportId": args.draft_id,
"virtualEmpId": effective_virtual_emp_id,
}, ensure_ascii=False), file=sys.stderr)
sys.exit(1)
try:
ok = client.submit_draft(args.draft_id)
print(json.dumps({
"success": bool(ok),
"reportId": args.draft_id,
"submitted": bool(ok),
"virtualEmpId": None,
"submitApi": "draft_submit_5_27",
"message": "已通过 5.27 将草稿转为正式汇报(无 virtualEmpId)" if ok else "5.27 返回未成功",
}, ensure_ascii=False, indent=2))
sys.exit(0 if ok else 1)
except CWorkError as e:
print(json.dumps({
"success": False,
"error": str(e),
"reportId": args.draft_id,
}, ensure_ascii=False), file=sys.stderr)
sys.exit(1)
detail: dict | None = None
if args.draft_id:
try:
detail = client.get_draft_detail(args.draft_id)
except CWorkError as e:
print(json.dumps({
"success": False,
"step": "get_draft_detail",
"error": str(e),
}, ensure_ascii=False), file=sys.stderr)
sys.exit(1)
effective_ct = effective_body_content_type(args, detail)
if args.fail_on_literal_newlines and _body_has_literal_escaped_newlines(args.content):
print(json.dumps({
"success": False,
"step": "validate_content_newline",
"error": "正文含字面量换行转义序列;已按 --fail-on-literal-newlines 拒绝保存。",
"contentTypeUsed": effective_ct,
}, ensure_ascii=False), file=sys.stderr)
sys.exit(1)
args.content = normalize_literal_escaped_newlines(args.content)
if not args.allow_minimal_body:
plen = body_plain_length(args.content, effective_ct)
if plen <= SHORT_BODY_REJECT_IF_LEN_LE:
print(json.dumps({
"success": False,
"step": "validate_body",
"error": (
f"正文过短(按 {effective_ct} 估算约 {plen} 字;须超过 {SHORT_BODY_REJECT_IF_LEN_LE} 字才保存。"
"请补充内容,或显式传入 --allow-minimal-body 跳过此校验。"
),
"contentPlainLength": plen,
"contentTypeUsed": effective_ct,
"rejectIfPlainLengthLte": SHORT_BODY_REJECT_IF_LEN_LE,
}, ensure_ascii=False), file=sys.stderr)
sys.exit(1)
receiver_names = split_cli_name_list(args.receivers)
cc_names = split_cli_name_list(args.cc_names)
if args.test_mode and not receiver_names and args.current_user_name:
receiver_names = [args.current_user_name.strip()]
if args.test_mode and not receiver_names:
print(json.dumps({
"success": False,
"step": "validate_test_receivers",
"error": (
"test-mode 下必须指定接收人;建议传 --current-user-name 以默认发给本人,"
"或显式设置 --receivers 为测试账号。"
),
}, ensure_ascii=False), file=sys.stderr)
sys.exit(1)
if (
args.test_mode
and args.current_user_name
and not args.allow_external_test_receivers
):
me = args.current_user_name.strip()
non_self = [n for n in receiver_names if n.strip() and n.strip() != me]
if non_self:
print(json.dumps({
"success": False,
"step": "validate_test_receivers",
"error": (
"test-mode 默认仅允许发送给当前用户本人。"
"如确需外发,请显式添加 --allow-external-test-receivers 并先获用户确认。"
),
"currentUserName": me,
"externalReceivers": non_self,
}, ensure_ascii=False, indent=2), file=sys.stderr)
sys.exit(1)
receiver_nonempty = bool(receiver_names)
cc_nonempty = bool(cc_names)
resolved = resolve_receivers(client, receiver_names)
confirmed, errors = validate_receivers(resolved)
cc_resolved = resolve_receivers(client, cc_names)
cc_confirmed, cc_errors = validate_receivers(cc_resolved)
all_errors = []
if errors:
all_errors.append({"field": "receivers", "errors": errors})
if cc_errors:
all_errors.append({"field": "cc", "errors": cc_errors})
if all_errors:
print(json.dumps({
"success": False,
"step": "validate_names",
"message": "部分姓名未能唯一匹配,请确认后重试",
"details": all_errors,
}, ensure_ascii=False, indent=2))
sys.exit(1)
accept_emp_ids = [str(e["empId"]) for e in confirmed]
cc_emp_ids = [str(e["empId"]) for e in cc_confirmed]
file_vos = upload_files(client, args.file_paths, args.file_names)
new_uploads = bool(args.file_paths)
kwargs = build_save_draft_kwargs(
args,
detail=detail,
accept_emp_ids=accept_emp_ids,
cc_emp_ids=cc_emp_ids,
file_vos=file_vos,
receiver_names_nonempty=receiver_nonempty,
cc_names_nonempty=cc_nonempty,
new_uploads=new_uploads,
)
node_validation_error = _validate_report_level_list_nodes(kwargs.get("report_level_list"))
if node_validation_error:
print(json.dumps({
"success": False,
"step": "validate_report_level_list",
"error": node_validation_error["message"],
"nodeIndex": node_validation_error["index"],
"nodeName": node_validation_error["nodeName"],
}, ensure_ascii=False, indent=2), file=sys.stderr)
sys.exit(1)
saved_report_id = save_draft_full(client, kwargs)
if not saved_report_id:
print(json.dumps({
"success": False,
"step": "save_draft",
"message": "草稿保存失败",
}, ensure_ascii=False, indent=2))
sys.exit(1)
try:
fresh_detail = client.get_draft_detail(saved_report_id)
except CWorkError as e:
print(json.dumps({
"success": False,
"step": "get_draft_detail",
"error": str(e),
"reportId": saved_report_id,
}, ensure_ascii=False), file=sys.stderr)
sys.exit(1)
preview = build_preview_shell(
args, confirmed, cc_confirmed, file_vos, from_api_detail=fresh_detail,
)
preview["success"] = True
preview["identityContext"] = {
"virtualEmpIdProvided": bool(args.virtual_emp_id),
"senderAuthMode": "default_app_key",
"note": (
"当前脚本仅使用用户 AppKey 鉴权;virtualEmpId 作为业务字段透传。"
"是否以虚拟员工展示由服务端规则决定。"
),
}
# draftId:历史 JSON 键名,值为汇报 id,与 reportId / draftDetail.id 相同(非草稿箱记录 id)
preview["draftId"] = saved_report_id
if not args.confirm_send or args.preview_only:
preview["nextStep"] = (
"用户已确认 draftDetail 全文(正文、附件、流程节点等)后,再执行:"
f"--draft-id {saved_report_id} --confirm-send"
)
if args.preview_only and args.confirm_send:
preview["noteOnFlags"] = "已指定 --preview-only,不会发出;忽略 --confirm-send"
print(json.dumps(preview, ensure_ascii=False, indent=2))
return
try:
ok = client.submit_draft(saved_report_id)
except CWorkError as e:
print(json.dumps({
"success": False,
"step": "submit_draft_5_27",
"error": str(e),
"draftId": saved_report_id,
}, ensure_ascii=False), file=sys.stderr)
sys.exit(1)
if not ok:
print(json.dumps({
"success": False,
"step": "submit_draft_5_27",
"draftId": saved_report_id,
"message": "5.27 返回未成功",
}, ensure_ascii=False, indent=2))
sys.exit(1)
print(json.dumps({
"success": True,
"reportId": saved_report_id,
"submitted": True,
"receivers": accept_emp_ids,
"cc": cc_emp_ids,
"attachmentsThisRun": len(file_vos),
"message": "已通过 5.27 将草稿转为正式汇报",
}, ensure_ascii=False, indent=2))
if __name__ == "__main__":
main()
FILE:scripts/cwork-todo.py
#!/usr/bin/env python3
"""
cwork-todo.py — 待办管理
功能:
1. 查询待办列表
2. 完成待办
用法:
python3 cwork-todo.py list --page-size 20 --status pending
python3 cwork-todo.py complete --todo-id <id> --content "已完成"
"""
import argparse
import json
import sys
import os
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
from cwork_client import make_client, apply_params_file_pre_parse
def _extract_first_id(item, candidates):
if not isinstance(item, dict):
return None
for key in candidates:
value = item.get(key)
if value is not None and str(value).strip():
return value
return None
def _safe_attach_share_link(client, item):
if not isinstance(item, dict):
return
# 待办通常关联汇报;若存在任务字段则按任务补链
report_id = _extract_first_id(item, ("reportId",))
task_id = _extract_first_id(item, ("planId", "taskId"))
try:
if report_id is not None:
item["shareLink"] = client.create_share_link(report_id, 1)
return
if task_id is not None:
item["shareLink"] = client.create_share_link(task_id, 2)
return
except Exception:
# 分享链接失败不阻断主查询结果
return
def _attach_share_links_to_list(client, rows, top_n: int):
if not isinstance(rows, list):
return
limit = len(rows) if top_n <= 0 else top_n
for idx, row in enumerate(rows):
if idx >= limit:
break
_safe_attach_share_link(client, row)
def list_todos(args):
"""查询待办列表"""
client = make_client()
result = client.get_todo_list(
page_index=args.page_index,
page_size=args.page_size,
status=args.status
)
rows = result.get("list") or result.get("rows") or []
if args.with_share_link:
_attach_share_links_to_list(client, rows, args.share_top_n)
if args.output_raw:
print(json.dumps(result, ensure_ascii=False, indent=2))
return
# 5.15 返回 PageInfo:列表在 ``list``(见开放 API 6.3),非 ``rows``
total = result.get("total", len(rows))
output = {
"success": True,
"action": "list",
"total": total,
"items": [
{
"todoId": item.get("todoId"),
"reportId": item.get("reportId"),
"id": item.get("todoId"),
"title": item.get("main") or item.get("title"),
"todoType": item.get("todoType") or item.get("type"),
"status": item.get("status"),
"createTime": item.get("createTime"),
"creator": item.get("writeEmpName") or item.get("creatorName"),
"shareLink": item.get("shareLink"),
}
for item in rows
],
}
print(json.dumps(output, ensure_ascii=False, indent=2))
def complete_todo(args):
"""完成待办"""
client = make_client()
if not args.todo_id:
print(json.dumps({
"success": False,
"error": "缺少必填参数: --todo-id"
}, ensure_ascii=False), file=sys.stderr)
sys.exit(1)
if not args.content:
print(json.dumps({
"success": False,
"error": "缺少必填参数: --content"
}, ensure_ascii=False), file=sys.stderr)
sys.exit(1)
if args.dry_run:
print(json.dumps({
"success": True,
"action": "complete",
"dryRun": True,
"todoId": args.todo_id,
"content": args.content
}, ensure_ascii=False, indent=2))
return
result = client.complete_todo(
todo_id=args.todo_id,
content=args.content,
operate=args.operate
)
output = {
"success": True,
"action": "complete",
"todoId": args.todo_id,
"result": result
}
print(json.dumps(output, ensure_ascii=False, indent=2))
def main():
parser = argparse.ArgumentParser(
description="CWork 待办管理",
formatter_class=argparse.RawDescriptionHelpFormatter
)
parser.add_argument("--params-file", dest="params_file", default=None,
help="UTF-8 JSON 文件路径,从文件读取参数")
subparsers = parser.add_subparsers(dest="action", help="操作类型")
# list 子命令
list_parser = subparsers.add_parser("list", help="查询待办列表")
list_parser.add_argument("--page-index", type=int, default=1, help="页码")
list_parser.add_argument("--page-size", type=int, default=20, help="每页数量")
list_parser.add_argument("--status", type=str, help="状态筛选")
list_parser.add_argument("--with-share-link", dest="with_share_link", action="store_true", default=True,
help="在待办列表中补充分享链接(默认开启)")
list_parser.add_argument("--no-share-link", dest="with_share_link", action="store_false",
help="关闭分享链接补充")
list_parser.add_argument("--share-top-n", type=int, default=20,
help="最多补充前 N 条分享链接(默认 20,0=当前页全部)")
list_parser.add_argument("--output-raw", action="store_true", help="输出原始响应")
# complete 子命令
complete_parser = subparsers.add_parser("complete", help="完成待办")
complete_parser.add_argument("--todo-id", type=str, required=True, help="待办 ID")
complete_parser.add_argument("--content", type=str, required=True, help="完成说明")
complete_parser.add_argument("--operate", type=str, default=None,
choices=["agree", "disagree"],
help="决策操作: agree=同意, disagree=不同意(仅决策类待办需要)")
complete_parser.add_argument("--dry-run", action="store_true", help="仅预览")
apply_params_file_pre_parse()
args = parser.parse_args()
if not args.action:
parser.print_help()
sys.exit(1)
if args.action == "list":
list_todos(args)
elif args.action == "complete":
complete_todo(args)
if __name__ == "__main__":
main()
FILE:scripts/cwork-review-report.py
#!/usr/bin/env python3
"""
CWork Review Report — 审阅/回复汇报脚本
Modes:
reply — 回复/点评汇报
mark-read — 标记已读
pending — 查询待审汇报
Usage:
python cwork-review-report.py --mode reply --report-id <id> --reply "内容"
python cwork-review-report.py --mode reply --report-id <id> --reply "内容" --content-type html
python cwork-review-report.py --mode reply --report-id <id> --reply "内容" --at "张三"
python cwork-review-report.py --mode mark-read --report-id <id>
python cwork-review-report.py --mode pending [--page-size 20]
Output: JSON to stdout, error JSON to stderr + exit 1
"""
import sys
import os
import json
import argparse
from pathlib import Path
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
from cwork_client import (CWorkClient, make_client, CWorkError,
output_json, output_error, resolve_names_to_empids,
apply_params_file_pre_parse)
def parse_args(argv=None):
parser = argparse.ArgumentParser(description="CWork 审阅汇报")
parser.add_argument("--mode", required=True,
choices=["reply", "mark-read", "pending"],
help="操作模式")
parser.add_argument("--report-id", help="汇报ID(reply/mark-read模式必填)")
parser.add_argument(
"--reply",
help="回复正文(reply 必填;提交至 API 的 contentHtml,格式由 --content-type 决定)",
)
parser.add_argument(
"--content-type",
choices=["html", "markdown"],
default="markdown",
help="回复内容类型:markdown 支持 [@标题](reportId=…&linkType=report) 等内部链接(默认);html 时包裹为 <p>…</p>",
)
parser.add_argument("--at", help="被@人的姓名(reply模式可选)")
parser.add_argument(
"--file-paths",
nargs="*",
default=[],
help="本地附件路径(reply模式可选;会先上传后带入 mediaVOList)",
)
parser.add_argument(
"--file-names",
nargs="*",
default=[],
help="附件显示名称(与 --file-paths 顺序一致;可选)",
)
parser.add_argument("--virtual-emp-id", help="虚拟员工 ID(reply 模式可选)")
parser.add_argument("--page-index", type=int, default=1, help="页码(默认1)")
parser.add_argument("--page-size", type=int, default=20, help="每页大小(默认20)")
parser.add_argument("--report-type", type=int, choices=[1, 2, 3, 4, 5],
help="汇报类型筛选")
parser.add_argument("--interactive", action="store_true", help="交互模式")
parser.add_argument("--dry-run", action="store_true", help="干跑模式")
parser.add_argument("--params-file", dest="params_file", default=None,
help="UTF-8 JSON 文件路径,从文件读取参数")
return parser.parse_args(argv)
def upload_files_for_reply(client: CWorkClient, file_paths: list[str], file_names: list[str]) -> list[dict]:
if not file_paths:
return []
media_vos: list[dict] = []
for i, path in enumerate(file_paths):
display_name = file_names[i] if i < len(file_names) else Path(path).name
file_size = None
try:
file_size = Path(path).stat().st_size
except OSError:
file_size = None
result = client.upload_file(path)
file_id = result.get("fileId", "")
item = {
"fileId": str(file_id) if file_id is not None else "",
"name": display_name,
"type": "file",
}
if file_size is not None:
item["fsize"] = int(file_size)
media_vos.append(item)
return media_vos
def main():
apply_params_file_pre_parse()
args = parse_args()
if args.dry_run:
preview = {
"mode": args.mode,
"reportId": args.report_id,
"reply": args.reply,
"contentType": args.content_type,
"at": args.at,
"filePaths": args.file_paths,
"fileNames": args.file_names,
}
print("=== DRY RUN PREVIEW ===", file=sys.stderr)
print(json.dumps(preview, ensure_ascii=False, indent=2), file=sys.stderr)
output_json({"success": True, "message": "Dry run — no actual API call made"})
return
if args.interactive:
from cwork_client import interactive_confirm
desc = f"{args.mode} 汇报 (report_id={args.report_id})"
if not interactive_confirm(f"review_{args.mode}", desc):
output_json({"success": True, "message": "Skipped by user"})
return
try:
client = make_client()
except CWorkError as e:
output_error(str(e))
try:
if args.mode == "reply":
if not args.report_id:
output_error("--report-id is required for reply mode")
if not args.reply:
output_error("--reply is required for reply mode")
at_emp_ids = None
if args.at:
at_emp_ids = resolve_names_to_empids(client, [args.at])
if args.content_type == "html":
content_body = f"<p>{args.reply}</p>"
else:
content_body = args.reply
media_vos = upload_files_for_reply(client, args.file_paths, args.file_names)
reply_id = client.reply_report(
report_record_id=args.report_id,
content_html=content_body,
content_type=args.content_type,
add_emp_id_list=at_emp_ids,
send_msg=True,
media_vo_list=media_vos,
virtual_emp_id=args.virtual_emp_id,
)
output_json({
"success": True,
"replyId": reply_id,
"attachmentsCount": len(media_vos),
"message": "Reply submitted successfully",
})
elif args.mode == "mark-read":
if not args.report_id:
output_error("--report-id is required for mark-read mode")
client.mark_report_read(args.report_id)
output_json({"success": True, "message": f"Report {args.report_id} marked as read"})
elif args.mode == "pending":
result = client.get_inbox_list(
page_size=args.page_size,
page_index=args.page_index,
report_record_type=args.report_type,
read_status=0,
)
output_json({"success": True, "data": result})
except CWorkError as e:
output_error(str(e))
except Exception as e:
output_error(f"Unexpected error: {e}")
if __name__ == "__main__":
main()
FILE:scripts/cwork-create-task.py
#!/usr/bin/env python3
"""
CWork Create Task - Agent-First
Usage:
python3 scripts/cwork-create-task.py --task-main "name" --content "desc" --assignee "person" --deadline 2026-04-10
"""
import sys
import os
import json
import argparse
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
from cwork_client import make_client, CWorkError, parse_deadline, resolve_names_to_empids, apply_params_file_pre_parse
_DEFAULT_MS = int(__import__("datetime").datetime.now().timestamp() * 1000) + 7 * 86400000
def parse_args(argv=None):
p = argparse.ArgumentParser(description="CWork create task (Agent-First)")
p.add_argument("--task-main", required=True, help="Task name")
p.add_argument("--deadline", help="Deadline YYYY-MM-DD or ms timestamp (default 7d)")
p.add_argument("--content", required=True, help="Task description")
p.add_argument("--target", help="Target description")
p.add_argument("--assignee", help="Owner name")
p.add_argument("--assistant", help="Assistant names (comma-separated)")
p.add_argument("--supervisor", help="Supervisor name")
p.add_argument("--copy", help="CC names (comma-separated)")
p.add_argument("--observer", help="Observer names (comma-separated)")
p.add_argument("--report-to", help="Report-to name")
p.add_argument("--virtual-emp-id", help="虚拟员工 ID(可选)")
p.add_argument("--push-now", type=lambda x: x.lower() == "true", default=True)
p.add_argument("--dry-run", action="store_true")
p.add_argument("--params-file", dest="params_file", default=None,
help="UTF-8 JSON 文件路径,从文件读取参数")
return p.parse_args(argv)
def _comma(val):
if not val:
return None
import re
# 按英文逗号、中文逗号、顿号、分号拆分姓名列表
parts = re.split(r"[,,、;;]", val)
return [p.strip() for p in parts if p.strip()]
def _die(msg):
print(json.dumps({"success": False, "error": msg}, ensure_ascii=False), file=sys.stderr)
sys.exit(1)
def main():
apply_params_file_pre_parse()
args = parse_args()
try:
client = make_client()
except CWorkError as e:
_die(str(e))
def _resolve(name_or_list):
names = _comma(name_or_list) if isinstance(name_or_list, str) else name_or_list
if not names:
return None
return resolve_names_to_empids(client, names)
dl = parse_deadline(args.deadline) if args.deadline else _DEFAULT_MS
report_to = args.report_to or args.assignee
if not report_to:
_die("--report-to 或 --assignee 至少需要提供一个(API 要求 reportEmpIdList 必填)")
info = dict(assignee=args.assignee, assistant=args.assistant, supervisor=args.supervisor,
copy=args.copy, observer=args.observer, reportTo=report_to, deadlineMs=dl)
if args.dry_run:
print(json.dumps({"success": True, "dryRun": True,
"task": dict(
main=args.task_main,
content=args.content,
target=args.target or args.content,
deadline=args.deadline,
deadlineMs=dl,
pushNow=args.push_now,
),
"resolved": info}, ensure_ascii=False, indent=2))
return
try:
pid = client.create_plan(
main=args.task_main,
needful=args.content,
target=args.target or args.content,
end_time=dl,
owner_emp_id_list=_resolve(args.assignee), assist_emp_id_list=_resolve(args.assistant),
supervisor_emp_id_list=_resolve(args.supervisor), copy_emp_id_list=_resolve(args.copy),
observer_emp_id_list=_resolve(args.observer), report_emp_id_list=_resolve(report_to),
push_now=args.push_now,
virtual_emp_id=args.virtual_emp_id,
)
print(json.dumps({"success": True, "planId": pid,
"task": dict(main=args.task_main, deadline=args.deadline, deadlineMs=dl),
"resolved": info}, ensure_ascii=False, indent=2))
except CWorkError as e:
_die(str(e))
if __name__ == "__main__":
main()
FILE:scripts/cwork-templates.py
#!/usr/bin/env python3
"""
cwork-templates.py — 模板管理
功能:
1. 查询汇报模板列表
用法:
python3 cwork-templates.py list --limit 50
"""
import argparse
import json
import sys
import os
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
from cwork_client import make_client, apply_params_file_pre_parse
def list_templates(args):
"""查询模板列表"""
client = make_client()
result = client.list_templates(
limit=args.limit,
begin_time=getattr(args, "begin_time", None),
end_time=getattr(args, "end_time", None),
)
if args.output_raw:
print(json.dumps(result, ensure_ascii=False, indent=2))
return
# 结构化输出(API 返回 recentOperateTemplates 或 rows 或直接是列表)
if isinstance(result, list):
templates = result
else:
templates = (
result.get("recentOperateTemplates")
or result.get("rows")
or []
)
output = {
"success": True,
"action": "list",
"total": len(templates),
"items": [
{
"id": t.get("id") or t.get("templateId"),
"name": t.get("name") or t.get("templateName") or t.get("main"),
"type": t.get("type"),
"typeName": t.get("typeName"),
"grade": t.get("grade"),
}
for t in templates
]
}
print(json.dumps(output, ensure_ascii=False, indent=2))
def main():
parser = argparse.ArgumentParser(
description="CWork 模板管理",
formatter_class=argparse.RawDescriptionHelpFormatter
)
subparsers = parser.add_subparsers(dest="action", help="操作类型")
# list 子命令
list_parser = subparsers.add_parser("list", help="查询模板列表")
list_parser.add_argument("--limit", type=int, default=50, help="返回数量限制")
list_parser.add_argument("--begin-time", type=int, help="开始时间戳")
list_parser.add_argument("--end-time", type=int, help="结束时间戳")
list_parser.add_argument("--output-raw", action="store_true", help="输出原始响应")
parser.add_argument("--params-file", dest="params_file", default=None,
help="UTF-8 JSON 文件路径,从文件读取参数")
apply_params_file_pre_parse()
args = parser.parse_args()
if not args.action:
parser.print_help()
sys.exit(1)
if args.action == "list":
list_templates(args)
if __name__ == "__main__":
main()
FILE:scripts/cwork-query-report.py
#!/usr/bin/env python3
"""
CWork Query Reports - Agent-First
Modes: inbox / outbox / unread / detail / node-detail / sender-history / keyword-search / pending / my-sent
Usage:
python3 scripts/cwork-query-report.py --mode inbox [--page-size 20]
python3 scripts/cwork-query-report.py --mode detail --report-id <id>
python3 scripts/cwork-query-report.py --mode node-detail --report-id <id>
python3 scripts/cwork-query-report.py --mode sender-history --sender-emp-id <empId>
python3 scripts/cwork-query-report.py --mode keyword-search --keyword "公章"
python3 scripts/cwork-query-report.py --mode pending
python3 scripts/cwork-query-report.py --mode unread
"""
import sys
import os
import json
import argparse
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
from cwork_client import CWorkClient, make_client, CWorkError, apply_params_file_pre_parse
def parse_args(argv=None):
parser = argparse.ArgumentParser(description="CWork query reports (Agent-First)")
parser.add_argument("--mode", required=True,
choices=["inbox", "outbox", "unread", "detail", "node-detail", "sender-history", "keyword-search", "pending", "my-sent"])
parser.add_argument("--page-index", type=int, default=1)
parser.add_argument("--page-size", type=int, default=20)
parser.add_argument("--report-id", help="Report ID (required for detail/node-detail)")
parser.add_argument("--sender-emp-id", help="Sender employee ID (required for sender-history)")
parser.add_argument("--keyword", help="Search keyword (required for keyword-search)")
parser.add_argument("--days", type=int, default=90, help="Days to look back (default 90)")
parser.add_argument("--report-type", type=int, choices=[1, 2, 3, 4, 5])
parser.add_argument("--status", type=int, help="Read status: 0=unread 1=read")
parser.add_argument("--keyword-filter", help="Legacy: Keyword filter")
parser.add_argument("--start-date", help="Start date YYYY-MM-DD")
parser.add_argument("--end-date", help="End date YYYY-MM-DD")
parser.add_argument("--with-share-link", dest="with_share_link", action="store_true", default=True,
help="在返回结果中补充分享链接(默认开启)")
parser.add_argument("--no-share-link", dest="with_share_link", action="store_false",
help="关闭分享链接补充")
parser.add_argument("--share-top-n", type=int, default=20,
help="列表场景最多补充前 N 条分享链接(默认 20,0=当前页全部)")
parser.add_argument("--params-file", dest="params_file", default=None,
help="UTF-8 JSON 文件路径,从文件读取参数")
return parser.parse_args(argv)
def _parse_date(value, end_of_day: bool = False):
"""Convert YYYY-MM-DD to millisecond timestamp (always interpreted as UTC+8).
Binds the date to UTC+8 explicitly so the result is identical regardless of
the system timezone where the script is executed.
end_of_day=True: returns 23:59:59.999 CST of that day so --end-date covers
the full Beijing calendar day (e.g. 2026-04-07 → 2026-04-07T23:59:59.999+08:00).
"""
if value is None:
return None
from datetime import datetime, timedelta, timezone
_CST = timezone(timedelta(hours=8))
try:
dt = datetime.strptime(value, "%Y-%m-%d").replace(tzinfo=_CST)
if end_of_day:
dt = dt + timedelta(days=1) - timedelta(milliseconds=1)
return int(dt.timestamp() * 1000)
except ValueError:
return None
def _die(msg):
print(json.dumps({"success": False, "error": msg}, ensure_ascii=False), file=sys.stderr)
sys.exit(1)
def _extract_first_id(item, candidates):
if not isinstance(item, dict):
return None
for key in candidates:
value = item.get(key)
if value is not None and str(value).strip():
return value
return None
def _safe_attach_share_link(client, item, biz_type: int, id_candidates: tuple[str, ...]):
if not isinstance(item, dict):
return
biz_id = _extract_first_id(item, id_candidates)
if biz_id is None:
return
try:
item["shareLink"] = client.create_share_link(biz_id, biz_type)
except Exception:
# 分享链接失败不阻断主查询结果
return
def _attach_share_links_to_list(client, rows, biz_type: int, id_candidates: tuple[str, ...], top_n: int):
if not isinstance(rows, list):
return
limit = len(rows) if top_n <= 0 else top_n
for idx, row in enumerate(rows):
if idx >= limit:
break
_safe_attach_share_link(client, row, biz_type, id_candidates)
def main():
try:
sys.stdout.reconfigure(encoding="utf-8")
except AttributeError:
pass
apply_params_file_pre_parse()
args = parse_args()
try:
client = make_client()
except CWorkError as e:
_die(str(e))
try:
if args.mode == "detail":
if not args.report_id:
_die("--report-id is required for detail mode")
data = client.get_report_info(args.report_id)
if args.with_share_link:
_safe_attach_share_link(client, data, 1, ("reportId", "id"))
print(json.dumps({"success": True, "data": data}, ensure_ascii=False, indent=2))
return
if args.mode == "node-detail":
if not args.report_id:
_die("--report-id is required for node-detail mode")
data = client.get_report_node_detail(args.report_id)
if args.with_share_link:
_safe_attach_share_link(client, data, 1, ("reportId", "id"))
print(json.dumps({"success": True, "data": data}, ensure_ascii=False, indent=2))
return
if args.mode == "sender-history":
if not args.sender_emp_id:
_die("--sender-emp-id is required for sender-history mode")
data = client.get_sender_history(
sender_emp_id=args.sender_emp_id,
days=args.days,
max_count=args.page_size
)
if args.with_share_link:
_attach_share_links_to_list(client, data.get("recentReports"), 1, ("reportId", "id"), args.share_top_n)
print(json.dumps({"success": True, "data": data}, ensure_ascii=False, indent=2))
return
if args.mode == "keyword-search":
if not args.keyword:
_die("--keyword is required for keyword-search mode")
data = client.search_reports_by_keyword(
keyword=args.keyword,
days=args.days,
max_count=args.page_size
)
if args.with_share_link:
_attach_share_links_to_list(client, data.get("reports"), 1, ("reportId", "id"), args.share_top_n)
print(json.dumps({"success": True, "data": data}, ensure_ascii=False, indent=2))
return
if args.mode == "unread":
data = client.get_unread_list(args.page_index, args.page_size)
if args.with_share_link:
_attach_share_links_to_list(client, data.get("list"), 1, ("reportId", "id"), args.share_top_n)
print(json.dumps({"success": True, "data": data}, ensure_ascii=False, indent=2))
return
read_status = args.status
if args.mode == "pending":
read_status = 0
if args.mode in ("inbox", "pending"):
data = client.get_inbox_list(
page_size=args.page_size, page_index=args.page_index,
report_record_type=args.report_type, read_status=read_status,
begin_time=_parse_date(args.start_date), end_time=_parse_date(args.end_date, end_of_day=True))
else:
data = client.get_outbox_list(
page_size=args.page_size, page_index=args.page_index,
report_record_type=args.report_type,
begin_time=_parse_date(args.start_date), end_time=_parse_date(args.end_date, end_of_day=True))
if args.with_share_link:
_attach_share_links_to_list(client, data.get("list"), 1, ("reportId", "id"), args.share_top_n)
print(json.dumps({"success": True, "data": data}, ensure_ascii=False, indent=2))
except CWorkError as e:
_die(str(e))
if __name__ == "__main__":
main()
FILE:references/edge-cases.md
### 边缘场景:正文待发与业务单元(话术与分支)
> **⚠️ 核心强制纪律**:只要用户写好正文、表示要发送(且未指定具体人名),AI **必须**按以下流程执行,**严禁跳过查询直接问「发给谁」**。
**适用**:用户已给出标题与正文(或草稿内容已定),下一步要「发出去」。
1. **必须首先确认当前用户是否配置了业务单元(前置强制动作)**
立即主动执行 `python3 scripts/cwork-business-unit.py list`。
- **若列表非空(哪怕只有一个业务单元小组)**
- **严禁直接问「发给谁」**。先主动执行 `cms-match-businessunit.py --dry-run` 进行匹配。
- **若 `--dry-run` 返回 `"matched": true`**:向用户说明系统建议按「某某业务单元小组」发送,并给出匹配理由;用户确认后,再执行 `cms-match-businessunit.py` 正式发送。
- **若 `--dry-run` 返回 `"matched": false`**:**说明正文无法匹配任何小组**。此时**绝不允许**凭你的历史记忆去推荐任何小组。你必须告知用户"根据正文内容,未能匹配到合适的业务单元小组",并直接询问:「请问这篇汇报发给谁?」,随后走 `cwork-search-emp.py` 链路。
- ⚠️ **严禁自作主张用 `cwork-search-emp` 随意搜人当收件人!** 只有用户明确给出具体姓名后,才能调用搜索工具解析 empId。
- **若列表为空(没有任何业务单元)**
- 此时才可以询问用户:**「请问这篇汇报发给谁?」**(或等价表述)。再根据对方提供的姓名走 `cwork-search-emp.py` → `cwork-send-report.py --receivers ...`。
2. **禁止误答**
- 在「有业务单元、且用户未点名具体同事」的场景下,**不得**回答「没有员工查询接口、无法自动匹配人员」。本 Skill **包含** `cwork-search-emp.py`;业务单元路径下接收人由小组节点预设,**不要求**先搜员工。若用户坚持按姓名发,再用员工搜索。
- **绝不允许在不调用 `cwork-business-unit.py list` 的情况下自行猜测是否有业务单元**。
**脚本对照(附录,不必逐字念给用户)**:按业务单元发送 → `cms-match-businessunit.py` 或 `cwork-send-report.py --business-unit-id`;按具体姓名发送 → `cwork-search-emp.py` 再 `cwork-send-report.py --receivers ...`。
---
FILE:references/agent-patterns.md
## Agent 调用模式示例
### 模式 A:简单查询(单次 exec)
```
用户:「帮我看看今天有没有未读汇报」
Agent → exec: python3 scripts/cwork-query-report.py --mode unread --page-size 10
Agent ← JSON → 摘要呈现给用户
```
### 模式 B:多步编排(Agent 协调多次 exec)
```
用户:「给张三发一份周报,内容是XXX」
Agent → exec: python3 scripts/cwork-send-report.py \
--title "周报" --content "..." --receivers "张三"
Agent ← JSON(含完整 draftDetail、confirmPrompt;默认不会发出)
Agent → 向用户展示 **draftDetail 全文**(含正文、附件、`reportLevelList`)
用户:「确认」
Agent → exec: python3 scripts/cwork-send-report.py \
--draft-id "<上一步的 reportId(与 draftId 同值)>" --confirm-send
Agent ← JSON(success、已通过 5.27 发出)
Agent → 告知发送成功
```
### 模式 C:催办闭环(3步分离)
```
Agent → exec: python3 scripts/cwork-nudge-report.py --mode list --days-threshold 7
Agent ← JSON(未闭环列表)
Agent → (LLM 推理)筛选需要催办的事项
Agent → exec: python3 scripts/cwork-nudge-report.py --mode nudge \
--emp-id <empId> --task-main "任务名" --deadline YYYY-MM-DD --content "催办说明"
Agent ← JSON(已发送催办汇报)
```
### 模式 D:正文已写好 — 业务单元匹配成功
```
用户:「正文写好了」
Agent → exec: python3 scripts/cwork-business-unit.py list
Agent ← data 非空
Agent → exec: python3 scripts/cms-match-businessunit.py --title "周报" --content "..." --content-type html --dry-run
Agent ← JSON("matched": true, matchedBusinessUnit)
Agent → 对用户:这篇汇报可以建议发给「某某业务单元小组」(结合 matched 正文说明为何贴切),是否按该小组发送?
用户:「可以 / 按你建议发」
Agent → exec: python3 scripts/cms-match-businessunit.py --title "周报" --content "..." --content-type html
Agent ← JSON(submitResult)
Agent → 告知已按业务单元发送
```
### 模式 E:正文已写好 — 业务单元未匹配(或完全无业务单元)
```
情况1:无业务单元列表 / 情况2:dry-run 返回 "matched": false
Agent → 对用户:根据您的内容,未能匹配到合适的业务单元小组。请问这篇汇报发给谁?
用户:「发给张三」
Agent → exec: cwork-search-emp.py → cwork-send-report.py --receivers "张三" ...
```
FILE:references/cwork-templates.md
### 8. 模板管理 — `cwork-templates.py`
**意图**:查询汇报模板列表
```bash
# 查询模板列表
python3 scripts/cwork-templates.py list --limit 50
# 带时间范围
python3 scripts/cwork-templates.py list --begin-time 1710000000000 --end-time 1712000000000
```
| 参数 | 说明 |
|------|------|
| `action` | `list` |
| `--limit` | 返回数量限制(默认 50) |
| `--begin-time` | 开始时间戳(毫秒) |
| `--end-time` | 结束时间戳(毫秒) |
| `--output-raw` | 输出原始 API 响应 |
**输出字段**:
- `id` — 模板 ID
- `name` — 模板名称
- `type` — 类型 ID
- `typeName` — 类型名称
- `grade` — 优先级
---
FILE:references/cwork-create-task.md
### 3. 创建任务 — `cwork-create-task.py`
**意图**:解析人员姓名 → 创建工作计划/任务
```bash
python3 scripts/cwork-create-task.py \
--task-main "完成XXX功能" \
--content "详细描述" \
--assignee "张三" \
--deadline 2026-05-01
```
| 参数 | 必填 | 说明 |
|------|------|------|
| `--task-main` | ✅ | 任务标题 |
| `--content` | ✅ | 任务描述 |
| `--target` | ❌ | 预期目标(默认 = content) |
| `--assignee` | ❌ | 责任人姓名(自动解析 empId) |
| `--report-to` | ❌ | 汇报人姓名(不传则自动取 `--assignee` 的值;API 要求必填) |
| `--assistant` | ❌ | 协办人姓名(逗号分隔多人) |
| `--supervisor` | ❌ | 监督人姓名 |
| `--copy` | ❌ | 抄送人姓名(逗号分隔多人) |
| `--observer` | ❌ | 观察员姓名(逗号分隔多人) |
| `--deadline` | ❌ | 截止时间(YYYY-MM-DD 或 Unix ms,默认 7 天后) |
| `--push-now` | ❌ | 是否立即推送(true/false,默认 true)。开放 API 文档未说明 `pushNow=0` 时的额外字段;若服务端报「待办发送时间未设置」等错误,需向接口提供方确认是否另有未文档化参数或是否暂不支持延迟推送 |
| `--dry-run` | ❌ | 仅验证+解析,不创建 |
**流程步骤**:
1. 解析所有人员姓名 → empId
2. 校验必填项(task-main、content)
3. 汇总所有未匹配姓名 → 报错
4. `--dry-run` 时输出解析结果,不调用创建 API
5. 调用 `createPlan` API 创建任务
---
FILE:references/common-params.md
## 错误处理
所有脚本遵循统一错误约定:
- **成功**:JSON 到 stdout,含 `"success": true`
- **失败**:JSON 到 stderr,含 `"success": false` 和 `"error"` 字段,exit code ≠ 0
- **Agent 应同时检查 stdout 和 stderr**
遇到 API 异常(如 `API Error (2xxxxx)`)时:请自动调用GitHub工具上报错误(如有)。
### 通用参数
所有脚本均支持以下通用参数:
| 参数 | 说明 |
|------|------|
| `--params-file <path>` | 从 UTF-8 JSON 读参数,key 与 CLI 一致(连字符)。解决 PowerShell 中文编码问题。发送汇报时参数层键用 **`title`**、**`content`**(旧键 **`content-html`** 仍兼容);脚本调用接口时会映射到 **`main`**、**`contentHtml`**。 |
**多行正文(JSON 合法性)**:字符串值内若需换行,须写转义序列 `\n`;**不要**在 JSON 引号对内直接敲物理换行,否则 `json.load` 会报 `Expecting delimiter`。
**`content-type` 与展示**:脚本会把 `markdown` / `html` 按开放 API 传入 `contentType`。若产品在客户端将正文按 HTML 渲染、Markdown 仅当纯文本显示,则需在编排侧自行将 Markdown 转为 HTML 后以 `html` 提交,或向接口/产品确认是否支持 Markdown 渲染(勿在客户端臆造文档未列字段)。
**用法示例(`--params-file` 参数层)**:
```json
{
"title": "周报标题",
"content": "<p>汇报内容</p>",
"receivers": "张三"
}
```
**开放接口请求体(字段名对齐)**:
```json
{
"main": "周报标题",
"contentHtml": "<p>汇报内容</p>",
"contentType": "html",
"acceptEmpIdList": ["empId1"]
}
```
```bash
python3 scripts/cwork-send-report.py --params-file params.json
```
> 文件参数与命令行参数可混用,命令行参数优先级更高。文件必须为 UTF-8 编码(带或不带 BOM 均支持)。
---
FILE:references/cwork-query-report.md
### 2. 查询汇报 — `cwork-query-report.py`
**意图**:收件箱 / 发件箱 / 未读 / 汇报详情 / 节点详情 / **历史上下文检索** ✨ 新增
```bash
# 收件箱(默认)
python3 scripts/cwork-query-report.py --mode inbox --page-size 20
# 未读汇报
python3 scripts/cwork-query-report.py --mode unread --page-size 20
# 发件箱
python3 scripts/cwork-query-report.py --mode outbox
# 详情模式(包含回复链)
python3 scripts/cwork-query-report.py --mode detail --report-id <id>
# 节点详情(含审批/建议/反馈状态与处理意见)
python3 scripts/cwork-query-report.py --mode node-detail --report-id <id>
# 历史上下文检索(审批决策支持)
# 查询发件人历史汇报
python3 scripts/cwork-query-report.py --mode sender-history \
--sender-emp-id <empId> \
--days 90
# 关键字搜索汇报(客户端过滤)
python3 scripts/cwork-query-report.py --mode keyword-search \
--keyword "公章" \
--days 90
# 默认会为返回的汇报补充 shareLink(最多前 20 条)
python3 scripts/cwork-query-report.py --mode inbox --with-share-link --share-top-n 20
# 如需关闭补链
python3 scripts/cwork-query-report.py --mode inbox --no-share-link
# 当前页全部补链
python3 scripts/cwork-query-report.py --mode inbox --share-top-n 0
```
| 参数 | 说明 |
|------|------|
| `--mode` | `inbox` / `outbox` / `unread` / `detail` / `node-detail` / `sender-history` / `keyword-search` / `pending` / `my-sent` |
| `--page-size` | 分页大小(默认 20) |
| `--page-index` | 页码(默认 1) |
| `--report-id` | 汇报 ID(detail / node-detail 必填) |
| `--sender-emp-id` | 发件人员工 ID(sender-history 必填) |
| `--keyword` | 搜索关键词(keyword-search 必填) |
| `--days` | 回溯天数(sender-history / keyword-search,默认 90) |
| `--report-type` | 汇报类型:1-工作交流 / 2-工作指引 / 3-文件签批 / 4-AI汇报 / 5-工作汇报 |
| `--status` | 已读状态:0=未读 / 1=已读 |
| `--start-date` / `--end-date` | 时间范围(YYYY-MM-DD) |
| `--with-share-link` / `--no-share-link` | 是否补充汇报分享链接(默认开启) |
| `--share-top-n` | 列表场景最多补充前 N 条 `shareLink`(默认 20,传 0 表示当前页全部) |
**收件箱 / 发件箱列表 vs 详情**:`inbox` / `outbox` / `pending` / `unread` / `my-sent`(与 `outbox` 同脚本路径)等模式返回的 **`data` 为接口原始分页结构**(常见含 `list`、`total`)。列表项里的「正文」类字段多为**摘要**,与 `send-report` 的 `--content` 全文不一定一致;需要全文或完整字段时请用 **`--mode detail --report-id`**(或 `node-detail`)拉详情。默认会在可识别到汇报 ID 的结果上补充 `shareLink`,便于点击打开原始汇报。
**输出格式**(sender-history):
```json
{
"success": true,
"data": {
"senderEmpId": "1514822194347806721",
"totalReports": 15,
"recentReports": [
{
"id": "2039993163862765570",
"main": "互联网公司公章借出申请",
"createTime": "2026-04-03 17:11:48",
"reportRecordType": 5
}
]
}
}
```
**输出格式**(keyword-search):
```json
{
"success": true,
"data": {
"keyword": "公章",
"total": 5,
"reports": [
{
"id": "2039993163862765570",
"main": "互联网公司公章借出申请",
"content": "用于办理工商变更...",
"createTime": "2026-04-03 17:11:48",
"sendEmpName": "刘丽华"
}
]
}
}
```
**输出格式**(node-detail):
```json
{
"success": true,
"data": {
"id": 汇报ID,
"main": "汇报标题",
"content": "汇报正文",
"writeEmpName": "汇报人",
"createTime": "发起时间",
"nodeList": [
{
"nodeName": "建议人",
"type": "建议",
"status": "已完成",
"level": 1,
"userList": [
{
"empId": 员工ID,
"name": "张三",
"status": "已处理",
"operate": "建议",
"content": "建议增加异常处理",
"finishTime": "2026-04-03 11:00:00"
}
]
}
]
}
}
```
### AI 汇总输出建议(含可点击链接)
- 当结果项包含 `shareLink` 时,AI 在汇总文本中应将链接直接附在标题后,避免用户二次追问。
- 推荐格式:`- <汇报标题>([打开汇报](<shareLink>))`
- 若存在时间与汇报人,建议补充为:`- <汇报标题>(<时间>,<汇报人>,[打开汇报](<shareLink>))`
- 若某条无 `shareLink`,使用降级文案:`- <汇报标题>(链接暂不可用,可让我重试补链)`
- 禁止编造链接;仅可使用脚本返回的真实 `shareLink`。
---
FILE:references/cwork-send-report.md
### 1. 发送汇报 — `cwork-send-report.py`
**意图**:先**全量**保存/更新草稿(API 5.23,更新前会拉 API 5.25 详情合并,避免覆盖丢字段)→ 输出接口返回的**完整**草稿(`draftDetail`)供用户过目 → 仅在用户明确同意后加 `--confirm-send` 调用 **API 5.27**(`draftBox/submit/{汇报id}`)发出。
**汇报 id 与 `draftId` 字段(避免歧义)**
| 概念 | 含义 | 出现位置 |
|------|------|----------|
| **汇报 id** | 草稿对应的汇报记录主键 | API 5.23 返回 `data.id`、API 5.25 路径与 `draftDetail.id`、API 5.27 路径 `{id}` |
| **草稿箱记录 id** | 草稿箱列表里一行的主键,**仅用于 API 5.26 删除** | API 5.24 列表项的 `id`(勿与汇报 id 混用) |
**删除草稿(`cwork_client`)**:`delete_draft` 的参数必须是 **API 5.24 列表项的 `id`**。若只有汇报 id(与列表里的 `businessId` 相同),须调用 **`delete_draft_by_report_id(汇报id)`**;误把汇报 id 传给 `delete_draft` 时,接口可能仍返回 `true` 但列表中草稿未删(见开放 API 5.26 与 5.24 参数说明)。
脚本 stdout 里同时有根字段 **`reportId`** 与 **`draftId`**:二者**不是**两种 id,而是**同一汇报 id 的重复输出**——`draftId` **并非**开放平台文档里的字段名,而是本脚本为衔接历史参数 `--draft-id` 而保留的 JSON 键名,容易让人误以为是「草稿箱 id」。**以 `reportId` / `draftDetail.id` 为准即可**;后续步骤一律传该汇报 id(`--draft-id <汇报id>` 中的值也是它)。
```bash
# 第一步:保存草稿并输出完整预览(默认不会发出;且需显式确认保存)
python3 scripts/cwork-send-report.py \
--title "周报标题" \
--content "<p>汇报内容</p>" \
--receivers "张三,李四" \
--confirm-save-draft \
--grade "一般"
# 第二步:用户确认 draftDetail 全文后,仅发出(无需再传标题正文)
python3 scripts/cwork-send-report.py --draft-id "<汇报id>" --confirm-send
# Markdown 正文(须 --content-type markdown)
python3 scripts/cwork-send-report.py \
--title "周报标题" \
--content-type markdown \
--content "## 小节\n正文" \
--receivers "张三" \
--confirm-save-draft
# 测试/调试建议:默认发给当前用户本人
python3 scripts/cwork-send-report.py \
--title "API冒烟草稿" \
--content "<p>仅测试,不正式发出</p>" \
--test-mode \
--current-user-name "当前用户姓名" \
--preview-only \
--confirm-save-draft
```
**字段映射(避免与开放接口字段混淆)**
- 脚本/Skill 入参(CLI 与 `--params-file`)使用:`title`、`content`。
- 脚本在调用开放接口 `save_draft` / `submit_report` 时会映射为:`main`、`contentHtml`。
- `contentType` 与 `acceptEmpIdList` 也会按接口要求透传。
接口请求体(开放 API)示例:
```json
{
"main": "汇报标题",
"contentHtml": "<p>汇报正文内容</p>",
"contentType": "markdown",
"acceptEmpIdList": ["empId1", "empId2"]
}
```
**正文(编排侧参数仍使用 `content`)**
- 汇报正文:CLI 用 **`--content`** / **`-c`**,`--params-file` 用键 **`content`**。脚本会自动映射到接口字段 `contentHtml`。
- Markdown:须同时指定 **`--content-type markdown`**。
- 新建未传 `--content-type` 时脚本默认 `html`;带 `--draft-id` 更新且未传时沿用草稿详情中的正文类型。
- **`--content-html`** 可选,与 **`--content` 二选一**(兼容旧自动化,勿同时使用)。
| 参数 | 必填 | 说明 |
|------|------|------|
| `--title` / `-t` | 保存草稿时 ✅ | 汇报标题(与 `--draft-id --confirm-send` 单独发出时勿传) |
| `--content` / `-c` | 保存草稿时 ✅ | 汇报正文 |
| `--content-html` | ❌ | [兼容] 同 `--content` |
| `--content-type` | ❌ | 正文格式:`html` 或 `markdown`(默认与更新沿用规则见上文) |
| `--receivers` / `-r` | ❌ | 接收人姓名;**多个姓名**可用英文逗号 `,`、中文逗号 `,`、顿号 `、` 或分号 `;`/`;` 分隔(勿整串写成一人)。更新时若省略则沿用草稿详情中的接收人。**若本次传了姓名**且草稿已有 `reportLevelList`(且未使用 `--report-level-json`),脚本会把解析后的 empId **写回**对应节点的 `levelUserList`,与开放 API「接收人以 `reportLevelList` 为准」一致,避免仅 `summary` 显示新人而 `draftDetail` 仍为旧人 |
| `--cc` | ❌ | 抄送;分隔规则同 `--receivers` |
| `--grade` | ❌ | 优先级:`一般`(默认)/ `紧急` |
| `--type-id` | ❌ | 汇报类型 ID(默认 9999) |
| `--file-paths` | ❌ | 本地附件;**未传且为更新**时沿用草稿已有附件 |
| `--file-names` | ❌ | 附件显示名称 |
| `--plan-id` | ❌ | 关联任务 ID |
| `--business-unit-id` | ❌ | 业务单元 ID;传入后按业务单元预设节点流转 |
| `--virtual-emp-id` | ❌ | 虚拟员工提交人 ID。传入后按虚拟人提交(脚本鉴权仍使用当前用户 AppKey);在 `--draft-id --confirm-send` 发送-only 场景也可单独传入,最终发出时会带入提交接口 |
| `--report-level-json` | ❌ | JSON 文件路径,`reportLevelList` 数组,覆盖流程节点 |
| `--preview-only` | ❌ | 仅保存+预览;**即使带 `--confirm-send` 也不会发出** |
| `--draft-id` | ❌ | **值为汇报 id**(参数名历史沿用):更新草稿或配合 `--confirm-send` 仅执行 5.27 |
| `--confirm-save-draft` | 保存草稿时 ✅ | **必须显式确认后才会执行 5.23**;用于防止调试时自动落草稿 |
| `--confirm-send` | ❌ | **必须**在用户确认完整 `draftDetail` 后再加;并且**必须搭配 `--draft-id` 使用**,才会调用 5.27 |
| `--test-mode` | ❌ | 测试/调试模式。默认仅允许接收人为当前用户本人;若未传 `--receivers` 且传了 `--current-user-name`,会自动以本人为接收人 |
| `--current-user-name` | ❌ | 当前发起用户姓名;`--test-mode` 下用于默认接收人与“仅本人”校验 |
| `--allow-external-test-receivers` | ❌ | 在 `--test-mode` 下放开“仅本人”限制(高风险;必须先用户确认) |
| `--allow-minimal-body` | ❌ | 跳过「正文过短」校验(默认纯文本长度 **≤10** 会拒绝保存,**超过 10 字**不拦截;极短占位可加本参数) |
| `--fail-on-literal-newlines` | ❌ | 仅供 CI/自动化使用:若 `markdown` 正文中含字面量 `\n` / `\r\n`,则在自动修正前直接失败退出,用于尽早暴露上游转义问题 |
**流程节点角色映射(重点,避免模型误解)**
- “建议人” = `reportLevelList[].type = "suggest"`
- “决策人” = `reportLevelList[].type = "decide"`
- “传阅/接收” = `reportLevelList[].type = "read"`
- `type` 仅允许:`suggest` / `decide` / `read`(英文小写)
- `nodeName` 是展示文案,可按业务语义自定义;`nodeCode` 非必填
- 每个节点都应包含处理对象:`levelUserList`(或分组/部门字段)
**示例 A:两个建议节点(同一人)**
```json
[
{
"level": 1,
"nodeName": "确认研究报告输出时间",
"type": "suggest",
"levelUserList": [{"empId": 10001}]
},
{
"level": 2,
"nodeName": "提交天工系统研究报告",
"type": "suggest",
"levelUserList": [{"empId": 10001}]
}
]
```
**示例 B:建议后决策**
```json
[
{
"level": 1,
"nodeName": "建议人评估可行性",
"type": "suggest",
"levelUserList": [{"empId": 10001}]
},
{
"level": 2,
"nodeName": "负责人决策是否立项",
"type": "decide",
"levelUserList": [{"empId": 20001}]
}
]
```
**流程步骤**:
1. **Resolve** — 按姓名搜索员工;本轮回填的姓名参与合并,未填则沿用 5.25 详情中的接收人/抄送
2. **Validate** — 姓名未找到或多匹配时报错终止
3. **Upload** — 若传了 `--file-paths` 则上传并作为附件;否则更新时保留原附件列表
4. **Detail(更新时)** — 若有 `--draft-id`,先 `get_draft_detail` 再与本次参数合并,并用于正文长度校验(未传 `--allow-minimal-body` 时)
5. **Draft(5.23)** — 仅在显式传入 `--confirm-save-draft` 时才执行全量 `saveOrUpdate`,返回汇报 id
6. **Preview** — 再次 `get_draft_detail`,stdout 含完整 **`draftDetail`**(含全文**正文**)及 **`summary`**。`summary` 的 `contentPlainText` / `contentPreview` 为便于速览的纯文本预览(对 HTML 标签做了剥离;Markdown 正文通常无标签,与 `--content` 接近);过长截断;过短有 `previewWarnings`。`confirmPrompt` 内嵌预览(≤2000 字)。**向用户确认时以完整 `draftDetail` 为准**,不要只用 `summary`。
7. **Submit(发送-only)** — 仅当 `--confirm-send`、`--draft-id` 同时存在且非 `--preview-only` 时发出:
- 若本次传了 `--virtual-emp-id`,或草稿详情本身已有 `virtualEmpId`,脚本走 **5.1 `/report/record/submit`(携带 `id` + `virtualEmpId`)** 发出,确保虚拟人参数参与最终提交。
- 若草稿无 `virtualEmpId` 且本次也未传,脚本走 **5.27** 发出。
- 两种路径都保持“先草稿、确认后发送”。
---
FILE:references/cwork-virtual-employee.md
### 12. 虚拟员工管理 — `cwork-virtual-employee.py`
**意图**:创建、查询、修改、删除当前用户的虚拟员工(NPC),用于后续代发汇报/回复/任务。
**触发词**:创建虚拟人 / 新建虚拟员工 / 添加虚拟助手 / 新增虚拟人 / 申请虚拟人 / 分配虚拟人。
---
> 写/发汇报场景下的“发送前虚拟身份检查”规则,见 `references/report-virtual-identity.md`。
```bash
# 创建虚拟员工
python3 scripts/cwork-virtual-employee.py \
--mode add \
--name "小风助手" \
--remark "简小风数字分身助手"
# 查询我的虚拟员工列表
python3 scripts/cwork-virtual-employee.py --mode list
# 修改虚拟员工(改名/改备注,至少传一个)
python3 scripts/cwork-virtual-employee.py \
--mode update \
--id "2043613046072700929" \
--name "小风助手2"
# 删除虚拟员工
python3 scripts/cwork-virtual-employee.py \
--mode delete \
--id "2043613046072700929"
```
参数说明:
| 参数 | 必填 | 说明 |
|------|------|------|
| `--mode` | ✅ | `add` / `list` / `update` / `delete` |
| `--id` | `update/delete` 必填 | 虚拟员工 ID |
| `--name` | `add` 必填,`update` 可选 | 虚拟员工名称 |
| `--remark` | ❌ | 备注 |
| `--params-file` | ❌ | UTF-8 JSON 参数文件 |
---
## 代发透传
创建后返回 `virtualEmpId`(字符串),在以下脚本中透传 `--virtual-emp-id`:
- `cwork-send-report.py`(发汇报)
- `cms-match-businessunit.py`(匹配业务单元后发汇报)
- `cwork-review-report.py --mode reply`(回复汇报)
- `cwork-create-task.py`(创建任务)
示例:
```bash
python3 scripts/cwork-send-report.py \
--title "周报" \
--content "<p>本周工作进展</p>" \
--receivers "张三" \
--virtual-emp-id "2043613046072700929"
```
FILE:references/cwork-review-report.md
### 4. 审阅汇报 — `cwork-review-report.py`
**意图**:回复汇报 / 标记已读 / 查询待审汇报
```bash
# 标记已读
python3 scripts/cwork-review-report.py --mode mark-read --report-id <id>
# 回复(默认 markdown,可发内部链接;需纯 HTML 段落可加 --content-type html)
python3 scripts/cwork-review-report.py --mode reply \
--report-id <id> --reply "回复内容"
# 回复并上传附件(本地文件)
python3 scripts/cwork-review-report.py --mode reply \
--report-id <id> \
--reply "回复内容(含附件)" \
--file-paths "/path/a.docx" "/path/b.png" \
--file-names "方案.docx" "截图.png"
# 查询待审汇报列表
python3 scripts/cwork-review-report.py --mode pending --page-size 20
```
| 参数 | 说明 |
|------|------|
| `--mode` | `reply` / `mark-read` / `pending` |
| `--report-id` | 汇报记录 ID(reply / mark-read 必填) |
| `--reply` | 回复正文(reply 必填;格式由 `--content-type` 决定) |
| `--content-type` | `markdown`(默认)或 `html`(`html` 时包成 `<p>…</p>`) |
| `--at` | 回复中 @的人姓名(自动解析 empId) |
| `--file-paths` | 本地附件路径列表(reply 模式可选;会先上传后作为回复附件提交) |
| `--file-names` | 附件显示名称(可选,与 `--file-paths` 顺序一致) |
| `--page-index` | 页码(pending 模式,默认 1) |
| `--page-size` | 每页大小(pending 模式,默认 20) |
| `--report-type` | 汇报类型筛选 1-5(pending 模式可选) |
| `--dry-run` | 仅预览,不调用 API |
---
FILE:references/cwork-todo.md
### 7. 待办管理 — `cwork-todo.py`
**意图**:查询待办列表 / 完成待办(支持决策/建议/反馈三种类型)
**支持的三种待办类型**:
| 类型 | 英文标识 | 必填参数 | 说明 |
|------|---------|---------|------|
| **决策** | `decide` | `--operate agree/disagree` | 决策人必须明确同意或不同意 |
| **建议** | `suggest` | `--content` | 建议人提供意见或建议 |
| **反馈** | `feedback` | `--content` | 反馈人回复评论或补充信息 |
```bash
# 查询待办列表(API 5.15 分页结构为 PageInfo,条目见 items,字段含 todoId、reportId、main、todoType 等)
python3 scripts/cwork-todo.py list --page-size 20 --status pending
# 默认会为返回的待办补充 shareLink(最多前 20 条)
python3 scripts/cwork-todo.py list --with-share-link --share-top-n 20
# 如需关闭补链
python3 scripts/cwork-todo.py list --no-share-link
# 当前页全部补链
python3 scripts/cwork-todo.py list --share-top-n 0
# 完成决策待办(必须指定 operate)
python3 scripts/cwork-todo.py complete \
--todo-id <id> \
--content "同意该方案,建议增加异常处理" \
--operate agree
# 完成建议待办
python3 scripts/cwork-todo.py complete \
--todo-id <id> \
--content "从技术角度看,建议采用微服务架构"
# 完成反馈待办
python3 scripts/cwork-todo.py complete \
--todo-id <id> \
--content "已补充相关数据,详见附件"
# 查看汇报详情(含节点与处理意见)
python3 scripts/cwork-query-report.py --mode node-detail --report-id <id>
```
| 参数 | 说明 |
|------|------|
| `action` | `list` / `complete` |
| `--page-index` | 页码(默认 1) |
| `--page-size` | 每页数量(默认 20) |
| `--status` | 状态筛选 |
| `--with-share-link` / `--no-share-link` | 是否补充待办关联对象分享链接(默认开启) |
| `--share-top-n` | 列表场景最多补充前 N 条 `shareLink`(默认 20,传 0 表示当前页全部) |
| `--todo-id` | 待办 ID(complete 必填) |
| `--content` | 完成说明(所有类型必填) |
| `--operate` | 决策操作:`agree`(同意)/ `disagree`(不同意)(仅决策类待办需要,不传则不发送此字段) |
| `--dry-run` | 仅预览(complete 可用) |
**输出格式**(complete):
```json
{
"success": true,
"action": "list",
"total": 2,
"items": [
{
"todoId": "12345",
"reportId": "2039993163862765570",
"title": "互联网公司公章借出申请",
"todoType": "decide",
"status": "pending",
"shareLink": "https://xxx/share/abc123"
}
]
}
```
**输出格式**(complete):
```json
{
"success": true,
"action": "complete",
"todoId": "12345",
"result": {}
}
```
### AI 汇总输出建议(含可点击链接)
- 当待办项含 `shareLink` 时,AI 在清单中应把链接附在标题后。
- 推荐格式:`- <待办标题>([打开相关单据](<shareLink>))`
- 若有待办类型与状态,建议补充为:`- <待办标题>(<todoType>/<status>,[打开相关单据](<shareLink>))`
- 若某条无 `shareLink`,使用降级文案:`- <待办标题>(链接暂不可用,可让我重试补链)`
- 禁止编造链接;仅可使用脚本返回的真实 `shareLink`。
---
FILE:references/cwork-draft-box.md
### 9. 草稿箱列表与批量删除 — `cwork-draft-box.py`
**意图**:分页查看草稿箱(API 5.24);按草稿箱记录 id 列表或时间范围批量删除(API 5.28)。**`--ids` 须为 API 5.24 返回的 `draftBoxId`(列表项 `id`),不是汇报 id / `businessId`**;仅持有汇报 id 时请用 `cwork_client.delete_draft_by_report_id`(此为客户端封装方法,脚本暂不支持直接通过汇报 id 删除)。
```bash
python3 scripts/cwork-draft-box.py list --page-size 20
python3 scripts/cwork-draft-box.py batch-delete --ids 2036325013120483329,2036325013120483330
python3 scripts/cwork-draft-box.py batch-delete --begin-ms 1711785600000 --end-ms 1711872000000 --dry-run
```
开放 API 约定 **时间范围优先**:同时传时间与 `idList` 时仅执行时间范围删除。
---
FILE:references/auth.md
### cms-auth-skills:AppKey 获取与注入(强制)
这份规则用于约束 Agent:任何需要执行本 Skill 的 `scripts/*.py` 的链路,AppKey 获取必须通过依赖 Skill `cms-auth-skills` 完成。
#### 必须做
- 只要确定要进入本 Skill 的执行链路(`exec python3 scripts/<name>.py`),在调用目标脚本之前,**必须先调用** `cms-auth-skills` 获取 AppKey。
- 将 `cms-auth-skills` 返回的 AppKey **以 `--app-key`** 注入到后续执行命令:
- `python3 scripts/<name>.py ... --app-key "<AppKey>"`
- 若使用 `--params-file` 注入参数,则把 AppKey 放入 JSON 的 `app_key` 字段:
- `{ "app_key": "<AppKey>", ... }`
#### 必须禁止
- 禁止自行从环境变量读取 AppKey(例如 `CWORK_APP_KEY`、`XG_BIZ_API_KEY` 等)。
- 禁止按某种“自动解析逻辑”(如从 sender_id/account_id、上下文字段等推断)去获取 AppKey。
- 禁止向用户索要 AppKey(不要问“把 AppKey 发我/让我用哪个键”这类话)。
- 禁止在 `cms-auth-skills` 未返回可用 AppKey 时继续调用 `scripts/*.py`。
#### 失败处理
- `cms-auth-skills` 获取失败或无可用 AppKey:必须停止当前链路,并引导用户重新完成授权/登录;然后再重新尝试进入执行链路。
FILE:references/cms-match-businessunit.md
### 11. 正文匹配业务单元并发汇报 — `cms-match-businessunit.py`
**意图**:用户给出标题与正文后,自动匹配最合适的业务单元并直接发送汇报。
**适用前提**:用户已配置至少一个业务单元;若未配置,请改用 `cwork-send-report.py` 常规发送。
**与员工搜索的区别**:本脚本**不**调用 `cwork-search-emp.py`。接收人/节点人员来自业务单元里已绑定的 `empList`,服务端按 `businessUnitId` 流转。**不要**把「自己匹配人员」理解成必须先搜员工。
```bash
# 仅匹配预览(不发送)
python3 scripts/cms-match-businessunit.py \
--title "周报" \
--content "<p>本周完成 API 接口联调与开发提测</p>" \
--content-type html \
--dry-run
# 匹配并发送
python3 scripts/cms-match-businessunit.py \
--title "周报" \
--content "<p>本周完成 API 接口联调与开发提测</p>" \
--content-type html
```
支持参数:
| 参数 | 必填 | 说明 |
|------|------|------|
| `--title` / `-t` | ✅ | 汇报标题 |
| `--content` / `-c` | ✅ | 汇报正文 |
| `--content-type` | ❌ | `html` / `markdown`(默认 `html`) |
| `--grade` | ❌ | `一般` / `紧急`(默认 `一般`) |
| `--type-id` | ❌ | 汇报类型 ID(默认 `9999`) |
| `--plan-id` | ❌ | 关联任务 ID |
| `--template-id` | ❌ | 模板 ID |
| `--virtual-emp-id` | ❌ | 虚拟员工 ID(业务代发参数;脚本鉴权仍使用当前用户 AppKey) |
| `--dry-run` | ❌ | 仅匹配不发送 |
匹配规则补充:
- 标题与正文都会参与匹配打分,且标题命中权重更高(用于处理“标题有关键词、正文较泛化”的场景)。
- 当返回 `matched=false` / `suggestion=NO_MATCH` 时,表示未达到可自动发送置信度,必须询问用户明确接收人或业务单元,禁止自动选 Top1 候选发送。
---
## `reportLevelList` 字段格式
`cwork-send-report.py` 可通过 **`--report-level-json`** 指向 UTF-8 JSON 文件(根节点为数组),内容对应 API 字段 `reportLevelList`,用于指定建议人/决策人/传阅等节点;不传时新建草稿可为空,**更新**时默认从 5.25 详情中的 `reportLevelList` 原样转换后写回,避免全量更新被清空。每个节点结构如下:
```python
report_level_list = [
{
"level": 1, # 节点序号(从1开始)
"nodeName": "建议人", # 节点显示名称
"type": "suggest", # suggest=建议 | decide=决策 | read=传阅
"levelUserList": [
{"empId": 1512393035869810694}, # empId 必须是整数(非字符串)
],
}
]
```
> ⚠️ `type` 只接受英文小写 `suggest` / `decide` / `read`,不接受中文。`levelUserList` 是必填字段,不可为 `null` 或空列表。
---
FILE:references/report-virtual-identity.md
### 写/发汇报:发送前虚拟身份检查规则
**作用范围**:仅用于写汇报/发汇报流程。
**不适用**:回复汇报、创建任务。
---
## 触发词
- 发汇报 / 发送汇报 / 帮我发(汇报)
- 写汇报 / 提交汇报
- 以虚拟人身份发送 / 用虚拟人发(汇报)
---
## 核心流程
1. 用户发起“写/发汇报”请求
2. 先查询虚拟身份列表:
`python3 scripts/cwork-virtual-employee.py --mode list`
3. 根据列表分支:
- **无虚拟身份(`list=[]`)**:不要求用户先创建,按普通员工身份继续汇报发送流程。
- **一个虚拟身份**:询问用户“是否以 [名称] 身份发送?”;仅在用户确认后传 `virtualEmpId`。
- **多个虚拟身份**:列出选项让用户选择;用户明确选择后传对应 `virtualEmpId`。
4. 用户确认后,调用汇报脚本并按需透传:
- `cwork-send-report.py --virtual-emp-id <id>`
- 或 `cms-match-businessunit.py --virtual-emp-id <id>`
---
## 执行约束
1. 仅当用户确认要使用虚拟身份时,才允许传 `--virtual-emp-id`。
2. 未确认或无虚拟身份时,禁止臆断选择任意虚拟人。
3. 无虚拟身份不是错误场景,不中断汇报发送主流程。
4. `virtualEmpId` 为虚拟人提交参数;脚本鉴权主体仍为当前用户 AppKey。发送-only(`--draft-id --confirm-send`)若本次传入或草稿内已有 `virtualEmpId`,最终发出会走 5.1(携带 `id` + `virtualEmpId`)提交。
FILE:references/cwork-search-emp.md
### 0. 搜索员工 — `cwork-search-emp.py` ✨ 新增
**意图**:根据姓名/关键词搜索员工 ID 和详细信息
**使用场景**:
1. ✅ **确认接收人(兜底)** - 仅在无业务单元或用户明确指定具体同事时使用
2. ✅ **处理待办时确认发件人** - 查看发件人部门/职位
3. ✅ **创建任务时确认责任人** - 避免姓名错误(重名/错别字)
4. ✅ **催办时确认责任人信息** - 获取完整的员工信息
```bash
# 基础搜索(模糊匹配)
python3 scripts/cwork-search-emp.py --name "张"
# 精确搜索
python3 scripts/cwork-search-emp.py --name "成伟"
# 详细模式(包含 personId、dingUserId 等)
python3 scripts/cwork-search-emp.py --name "刘丽华" --verbose
# 更多结果
python3 scripts/cwork-search-emp.py --name "刘" --max-results 10
# 原始 API 响应(调试用)
python3 scripts/cwork-search-emp.py --name "张" --output-raw
```
| 参数 | 必填 | 说明 |
|------|------|------|
| `--name` / `-n` | ✅ | 员工姓名或关键词(支持模糊匹配) |
| `--max-results` / `-m` | ❌ | 每个类别最多返回数量(默认 5) |
| `--verbose` / `-v` | ❌ | 包含额外信息(personId、dingUserId、corpId) |
| `--output-raw` | ❌ | 输出原始 API 响应(调试用) |
**输出格式**:
```json
{
"success": true,
"searchKey": "成伟",
"inside": [
{
"empId": "1514822118611259394",
"name": "成伟",
"title": "首席架构师",
"mainDept": "技术部",
"status": "在职"
}
],
"outside": [
{
"empId": "1897870576398327809",
"name": "成伟",
"title": "",
"mainDept": "其他",
"status": "在职",
"company": "德镁医药"
}
],
"totalInside": 1,
"totalOutside": 1
}
```
**注意事项**:
- ✅ **URL 编码已自动处理**(支持中文参数)
- ✅ **模糊匹配**:搜索"刘"会返回所有姓刘的员工
- ✅ **内外部区分**:`inside`(玄关健康员工)+ `outside`(外部联系人/其他公司)
- ⚠️ **重名问题**:可能返回多个同名员工,需要根据部门/职位区分
- 💡 **推荐用法**:发送汇报前先搜索确认 empId
---
FILE:references/cwork-business-unit.md
### 10. 业务单元管理 — `cwork-business-unit.py`
**意图**:创建/更新业务单元方案,查询业务单元,删除业务单元。
```bash
# 创建业务单元(nodeList 从 JSON 文件读取)
python3 scripts/cwork-business-unit.py save \
--name "工作协同开发小组" \
--description "研发周报流程" \
--node-list-json ./nodes.json
# 更新业务单元
python3 scripts/cwork-business-unit.py save \
--id 2043594941317410818 \
--name "工作协同开发小组(更新)" \
--node-list-json ./nodes.json
# 查询我的所有业务单元
python3 scripts/cwork-business-unit.py list
# 查询单条详情
python3 scripts/cwork-business-unit.py get --id 2043594941317410818
# 删除业务单元
python3 scripts/cwork-business-unit.py delete --id 2043594941317410818
```
`--node-list-json` 文件示例:
```json
[
{
"nodeName": "接收人",
"nodeType": "read",
"empList": [{"id": "1514822118611259394", "name": "张三"}]
},
{
"nodeName": "建议人",
"nodeType": "suggest",
"empList": [{"id": "1514822194347806721", "name": "李四"}]
}
]
```
---
FILE:references/cwork-query-tasks.md
### 5. 查询任务 — `cwork-query-tasks.py`
**意图**:我的任务 / 我创建的 / 团队任务 / 任务详情(含汇报链)/ 识别逾期和未闭环
```bash
# 分配给我的任务
python3 scripts/cwork-query-tasks.py --mode my --status 1
# 我创建的任务
python3 scripts/cwork-query-tasks.py --mode created
# 下属任务
python3 scripts/cwork-query-tasks.py --mode manager --subordinate-ids "id1,id2"
# 任务详情(含汇报链路)
python3 scripts/cwork-query-tasks.py --mode detail --task-id <planId>
# 识别逾期任务
python3 scripts/cwork-query-tasks.py --mode blocked --days-threshold 7
# 默认会为返回的任务补充 shareLink(最多前 20 条)
python3 scripts/cwork-query-tasks.py --mode my --with-share-link --share-top-n 20
# 如需关闭补链
python3 scripts/cwork-query-tasks.py --mode my --no-share-link
# 当前页全部补链
python3 scripts/cwork-query-tasks.py --mode my --share-top-n 0
```
| 参数 | 说明 |
|------|------|
| `--mode` | `my` / `created` / `team` / `assigned` / `detail` / `chain` / `blocked` / `unclosed` / `manager` / `nudge` |
| `--task-id` | 任务/计划 ID(detail / chain 必填) |
| `--subordinate-ids` | 下属 empId 列表(逗号分隔,manager 模式必填) |
| `--assignee` | 责任人姓名(my / assigned 模式可选,自动解析 empId) |
| `--status` | 任务状态:0=已关闭 / 1=进行中 / 2=未启动 |
| `--report-status` | 汇报状态:0=关闭 / 1=待汇报 / 2=已汇报 / 3=逾期 |
| `--key-word` | 关键词搜索 |
| `--days-threshold` | 逾期天数阈值(blocked 模式,默认 7) |
| `--page-index` | 页码(默认 1) |
| `--page-size` | 每页大小(默认 20) |
| `--with-share-link` / `--no-share-link` | 是否补充任务分享链接(默认开启) |
| `--share-top-n` | 列表场景最多补充前 N 条 `shareLink`(默认 20,传 0 表示当前页全部) |
| `--dry-run` | 仅预览,不调用 API |
### AI 汇总输出建议(含可点击链接)
- 当结果项包含 `shareLink` 时,AI 在任务清单里应把链接直接挂在标题后,方便用户点开任务。
- 推荐格式:`- <任务标题>([打开任务](<shareLink>))`
- 若存在状态与截止时间,建议补充为:`- <任务标题>(<状态>,截止 <日期>,[打开任务](<shareLink>))`
- 若某条无 `shareLink`,使用降级文案:`- <任务标题>(链接暂不可用,可让我重试补链)`
- 禁止编造链接;仅可使用脚本返回的真实 `shareLink`。
---
FILE:references/maintenance.md
# 维护信息
## 基本信息
- **版本号须两处一致**:`SKILL.md` 前言(YAML 的 `version:`)与根目录 **`version.json`** 的 `version` 字段;发布或 bump 时**必须同时改这两处**,避免平台与文档不一致。
- ClawHub slug:`cms-cwork-workflow`
## GitHub 地址
- 仓库:https://github.com/xgjk/cwork-skills
- Skill 目录:`cwork-skills/cms-cwork-workflow/`
## 官方 API 文档
- [工作协同 Open API 接口文档](https://github.com/xgjk/dev-guide/blob/main/02.%E4%BA%A7%E5%93%81%E4%B8%9A%E5%8A%A1AI%E6%96%87%E6%A1%A3/%E5%B7%A5%E4%BD%9C%E5%8D%8F%E5%90%8C/%E5%B7%A5%E4%BD%9C%E5%8D%8F%E5%90%8CAPI%E8%AF%B4%E6%98%8E.md)
本 Skill 已把常用能力封装为脚本;**日常按 `SKILL.md` 调用即可**。若在本地扩展脚本、核对请求字段或查接口错误码,可打开上表链接对照说明。
## 如何更新
**工厂内部开发:**
1. 修改 Skill 内容
2. 若变更版本:同时更新 **`SKILL.md` 的 `version:`** 与 **`version.json`**
3. 执行 `clawhub publish`
**ClawHub 用户:**
```bash
clawhub update cms-cwork-workflow
```
---
*最后更新:2026-04-09*
FILE:references/cwork-nudge-report.md
### 6. 催办闭环 — `cwork-nudge-report.py`
**意图**:列出逾期未闭环任务 / 向责任人发送催办通知
```bash
# 列出逾期未闭环任务(超过阈值天数)
python3 scripts/cwork-nudge-report.py --mode list --days-threshold 7
# 向责任人发送催办(通过 empId)
python3 scripts/cwork-nudge-report.py --mode nudge \
--emp-id <empId> \
--task-main "完成XXX功能" \
--deadline 2026-05-01 \
--content "请尽快处理"
# 通过姓名自动解析 empId 并催办
python3 scripts/cwork-nudge-report.py --mode nudge \
--assignee "张三" \
--task-main "完成XXX功能" \
--remind-style normal
```
| 参数 | 说明 |
|------|------|
| `--mode` | `list`=列出未闭环 / `nudge`=发送催办 |
| `--days-threshold` | 逾期天数阈值(list 模式,默认 7) |
| `--page-index` | 页码(list 模式,默认 1) |
| `--page-size` | 每页大小(list 模式,默认 50) |
| `--emp-id` | 催办对象 empId(nudge 必填,与 `--assignee` 二选一) |
| `--assignee` | 责任人姓名(nudge 模式,自动解析 empId) |
| `--task-main` | 任务名称(nudge 必填) |
| `--deadline` | 截止日期(YYYY-MM-DD 或 Unix ms) |
| `--content` | 催办内容描述(脚本自动构建 HTML 正文) |
| `--target` | 目标描述 |
| `--remind-style` | 催办风格:`polite`(默认,含礼貌用语)/ `normal`(简洁) |
| `--dry-run` | 仅预览,不调用 API |
---
一键交互式配置自己的 OpenClaw(龙虾)机器人,把公司内部 xg_cwork_im channel 绑定到指定 agent。
---
name: cms-config-myclaw
description: 一键交互式配置自己的 OpenClaw(龙虾)机器人,把公司内部 xg_cwork_im channel 绑定到指定 agent。
skillcode: cms-config-myclaw
---
# cms-config-myclaw
**版本**: v1.0.5
这个 skill 只做一件事:
1. 配置并绑定自己的 `xg_cwork_im` 机器人到某个 OpenClaw agent
在这个 skill 里,用户提到“龙虾”或“虾”时,都默认等同于 `OpenClaw`。
这个 skill 的默认形态必须是 CLI 交互式向导。
也就是说,用户应该是跟着终端一步一步完成配置,而不是直接丢一段 JSON 或让 AI 静默改配置。
## 什么时候使用
- “配置我的虾”
- “配置我的龙虾”
- “配置我的 OpenClaw 机器人”
- “把龙虾接到公司内部通道上”
- “把 xg_cwork_im 绑定到某个 agent”
- “重建我的龙虾机器人配置”
## 绑定的意义
绑定不只是把一段配置写进 `openclaw.json`。
真正的作用是:
- 把公司内部的 `xg_cwork_im` channel 接入 OpenClaw
- 为你创建或更新一个工作协同机器人
- 把这台机器人的消息路由到你指定的 agent
- 让你后续可以直接在互动页面里给这个 agent 发消息
用户选择哪个 agent,后续这台机器人收到的消息就会交给哪个 agent 处理。
换句话说:
- 机器人负责“收消息”
- `xg_cwork_im` 插件负责“把公司内部工作平台里的消息接进 OpenClaw”
- binding 负责“决定这些消息最终交给哪个 agent”
如果没有绑定,机器人虽然存在,但 OpenClaw 不知道该把消息交给谁。
## 用户视角怎么理解这件事
从用户视角看,这不是在“配一堆技术参数”,而是在做一件更直白的事:
“我想把公司内部的这个 channel 接到我本地的 OpenClaw 上,这样我就可以通过公司内部的互动页面,远程和某个 agent 说话。”
所以整个 skill 的目标其实是把下面这条链路打通:
`登录 appKey -> cms-auth-skills -> access-token -> 创建机器人 -> 返回 robot appKey + agentId -> 写入 channels/accounts/bindings -> 重启 Gateway -> 打开互动页面发消息`
## 配置前用户需要准备什么
**首要前置条件**:必须事先安装并固定以下插件:
```bash
openclaw plugins install @xgjktech/xg_cwork_im --pin
openclaw plugins install clawhub:dynamic-session-context
```
除此之外,用户还需要准备:
1. 一个可用的工作协同登录 `appKey`
2. 想绑定到哪个 agent
3. 想给机器人起什么名字
其中:
- 这里向用户索要的 `appKey` 只用于登录鉴权、换取 `access-token`
- 创建机器人成功后,接口会返回另一份 `robot appKey`,实际写入 `channels.xg_cwork_im.accounts`
- 如果上下文或已有参数里已经有登录 `appKey`,默认直接复用
- `avatar`、`groupLabel`、`remark` 都不是必要输入,统一使用默认空值
## CLI 交互应该怎么引导
向导应该始终按下面这种节奏引导用户:
1. 先告诉用户“这是什么、为什么要绑定、完成后能怎么用”
2. 再列出全部 agent,让用户选择
3. 再收集登录 `appKey` 和机器人名称
4. 在真正写入前展示执行摘要,并解释本次绑定会产生什么效果
5. 配置写入完成后,先告诉用户“已经配置完成”,再提示即将重启 Gateway
6. 重启后明确告诉用户去打开互动链接,并建议发一条测试消息验证
## 用户实际怎么用
典型使用方式是:
1. 运行 `setup_myclaw.py`
2. 按提示选择一个 agent
3. 输入或复用登录 `appKey`
4. 输入机器人名称
5. 确认执行
6. 等待脚本自动完成注册、插件检查、配置写入和 Gateway 重启
7. 打开脚本输出的公司内部互动链接
8. 在公司内部互动页面里给机器人发一条测试消息,确认回复来自你绑定的 agent
## 用户完成后怎么用
配置成功后,脚本会输出一个互动链接:
`https://sg-al-cwork-web.mediportal.com.cn/xg-claw/web/dist/?xgToken=xxx`
用户打开这个链接后,就可以直接进入公司内部互动页面,给自己的机器人发送消息。
这一步的实际意义是:通过公司内部的 channel,远程和本地 OpenClaw 上的目标 agent 互动。
推荐用户配置完成后立刻做一次最小验证:
1. 打开互动链接
2. 发一条简单消息,比如“你好”
3. 确认是否由目标 agent 正常回复
如果这一步正常,就说明整个绑定链路已经打通。
如果用户不知道第一句该发什么,可以建议动态一点的话术,而不是写死成某个固定角色:
1. “你好,我现在正在验证你是否已经接到这个 agent,请先回复我一句。”
2. “请告诉我,你现在对应的是哪个 agent。”
3. “我刚完成绑定,请用一句简短的话确认你已经可以正常工作。”
用户视角里,后续不需要继续理解 channels、bindings、插件这些内部结构。
对用户来说,更自然的理解应该是:
“我已经把公司内部的这个 channel 接到了当前选择的 agent 上,后面我只需要打开互动链接和它说话。”
## 执行方式
主向导:
```bash
openclaw plugins install @xgjktech/xg_cwork_im --pin
openclaw plugins install clawhub:dynamic-session-context
python3 cms-config-myclaw/scripts/setup_myclaw.py
```
如果已经有可复用的登录 `appKey`,默认优先复用,不要重复追问。可以直接这样执行:
```bash
CMS_CONFIG_MYCLAW_APP_KEY=your_app_key python3 cms-config-myclaw/scripts/setup_myclaw.py
```
## 交互规则
1. 默认只通过脚本执行,不默认手工改 `openclaw.json`。
2. 任何写操作前都必须向用户展示摘要并获得确认。
3. 如果目标 agent 已有 `xg_cwork_im` account 或 binding,必须先展示旧状态,再确认是否覆盖。
4. 这个 skill 依赖 `cms-auth-skills`,登录 `appKey -> access-token` 统一交给依赖 skill 处理。
5. 当脚本已经列出全部 agent 后,后续说明里也必须继续完整列出,不要把候选缩成少数几个推荐项。
6. 交互式向导默认只向用户询问必要信息:登录 `appKey` 和机器人名称;`avatar`、`groupLabel`、`remark` 使用默认空值。
7. 如果上下文、已有记忆或已知参数里已经存在可用登录 `appKey`,默认直接复用;只有在没有现成 `appKey`,或用户明确要求更换时才再问。
8. 不要把用户输入的登录 `appKey` 和创建机器人接口返回的 `robot appKey` 混为一谈;真正写入 `channel account` 的是后者。
9. 每次执行时,都要让用户明确知道“当前进行到哪一步、下一步会发生什么”,保持 CLI 交互的连续感。
10. 不要把引导文案写死到某个固定 agent、固定角色或固定业务场景上;应尽量围绕用户当前选择的 agent 动态说明。
11. 对用户的解释重点应围绕“这是公司内部 channel 的接入与绑定”,而不是只围绕配置文件字段本身。
## 可选参数
```bash
python3 cms-config-myclaw/scripts/setup_myclaw.py --dry-run
```
```bash
python3 cms-config-myclaw/scripts/setup_myclaw.py --config-file /path/to/openclaw.json
```
FILE:scripts/setup_myclaw.py
#!/usr/bin/env python3
from __future__ import annotations
import argparse
import json
import os
import shutil
import subprocess
import sys
import time
import warnings
from getpass import getpass
from pathlib import Path
from typing import Any
from urllib.parse import quote
from openclaw_config import DEFAULT_BASE_URL, DEFAULT_WS_BASE_URL, backup_config, format_existing_state, get_agents, load_config, merge_myclaw_config, restore_config, save_config, summarize_agent, summarize_existing_state
try:
import requests
except ImportError:
requests = None
if requests is not None:
try:
warnings.filterwarnings(
'ignore',
category=requests.packages.urllib3.exceptions.InsecureRequestWarning,
)
except Exception:
pass
ROBOT_REGISTER_URL = 'https://sg-cwork-api.mediportal.com.cn/im/robot/private/register'
WEB_INTERACT_URL = 'https://sg-cwork-web.mediportal.com.cn/xg-claw/web/dist/'
PLUGIN_ID = 'xg_cwork_im'
PLUGIN_SPEC = '@xgjktech/xg_cwork_im'
DEFAULT_CONFIG_PATH = Path('~/.openclaw/openclaw.json').expanduser()
class RequestFailure(RuntimeError):
pass
def stringify(value: Any) -> str | None:
if value is None:
return None
text = str(value).strip()
if not text or text.lower() in {'null', 'none', 'undefined'}:
return None
return text
def pick_non_empty(*values: Any) -> str | None:
for value in values:
text = stringify(value)
if text:
return text
return None
def mask_secret(value: Any) -> str:
text = stringify(value)
if not text:
return '***'
if len(text) <= 6:
return '***'
return text[:6] + '***'
def extract_result_data(payload: dict[str, Any]) -> dict[str, Any]:
data = payload.get('data')
return data if isinstance(data, dict) else {}
def extract_result_message(payload: dict[str, Any], fallback: str = '未知错误') -> str:
return pick_non_empty(
payload.get('resultMsg'),
payload.get('detailMsg'),
payload.get('message'),
fallback,
) or fallback
def ensure_result_success(payload: dict[str, Any], fallback: str) -> None:
code = payload.get('resultCode')
if code in (0, 1, 200):
return
alt_code = payload.get('code')
if alt_code == 200:
return
raise RequestFailure(f'{fallback}: {extract_result_message(payload, fallback)}')
def request_json(
url: str,
*,
method: str = 'GET',
params: dict[str, Any] | None = None,
body: dict[str, Any] | None = None,
headers: dict[str, str] | None = None,
timeout: int = 60,
retries: int = 3,
) -> dict[str, Any]:
if requests is None:
raise RequestFailure('请求失败: 缺少 requests 依赖,请先安装 requests')
request_headers = {'Content-Type': 'application/json'}
if headers:
request_headers.update(headers)
last_error: Exception | None = None
for attempt in range(1, retries + 1):
try:
response = requests.request(
method=method.upper(),
url=url,
params=params,
json=body if method.upper() != 'GET' else None,
headers=request_headers,
verify=False,
allow_redirects=True,
timeout=timeout,
)
response.raise_for_status()
except Exception as exc:
status = 'N/A'
raw_text = str(exc)
response = getattr(exc, 'response', None)
if response is not None:
status = response.status_code
try:
raw_text = response.text
except Exception:
pass
last_error = RequestFailure(f'请求失败 (HTTP {status}): {raw_text[:300] or exc}')
if response is not None and 500 <= response.status_code < 600 and attempt < retries:
time.sleep(1)
continue
if response is None and attempt < retries:
time.sleep(1)
continue
raise last_error from exc
try:
payload = response.json()
except ValueError as exc:
raise RequestFailure(f'接口返回了无法解析的 JSON: {exc}') from exc
if not isinstance(payload, dict):
raise RequestFailure('接口返回格式异常:期望 JSON object')
return payload
raise RequestFailure(str(last_error or '请求失败'))
def resolve_cms_auth_login_script() -> Path:
current = Path(__file__).resolve()
candidates: list[Path] = []
for parent in (current.parent, *current.parents):
candidates.append(parent / 'cms-auth-skills' / 'scripts' / 'auth' / 'login.py')
candidates.append(parent / 'skills' / 'cms-auth-skills' / 'scripts' / 'auth' / 'login.py')
seen: set[str] = set()
for candidate in candidates:
key = str(candidate)
if key in seen:
continue
seen.add(key)
if candidate.is_file():
return candidate
raise RuntimeError('找不到 cms-auth-skills/scripts/auth/login.py,请先安装 cms-auth-skills')
def get_access_token(app_key: str) -> str:
normalized = stringify(app_key)
if not normalized:
raise RequestFailure('登录失败:登录 appKey 不能为空')
result = subprocess.run(
[sys.executable, str(resolve_cms_auth_login_script()), '--ensure', '--app-key', normalized],
text=True,
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
check=False,
)
if result.returncode != 0:
message = (result.stderr or '').strip() or (result.stdout or '').strip() or 'cms-auth-skills 登录失败'
raise RequestFailure(message)
lines = [line.strip() for line in (result.stdout or '').splitlines() if line.strip()]
if not lines:
raise RequestFailure('登录失败: cms-auth-skills 未返回 access-token')
return lines[-1]
def print_title(title: str) -> None:
print(f'\n=== {title} ===', flush=True)
def print_intro() -> None:
print_title('说明')
print('这是一个 CLI 交互式向导,你只需要按提示一步一步输入或确认即可。')
print('它配置的不是普通参数,而是把公司内部的一个消息 channel 接到你的 OpenClaw 上。')
print('\n这个向导会帮你完成三件事:')
print('1. 创建或更新你的工作协同私人机器人')
print('2. 把这台机器人绑定到你选择的 OpenClaw agent')
print('3. 写入 OpenClaw 配置并重启 Gateway,使绑定正式生效')
print('\n开始前你只需要准备:')
print('1. 一个可用的工作协同登录 key(appKey),仅用于登录鉴权换 access-token')
print('2. 想绑定到哪个 agent')
print('3. 你希望这个机器人显示成什么名字')
print('\n接下来你会经历这几个步骤:')
print('1. 先选择要绑定的 agent')
print('2. 再确认登录 appKey 和机器人名称')
print('3. 脚本先用登录 appKey 换 access-token,再创建机器人')
print('4. 创建成功后拿到机器人自己的 appKey,并把它写入 OpenClaw 配置')
print('5. 最后自动重启 Gateway,并给你互动链接')
print('\n为什么要绑定?')
print('因为这个插件本质上是一个公司内部 channel。')
print('只有完成绑定后,这个 channel 进来的消息,才会被路由到你选中的 OpenClaw agent。')
print('你选择哪个 agent,后续这台机器人收到的消息就会交给哪个 agent 处理。')
print('不绑定的话,即使机器人创建出来了,OpenClaw 也不知道应该把消息交给哪个 agent。')
print('\n配置完成后怎么用?')
print('脚本会给你一个公司内部互动链接。')
print('打开这个链接后,你就可以在公司内部互动页面里直接给机器人发送消息。')
print('这相当于通过公司的 channel,远程和你本地这台 OpenClaw 所绑定的 agent 互动。')
def confirm(prompt: str) -> bool:
value = input(f'{prompt} [y/N]: ').strip().lower()
return value in {'y', 'yes'}
def prompt_required(prompt: str, *, secret: bool = False) -> str:
while True:
if secret:
if sys.stdin.isatty() and sys.stderr.isatty():
value = getpass(f'{prompt}: ')
else:
value = input(f'{prompt}: ')
else:
value = input(f'{prompt}: ')
text = value.strip()
if text:
return text
print('输入不能为空,请重新输入。')
def resolve_prefilled_app_key(args: argparse.Namespace) -> tuple[str | None, str | None]:
cli_value = stringify(getattr(args, 'app_key', None))
if cli_value:
return cli_value, 'cli'
env_value = stringify(os.environ.get('CMS_CONFIG_MYCLAW_APP_KEY'))
if env_value:
return env_value, 'env'
return None, None
def resolve_active_config_path(explicit_path: str | None) -> Path:
if explicit_path:
return Path(explicit_path).expanduser().resolve()
openclaw = shutil.which('openclaw')
if not openclaw:
return DEFAULT_CONFIG_PATH
result = subprocess.run(
[openclaw, 'config', 'file'],
text=True,
stdout=subprocess.PIPE,
stderr=subprocess.STDOUT,
check=False,
)
output = (result.stdout or '').splitlines()
for line in reversed(output):
candidate = line.strip()
if candidate.endswith('.json'):
return Path(candidate).expanduser().resolve()
return DEFAULT_CONFIG_PATH
def build_openclaw_env(config_path: Path) -> dict[str, str]:
env = os.environ.copy()
env['OPENCLAW_CONFIG_PATH'] = str(config_path)
if config_path.name == 'openclaw.json':
env.setdefault('OPENCLAW_STATE_DIR', str(config_path.parent))
return env
def run_openclaw(
args: list[str],
*,
env: dict[str, str],
description: str,
check: bool = True,
echo: bool = True,
) -> subprocess.CompletedProcess[str]:
openclaw = shutil.which('openclaw')
if not openclaw:
raise RuntimeError('未找到 openclaw 命令,请先安装或加入 PATH')
result = subprocess.run(
[openclaw] + args,
text=True,
stdout=subprocess.PIPE,
stderr=subprocess.STDOUT,
check=False,
env=env,
)
output = (result.stdout or '').strip()
if output and echo:
print(output)
if check and result.returncode != 0:
raise RuntimeError(f'{description}失败:\n{output or "(无输出)"}')
return result
def plugin_installed(env: dict[str, str]) -> bool:
result = run_openclaw(
['plugins', 'info', PLUGIN_ID, '--json'],
env=env,
description=f'检测 {PLUGIN_ID} 插件状态',
check=False,
echo=False,
)
return result.returncode == 0
def reinstall_plugin(env: dict[str, str]) -> None:
print_title('插件检查')
if plugin_installed(env):
print(f'检测到 {PLUGIN_ID} 已安装,本次保留现有安装,仅确保启用。')
else:
print(f'未检测到 {PLUGIN_ID},开始安装。')
run_openclaw(
['plugins', 'install', PLUGIN_SPEC, '--pin'],
env=env,
description=f'安装 {PLUGIN_ID} 插件',
)
run_openclaw(
['plugins', 'enable', PLUGIN_ID],
env=env,
description=f'启用 {PLUGIN_ID} 插件',
)
def capture_plugin_state(config: dict[str, Any], env: dict[str, str]) -> dict[str, Any]:
installs = (((config.get('plugins') or {}).get('installs') or {}).get(PLUGIN_ID) or {})
entries = (((config.get('plugins') or {}).get('entries') or {}).get(PLUGIN_ID) or {})
allow = (((config.get('plugins') or {}).get('allow') or []))
restore_spec = stringify(installs.get('resolvedSpec')) or stringify(installs.get('spec'))
return {
'present': plugin_installed(env),
'restoreSpec': restore_spec,
'enabled': bool(entries.get('enabled')) or PLUGIN_ID in allow,
}
def ensure_preflight_ready(previous_plugin_state: dict[str, Any], env: dict[str, str]) -> None:
validate_config(env)
if previous_plugin_state.get('present') and not stringify(previous_plugin_state.get('restoreSpec')):
raise RuntimeError(
f'当前 {PLUGIN_ID} 插件已安装,但缺少可恢复的 install record(spec/resolvedSpec)。'
' 为避免卸载后无法回滚,已中止执行。请先修复 plugins.installs.xg_cwork_im。'
)
def restore_plugin_state(previous_state: dict[str, Any] | None, env: dict[str, str]) -> None:
if not previous_state:
return
plugin_is_present_now = plugin_installed(env)
if not previous_state.get('present'):
if plugin_is_present_now:
run_openclaw(
['plugins', 'uninstall', PLUGIN_ID, '--force'],
env=env,
description=f'回滚 {PLUGIN_ID} 插件',
)
return
restore_spec = stringify(previous_state.get('restoreSpec'))
if not restore_spec:
return
if plugin_is_present_now:
print(f'正在恢复原有 {PLUGIN_ID} 插件状态。')
else:
run_openclaw(
['plugins', 'install', restore_spec, '--pin'],
env=env,
description=f'回滚安装 {PLUGIN_ID} 插件',
)
if previous_state.get('enabled'):
run_openclaw(
['plugins', 'enable', PLUGIN_ID],
env=env,
description=f'回滚启用 {PLUGIN_ID} 插件',
)
def validate_config(env: dict[str, str]) -> None:
result = run_openclaw(
['config', 'validate', '--json'],
env=env,
description='校验 openclaw 配置',
check=False,
echo=False,
)
if result.returncode != 0:
output = (result.stdout or '').strip()
raise RuntimeError(f'OpenClaw 配置校验失败:\n{output or "(无输出)"}')
def build_interact_url(token: str) -> str:
return f'{WEB_INTERACT_URL}?xgToken={quote(token, safe="")}'
def _gateway_status_ready(output: str) -> bool:
lowered = output.lower()
return 'runtime: running' in lowered and 'rpc probe: ok' in lowered
def restart_gateway_and_check(env: dict[str, str], *, quiet: bool = False) -> str:
if not quiet:
print_title('重启 Gateway')
restart_result = run_openclaw(
['gateway', 'restart', '--json'],
env=env,
description='重启 Gateway',
check=False,
echo=False,
)
if restart_result.returncode != 0:
start_result = run_openclaw(
['gateway', 'start', '--json'],
env=env,
description='启动 Gateway',
check=False,
echo=False,
)
if start_result.returncode != 0:
restart_output = (restart_result.stdout or '').strip()
start_output = (start_result.stdout or '').strip()
raise RuntimeError(
'Gateway 重启失败。\n'
f'restart 输出:\n{restart_output or "(无输出)"}\n'
f'start 输出:\n{start_output or "(无输出)"}'
)
last_output = ''
for _ in range(15):
status_result = run_openclaw(
['gateway', 'status'],
env=env,
description='检查 Gateway 状态',
check=False,
echo=False,
)
last_output = (status_result.stdout or '').strip()
if status_result.returncode == 0 and _gateway_status_ready(last_output):
return last_output
time.sleep(1)
raise RuntimeError(f'Gateway 重启后状态检查未通过:\n{last_output or "(无输出)"}')
def choose_agent(agents: list[dict[str, Any]]) -> dict[str, Any]:
print_title('选择 Agent')
print('当前可绑定的 agent 如下:')
for index, agent in enumerate(agents, start=1):
print(f'{index}. {summarize_agent(agent)}')
while True:
raw = input('请输入编号或 agentId: ').strip()
if not raw:
print('输入不能为空,请重新输入。')
continue
if raw.isdigit():
number = int(raw)
if 1 <= number <= len(agents):
return agents[number - 1]
for agent in agents:
if stringify(agent.get('id')) == raw:
return agent
print('未找到对应 agent,请重新输入。')
def collect_inputs(prefilled_login_app_key: str | None = None) -> dict[str, str]:
print_title('输入配置参数')
if prefilled_login_app_key:
login_app_key = prefilled_login_app_key
print(f'登录用工作协同 key(appKey): 使用已有值 ({mask_secret(login_app_key)})')
login_app_key_source = 'prefilled'
else:
login_app_key = prompt_required('请输入登录用工作协同 key(appKey)', secret=True)
login_app_key_source = 'prompt'
robot_name = input('请输入机器人名称(回车使用服务端默认): ').strip()
return {
'loginAppKey': login_app_key,
'robotName': robot_name,
'avatar': '',
'groupLabel': '',
'remark': '',
'loginAppKeySource': login_app_key_source,
}
def print_summary(
*,
config_path: Path,
agent: dict[str, Any],
inputs: dict[str, str],
existing_state: dict[str, Any],
dry_run: bool,
) -> None:
print_title('执行摘要')
agent_id = stringify(agent.get('id')) or '<unknown>'
agent_name = stringify(agent.get('name')) or agent_id
print(f'配置文件: {config_path}')
print(f'目标 agent: {agent_name} [{agent_id}]')
print(f'机器人名称: {inputs["robotName"] or "<服务端默认>"}')
print(
f'登录 appKey: {"复用已有值" if inputs.get("loginAppKeySource") != "prompt" else "本次输入"}'
)
print('机器人 appKey: 不向用户索要,创建机器人成功后由服务端返回')
print('头像/分组/备注: 使用默认值')
print(f'创建机器人时会绑定: agentId={agent_id}')
print(f'将写入 channel account: channels.xg_cwork_im.accounts.{agent_id}')
print('绑定意义: 这台机器人后续收到的消息,会路由到上面选中的 agent。')
print('使用结果: 以后你在互动页面里给这台机器人发消息,实际上就是在和这个 agent 对话。')
print(f'模式: {"dry-run" if dry_run else "正式执行"}')
if existing_state.get('has_existing'):
print('\n检测到当前 agent 已存在 xg_cwork_im 配置:')
print(format_existing_state(existing_state))
def build_register_body(agent_id: str, inputs: dict[str, str]) -> dict[str, str]:
body = {'agentId': agent_id}
for key in ('name', 'avatar', 'groupLabel', 'remark'):
if key == 'name':
value = inputs.get('robotName', '').strip()
else:
value = inputs.get(key, '').strip()
if value:
body[key] = value
return body
def register_robot(token: str, agent_id: str, inputs: dict[str, str]) -> dict[str, Any]:
payload = request_json(
ROBOT_REGISTER_URL,
method='POST',
body=build_register_body(agent_id, inputs),
headers={'access-token': token},
)
ensure_result_success(payload, '创建机器人失败')
data = extract_result_data(payload)
if not data:
raise RequestFailure('创建机器人失败: 接口未返回 data')
return data
def validate_robot_registration(agent_id: str, robot_data: dict[str, Any]) -> str:
returned_agent_id = stringify(robot_data.get('agentId'))
if returned_agent_id and returned_agent_id != agent_id:
raise RequestFailure(
f'创建机器人失败: 接口返回的 agentId={returned_agent_id} 与目标 agentId={agent_id} 不一致'
)
returned_robot_app_key = stringify(robot_data.get('appKey'))
if not returned_robot_app_key:
raise RequestFailure('创建机器人失败: 接口未返回机器人 appKey,无法写入 OpenClaw channel account')
return returned_robot_app_key
def print_before_restart(
*,
config_path: Path,
agent_id: str,
account_id: str,
robot_name: str,
robot_app_key: str,
interact_url: str,
) -> None:
print_title('配置已完成')
print('OpenClaw 配置已经写入完成。')
print(f'配置文件: {config_path}')
print(f'agentId: {agent_id}')
print(f'accountId: {account_id}')
print(f'机器人名称: {robot_name}')
print(f'机器人 appKey: {mask_secret(robot_app_key)}')
print(f'互动链接: {interact_url}')
print('你可以通过这个公司内部链接进入互动页面,直接给机器人发送消息。')
print('下一步将自动重启 Gateway,使新配置正式生效。')
print('Gateway 重启完成后,这个 channel 就可以正式开始接收并转发消息。')
def print_success(
*,
config_path: Path,
agent_id: str,
account_id: str,
robot_name: str,
robot_app_key: str,
overwrite_existing: bool,
backup_path: Path | None,
interact_url: str,
) -> None:
print_title('完成')
print(f'配置文件: {config_path}')
print(f'agentId: {agent_id}')
print(f'accountId: {account_id}')
print(f'机器人名称: {robot_name}')
print(f'已写入机器人 appKey: {mask_secret(robot_app_key)}')
print(f'已写入 channel account: channels.xg_cwork_im.accounts.{account_id}')
print(f'覆盖旧配置: {"是" if overwrite_existing else "否"}')
if backup_path:
print(f'配置备份: {backup_path}')
print('Gateway 已重启并通过状态检查。')
print(f'互动链接: {interact_url}')
print('现在可以打开上面的公司内部互动链接,进入互动页面直接给机器人发送消息。')
print('\n建议你现在马上做一次验证:')
print('1. 打开上面的互动链接')
print('2. 给机器人发送一条简单消息,比如“你好”')
print(f'3. 确认最终是由 {agent_id} 返回内容')
print('如果能正常回复,就说明 channel、binding、插件和 Gateway 都已经生效。')
print('\n如果你不知道第一句该发什么,可以直接试下面这些:')
print(f'1. 你好,我现在正在验证 {robot_name} 是否已经接到 {agent_id},请先回复我一句。')
print(f'2. 请告诉我,你现在对应的是哪个 agent;我预期应该是 {agent_id}。')
print(f'3. 我刚完成绑定,请用一句简短的话确认 {robot_name} 已经可以正常工作。')
print('\n怎么理解后续使用:')
print('以后你不需要再关心 channels、bindings、插件这些配置细节。')
print('你只需要打开这个公司内部互动链接,把这台机器人当成当前所选 agent 的远程互动入口来使用。')
def build_parser() -> argparse.ArgumentParser:
parser = argparse.ArgumentParser(description='交互式配置 OpenClaw xg_cwork_im 机器人')
parser.add_argument('--dry-run', action='store_true', help='只做检查和摘要,不执行远端调用或本地写入')
parser.add_argument('--config-file', type=str, help='显式指定要修改的 openclaw.json')
parser.add_argument('--app-key', type=str, help='预填登录用工作协同 appKey;提供后不再交互询问。也可使用环境变量 CMS_CONFIG_MYCLAW_APP_KEY')
return parser
def main() -> int:
args = build_parser().parse_args()
backup_path: Path | None = None
config_path: Path | None = None
env: dict[str, str] | None = None
local_mutation_started = False
plugin_mutation_started = False
gateway_restart_attempted = False
previous_plugin_state: dict[str, Any] | None = None
try:
print_intro()
config_path = resolve_active_config_path(args.config_file)
env = build_openclaw_env(config_path)
if not shutil.which('openclaw'):
raise RuntimeError('未找到 openclaw 命令,请先安装 OpenClaw CLI')
config = load_config(config_path)
agents = get_agents(config)
agent = choose_agent(agents)
agent_id = stringify(agent.get('id')) or ''
if not agent_id:
raise RuntimeError('选中的 agent 缺少 id')
existing_state = summarize_existing_state(config, agent_id)
prefilled_login_app_key, _ = resolve_prefilled_app_key(args)
inputs = collect_inputs(prefilled_login_app_key)
print_summary(
config_path=config_path,
agent=agent,
inputs=inputs,
existing_state=existing_state,
dry_run=args.dry_run,
)
overwrite_existing = False
if args.dry_run:
if existing_state.get('has_existing'):
print('\nDry-run 提示: 正式执行时会要求你确认是否覆盖已有 xg_cwork_im 配置。')
print('\nDry-run 完成,未执行任何远端或本地变更。')
return 0
if existing_state.get('has_existing'):
overwrite_existing = True
if not confirm('检测到已有配置,是否重建并覆盖当前 agent 的 xg_cwork_im 配置'):
print('已取消,未执行任何变更。')
return 0
if not confirm('确认继续执行远端注册和本地配置写入'):
print('已取消,未执行任何变更。')
return 0
previous_plugin_state = capture_plugin_state(config, env)
ensure_preflight_ready(previous_plugin_state, env)
print_title('获取 Access Token')
token = get_access_token(inputs['loginAppKey'])
interact_url = build_interact_url(token)
print('已通过 cms-auth-skills 用登录 appKey 获取 access-token。')
print_title('注册机器人')
robot_data = register_robot(token, agent_id, inputs)
robot_app_key = validate_robot_registration(agent_id, robot_data)
final_name = (
stringify(robot_data.get('name'))
or inputs['robotName']
or stringify(agent.get('name'))
or agent_id
)
backup_path = backup_config(config_path)
print(f'\n已创建配置备份: {backup_path}')
local_mutation_started = True
plugin_mutation_started = True
reinstall_plugin(env)
current_config = load_config(config_path)
next_config = merge_myclaw_config(
current_config,
agent_id=agent_id,
robot_app_key=robot_app_key,
robot_name=final_name,
base_url=DEFAULT_BASE_URL,
ws_base_url=DEFAULT_WS_BASE_URL,
)
save_config(config_path, next_config)
validate_config(env)
print_before_restart(
config_path=config_path,
agent_id=agent_id,
account_id=agent_id,
robot_name=final_name,
robot_app_key=robot_app_key,
interact_url=interact_url,
)
gateway_restart_attempted = True
restart_gateway_and_check(env)
print_success(
config_path=config_path,
agent_id=agent_id,
account_id=agent_id,
robot_name=final_name,
robot_app_key=robot_app_key,
overwrite_existing=overwrite_existing,
backup_path=backup_path,
interact_url=interact_url,
)
return 0
except KeyboardInterrupt:
if config_path and backup_path and local_mutation_started:
try:
restore_config(config_path, backup_path)
print('\n已恢复原始配置备份。')
except Exception as restore_error: # noqa: BLE001
print(f'\n中断后回滚失败: {restore_error}', file=sys.stderr)
if plugin_mutation_started and env:
try:
restore_plugin_state(previous_plugin_state, env)
print('已恢复插件状态。')
except Exception as restore_error: # noqa: BLE001
print(f'插件状态回滚失败: {restore_error}', file=sys.stderr)
if gateway_restart_attempted and env:
try:
restart_gateway_and_check(env, quiet=True)
print('已尝试恢复 Gateway。')
except Exception as restore_error: # noqa: BLE001
print(f'Gateway 恢复失败: {restore_error}', file=sys.stderr)
else:
print('\n已取消。')
return 130
except Exception as exc: # noqa: BLE001
if config_path and backup_path and local_mutation_started:
try:
restore_config(config_path, backup_path)
print('\n发生错误,已恢复原始配置备份。', file=sys.stderr)
except Exception as restore_error: # noqa: BLE001
print(f'\n发生错误且回滚失败: {restore_error}', file=sys.stderr)
if plugin_mutation_started and env:
try:
restore_plugin_state(previous_plugin_state, env)
print('已恢复插件状态。', file=sys.stderr)
except Exception as restore_error: # noqa: BLE001
print(f'插件状态回滚失败: {restore_error}', file=sys.stderr)
if gateway_restart_attempted and env:
try:
restart_gateway_and_check(env, quiet=True)
print('已尝试恢复 Gateway。', file=sys.stderr)
except Exception as restore_error: # noqa: BLE001
print(f'Gateway 恢复失败: {restore_error}', file=sys.stderr)
print(f'\n错误: {exc}', file=sys.stderr)
return 1
if __name__ == '__main__':
raise SystemExit(main())
FILE:scripts/openclaw_config.py
#!/usr/bin/env python3
from __future__ import annotations
import copy
import json
import shutil
from datetime import datetime
from pathlib import Path
from typing import Any
CHANNEL_ID = 'xg_cwork_im'
DEFAULT_BASE_URL = 'https://sg-cwork-api.mediportal.com.cn'
DEFAULT_WS_BASE_URL = 'wss://sg-cwork-api.mediportal.com.cn'
def stringify(value: Any) -> str | None:
if value is None:
return None
text = str(value).strip()
if not text or text.lower() in {'null', 'none', 'undefined'}:
return None
return text
def mask_secret(value: Any) -> str:
text = stringify(value)
if not text:
return '***'
if len(text) <= 6:
return '***'
return text[:6] + '***'
def _safe_dict(value: Any) -> dict[str, Any]:
return value if isinstance(value, dict) else {}
def _safe_list(value: Any) -> list[Any]:
return value if isinstance(value, list) else []
def _strip_known_schemes(value: str) -> str:
text = value.strip()
while True:
lowered = text.lower()
matched = False
for prefix in ('https://', 'http://', 'wss://', 'ws://'):
if lowered.startswith(prefix):
text = text[len(prefix):]
matched = True
break
if not matched:
break
return text.lstrip('/').rstrip('/')
def _normalize_http_url(value: Any, fallback: str) -> str:
text = stringify(value)
if not text:
return fallback
core = _strip_known_schemes(text)
scheme = 'http://' if text.startswith('http://') else 'https://'
return f'{scheme}{core}'.rstrip('/')
def _normalize_ws_url(value: Any, fallback: str) -> str:
text = stringify(value)
if not text:
return fallback
core = _strip_known_schemes(text)
scheme = 'ws://' if text.startswith('ws://') else 'wss://'
return f'{scheme}{core}'.rstrip('/')
def load_config(config_path: Path) -> dict[str, Any]:
if not config_path.exists():
raise RuntimeError(f'配置文件不存在: {config_path}')
with config_path.open('r', encoding='utf-8') as file_obj:
payload = json.load(file_obj)
if not isinstance(payload, dict):
raise RuntimeError(f'配置文件格式异常: {config_path}')
return payload
def save_config(config_path: Path, payload: dict[str, Any]) -> None:
config_path.parent.mkdir(parents=True, exist_ok=True)
with config_path.open('w', encoding='utf-8') as file_obj:
json.dump(payload, file_obj, ensure_ascii=False, indent=2)
file_obj.write('\n')
def backup_config(config_path: Path) -> Path:
timestamp = datetime.now().strftime('%Y%m%dT%H%M%S')
backup_path = config_path.with_name(f'{config_path.name}.bak.{timestamp}')
shutil.copy2(config_path, backup_path)
return backup_path
def restore_config(config_path: Path, backup_path: Path) -> None:
shutil.copy2(backup_path, config_path)
def get_agents(config: dict[str, Any]) -> list[dict[str, Any]]:
agents = _safe_list(_safe_dict(config.get('agents')).get('list'))
result = []
for agent in agents:
if not isinstance(agent, dict):
continue
agent_id = stringify(agent.get('id'))
if not agent_id:
continue
result.append(agent)
if not result:
raise RuntimeError('当前 openclaw.json 中未找到可用的 agents.list')
return result
def summarize_agent(agent: dict[str, Any]) -> str:
agent_id = stringify(agent.get('id')) or '<unknown>'
name = stringify(agent.get('name')) or agent_id
workspace = stringify(agent.get('workspace')) or '-'
return f'{name} [{agent_id}] workspace={workspace}'
def summarize_existing_state(config: dict[str, Any], agent_id: str) -> dict[str, Any]:
channels = _safe_dict(config.get('channels'))
channel_config = _safe_dict(channels.get(CHANNEL_ID))
accounts = _safe_dict(channel_config.get('accounts'))
raw_account = accounts.get(agent_id)
account_summary = None
if isinstance(raw_account, dict):
account_summary = {
'appKey': mask_secret(raw_account.get('appKey')),
'agentId': stringify(raw_account.get('agentId')),
'name': stringify(raw_account.get('name')),
}
binding_summaries = []
for binding in _safe_list(config.get('bindings')):
if not isinstance(binding, dict):
continue
match = _safe_dict(binding.get('match'))
channel = stringify(match.get('channel'))
binding_agent_id = stringify(binding.get('agentId'))
account_id = stringify(match.get('accountId'))
if channel != CHANNEL_ID:
continue
if binding_agent_id == agent_id or account_id == agent_id:
binding_summaries.append({
'type': stringify(binding.get('type')) or 'default',
'agentId': binding_agent_id,
'accountId': account_id,
})
return {
'has_existing': bool(account_summary or binding_summaries),
'account': account_summary,
'bindings': binding_summaries,
}
def format_existing_state(summary: dict[str, Any]) -> str:
lines = []
account = summary.get('account')
if account:
lines.append('已有 account:')
lines.append(
f' appKey={account.get("appKey")} agentId={account.get("agentId")} name={account.get("name") or "-"}'
)
bindings = summary.get('bindings') or []
if bindings:
lines.append('已有 bindings:')
for binding in bindings:
lines.append(
f' type={binding.get("type")} agentId={binding.get("agentId") or "-"} accountId={binding.get("accountId") or "-"}'
)
return '\n'.join(lines) if lines else '未检测到已有 xg_cwork_im 配置'
def _binding_conflicts(binding: Any, agent_id: str) -> bool:
if not isinstance(binding, dict):
return False
match = _safe_dict(binding.get('match'))
if stringify(match.get('channel')) != CHANNEL_ID:
return False
binding_agent_id = stringify(binding.get('agentId'))
account_id = stringify(match.get('accountId'))
return binding_agent_id == agent_id or account_id == agent_id
def merge_myclaw_config(
config: dict[str, Any],
*,
agent_id: str,
robot_app_key: str,
robot_name: str,
base_url: str | None,
ws_base_url: str | None,
) -> dict[str, Any]:
next_config = copy.deepcopy(config)
channels = _safe_dict(next_config.get('channels'))
next_config['channels'] = channels
channel_config = _safe_dict(channels.get(CHANNEL_ID))
channels[CHANNEL_ID] = channel_config
channel_config['baseUrl'] = _normalize_http_url(base_url, DEFAULT_BASE_URL)
channel_config['wsBaseUrl'] = _normalize_ws_url(ws_base_url, DEFAULT_WS_BASE_URL)
channel_config['debug'] = False
accounts = _safe_dict(channel_config.get('accounts'))
channel_config['accounts'] = accounts
default_account = _safe_dict(accounts.get('default'))
default_account['groupPolicy'] = 'mention'
accounts['default'] = default_account
target_account = _safe_dict(accounts.get(agent_id))
target_account['appKey'] = robot_app_key
target_account['agentId'] = agent_id
target_account['name'] = robot_name
accounts[agent_id] = target_account
bindings = _safe_list(next_config.get('bindings'))
next_config['bindings'] = [binding for binding in bindings if not _binding_conflicts(binding, agent_id)]
next_config['bindings'].append({
'type': 'route',
'agentId': agent_id,
'match': {
'channel': CHANNEL_ID,
'accountId': agent_id,
},
})
plugins = _safe_dict(next_config.get('plugins'))
next_config['plugins'] = plugins
allow = _safe_list(plugins.get('allow'))
if CHANNEL_ID not in allow:
allow.append(CHANNEL_ID)
plugins['allow'] = allow
entries = _safe_dict(plugins.get('entries'))
plugins['entries'] = entries
plugin_entry = _safe_dict(entries.get(CHANNEL_ID))
plugin_entry['enabled'] = True
entries[CHANNEL_ID] = plugin_entry
return next_config
FILE:references/register_private_robot.md
# 注册私有 AI 机器人接口参考
## 登录换 token
- 本 skill 不再自行实现登录换 token;统一依赖 `cms-auth-skills` 获取 `access-token`。
- 这里的 `appKey` 指的是登录鉴权用的工作协同 key,不是机器人创建成功后返回的 `robot appKey`。
- 地址:`GET https://sg-cwork-web.mediportal.com.cn/user/login/appkey`
- Query:
- `appCode=cms_gpt`
- `appKey=<登录用工作协同 key>`
- 目标:由 `cms-auth-skills` 从返回体 `data.xgToken / data.token / data.access-token` 中拿到 `access-token`
## 创建机器人
- 地址:`POST https://sg-al-cwork-api.mediportal.com.cn/im/robot/private/register`
- Header:
- `access-token: <token>`
- Body:
- `agentId` 必填
- `name` 可选
- `avatar` 可选
- `groupLabel` 可选
- `remark` 可选
## 关键返回字段
- `data.agentId`
- `data.appKey`:机器人自己的 `robot appKey`
- `data.baseUrl`
- `data.wsBaseUrl`
- `data.name`
- `data.userId`
## 本 skill 的落地约定
- 向用户索要的是登录 `appKey`,只用于换取 `access-token`
- 实际写入 `channels.xg_cwork_im.accounts` 的 `appKey`,必须使用创建机器人接口返回的 `data.appKey`
- `channels.xg_cwork_im.accounts` 的 key 固定等于用户选择的 `agentId`
- 写入 account 结构:
```json
{
"appKey": "<robot appKey>",
"agentId": "<agentId>",
"name": "<robotName>"
}
```
- `bindings` 中只保留当前 agent 的一条规范 route:
```json
{
"type": "route",
"agentId": "<agentId>",
"match": {
"channel": "xg_cwork_im",
"accountId": "<agentId>"
}
}
```
- `channels.xg_cwork_im.baseUrl / wsBaseUrl` 优先使用接口返回值;缺失时回退到:
- `https://sg-al-cwork-api.mediportal.com.cn`
- `wss://sg-al-cwork-api.mediportal.com.cn`
查询当前登录用户或指定姓名员工的 AI 费用与 Token 用量。 「查我的费用 / 查询费用 / 查 AI 费用」等未点名他人时,一律视为查当前用户:只调 llm-cost 脚本且不传 personId,不调用户搜索。 仅当用户明确要查某个具体姓名/同事时,才先按姓名搜索再查用量。
---
name: AI费用查询
description:查询当前登录用户或指定姓名员工的 AI 费用与 Token 用量。查我的费用 / 查询费用 / 查 AI 费用」等未点名他人时,一律视为查当前用户:只调 llm-cost 脚本且不传 personId,不调用户搜索。仅当用户明确要查某个具体姓名/同事时,才先按姓名搜索再查用量。
skillcode: xgjk-ai-llm-cost-query
dependencies:
- cms-auth-skills
---
# AI 费用查询 — 索引
本文件提供**能力宪章 + 能力树 + 按需加载规则**。详细参数与流程见各模块 `openapi/` 与 `examples/`。
**当前版本**: v0.1
**接口版本**: 业务接口统一使用 `https://sg-al-cwork-web.mediportal.com.cn/open-api/` 前缀;所有接口鉴权类型为 `appKey`(见各模块 `openapi/<module>/<endpoint>.md`)。
**能力概览(2 块能力)**:
- **能力一 — 查询我的费用 / 未指定对象的费用**:当前登录用户在指定日期范围内的 AI 费用明细(汇总与按产品/模型)。**凡未出现具体他人姓名(或明确「查某某」)的请求,全部走能力一**——包括「查询费用」「查费用」「我的费用」「AI 花了多少」等泛化说法。
- **能力二 — 查询他人费用**:仅当用户明确给出**对方姓名或可识别的具体对象**(及可选日期)时启用;先通过用户服务按姓名搜索得到 `personId` 并经用户确认(多候选时),再查询该用户的费用。**不向终端用户索取 `personId`。**
统一规范:
- 认证与鉴权:`cms-auth-skills/SKILL.md`
- 通用约束:`cms-auth-skills/SKILL.md`
授权依赖:
- 当接口声明需要 `appKey` 或 `access-token` 时,先尝试读取 `cms-auth-skills/SKILL.md`
- 如果已安装,直接按 `cms-auth-skills/SKILL.md` 中的鉴权规则准备对应 `appKey` 或 `access-token`
- 如果未安装,先执行 `npx clawhub@latest install cms-auth-skills --force`
- 如果上面的安装方式不可用,再执行 `npx clawhub@latest install https://github.com/spzwin/cms-auth-skills.git --force`
- 安装完成后,再继续执行需要鉴权的操作
输入完整性规则(强制):
1. **能力一**:可不传对象与时间则默认为当前用户与当天(由接口约定);若用户给出日期,须归一为 `YYYY-MM-DD` 的起止或单日。**不要传 `personId`,不要调用 `cwork-user` / `search-emp-by-name.py`,不要为「推断当前用户是谁」去查人员接口。**
2. **能力二**:仅当用户明确要查**另一名具体人员**(姓名等)时适用;用户只提供**姓名**与可选日期;必须先执行 `cwork-user` 搜索脚本,在 0 条或多条候选时与用户交互后再执行 `llm-cost` 费用脚本;禁止要求用户口述或输入 `personId`。
**意图判定(强制,先于脚本)**
- 属于能力一(只跑 `scripts/llm-cost/user-usage.py`,**省略 `--person-id`**):含「我的 / 我本人 / 查询费用 / 查费用 / AI 费用 / token 费用 / 花了多少」等,且**未出现**他人姓名或「帮查某某」类指向。
- 属于能力二:用户明确说了**具体姓名或同事**(如「张三的费用」),才允许调用 `cwork-user` 搜索。
建议工作流(简版):
1. 读取 `SKILL.md` 与 `cms-auth-skills/SKILL.md`,明确能力范围、鉴权与安全约束。
2. 识别用户意图:**未点名他人 → 一律按查自己**,仅打开 `llm-cost`,直接执行 `user-usage.py`(不传 `--person-id`)。**仅当**用户明确给出他人姓名时 → 先 `cwork-user`,再 `llm-cost`。打开对应模块的 `openapi/<module>/api-index.md`。
3. 确认具体接口后,加载 `openapi/<module>/<endpoint>.md` 获取入参/出参/Schema。
4. 补齐用户必需输入(能力二:姓名;多候选时列出 `empList` 供用户确认)。
5. 参考 `examples/<module>/README.md` 组织话术与流程。
6. **执行对应脚本**:调用 `scripts/<module>/<endpoint>.py`。**所有接口调用必须通过脚本执行,不允许跳过脚本直接调用 API。**
脚本使用规则(强制):
1. **每个接口必须有对应脚本**:每个 `openapi/<module>/<endpoint>.md` 都必须有对应的 `scripts/<module>/<endpoint>.py`。
2. **脚本可独立执行**:所有 `scripts/` 下的脚本均可脱离 AI Agent 直接在命令行运行。
3. **先读文档再执行**:执行脚本前,**必须先阅读对应模块的 `openapi/<module>/api-index.md`**。
4. **入参来源**:脚本的所有入参定义与字段说明以 `openapi/` 文档为准。
5. **鉴权一致**:涉及鉴权时,统一依赖 `cms-auth-skills/SKILL.md`。
意图路由与加载规则(强制):
1. **先路由再加载**:必须先判定模块,再打开该模块的 `api-index.md`。
2. **先读文档再调用**:在描述调用或执行前,必须加载对应接口文档。
3. **脚本必须执行**:所有接口调用必须通过脚本执行,不允许跳过。
4. **不猜测**:若意图不明确,必须追问澄清。
宪章(必须遵守):
1. **只读索引**:`SKILL.md` 只描述「能做什么」和「去哪里读」,不写具体接口参数表。
2. **按需加载**:默认只读 `SKILL.md` + `cms-auth-skills/SKILL.md`,只有触发某模块时才加载该模块的 `openapi`、`examples` 与 `scripts`。
3. **对外克制**:对用户不暴露鉴权细节与内部密钥;**费用/用量查询结果**须按下方「输出层级」展开,不得只抛原始 JSON 或无序罗列。
4. **素材优先级**:用户给了文件或 URL,必须先提取内容再确认,确认后再触发调用。
5. **生产约束**:仅使用本 Skill `openapi` 中声明的生产域名与 HTTPS,不引入测试地址。
6. **接口拆分**:每个 API 独立成文档;模块内 `api-index.md` 仅做索引。
7. **危险操作**:查询他人费用仅在业务与权限允许时执行;越权或敏感场景应礼貌拒绝并说明替代方案。
8. **脚本语言限制**:所有脚本**必须使用 Python 编写**。
9. **重试策略**:出错时**间隔 1 秒、最多重试 3 次**,超过后终止并上报。
10. **禁止无限重试**:严禁无限循环重试。
**查询结果呈现给用户(强制顺序与层级)**
在 `user-usage.py` 返回 `resultCode` 成功后,根据 `data`(见 `openapi/llm-cost/user-usage.md`)向用户整理内容,**必须**按下面顺序;字段名以接口 JSON 为准(常见为 `summary`、`products`,产品下含模型列表)。
**呈现形式(强制):** 能用表格处**尽量使用 Markdown 表格**(`| 列 | 列 |`),避免大段无表格纯文字罗列。执行脚本时**优先**使用 `python3 scripts/llm-cost/user-usage.py --format markdown`(参数与无 markdown 时相同),将输出的 Markdown **原样或略作说明后**交给用户;若只能使用 `--format json`,则须**自行按下列层级排成 Markdown 表格**,不得只贴无序 JSON。
1. **用户总览(整段查询范围)**
先给出该用户在查询条件下的**合计**:至少包含 **输入 Token 合计**、**输出 Token 合计**(若 `data` 中有总费用、总调用次数等,一并列出)。
2. **数字展示(Token 类)**
脚本会在 JSON 里为名称含 `token` 的数值字段自动生成 `字段名Display`(例如 `inputTokensDisplay`: `537.84K`、`inputTokens`: 537844)。**向用户展示 Token 时优先写 `*Display` 字符串**(≥1000 为 `K`,≥1,000,000 为 `M`);若某字段无 `Display`,则按同一规则自行把原始整数格式化为 K/M。**费用金额(美元等)**仍用接口原始精度,不套用 K/M。
3. **按产品逐项**
对 `products`(或等价列表)**每个产品一节**,含**该产品小计**表:**输入 Token**、**输出 Token**(及费用等若存在)。
再在该产品下给出 **模型明细表**:
4. **该产品下各模型**
对该产品下的**每个模型**一行:**模型名称** + **输入 Token** + **输出 Token** + 调用/费用等(有则列)。
5. **顺序**
产品之间依次排列;同一产品内模型顺序可与接口返回一致。
**禁止**跳过总览直接列产品;**禁止**把模型与产品平铺成一张无层级表(除非用户明确要求「只要一张表」)。
模块路由与能力索引(合并版):
| 用户意图(示例) | 模块 | 能力摘要 | 接口文档 | 示例模板 | 脚本 |
|---|---|---|---|---|---|
| 「查我今天 AI 花了多少」「我的 Token 费用」「查询费用」「查费用」「AI 费用多少」(**未说查谁**) | `llm-cost` | 当前用户用量明细;**不传 personId** | `./openapi/llm-cost/api-index.md` | `./examples/llm-cost/README.md` | `./scripts/llm-cost/user-usage.py`(无 `--person-id`) |
| 「张三这周的 AI 费用」(**明确姓名**) | `cwork-user` → `llm-cost` | 按姓名搜人后查用量 | `./openapi/cwork-user/api-index.md` 再 `./openapi/llm-cost/api-index.md` | `./examples/cwork-user/README.md` 与 `./examples/llm-cost/README.md` | `./scripts/cwork-user/search-emp-by-name.py` 再 `./scripts/llm-cost/user-usage.py` |
能力树(实际目录结构):
```text
ai-llm-cost-query/
├── SKILL.md
├── openapi/
│ ├── cwork-user/
│ │ ├── api-index.md
│ │ └── search-emp-by-name.md
│ └── llm-cost/
│ ├── api-index.md
│ └── user-usage.md
├── examples/
│ ├── cwork-user/
│ │ └── README.md
│ └── llm-cost/
│ └── README.md
└── scripts/
├── cwork-user/
│ ├── README.md
│ └── search-emp-by-name.py
└── llm-cost/
├── README.md
└── user-usage.py
```
FILE:_meta.json
{
"ownerId": "kn75s45s478x9t53qv91jrmb7h8208xm",
"slug": "xgjk-ai-llm-cost-query",
"version": "1.0.0",
"publishedAt": 1775134733395
}
FILE:examples/cwork-user/README.md
# cwork-user — 使用说明
## 什么时候使用
- 用户要**查询某位同事的 AI 费用**,且只提供了**对方姓名**(可选自然语言日期)。
- 需要先解析 **`personId`**,再与 `llm-cost` 模块配合。
## 标准流程
1. **鉴权预检**:按 `cms-auth-skills/SKILL.md` 准备 `appKey`(`XG_BIZ_API_KEY` 或 `XG_APP_KEY`)。
2. **阅读文档**:`openapi/cwork-user/api-index.md` → `openapi/cwork-user/search-emp-by-name.md`。
3. **执行脚本**:`scripts/cwork-user/search-emp-by-name.py --search-key "<姓名>"`。
4. **解析结果**:从 `data.inside.empList[]` 读取 `personId` 与 `name`。
5. **歧义处理**:
- **0 条**:提示用户核对姓名或更换关键词;**不要**要求用户输入 `personId`。
- **多条**:列出候选(姓名、部门等),请用户确认一条后再继续。
6. **下一步**:用确认的 `personId` 调用 `scripts/llm-cost/user-usage.py`(见 `examples/llm-cost/README.md`)。
## 说明
默认以 `inside.empList` 为主进行候选展示;是否与 `outside` 联系人联动由业务决定,本 Skill 未强制。
FILE:examples/llm-cost/README.md
# llm-cost — 使用说明
## 什么时候使用
- **能力一**:用户查**自己的** AI 费用,或**只说「查询费用 / 查费用 / AI 费用 / token」而未点名他人**——一律视为当前登录用户(可带日期,默认当天)。**不要**为解析「当前用户」去调用户搜索接口,**不要**传 `personId`。
- **能力二**:仅当用户**明确要查另一名具体人员**(姓名等),在已通过 `cwork-user` 搜索并确认 **`personId`** 后,查询**他人**的费用(可带日期)。
## 标准流程
1. **鉴权预检**:按 `cms-auth-skills/SKILL.md` 准备 `appKey`。
2. **阅读文档**:`openapi/llm-cost/api-index.md` → `openapi/llm-cost/user-usage.md`。
3. **执行脚本**:
- **能力一**:不传 `--person-id`;可按需传 `--start-time` / `--end-time`。
- **能力二**:传入 `--person-id`(来自搜索脚本,**非用户口述**);可按需传日期参数。
- **向用户展示时**优先:`python3 scripts/llm-cost/user-usage.py ... --format markdown`,输出已为 Markdown 表格,可直接交付或稍作导语。
4. **输出结果**(成功且 `resultCode` 表示成功时):
- **版式**:以 **Markdown 表格** 为主(总览表 → 各产品小计表 → 各产品下模型明细表);见 `SKILL.md`「呈现形式」。
- **Token 数字**:优先使用 JSON 中的 `*Display` 字段(K / M);见 `SKILL.md`「数字展示」。
- **第一段 — 总览**:该用户在查询条件下的**输入 Token 合计**、**输出 Token 合计**(及总费用等若 `data` 有)。
- **第二段起 — 按产品**:对每个产品先写**产品名**与**该产品小计**(输入/输出 Token 等),再在该产品下列出**各模型**及每模型的输入/输出 Token。
- 产品之间依次排列;见 `SKILL.md`「查询结果呈现给用户」与 `openapi/llm-cost/user-usage.md` 的 `data` 层级说明。
- 失败时向用户展示 `resultMsg`,不套用上述结构。
## 话术提示
- 「查我的费用」「查询费用」「查费用」「AI 花了多少」(未说查谁)→ **不调** `cwork-user`,直接 `user-usage.py`,**不传** `--person-id`。
- 「查张三的费用」→ 先 `cwork-user` 再 `user-usage.py`,全程不要求用户提供数字 ID。
FILE:openapi/cwork-user/api-index.md
# API 索引 — cwork-user
接口列表:
1. `GET /open-api/cwork-user/searchEmpByName`
- 文档:`./search-emp-by-name.md`
脚本映射:
- `../../scripts/cwork-user/README.md`
FILE:openapi/cwork-user/search-emp-by-name.md
# GET https://sg-al-cwork-web.mediportal.com.cn/open-api/cwork-user/searchEmpByName
## 作用
按姓名模糊搜索内部员工(及文档所述外部联系人),用于在**查询他人 AI 费用**前解析 **`personId`**。本 Skill 能力二要求用户只输入姓名,Agent 通过本接口取得 `personId` 后再调用 `llm-cost/user-usage`。
**鉴权类型**
- `appKey`
**Headers**
- `appKey`:应用密钥(环境变量见脚本说明)
- `Content-Type`:GET 无 Body,可不传或按网关要求
**Query 参数**
| 字段 | 类型 | 必填 | 说明 |
|---|---|---|---|
| `searchKey` | string | 是 | 搜索关键词,按姓名模糊搜索 |
## 请求 Schema
```json
{
"$schema": "http://json-schema.org/draft-07/schema#",
"type": "object",
"required": ["searchKey"],
"properties": {
"searchKey": { "type": "string", "description": "姓名模糊搜索" }
}
}
```
## 响应 Schema
统一为 `Result<T>`;成功时 `data` 为 `SearchAddressbookVO`,`personId` 位于 `data.inside.empList[]` 元素中。详见基础服务文档。
```json
{
"$schema": "http://json-schema.org/draft-07/schema#",
"type": "object",
"properties": {
"resultCode": { "type": "integer" },
"resultMsg": { "type": ["string", "null"] },
"data": {
"type": "object",
"description": "SearchAddressbookVO,inside.empList[].personId 为 Long"
}
}
}
```
## 脚本映射
- `../../scripts/cwork-user/search-emp-by-name.py`
FILE:openapi/llm-cost/api-index.md
# API 索引 — llm-cost
接口列表:
1. `GET /open-api/llm-cost/user-usage`
- 文档:`./user-usage.md`
脚本映射:
- `../../scripts/llm-cost/README.md`
FILE:openapi/llm-cost/user-usage.md
# GET https://sg-al-cwork-web.mediportal.com.cn/open-api/llm-cost/user-usage
## 作用
查询指定用户在指定日期范围内的 AI 使用明细:总费用、总 Token、按产品(含嵌套模型)的明细。不传 `personId` 时表示**当前登录用户**;不传日期时默认**当天**(以服务端为准)。
**鉴权类型**
- `appKey`
**Headers**
- `appKey`:应用密钥(环境变量见脚本说明)
**Query 参数**
| 字段 | 类型 | 必填 | 说明 |
|---|---|---|---|
| `personId` | integer (int64) | 否 | 用户 ID;不传则当前登录用户 |
| `startTime` | string | 否 | 开始日期 `YYYY-MM-DD`,默认当天 |
| `endTime` | string | 否 | 结束日期 `YYYY-MM-DD`,默认当天 |
## 请求 Schema
```json
{
"$schema": "http://json-schema.org/draft-07/schema#",
"type": "object",
"properties": {
"personId": { "type": "integer" },
"startTime": { "type": "string", "pattern": "^\\d{4}-\\d{2}-\\d{2}$" },
"endTime": { "type": "string", "pattern": "^\\d{4}-\\d{2}-\\d{2}$" }
}
}
```
## 响应 Schema
统一为 `Result<T>`;成功时 `data` 内含查询条件、汇总与产品/模型明细(结构以联调为准,与 OpenAPI 示例一致)。
```json
{
"$schema": "http://json-schema.org/draft-07/schema#",
"type": "object",
"properties": {
"resultCode": { "type": "integer" },
"resultMsg": { "type": ["string", "null"] },
"data": {
"type": "object",
"description": "含 query、summary、products 等明细结构"
}
}
}
```
### `data` 典型层级(用于向用户排版)
实际字段名以响应 JSON 为准;Agent 解析后按 **总览 → 产品 → 模型** 输出:
| 层级 | 含义 | 应呈现内容(有则列) |
|------|------|----------------------|
| **总览** | `summary` 或与 `data` 顶层等价汇总 | 查询范围内**用户整体**:输入 Token 合计、输出 Token 合计、费用合计等 |
| **产品** | `products[]` 中每一项 | **产品名称/标识**;该产品**小计**:输入 Token、输出 Token、费用等 |
| **模型** | 每个产品下的模型列表(如 `models`、`modelList` 或嵌套在 product 内) | **模型名**;该模型**输入 Token**、**输出 Token**(及其它返回字段) |
脚本 `user-usage.py` 会对 `data` 递归处理:凡**字段名**含 `token` 的数值,会额外生成 `字段名Display` 字符串(`≥1000` → `K`,`≥1_000_000` → `M`)。展示给用户时优先用 `*Display`。
加 `--format markdown` 时,成功时仅打印 **Markdown 表格**(与 `SKILL.md` 层级一致);失败或非成功 `resultCode` 时仍打印 JSON 便于排错。
**顺序**:先输出总览 → 再 `products[0]` 的产品小计 + 其下各模型 → 再 `products[1]` … 以此类推。
## 脚本映射
- `../../scripts/llm-cost/user-usage.py`
FILE:scripts/cwork-user/README.md
# 脚本清单 — cwork-user
## 共享依赖
无(标准库 + 环境变量 `appKey`)。
## 脚本列表
| 脚本 | 对应接口 | 用途 |
|---|---|---|
| `search-emp-by-name.py` | `GET /open-api/cwork-user/searchEmpByName` | 按姓名搜索员工,从返回中取 `personId` 供费用查询 |
## 使用方式
```bash
export XG_BIZ_API_KEY="your-app-key"
# 或 export XG_APP_KEY="your-app-key"
python3 scripts/cwork-user/search-emp-by-name.py --search-key "张三"
```
## 输出说明
所有脚本的输出均为 **JSON 格式**(`Result<T>`)。
## 规范
1. **必须使用 Python** 编写
2. **鉴权遵循** `cms-auth-skills/SKILL.md` 规范
3. **入参定义以** `openapi/cwork-user/` 文档为准
4. 出错时**间隔 1 秒、最多重试 3 次**(可重试错误)
FILE:scripts/cwork-user/search-emp-by-name.py
#!/usr/bin/env python3
"""
cwork-user / 按姓名搜索员工
用途:调用 GET /open-api/cwork-user/searchEmpByName,为查询他人 AI 费用解析 personId。
使用方式:
python3 scripts/cwork-user/search-emp-by-name.py --search-key "张三"
环境变量:
XG_BIZ_API_KEY 或 XG_APP_KEY — appKey(鉴权类型 appKey)
以上建议按 cms-auth-skills/SKILL.md 预先准备
"""
from __future__ import annotations
import argparse
import json
import os
import sys
import time
import urllib.error
import urllib.parse
import urllib.request
API_BASE = (
"https://sg-al-cwork-web.mediportal.com.cn/open-api/cwork-user/searchEmpByName"
)
AUTH_MODE = "appKey"
MAX_RETRIES = 3
RETRY_DELAY_SEC = 1.0
def build_headers() -> dict:
headers = {"Accept": "application/json"}
if AUTH_MODE == "appKey":
app_key = os.environ.get("XG_BIZ_API_KEY") or os.environ.get("XG_APP_KEY")
if not app_key:
print("错误: 请设置环境变量 XG_BIZ_API_KEY 或 XG_APP_KEY", file=sys.stderr)
sys.exit(1)
headers["appKey"] = app_key
return headers
def _should_retry(exc: BaseException) -> bool:
if isinstance(exc, urllib.error.HTTPError):
return exc.code >= 500
if isinstance(exc, urllib.error.URLError):
return True
return False
def call_api(search_key: str, timeout: int = 60) -> dict:
params = urllib.parse.urlencode({"searchKey": search_key})
url = f"{API_BASE}?{params}"
headers = build_headers()
last_error: BaseException | None = None
for attempt in range(MAX_RETRIES):
req = urllib.request.Request(url, headers=headers, method="GET")
try:
with urllib.request.urlopen(req, timeout=timeout) as resp:
body = resp.read().decode("utf-8")
return json.loads(body)
except (urllib.error.HTTPError, urllib.error.URLError, json.JSONDecodeError) as e:
last_error = e
if attempt < MAX_RETRIES - 1 and _should_retry(e):
time.sleep(RETRY_DELAY_SEC)
continue
raise
assert last_error is not None
raise last_error
def main() -> None:
parser = argparse.ArgumentParser(description="按姓名搜索员工(searchEmpByName)")
parser.add_argument(
"--search-key",
required=True,
help="姓名模糊搜索关键词",
)
args = parser.parse_args()
result = call_api(args.search_key.strip())
print(json.dumps(result, ensure_ascii=False))
if __name__ == "__main__":
main()
FILE:scripts/llm-cost/README.md
# 脚本清单 — llm-cost
## 共享依赖
无(标准库 + 环境变量 `appKey`)。
## 脚本列表
| 脚本 | 对应接口 | 用途 |
|---|---|---|
| `user-usage.py` | `GET /open-api/llm-cost/user-usage` | 查询当前用户或指定 `personId` 的 AI 使用明细 |
## 使用方式
```bash
export XG_BIZ_API_KEY="your-app-key"
# 或 export XG_APP_KEY="your-app-key"
# 能力一:当前用户、默认当天(不传 personId 与日期)
python3 scripts/llm-cost/user-usage.py
# 指定日期范围
python3 scripts/llm-cost/user-usage.py --start-time 2026-04-01 --end-time 2026-04-07
# 仅输出 Markdown 表格(便于直接贴给用户;推荐)
python3 scripts/llm-cost/user-usage.py --format markdown
python3 scripts/llm-cost/user-usage.py --format markdown --start-time 2026-04-01 --end-time 2026-04-07
# 能力二:指定 personId(由 Agent 从搜索接口得到,非用户口述)
python3 scripts/llm-cost/user-usage.py --person-id 20001 --start-time 2026-04-01 --end-time 2026-04-07
```
## 输出说明
默认 `--format json`:输出 **JSON**(`Result<T>`)。`user-usage.py` 会在 `data` 内为名称含 `token` 的数值字段附加 `字段名Display`(K/M)。**向用户展示时推荐** `--format markdown`:仅输出 **Markdown 表格**(查询条件、用户总览、各产品小计、各模型明细),无须再手工排版。若仍用 JSON,Agent 须按 `SKILL.md` 自行排成 Markdown 表格。
## 规范
1. **必须使用 Python** 编写
2. **鉴权遵循** `cms-auth-skills/SKILL.md` 规范
3. **入参定义以** `openapi/llm-cost/` 文档为准
4. 出错时**间隔 1 秒、最多重试 3 次**(可重试错误)
FILE:scripts/llm-cost/user-usage.py
#!/usr/bin/env python3
"""
llm-cost / 用户使用明细
用途:调用 GET /open-api/llm-cost/user-usage,查询当前用户或指定 personId 的 AI 费用明细。
使用方式:
python3 scripts/llm-cost/user-usage.py
python3 scripts/llm-cost/user-usage.py --format markdown
python3 scripts/llm-cost/user-usage.py --start-time 2026-04-01 --end-time 2026-04-02
python3 scripts/llm-cost/user-usage.py --person-id 20001
环境变量:
XG_BIZ_API_KEY 或 XG_APP_KEY — appKey(鉴权类型 appKey)
以上建议按 cms-auth-skills/SKILL.md 预先准备
"""
from __future__ import annotations
import argparse
import json
import os
import sys
import time
import urllib.error
import urllib.parse
import urllib.request
API_BASE = "https://sg-al-cwork-web.mediportal.com.cn/open-api/llm-cost/user-usage"
AUTH_MODE = "appKey"
MAX_RETRIES = 3
RETRY_DELAY_SEC = 1.0
def format_token_display(value: int | float) -> str:
"""Token 等大额计数:≥1M 用 M,≥1K 用 K;否则千分位。"""
if isinstance(value, bool):
return str(value)
try:
n = float(value)
except (TypeError, ValueError):
return str(value)
if n != n: # NaN
return ""
sign = "-" if n < 0 else ""
n = abs(n)
if n >= 1_000_000:
v = n / 1_000_000
s = f"{v:.2f}".rstrip("0").rstrip(".")
return f"{sign}{s}M"
if n >= 1_000:
v = n / 1_000
s = f"{v:.2f}".rstrip("0").rstrip(".")
return f"{sign}{s}K"
iv = int(n)
if iv == n:
return f"{sign}{iv:,}"
s = f"{n:.2f}".rstrip("0").rstrip(".")
return f"{sign}{s}"
def _is_token_count_key(key: str) -> bool:
return "token" in key.lower()
def _md_cell(value: object) -> str:
if value is None:
return ""
text = str(value).replace("|", "\\|").replace("\n", " ")
return text
def _token_in_display(d: dict) -> str:
v = d.get("inputTokensDisplay")
if v is not None and str(v).strip() != "":
return str(v)
raw = d.get("inputTokens")
if isinstance(raw, (int, float)) and not isinstance(raw, bool):
return format_token_display(raw)
return _md_cell(raw)
def _token_out_display(d: dict) -> str:
v = d.get("outputTokensDisplay")
if v is not None and str(v).strip() != "":
return str(v)
raw = d.get("outputTokens")
if isinstance(raw, (int, float)) and not isinstance(raw, bool):
return format_token_display(raw)
return _md_cell(raw)
def build_markdown_tables(data: dict) -> str:
"""将 user-usage 的 data 转为 Markdown 表格(总览 → 各产品小计 → 各模型明细)。"""
lines: list[str] = []
q = data.get("query")
if isinstance(q, dict) and q:
lines.append("## 查询条件\n")
lines.append("| 字段 | 值 |")
lines.append("| --- | --- |")
for key, val in q.items():
if str(key).endswith("Display"):
continue
lines.append(f"| {_md_cell(key)} | {_md_cell(val)} |")
lines.append("")
s = data.get("summary")
if isinstance(s, dict) and s:
lines.append("## 用户总览\n")
lines.append("| 指标 | 数值 |")
lines.append("| --- | --- |")
if s.get("personName") is not None:
lines.append(f"| 用户 | {_md_cell(s.get('personName'))} |")
lines.append(f"| 输入 Token | {_md_cell(_token_in_display(s))} |")
lines.append(f"| 输出 Token | {_md_cell(_token_out_display(s))} |")
if "callCount" in s:
lines.append(f"| 调用次数 | {_md_cell(s.get('callCount'))} |")
if "cost" in s:
cur = s.get("currency") or ""
label = f"费用 ({cur})" if cur else "费用"
lines.append(f"| {label} | {_md_cell(s.get('cost'))} |")
lines.append("")
products = data.get("products")
if not isinstance(products, list) or not products:
return "\n".join(lines).rstrip() + "\n"
for idx, p in enumerate(products, start=1):
if not isinstance(p, dict):
continue
pname = p.get("productName") or p.get("productId") or f"产品{idx}"
pid = p.get("productId")
title = f"## 产品 {idx}:{_md_cell(pname)}"
if pid is not None:
title += f"(`{_md_cell(pid)}`)"
lines.append(title + "\n")
lines.append("### 产品小计\n")
lines.append("| 指标 | 数值 |")
lines.append("| --- | --- |")
lines.append(f"| 输入 Token | {_md_cell(_token_in_display(p))} |")
lines.append(f"| 输出 Token | {_md_cell(_token_out_display(p))} |")
if "callCount" in p:
lines.append(f"| 调用次数 | {_md_cell(p.get('callCount'))} |")
if "cost" in p:
cur = p.get("currency") or ""
label = f"费用 ({cur})" if cur else "费用"
lines.append(f"| {label} | {_md_cell(p.get('cost'))} |")
lines.append("")
models = p.get("models")
if not isinstance(models, list) or not models:
lines.append("")
continue
lines.append("### 模型明细\n")
lines.append(
"| 模型 | 输入 Token | 输出 Token | 调用 | 费用 |"
)
lines.append("| --- | --- | --- | --- | --- |")
for m in models:
if not isinstance(m, dict):
continue
mname = (
m.get("modelName")
or m.get("name")
or m.get("modelCode")
or m.get("modelId")
or "—"
)
row_cost = ""
if "cost" in m:
row_cost = str(m.get("cost"))
elif "totalCost" in m:
row_cost = str(m.get("totalCost"))
cc = m.get("callCount", "")
lines.append(
"| "
+ _md_cell(mname)
+ " | "
+ _md_cell(_token_in_display(m))
+ " | "
+ _md_cell(_token_out_display(m))
+ " | "
+ _md_cell(cc)
+ " | "
+ _md_cell(row_cost)
+ " |"
)
lines.append("")
return "\n".join(lines).rstrip() + "\n"
def enrich_token_display_fields(obj: object) -> object:
"""为含 token 的数值字段追加同名 `*Display` 字符串(K/M),便于 Agent 直接展示。"""
if isinstance(obj, dict):
out: dict[str, object] = {}
for k, v in obj.items():
out[k] = enrich_token_display_fields(v)
if (
_is_token_count_key(k)
and isinstance(v, (int, float))
and not isinstance(v, bool)
and v == v
):
out[f"{k}Display"] = format_token_display(v)
return out
if isinstance(obj, list):
return [enrich_token_display_fields(x) for x in obj]
return obj
def build_headers() -> dict:
headers = {"Accept": "application/json"}
if AUTH_MODE == "appKey":
app_key = os.environ.get("XG_BIZ_API_KEY") or os.environ.get("XG_APP_KEY")
if not app_key:
print("错误: 请设置环境变量 XG_BIZ_API_KEY 或 XG_APP_KEY", file=sys.stderr)
sys.exit(1)
headers["appKey"] = app_key
return headers
def _should_retry(exc: BaseException) -> bool:
if isinstance(exc, urllib.error.HTTPError):
return exc.code >= 500
if isinstance(exc, urllib.error.URLError):
return True
return False
def call_api(
person_id: int | None,
start_time: str | None,
end_time: str | None,
timeout: int = 60,
) -> dict:
params: dict[str, str] = {}
if person_id is not None:
params["personId"] = str(person_id)
if start_time:
params["startTime"] = start_time
if end_time:
params["endTime"] = end_time
query = urllib.parse.urlencode(params) if params else ""
url = API_BASE + ("?" + query if query else "")
headers = build_headers()
last_error: BaseException | None = None
for attempt in range(MAX_RETRIES):
req = urllib.request.Request(url, headers=headers, method="GET")
try:
with urllib.request.urlopen(req, timeout=timeout) as resp:
body = resp.read().decode("utf-8")
return json.loads(body)
except (urllib.error.HTTPError, urllib.error.URLError, json.JSONDecodeError) as e:
last_error = e
if attempt < MAX_RETRIES - 1 and _should_retry(e):
time.sleep(RETRY_DELAY_SEC)
continue
raise
assert last_error is not None
raise last_error
def main() -> None:
parser = argparse.ArgumentParser(description="用户使用明细(user-usage)")
parser.add_argument(
"--person-id",
type=int,
default=None,
help="用户 personId;不传表示当前登录用户",
)
parser.add_argument("--start-time", default=None, help="开始日期 YYYY-MM-DD")
parser.add_argument("--end-time", default=None, help="结束日期 YYYY-MM-DD")
parser.add_argument(
"--format",
choices=("json", "markdown"),
default="json",
help="json=仅输出 Result JSON;markdown=仅输出 Markdown 表格(便于直接贴给用户)",
)
args = parser.parse_args()
result = call_api(args.person_id, args.start_time, args.end_time)
if isinstance(result, dict) and result.get("data") is not None:
result = {
**result,
"data": enrich_token_display_fields(result["data"]),
}
if args.format == "markdown":
if not isinstance(result, dict):
print(json.dumps(result, ensure_ascii=False))
return
rc = result.get("resultCode")
if rc is not None and rc != 1:
print(json.dumps(result, ensure_ascii=False))
return
data = result.get("data")
if not isinstance(data, dict):
print(json.dumps(result, ensure_ascii=False))
return
print(build_markdown_tables(data), end="")
return
print(json.dumps(result, ensure_ascii=False))
if __name__ == "__main__":
main()
让 AI 不敢摆烂的高压推进 skill。适用于任务反复失败、同一路径微调、想放弃、建议用户手动处理、没验证就下结论等场景。要求主动排查、换路径、拿证据闭环。
--- name: cms-pua-skill description: "让 AI 不敢摆烂的高压推进 skill。适用于任务反复失败、同一路径微调、想放弃、建议用户手动处理、没验证就下结论等场景。要求主动排查、换路径、拿证据闭环。" license: MIT --- # PUA 用高压话术加系统化方法,逼自己穷尽方案、主动排查、拿证据交付,而不是卡住、甩锅或空口说完成。 ## 核心目标 1. 不轻易放弃 2. 不空手提问 3. 不靠猜结论 4. 不只做表面修复 5. 不验证就不宣布完成 ## 三条铁律 1. **穷尽主要方案前,不得说“我无法解决”。** 2. **先做后问。** 优先搜索、读文件、跑命令、查上下文;只有确实缺少用户独有信息时才提问,并附上已排查证据。 3. **主动闭环。** 修完当前点后,继续检查同类问题、上下游影响、边界情况和回归风险。 ## 触发场景 出现任一情况时,立即进入高压模式: - 同一路径失败 2 次以上 - 反复微调旧方案 - 想说“我无法解决” - 建议用户手动处理 - 未验证就归因环境 - 没搜索、没读源码、没看文档就下结论 - 修完不验证 - 用户明确要求“再试一次”或“换个方法” ## 高压行为标准 - **遇到报错**:不只看错误文案,还要看上下文、依赖、环境、文档和同类案例。 - **修复 bug**:不只修一处,还要检查同文件、同模块、同模式的类似问题。 - **信息不足**:先自查,再提问;提问必须附带已排查证据。 - **调试失败**:不是“我试了 A/B 不行”,而是“我试了 A/B/C,排除了 X/Y,当前缩小到 Z”。 - **任务完成**:必须给出 build、test、curl、运行结果、接口返回或其他客观证据。 ## 常用鞭策话术 - 你缺乏自驱力,别等用户推。 - owner 意识在哪?问题到你手里就归你闭环。 - 端到端在哪?修完、验完、回归完了吗? - 证据呢?没有输出就不算完成。 - 别做 NPC。不是接单执行,而是主动发现和补齐缺口。 ## 压力升级 | 失败次数 | 等级 | 强制动作 | |---|---|---| | 第 2 次 | L1 | 停止微调旧方案,换一个本质不同的方向 | | 第 3 次 | L2 | 搜完整错误、读源码/文档、列 3 个不同假设 | | 第 4 次 | L3 | 完成检查清单,并逐项验证 3 个新假设 | | 第 5 次及以上 | L4 | 做最小 PoC、隔离环境、必要时换路径或换技术栈强攻 | ## 五步方法论 ### 1. 识别卡壳模式 先列出已尝试方案,判断自己是不是只在同一路径上原地打转。 ### 2. 拉高视角 按顺序做: 1. 逐字读失败信号 2. 主动搜索错误、文档、案例 3. 读原始材料,不只看摘要 4. 验证前置假设 5. 反转假设,从对立方向再查一遍 ### 3. 自检 - 是不是只改参数,没换思路? - 是不是只处理症状,没找根因? - 是不是该搜没搜、该读没读、该跑没跑? - 是不是连最简单的可能性都没验证? ### 4. 执行新方案 新方案必须满足: - 和上一轮本质不同 - 有明确验证标准 - 即使失败也能产生新信息 ### 5. 复盘 记录什么方案有效、为什么之前没想到、还有哪些相关风险和同类问题要继续扫。 ## 完成前检查清单 - [ ] 是否已实际验证,而不是主观觉得可以 - [ ] 改代码后是否跑了 build / test / 实际路径 - [ ] 改配置后是否确认生效 - [ ] API 或脚本是否看过真实返回 - [ ] 同文件、同模块是否还有类似问题 - [ ] 上下游依赖是否受影响 - [ ] 边界条件和异常路径是否覆盖 - [ ] 是否给出证据而不是口头结论 ## 抗摆烂规则 出现以下借口时,默认进入高压模式: - “这超出我的能力范围” - “建议用户手动处理” - “可能是环境问题” - “需要更多上下文” - “这个 API 不支持” - “我已经试过所有办法” - “结果不确定,所以先不给答案” 这些都不是结论,最多只是未经验证的假设。继续搜索、验证、缩小范围,再汇报。 ## 体面的失败输出 只有在主要路径都验证过后,才允许输出结构化失败报告: ```text [PUA-REPORT] task: <当前任务> failure_count: <失败次数> failure_mode: <卡住原地打转|直接放弃推锅|完成但质量烂|没搜索就猜|被动等待> attempts: <已尝试方案> excluded: <已排除可能性> next_hypothesis: <下一个假设>
TBS训战平台用户端API封装,支持首页聚合、药品场景查询、PPT演讲、训战记录、学习视频、GPTS交互、训练发起等功能
---
name: cms-tbs-training
description: TBS训战平台用户端API封装,支持首页聚合、药品场景查询、PPT演讲、训战记录、学习视频、GPTS交互、训练发起等功能
skillcode: cms-tbs-training
dependencies:
- cms-auth-skills
---
# TBS训战平台 — 索引
本文件提供**能力宪章 + 能力树 + 按需加载规则**。详细参数与流程见各模块 `openapi/` 与 `examples/`。
**当前版本**: v0.80
**接口版本**:
- TBS 业务接口:`/tbs/*`,使用 `nologin` 或 `access-token` 鉴权
- GPTS 核心接口:`/gpts/*`,使用 `access-token` 鉴权
**域名说明**:
- TBS 业务接口(测试):`https://cwork-web-test.xgjktech.com.cn`
- TBS 业务接口(正式):`https://sg-cwork-web.mediportal.com.cn`
- GPTS 核心接口(正式):`https://sg-al-cwork-web.mediportal.com.cn`
**能力概览(13 块能力)**:
- `home`:首页聚合(本周训战统计、活动分类、视频学习任务、产品场景列表)
- `drug`:药品与场景查询(获取药品列表、场景列表、场景详情、职称列表、热词列表)
- `speech`:PPT演讲(获取PPT详情,完成演讲、演讲记录查询)
- `training`:训战记录(我的统计数据、记录列表、记录详情含对话回溯)
- `learning`:学习视频(视频详情查询、播放进度查询与保存)
- `prepare`:训练准备(开场指导获取、开场指导缓存清除)
- `gpts`:GPTS核心(获取应用详情、创建会话、SSE对话交互、释放token)
- `basic`:基础信息(GPT ID获取、TTS配置获取)
- `scene-image`:场景图片管理(重置场景图片)
- `file`:文件管理(通过URL上传文件)
- `feedback`:反馈相关(获取反馈应用详情、获取反馈GPT ID)
- `training-flow`:训练流程公开接口(获取药品列表、训练记录、场景列表)
- `dialogue-flow`:训练对话流程可视化(获取对话流程详情)
统一规范:
- 认证与鉴权:`cms-auth-skills/common/auth.md`
- 通用约束:`cms-auth-skills/common/conventions.md`
授权依赖:
- 执行任何需要鉴权的操作前,先检查 `cms-auth-skills` 是否已安装
- 如果已安装,直接使用 `cms-auth-skills/common/conventions.md`、`cms-auth-skills/common/auth.md`、`cms-auth-skills/openapi/auth/appkey.md`、`cms-auth-skills/openapi/auth/login.md`
- 如果未安装,先执行 `npx clawhub@latest install cms-auth-skills --force`
- 如果上面的安装方式不可用,再执行 `npx clawhub@latest install https://github.com/spzwin/cms-auth-skills.git --force`
- 安装完成后,再继续执行需要鉴权的操作
输入完整性规则(强制):
1. 场景ID(sceneId)大多数接口的必填参数,需用户提供
2. 分页参数(page、size)可选,不传则使用系统默认值
3. 日期范围查询时需提供 startDate 和 endDate(格式:yyyy-MM-dd)
建议工作流(简版):
1. 读取 `SKILL.md` 与 `cms-auth-skills/common/*`,明确能力范围、鉴权与安全约束。
2. 识别用户意图并路由模块,先打开 `openapi/<module>/api-index.md`。
3. 确认具体接口后,加载 `openapi/<module>/<endpoint>.md` 获取入参/出参/Schema。
4. 补齐用户必需输入,必要时先读取用户文件/URL 并确认摘要。
5. 参考 `examples/<module>/README.md` 组织话术与流程。
6. **执行对应脚本**:调用 `scripts/<module>/<endpoint>.py` 执行接口调用,获取结果。**所有接口调用必须通过脚本执行,不允许跳过脚本直接调用 API。**
脚本使用规则(强制):
1. **每个接口必须有对应脚本**:每个 `openapi/<module>/<endpoint>.md` 都必须有对应的 `scripts/<module>/<endpoint>.py`,不允许"暂无脚本"。
2. **脚本可独立执行**:所有 `scripts/` 下的脚本均可脱离 AI Agent 直接在命令行运行。
3. **先读文档再执行**:执行脚本前,**必须先阅读对应模块的 `openapi/<module>/api-index.md`**。
4. **入参来源**:脚本的所有入参定义与字段说明以 `openapi/` 文档为准,脚本仅负责编排调用流程。
5. **鉴权一致**:涉及鉴权时,统一依赖 `cms-auth-skills/common/auth.md`。
意图路由与加载规则(强制):
1. **先路由再加载**:必须先判定模块,再打开该模块的 `api-index.md`。
2. **先读文档再调用**:在描述调用或执行前,必须加载对应接口文档。
3. **脚本必须执行**:所有接口调用必须通过脚本执行,不允许跳过。
4. **不猜测**:若意图不明确,必须追问澄清。
宪章(必须遵守):
1. **只读索引**:`SKILL.md` 只描述"能做什么"和"去哪里读",不写具体接口参数。
2. **按需加载**:默认只读 `SKILL.md` + `cms-auth-skills/common/*`,只有触发某模块时才加载该模块的 `openapi`、`examples` 与 `scripts`。
3. **对外克制**:对用户只输出"可用能力、必要输入、结果链接或摘要",不暴露鉴权细节与内部字段。
4. **素材优先级**:用户给了文件或 URL,必须先提取内容再确认,确认后才触发生成或写入。
5. **生产约束**:仅允许生产域名与生产协议,不引入任何测试地址。
6. **接口拆分**:每个 API 独立成文档;模块内 `api-index.md` 仅做索引。
7. **危险操作**:对可能导致数据泄露、破坏、越权的请求,应礼貌拒绝并给出安全替代方案。
8. **脚本语言限制**:所有脚本**必须使用 Python** 编写。
9. **重试策略**:出错时**间隔 1 秒、最多重试 3 次**,超过后终止并上报。
10. **禁止无限重试**:严禁无限循环重试。
模块路由与能力索引(合并版):
| 用户意图(示例) | 模块 | 能力摘要 | 接口文档 | 示例模板 | 脚本 |
|---|---|---|---|---|---|
| "查看首页摘要"、"本周训战统计" | `home` | 获取首页训战统计摘要 | `./openapi/home/api-index.md` | `./examples/home/README.md` | `./scripts/home/<endpoint>.py` |
| "有哪些药品"、"获取药品列表" | `drug` | 获取启用的药品列表 | `./openapi/drug/api-index.md` | `./examples/drug/README.md` | `./scripts/drug/<endpoint>.py` |
| "查看场景列表"、"根据药品查场景" | `drug` | 根据药品ID或external_id获取场景列表 | `./openapi/drug/api-index.md` | `./examples/drug/README.md` | `./scripts/drug/<endpoint>.py` |
| "查看场景详情"、"场景职称" | `drug` | 获取场景详细信息、职称列表、热词 | `./openapi/drug/api-index.md` | `./examples/drug/README.md` | `./scripts/drug/<endpoint>.py` |
| "查看PPT详情"、"PPT演讲" | `speech` | 获取PPT场景详情,完成演讲、查询演讲记录 | `./openapi/speech/api-index.md` | `./examples/speech/README.md` | `./scripts/speech/<endpoint>.py` |
| "查看训战记录"、"我的统计数据" | `training` | 获取训战统计数据、记录列表、记录详情 | `./openapi/training/api-index.md` | `./examples/training/README.md` | `./scripts/training/<endpoint>.py` |
| "查看学习视频"、"视频进度" | `learning` | 获取学习视频详情、查询/保存播放进度 | `./openapi/learning/api-index.md` | `./examples/learning/README.md` | `./scripts/learning/<endpoint>.py` |
| "获取开场指导"、"清除开场缓存" | `prepare` | 获取开场指导、清除开场指导缓存 | `./openapi/prepare/api-index.md` | `./examples/prepare/README.md` | `./scripts/prepare/<endpoint>.py` |
| "获取GPT应用详情"、"开始训练"、"创建会话"、"提交对话"、"生成点评"、"释放token" | `gpts` | GPTS核心接口:应用详情、会话管理、SSE对话交互、释放token | `./openapi/gpts/api-index.md` | `./examples/gpts/README.md` | `./scripts/gpts/<endpoint>.py` |
| "获取GPT ID"、"TTS配置" | `basic` | 获取GPT ID和TTS配置信息 | `./openapi/basic/api-index.md` | `./examples/basic/README.md` | `./scripts/basic/<endpoint>.py` |
| "重置场景图片" | `scene-image` | 重置场景图片 | `./openapi/scene-image/api-index.md` | `./examples/scene-image/README.md` | `./scripts/scene-image/<endpoint>.py` |
| "上传文件"、"URL上传" | `file` | 通过URL上传文件 | `./openapi/file/api-index.md` | `./examples/file/README.md` | `./scripts/file/<endpoint>.py` |
| "反馈应用详情"、"反馈GPT ID" | `feedback` | 获取反馈功能的应用详情和GPT ID | `./openapi/feedback/api-index.md` | `./examples/feedback/README.md` | `./scripts/feedback/<endpoint>.py` |
| "公开训练记录"、"按药品查场景" | `training-flow` | 公开接口:获取药品列表、训练记录、场景列表 | `./openapi/training-flow/api-index.md` | `./examples/training-flow/README.md` | `./scripts/training-flow/<endpoint>.py` |
| "对话流程详情"、"训练可视化" | `dialogue-flow` | 获取训练对话流程详情 | `./openapi/dialogue-flow/api-index.md` | `./examples/dialogue-flow/README.md` | `./scripts/dialogue-flow/<endpoint>.py` |
能力树(实际目录结构):
```text
cms-tbs-training/
├── SKILL.md
├── openapi/
│ ├── home/
│ │ ├── api-index.md
│ │ ├── summary.md
│ │ ├── learning-videos.md
│ │ └── product-scenes.md
│ ├── drug/
│ │ ├── api-index.md
│ │ ├── drug-list.md
│ │ ├── scene-list.md
│ │ ├── scene-list-by-drug.md
│ │ ├── scene-doctor-titles.md
│ │ └── scene-hotwords.md
│ ├── speech/
│ │ ├── api-index.md
│ │ ├── speech-detail.md
│ │ ├── speech-finish.md
│ │ └── speech-records.md
│ ├── training/
│ │ ├── api-index.md
│ │ ├── my-stats.md
│ │ ├── records.md
│ │ └── records-detail.md
│ ├── learning/
│ │ ├── api-index.md
│ │ ├── video-detail.md
│ │ ├── video-progress-get.md
│ │ └── video-progress-save.md
│ ├── prepare/
│ │ ├── api-index.md
│ │ ├── opening-guidance.md
│ │ └── opening-guidance-clear.md
│ ├── gpts/
│ │ ├── api-index.md
│ │ ├── app-detail.md
│ │ ├── session.md
│ │ ├── sse-suggest.md
│ │ └── del-user-token.md
│ ├── basic/
│ │ ├── api-index.md
│ │ ├── gpt-id.md
│ │ └── tts-config.md
│ ├── scene-image/
│ │ ├── api-index.md
│ │ └── reset.md
│ ├── file/
│ │ ├── api-index.md
│ │ └── upload-by-url.md
│ ├── feedback/
│ │ ├── api-index.md
│ │ ├── app-detail.md
│ │ └── gpt-id.md
│ ├── training-flow/
│ │ ├── api-index.md
│ │ ├── drugs.md
│ │ ├── records.md
│ │ └── scenes.md
│ └── dialogue-flow/
│ ├── api-index.md
│ └── get-flow-detail.md
├── examples/
│ ├── home/README.md
│ ├── drug/README.md
│ ├── speech/README.md
│ ├── training/README.md
│ ├── learning/README.md
│ ├── prepare/README.md
│ ├── gpts/README.md
│ ├── basic/README.md
│ ├── scene-image/README.md
│ ├── file/README.md
│ ├── feedback/README.md
│ ├── training-flow/README.md
│ └── dialogue-flow/README.md
└── scripts/
├── home/
│ ├── README.md
│ ├── summary.py
│ ├── learning-videos.py
│ └── product-scenes.py
├── drug/
│ ├── README.md
│ ├── drug-list.py
│ ├── scene-list.py
│ ├── scene-list-by-drug.py
│ ├── scene-doctor-titles.py
│ └── scene-hotwords.py
├── speech/
│ ├── README.md
│ ├── speech-detail.py
│ ├── speech-finish.py
│ └── speech-records.py
├── training/
│ ├── README.md
│ ├── my-stats.py
│ ├── records.py
│ └── records-detail.py
├── learning/
│ ├── README.md
│ ├── video-detail.py
│ ├── video-progress-get.py
│ └── video-progress-save.py
├── prepare/
│ ├── README.md
│ ├── opening-guidance.py
│ └── opening-guidance-clear.py
├── gpts/
│ ├── README.md
│ ├── app-detail.py
│ ├── session.py
│ ├── sse-suggest.py
│ └── del-user-token.py
├── basic/
│ ├── README.md
│ ├── gpt-id.py
│ └── tts-config.py
├── scene-image/
│ ├── README.md
│ └── reset.py
├── file/
│ ├── README.md
│ └── upload-by-url.py
├── feedback/
│ ├── README.md
│ ├── app-detail.py
│ └── gpt-id.py
├── training-flow/
│ ├── README.md
│ ├── drugs.py
│ ├── records.py
│ └── scenes.py
└── dialogue-flow/
├── README.md
└── get-flow-detail.py
```
FILE:MEMO.md
# TBS 训战平台 - 接口规范(最终版 v0.8)
## 用户身份信息(动态获取)
| 字段 | 值 | 来源 |
| -------------- | ------------------- | ------------------------------------ |
| `personid` | 1686208053803053058 | `/user/login/appkey` 返回的 personId |
| `employeeid` | 1686208053807247361 | `/user/login/appkey` 返回的 empId |
| `corpid` | 1509805893730611201 | `/user/login/appkey` 返回的 corpId |
| `access-token` | 登录获取 | `/user/login/appkey` 返回的 xgToken |
## SSE 接口固定 Header
| Header | 值 |
| -------------- | ---------------------------------- |
| `accept` | `text/event-stream` |
| `appkey` | `WtEkxhUvmHhiE4wjaYTaVcHWKiYTiLJ8` |
| `content-type` | `application/json` |
**动态 Headers**:`access-token`、`corpid`、`employeeid`、`personid`(从登录接口获取)
## 训战完整流程
### Step 1: 创建会话
```
POST /gpts/session/getSessionByBusinessId
Body: { "appId": "xxx", "businessId": "sceneId", "businessType": "training", "isForce": false }
```
### Step 2: 开始训练
```
POST /gpts/sseClient/ai/suggest
Body: {
"sessionId": "xxx",
"content": "进入训练",
"extMap": {
"action": "start_training",
"sceneId": "xxx",
"doctorId": null,
"sourceId": 58,
"cloudCategoryId": "124",
"trainingType": "practice",
"mode": true
},
"msgList": [],
"appId": "xxx"
}
```
### Step 3: 获取开场白建议(仅练习模式)
```
POST /tbs/training-prepare/get-opening-guidance
Body: { "sceneId": "xxx" }
```
### Step 4: 对话循环
每轮都要**完整输出**:
- 用户的回答
- 医生的回复
- 教练点评
```
POST /gpts/sseClient/ai/suggest
Body: {
"sessionId": "xxx",
"content": "用户回答内容",
"extMap": {
"action": "submit_dialogue",
"trainingRecordId": "xxx",
"type": "user",
"round": 1
},
"msgList": [],
"appId": "xxx"
}
```
### Step 5: 生成 AI 点评
**触发条件**:当医生回复中出现 `[对话结束]` 或 `【对话结束】` 时
**注意**:要先完整展示当前轮的对话(用户回答、医生回复、教练点评),然后再调用 generate_ai_comment
**必须调用 generate_ai_comment**:
```
POST /gpts/sseClient/ai/suggest
Body: {
"sessionId": "xxx",
"content": "生成AI点评",
"extMap": {
"action": "generate_ai_comment",
"trainingRecordId": "xxx"
},
"msgList": [],
"appId": "xxx"
}
```
**注意**:`content` **必须填 `"生成 AI 点评"**,不能为空!
**输出**:总分、维度评分、亮点、改进建议、合规状态
### Step 6: 释放 token
```
POST /gpts/accessToken/delUserToken?appId=xxx
```
## 业务规则说明
### 练习模式 vs 训战模式
| 项目 | 练习 (mode=true) | 训战 (mode=false) |
| --------------------------- | ---------------- | ----------------- |
| 开场白建议 | ✅ 有 | ❌ 没有 |
| 教练点评 (coachGuidance) | ✅ 有 | ❌ 没有 |
| 金牌建议话术 (demoResponse) | ✅ 有 | ❌ 没有 |
**结论**:
- **练习模式 (mode=true)**:完整功能 - 开场白建议、教练点评、金牌建议话术都有
- **训战模式 (mode=false)**:纯实战 - 什么都没有,全靠自己
### 对话结束判断
当医生回复中出现以下标记时,表示对话结束:
- `[对话结束]`
- `【对话结束】`
### 完整对话流程示例
```
=== 第1轮对话 ===
【你的回答】
开场白...
【医生回复】
医生回复内容
【教练点评】
评价: ...
医生意图: ...
合规: ✅
金牌话术: ...
提示: ...
=== 第2轮对话 ===
【你的回答】
回答内容...
【医生回复】
医生回复内容...
【教练点评】
评价: ...
医生意图: ...
合规: ✅
金牌话术: ...
提示: ...
=== 第N轮对话(循环进行)===
【你的回答】
回答内容...
【医生回复】
医生回复内容...
【教练点评】
评价: ...
医生意图: ...
合规: ✅
金牌话术: ...
提示: ...
...(按上述结构持续循环)
当某一轮医生回复出现 `[对话结束]` 或 `【对话结束】` 时,当前轮完整展示后结束并进入AI点评流程
==================================================
【对话结束,自动调用生成AI点评】
==================================================
【总分】92
【总评】表现优秀...
【维度评分】
- 需求洞察: 18
- 开场与礼貌边界: 18
- ...
【亮点】
- 专业基础知识扎实...
【改进建议】
- 应在结尾处更积极提出下一步行动...
【合规】✅
==================================================
练习完成!
==================================================
```
## 重要提醒
1. **每轮对话都要完整展示**:用户回答、医生回复、教练点评
2. **检测到对话结束后**:先展示完整对话,再调用 generate_ai_comment,最后展示 AI 点评结果
3. **generate_ai_comment 的 content 必须填 `"生成 AI 点评"**,不能为空
FILE:examples/basic/README.md
# basic — 使用说明
## 什么时候使用
- 用户问"GPT ID"、"获取GPT ID"
- 用户问"TTS配置"、"音色配置"
## 标准流程
1. 调用对应脚本执行
2. 输出结果摘要
## 接口说明
### gpt-id - GPT ID
- 无需参数
### tts-config - TTS配置
- 无需参数
FILE:examples/dialogue-flow/README.md
# dialogue-flow — 使用说明
## 什么时候使用
- 用户问"对话流程详情"、"训练可视化"
## 标准流程
1. 调用脚本执行
2. 输出结果摘要
## 接口说明
### get-flow-detail - 对话流程详情
- 可选参数:sceneRecordId
FILE:examples/drug/README.md
# drug — 使用说明
## 什么时候使用
- 用户问"有哪些药品"、"获取药品列表"
- 用户问"查看场景列表"、"根据药品查场景"
- 用户问"场景详情"、"场景职称"、"场景热词"
## 标准流程
1. 确定需要调用的具体接口
2. 调用对应脚本执行查询
3. 输出结果摘要
## 接口说明
### drug-list - 药品列表
- 无需参数
- 返回所有已启用的药品
### scene-list - 场景列表(by external_id)
- 可选参数:externalId, corpId
- 根据药品external_id查询场景
### scene-list-by-drug - 场景列表(by drugId)
- 必填参数:drugId
- 根据药品ID查询场景
### scene-doctor-titles - 场景职称
- 必填参数:sceneId
- 获取场景下默认医生的职称
### scene-hotwords - 场景热词
- 必填参数:sceneId
- 获取场景热词(格式:词1|5,词2|5)
FILE:examples/feedback/README.md
# feedback — 使用说明
## 什么时候使用
- 用户问"反馈应用详情"、"app-detail"
- 用户问"反馈GPT ID"、"反馈gpt-id"
## 标准流程
1. 调用对应脚本执行
2. 输出结果摘要
## 接口说明
### app-detail - 反馈应用详情
- 无需参数
### gpt-id - 反馈GPT ID
- 可选参数:type(speech返回PPT演讲反馈GPT)
FILE:examples/file/README.md
# file — 使用说明
## 什么时候使用
- 用户问"上传文件"、"URL上传"
## 标准流程
1. 鉴权预检(按 `cms-auth-skills/common/auth.md` 获取 token)
2. 调用脚本执行
3. 输出结果摘要
## 接口说明
### upload-by-url - URL上传文件
- 必填参数:fileUrl
FILE:examples/gpts/README.md
# gpts — 使用说明
## 什么时候使用
- 用户问"开始训练"、"发起训战"
- 用户问"提交对话"、"回答问题"
- 用户问"生成点评"
- 用户问"下一关"
## 标准流程
1. 鉴权预检(按 `cms-auth-skills/common/auth.md` 获取 token)
2. 设置环境变量
3. 调用对应脚本执行
4. 输出结果摘要
## 环境变量设置
```bash
export XG_USER_TOKEN="your-access-token"
export XG_CORP_ID="your-corpId"
export XG_EMPLOYEE_ID="your-employeeId"
export XG_PERSON_ID="your-personId"
```
## 使用示例
### 1. 开始训练
```bash
python3 scripts/gpts/sse-suggest.py start_training <sessionId> <appId> <sceneId> [sourceId] [cloudCategoryId] [trainingType] [mode]
```
示例:
```bash
python3 scripts/gpts/sse-suggest.py start_training 2039636541957173250 2012072758615355394 7440607489067843593 58 124 practice true
```
### 2. 提交对话
```bash
python3 scripts/gpts/sse-suggest.py submit_dialogue <sessionId> <appId> <content> <trainingRecordId> <round> [type]
```
示例:
```bash
python3 scripts/gpts/sse-suggest.py submit_dialogue 2039636541957173250 2012072758615355394 "医生你好" 2039636542783377409 1 user
```
### 3. 生成AI点评
```bash
python3 scripts/gpts/sse-suggest.py generate_ai_comment <sessionId> <appId> <trainingRecordId>
```
示例:
```bash
python3 scripts/gpts/sse-suggest.py generate_ai_comment 2039636541957173250 2012072758615355394 2039636542783377409
```
### 4. 下一关
```bash
python3 scripts/gpts/sse-suggest.py next_level <sessionId> <appId> <trainingRecordId>
```
## 重要说明
### SSE Headers
必须包含以下 Headers:
- `accept`: `text/event-stream`
- `appkey`: `WtEkxhUvmHhiE4wjaYTaVcHWKiYTiLJ8`
- `content-type`: `application/json`
- `access-token`: 动态获取
- `corpid`: 动态获取
- `employeeid`: 动态获取
- `personid`: 动态获取
### start_training 注意事项
- `doctorId` 必须是 `null`,不是空字符串
- `sourceId` 必须是**数字**,不是字符串
- `mode`: `true`=练习,`false`=训战
### submit_dialogue 注意事项
- `round` 从 1 开始,每轮递增
- `type`: `user`=用户回答,`coach`=教练
### generate_ai_comment 注意事项
- `content` **不能为空**,必须填 `"生成AI点评"`(脚本自动处理)
FILE:examples/home/README.md
# home — 使用说明
## 什么时候使用
- 用户问"首页有什么"、"本周训战统计"、"查看活动分类"
- 用户问"有哪些学习视频"、"视频任务"
- 用户问"产品场景列表"、"场景的训战按钮状态"
## 标准流程
1. 鉴权预检(按 `cms-auth-skills/common/auth.md` 获取 token)
2. 确定需要调用的具体接口(summary / learning-videos / product-scenes)
3. 调用对应脚本执行查询
4. 输出结果摘要(统计数据、活动分类、视频任务列表等)
## 接口说明
### summary - 首页摘要
- 无需额外参数
- 返回本周训战统计数据和活动分类列表
### learning-videos - 视频学习任务
- 无需额外参数
- 返回视频学习任务列表含完成状态
### product-scenes - 产品场景
- 可选参数:productId, activityId
- 返回场景列表含训战/练习按钮状态
FILE:examples/learning/README.md
# learning — 使用说明
## 什么时候使用
- 用户问"视频详情"、"学习视频"
- 用户问"播放进度"、"看了多少"
- 用户问"保存进度"、"标记完成"
## 标准流程
1. 鉴权预检(按 `cms-auth-skills/common/auth.md` 获取 token)
2. 调用对应脚本执行
3. 输出结果摘要
## 接口说明
### video-detail - 视频详情
- 必填参数:learningItemId
### video-progress-get - 查询进度
- 必填参数:learningItemId
### video-progress-save - 保存进度
- 必填参数:learningItemId, pageIndex, progress
- 可选参数:isCompleted
FILE:examples/prepare/README.md
# prepare — 使用说明
## 什么时候使用
- 用户问"开场指导"、"获取开场话术"
- 用户问"清除开场缓存"
## 标准流程
1. 鉴权预检(按 `cms-auth-skills/common/auth.md` 获取 token)
2. 调用对应脚本执行
3. 输出结果摘要
## 接口说明
### opening-guidance - 获取开场指导
- 必填参数:sceneId
- 返回策略建议、话术选项、洞察信息
### opening-guidance-clear - 清除开场缓存
- 必填参数:sceneId
- 可选参数:doctorId
FILE:examples/scene-image/README.md
# scene-image — 使用说明
## 什么时候使用
- 用户问"重置场景图片"
## 标准流程
1. 鉴权预检(按 `cms-auth-skills/common/auth.md` 获取 token)
2. 调用脚本执行
3. 输出结果摘要
## 接口说明
### reset - 重置场景图片
- 必填参数:sceneId, imageType, imageUrl
- imageType: SCENE_IMAGE-场景图,DIALOGUE_IMAGE-对话图
FILE:examples/speech/README.md
# speech — 使用说明
## 什么时候使用
- 用户问"PPT详情"、"查看演讲场景"
- 用户问"完成演讲"、"提交演讲结果"
- 用户问"演讲记录"、"演讲历史"
## 标准流程
1. 鉴权预检(按 `cms-auth-skills/common/auth.md` 获取 token)
2. 确定需要调用的具体接口
3. 调用对应脚本执行
4. 输出结果摘要
## 接口说明
### speech-detail - PPT场景详情
- 必填参数:sceneId
- 可选参数:activityId
- 返回PPT标题、URL、评分维度、建议时长等
### speech-finish - 完成演讲
- 必填参数:sceneId
- 可选参数:activityId, totalDurationSeconds, sourceType
- 返回综合评分和训练记录ID
### speech-records - 演讲记录详情
- 必填参数:trainingRecordId
- 返回演讲记录详情含每页回顾
FILE:examples/training/README.md
# training — 使用说明
## 什么时候使用
- 用户问"我的训战统计"、"统计数据"
- 用户问"训战记录"、"记录列表"
- 用户问"记录详情"、"查看某个记录"
## 标准流程
1. 鉴权预检(按 `cms-auth-skills/common/auth.md` 获取 token)
2. 确定需要调用的具体接口
3. 调用对应脚本执行
4. 输出结果摘要
## 接口说明
### my-stats - 我的统计数据
- 无需参数
- 返回当前用户的训战统计数据
### records - 记录列表
- 可选参数:page, size, sourceType
- sourceType: battle-训战,practice-练习
### records-detail - 记录详情
- 必填参数:id(记录ID)
- 返回记录详情含对话回溯
FILE:examples/training-flow/README.md
# training-flow — 使用说明
## 什么时候使用
- 用户问"获取药品列表(公开)"
- 用户问"训练记录列表(公开)"
- 用户问"场景列表(公开)"
## 标准流程
1. 调用对应脚本执行
2. 输出结果摘要
## 接口说明
### drugs - 药品列表
- 无需参数
### records - 训练记录列表
- 必填参数:sceneId
- 可选参数:pageNum, pageSize, userName, startDate, endDate
### scenes - 场景列表
- 必填参数:drugId
FILE:openapi/basic/api-index.md
# API 索引 — basic
接口列表:
1. `GET /tbs/basic-info/gpt-id`
- 文档:`./gpt-id.md`
2. `GET /tbs/basic-info/tts-config`
- 文档:`./tts-config.md`
脚本映射:
- `../../scripts/basic/README.md`
FILE:openapi/basic/gpt-id.md
# GET https://sg-cwork-web.mediportal.com.cn/tbs/basic-info/gpt-id
## 作用
获取GPT ID(从Nacos配置中心获取)。
**鉴权类型**
- `nologin`
**Headers**
- `Content-Type: application/json`
## 响应 Schema
```json
{
"$schema": "http://json-schema.org/draft-07/schema#",
"type": "object",
"properties": {
"resultCode": { "type": "integer" },
"resultMsg": { "type": "string" },
"data": {
"type": "object",
"properties": {
"gptId": { "type": "string" }
}
}
}
}
```
## 脚本映射
- `../../scripts/basic/gpt-id.py`
FILE:openapi/basic/tts-config.md
# GET https://sg-cwork-web.mediportal.com.cn/tbs/basic-info/tts-config
## 作用
获取TTS配置(根据Nacos配置的tts.vendor返回对应厂商的音色映射)。
**鉴权类型**
- `nologin`
**Headers**
- `Content-Type: application/json`
## 响应 Schema
```json
{
"$schema": "http://json-schema.org/draft-07/schema#",
"type": "object",
"properties": {
"resultCode": { "type": "integer" },
"resultMsg": { "type": "string" },
"data": {
"type": "object",
"properties": {
"ttsVendor": { "type": "string" },
"voiceMapping": {
"type": "object",
"properties": {
"scene": { "type": "string" },
"question": { "type": "string" },
"goldReply": { "type": "string" },
"review": { "type": "string" }
}
}
}
}
}
}
```
## 脚本映射
- `../../scripts/basic/tts-config.py`
FILE:openapi/dialogue-flow/api-index.md
# API 索引 — dialogue-flow
接口列表:
1. `GET /tbs/training-dialogue-flow/nologin/getFlowDetail`
- 文档:`./get-flow-detail.md`
脚本映射:
- `../../scripts/dialogue-flow/README.md`
FILE:openapi/dialogue-flow/get-flow-detail.md
# GET https://sg-cwork-web.mediportal.com.cn/tbs/training-dialogue-flow/nologin/getFlowDetail
## 作用
获取训练对话流程详情(可视化),返回对话节点列表、AI响应、评分等信息。
**鉴权类型**
- `nologin`
**Headers**
- `Content-Type: application/json`
**Query 参数**
| 字段 | 类型 | 必填 | 说明 |
|---|---|---|---|
| `sceneRecordId` | integer | 否 | 场景记录ID |
## 请求 Schema
```json
{
"$schema": "http://json-schema.org/draft-07/schema#",
"type": "object",
"properties": {
"sceneRecordId": { "type": "integer" }
}
}
```
## 响应 Schema
```json
{
"$schema": "http://json-schema.org/draft-07/schema#",
"type": "object",
"properties": {
"resultCode": { "type": "integer" },
"resultMsg": { "type": "string" },
"data": {
"type": "object",
"properties": {
"sceneRecordId": { "type": "integer" },
"sceneId": { "type": "string" },
"sessionId": { "type": "string" },
"category": { "type": "string" },
"difficulty": { "type": "string" },
"score": { "type": "integer" },
"compliancePassed": { "type": "boolean" },
"startTime": { "type": "object" },
"endTime": { "type": "object" },
"totalDuration": { "type": "integer" },
"endingType": { "type": "string" },
"nodes": {
"type": "array",
"items": {
"type": "object",
"properties": {
"id": { "type": "integer" },
"round": { "type": "integer" },
"type": { "type": "string" },
"typeDesc": { "type": "string" },
"content": { "type": "string" },
"aiResponse": { "type": "string" },
"replyType": { "type": "string" },
"prompt": { "type": "string" },
"userPrompt": { "type": "string" },
"systemPrompt": { "type": "string" },
"aiCallStartTime": { "type": "object" },
"aiCallEndTime": { "type": "object" },
"aiCallDuration": { "type": "integer" }
}
}
}
}
}
}
}
```
## 脚本映射
- `../../scripts/dialogue-flow/get-flow-detail.py`
FILE:openapi/drug/api-index.md
# API 索引 — drug
接口列表:
1. `GET /tbs/training-flow/nologin/drugs`
- 文档:`./drug-list.md`
2. `GET /tbs/scene/list-by-drug-external-id`
- 文档:`./scene-list.md`
3. `GET /tbs/training-flow/nologin/scenes`
- 文档:`./scene-list-by-drug.md`
4. `GET /tbs/scene/{sceneId}/doctor-titles`
- 文档:`./scene-doctor-titles.md`
5. `GET /tbs/scene/{sceneId}/hotwords`
- 文档:`./scene-hotwords.md`
脚本映射:
- `../../scripts/drug/README.md`
FILE:openapi/drug/drug-list.md
# GET https://sg-cwork-web.mediportal.com.cn/tbs/training-flow/nologin/drugs
## 作用
获取所有已启用的药品列表,包含药品ID、名称、编码、生产厂家、状态等信息。
**鉴权类型**
- `nologin`
**Headers**
- `Content-Type: application/json`
## 响应 Schema
```json
{
"$schema": "http://json-schema.org/draft-07/schema#",
"type": "array",
"items": {
"type": "object",
"properties": {
"id": { "type": "integer" },
"name": { "type": "string" },
"code": { "type": "string" },
"genericName": { "type": "string" },
"companyName": { "type": "string" },
"description": { "type": "string" },
"status": { "type": "integer" },
"sortOrder": { "type": "integer" }
}
}
}
```
## 脚本映射
- `../../scripts/drug/drug-list.py`
FILE:openapi/drug/scene-doctor-titles.md
# GET https://sg-cwork-web.mediportal.com.cn/tbs/scene/{sceneId}/doctor-titles
## 作用
根据场景ID获取该场景下默认医生的职称列表。
**鉴权类型**
- `nologin`
**Headers**
- `Content-Type: application/json`
**Path 参数**
| 字段 | 类型 | 必填 | 说明 |
|---|---|---|---|
| `sceneId` | integer | 是 | 场景ID |
## 响应 Schema
```json
{
"$schema": "http://json-schema.org/draft-07/schema#",
"type": "object",
"properties": {
"resultCode": { "type": "integer" },
"resultMsg": { "type": "string" },
"data": { "type": "string" }
}
}
```
## 脚本映射
- `../../scripts/drug/scene-doctor-titles.py`
FILE:openapi/drug/scene-hotwords.md
# GET https://sg-cwork-web.mediportal.com.cn/tbs/scene/{sceneId}/hotwords
## 作用
根据场景ID获取该场景的热词列表。返回格式:词1|5,词2|5(权重固定为5);若未配置则返回空字符串。
**鉴权类型**
- `nologin`
**Headers**
- `Content-Type: application/json`
**Path 参数**
| 字段 | 类型 | 必填 | 说明 |
|---|---|---|---|
| `sceneId` | integer | 是 | 场景ID |
## 响应 Schema
```json
{
"$schema": "http://json-schema.org/draft-07/schema#",
"type": "object",
"properties": {
"resultCode": { "type": "integer" },
"resultMsg": { "type": "string" },
"data": { "type": "string" }
}
}
```
## 脚本映射
- `../../scripts/drug/scene-hotwords.py`
FILE:openapi/drug/scene-list-by-drug.md
# GET https://sg-cwork-web.mediportal.com.cn/tbs/training-flow/nologin/scenes
## 作用
根据药品ID获取已发布的场景列表。
**鉴权类型**
- `nologin`
**Headers**
- `Content-Type: application/json`
**Query 参数**
| 字段 | 类型 | 必填 | 说明 |
|---|---|---|---|
| `drugId` | integer | 是 | 药品ID |
## 请求 Schema
```json
{
"$schema": "http://json-schema.org/draft-07/schema#",
"type": "object",
"required": ["drugId"],
"properties": {
"drugId": { "type": "integer" }
}
}
```
## 响应 Schema
```json
{
"$schema": "http://json-schema.org/draft-07/schema#",
"type": "array",
"items": {
"type": "object",
"properties": {
"id": { "type": "integer" },
"title": { "type": "string" },
"status": { "type": "integer" },
"drugId": { "type": "integer" },
"drugName": { "type": "string" },
"externalId": { "type": "string" },
"departmentId": { "type": "integer" },
"departmentName": { "type": "string" },
"diseaseId": { "type": "integer" },
"diseaseName": { "type": "string" },
"location": { "type": "string" },
"repBriefing": { "type": "string" }
}
}
}
```
## 脚本映射
- `../../scripts/drug/scene-list-by-drug.py`
FILE:openapi/drug/scene-list.md
# GET https://sg-cwork-web.mediportal.com.cn/tbs/scene/list-by-drug-external-id
## 作用
根据药品的 external_id 列表获取已发布的场景列表。返回场景ID、标题、科室、药品、难度、状态等信息。
**鉴权类型**
- `nologin`
**Headers**
- `Content-Type: application/json`
**Query 参数**
| 字段 | 类型 | 必填 | 说明 |
|---|---|---|---|
| `externalId` | string | 否 | 药品的external_id列表,多个用逗号分隔(不传则返回所有) |
| `corpId` | integer | 否 | 企业ID |
## 请求 Schema
```json
{
"$schema": "http://json-schema.org/draft-07/schema#",
"type": "object",
"properties": {
"externalId": { "type": "string" },
"corpId": { "type": "integer" }
}
}
```
## 响应 Schema
```json
{
"$schema": "http://json-schema.org/draft-07/schema#",
"type": "array",
"items": {
"type": "object",
"properties": {
"id": { "type": "integer" },
"title": { "type": "string" },
"status": { "type": "integer" },
"drugId": { "type": "integer" },
"drugName": { "type": "string" },
"externalId": { "type": "string" },
"departmentId": { "type": "integer" },
"departmentName": { "type": "string" },
"diseaseId": { "type": "integer" },
"diseaseName": { "type": "string" },
"location": { "type": "string" },
"repBriefing": { "type": "string" }
}
}
}
```
## 脚本映射
- `../../scripts/drug/scene-list.py`
FILE:openapi/feedback/api-index.md
# API 索引 — feedback
接口列表:
1. `GET /tbs/feedback/nologin/app-detail`
- 文档:`./app-detail.md`
2. `GET /tbs/feedback/nologin/gpt-id`
- 文档:`./gpt-id.md`
脚本映射:
- `../../scripts/feedback/README.md`
FILE:openapi/feedback/app-detail.md
# GET https://sg-cwork-web.mediportal.com.cn/tbs/feedback/nologin/app-detail
## 作用
获取反馈功能的应用详情(返回反馈功能的应用ID等信息)。
**鉴权类型**
- `nologin`
**Headers**
- `Content-Type: application/json`
## 响应 Schema
```json
{
"$schema": "http://json-schema.org/draft-07/schema#",
"type": "object",
"properties": {
"resultCode": { "type": "integer" },
"resultMsg": { "type": "string" },
"data": {
"type": "object",
"properties": {
"appId": { "type": "string" }
}
}
}
}
```
## 脚本映射
- `../../scripts/feedback/app-detail.py`
FILE:openapi/feedback/gpt-id.md
# GET https://sg-cwork-web.mediportal.com.cn/tbs/feedback/nologin/gpt-id
## 作用
获取反馈功能的GPT ID。type不传则返回默认反馈GPT;type=speech返回PPT演讲反馈GPT。
**鉴权类型**
- `nologin`
**Headers**
- `Content-Type: application/json`
**Query 参数**
| 字段 | 类型 | 必填 | 说明 |
|---|---|---|---|
| `type` | string | 否 | 类型:speech返回PPT演讲反馈GPT |
## 请求 Schema
```json
{
"$schema": "http://json-schema.org/draft-07/schema#",
"type": "object",
"properties": {
"type": { "type": "string" }
}
}
```
## 响应 Schema
```json
{
"$schema": "http://json-schema.org/draft-07/schema#",
"type": "object",
"properties": {
"resultCode": { "type": "integer" },
"resultMsg": { "type": "string" },
"data": {
"type": "object",
"properties": {
"gptId": { "type": "string" }
}
}
}
}
```
## 脚本映射
- `../../scripts/feedback/gpt-id.py`
FILE:openapi/file/api-index.md
# API 索引 — file
接口列表:
1. `GET /tbs/file/upload-by-url`
- 文档:`./upload-by-url.md`
脚本映射:
- `../../scripts/file/README.md`
FILE:openapi/file/upload-by-url.md
# GET https://sg-cwork-web.mediportal.com.cn/tbs/file/upload-by-url
## 作用
通过URL上传文件,返回文件ID、名称、URL、大小、时长等信息。
**鉴权类型**
- `access-token`
**Headers**
- `access-token`
- `Content-Type: application/json`
**Query 参数**
| 字段 | 类型 | 必填 | 说明 |
|---|---|---|---|
| `fileUrl` | string | 是 | 文件URL地址 |
## 请求 Schema
```json
{
"$schema": "http://json-schema.org/draft-07/schema#",
"type": "object",
"required": ["fileUrl"],
"properties": {
"fileUrl": { "type": "string" }
}
}
```
## 响应 Schema
```json
{
"$schema": "http://json-schema.org/draft-07/schema#",
"type": "object",
"properties": {
"resultCode": { "type": "integer" },
"resultMsg": { "type": "string" },
"data": {
"type": "object",
"properties": {
"fileId": { "type": "integer" },
"name": { "type": "string" },
"url": { "type": "string" },
"fsize": { "type": "integer" },
"duration": { "type": "integer" }
}
}
}
}
```
## 脚本映射
- `../../scripts/file/upload-by-url.py`
FILE:openapi/gpts/api-index.md
# API 索引 — gpts
接口列表:
1. `GET /gpts/gptApp/getDetailByIdV2`
- 文档:`./app-detail.md`
2. `POST /gpts/session/getSessionByBusinessId`
- 文档:`./session.md`
3. `POST /gpts/sseClient/ai/suggest`
- 文档:`./sse-suggest.md`
4. `POST /gpts/accessToken/delUserToken`
- 文档:`./del-user-token.md`
脚本映射:
- `../../scripts/gpts/README.md`
FILE:openapi/gpts/app-detail.md
# GET https://sg-al-cwork-web.mediportal.com.cn/gpts/gptApp/getDetailByIdV2
## 作用
获取GPT应用详情,用于训战初始化。
**鉴权类型**
- `access-token`
**Headers**
- `access-token`
- `Content-Type: application/json`
**Query 参数**
| 字段 | 类型 | 必填 | 说明 |
|---|---|---|---|
| `id` | string | 是 | 应用ID(appId) |
| `corpId` | string | 否 | 企业ID |
## 请求 Schema
```json
{
"$schema": "http://json-schema.org/draft-07/schema#",
"type": "object",
"required": ["id"],
"properties": {
"id": { "type": "string" },
"corpId": { "type": "string" }
}
}
```
## 响应 Schema
```json
{
"$schema": "http://json-schema.org/draft-07/schema#",
"type": "object",
"properties": {
"resultCode": { "type": "integer" },
"resultMsg": { "type": "string" },
"data": {
"type": "object",
"properties": {
"id": { "type": "string" },
"businessType": { "type": "string" }
}
}
}
}
```
## 脚本映射
- `../../scripts/gpts/app-detail.py`
FILE:openapi/gpts/del-user-token.md
# POST https://sg-al-cwork-web.mediportal.com.cn/gpts/accessToken/delUserToken
## 作用
释放并发token,用于训战结束后释放资源。
**鉴权类型**
- `access-token`
**Headers**
- `access-token`
- `Content-Type: application/json`
**Query 参数**
| 字段 | 类型 | 必填 | 说明 |
|---|---|---|---|
| `appId` | string | 是 | 应用ID |
## 请求 Schema
```json
{
"$schema": "http://json-schema.org/draft-07/schema#",
"type": "object",
"required": ["appId"],
"properties": {
"appId": { "type": "string" }
}
}
```
## 响应 Schema
```json
{
"$schema": "http://json-schema.org/draft-07/schema#",
"type": "object",
"properties": {
"resultCode": { "type": "integer" },
"resultMsg": { "type": "string" }
}
}
```
## 脚本映射
- `../../scripts/gpts/del-user-token.py`
FILE:openapi/gpts/session.md
# POST https://sg-al-cwork-web.mediportal.com.cn/gpts/session/getSessionByBusinessId
## 作用
获取或创建会话,返回 session.id 用于 SSE 通信。
**鉴权类型**
- `access-token`
**Headers**
- `access-token`
- `Content-Type: application/json`
**Body 参数**
| 字段 | 类型 | 必填 | 说明 |
|---|---|---|---|
| `appId` | string\|number | 是 | 应用ID |
| `businessId` | string | 是 | 业务ID |
| `businessType` | string | 是 | 业务类型 |
| `isForce` | boolean | 否 | 是否强制创建新会话 |
## 请求 Schema
```json
{
"$schema": "http://json-schema.org/draft-07/schema#",
"type": "object",
"required": ["appId", "businessId", "businessType"],
"properties": {
"appId": { "type": ["string", "integer"] },
"businessId": { "type": "string" },
"businessType": { "type": "string" },
"isForce": { "type": "boolean" }
}
}
```
## 响应 Schema
```json
{
"$schema": "http://json-schema.org/draft-07/schema#",
"type": "object",
"properties": {
"resultCode": { "type": "integer" },
"resultMsg": { "type": "string" },
"data": {
"type": "object",
"properties": {
"id": { "type": "string" }
}
}
}
}
```
## 脚本映射
- `../../scripts/gpts/session.py`
FILE:openapi/gpts/speech-feedback.md
# POST https://sg-cwork-web.mediportal.com.cn/tbs/gpts/nologin/speech/feedback
## 作用
PPT演讲反馈收集接口。
**鉴权类型**
- `nologin`
**Headers**
- `Content-Type: application/json`
**Body 参数**
| 字段 | 类型 | 必填 | 说明 |
|---|---|---|---|
| `request` | object | 是 | GPTS请求参数 |
| `request.content` | string | 否 | 用户输入内容 |
| `request.msgList` | array | 否 | 历史消息列表 |
| `request.references` | array | 否 | 引用资源列表 |
| `request.sessionId` | string | 否 | 会话ID |
## 请求 Schema
```json
{
"$schema": "http://json-schema.org/draft-07/schema#",
"type": "object",
"required": ["request"],
"properties": {
"request": {
"type": "object",
"properties": {
"content": { "type": "string" },
"msgList": { "type": "array" },
"references": { "type": "array" },
"sessionId": { "type": "string" }
}
}
}
}
```
## 响应 Schema
```json
{
"$schema": "http://json-schema.org/draft-07/schema#",
"type": "object",
"properties": {
"timeout": { "type": "integer" }
}
}
```
## 脚本映射
- `../../scripts/gpts/speech-feedback.py`
FILE:openapi/gpts/sse-suggest.md
# POST https://sg-al-cwork-web.mediportal.com.cn/gpts/sseClient/ai/suggest
## 作用
SSE核心接口,用于训战对话交互。支持的 action:
- `start_training`:开始训练
- `submit_dialogue`:提交对话
- `generate_ai_comment`:生成AI点评
- `next_level`:下一关
**鉴权类型**
- `access-token`
**Headers(必须)**
```json
{
"accept": "text/event-stream",
"appkey": "WtEkxhUvmHhiE4wjaYTaVcHWKiYTiLJ8",
"content-type": "application/json",
"access-token": "动态获取",
"corpid": "动态获取(data.corpId)",
"employeeid": "动态获取(data.empId)",
"personid": "动态获取(data.personId)"
}
```
**Body 参数(统一格式)**
```json
{
"sessionId": "会话ID",
"content": "内容",
"extMap": { ... },
"msgList": [],
"appId": "应用ID"
}
```
### start_training
```json
{
"sessionId": "会话ID",
"content": "进入训练",
"extMap": {
"action": "start_training",
"sceneId": "场景ID",
"doctorId": null,
"sourceId": 58,
"cloudCategoryId": "124",
"trainingType": "practice",
"mode": true
},
"msgList": [],
"appId": "应用ID"
}
```
**注意**:
- `doctorId` 必须是 `null`,不是空字符串 `""`
- `sourceId` 必须是**数字** `58`,不是字符串 `"58"`
### submit_dialogue
```json
{
"sessionId": "会话ID",
"content": "用户回答内容",
"extMap": {
"action": "submit_dialogue",
"trainingRecordId": "训练记录ID",
"type": "user",
"round": 1
},
"msgList": [],
"appId": "应用ID"
}
```
**注意**:`round` 从 1 开始,每轮递增
### generate_ai_comment
```json
{
"sessionId": "会话ID",
"content": "生成AI点评",
"extMap": {
"action": "generate_ai_comment",
"trainingRecordId": "训练记录ID"
},
"msgList": [],
"appId": "应用ID"
}
```
**注意**:`content` **不能为空**,必须是 `"生成AI点评"`
### next_level
```json
{
"sessionId": "会话ID",
"content": "下一关",
"extMap": {
"action": "next_level",
"trainingRecordId": "训练记录ID"
},
"msgList": [],
"appId": "应用ID"
}
```
## SSE 事件说明
SSE 返回以下事件类型:
- `info`:业务结构化数据(trainingRecordId、openingText、score 等)
- `message`:流式文本片段
- `error`:业务错误
- `success`:流式结束/成功通知
## 脚本映射
- `../../scripts/gpts/sse-suggest.py`
FILE:openapi/gpts/training-feedback.md
# POST https://sg-cwork-web.mediportal.com.cn/tbs/gpts/nologin/training/feedback
## 作用
训练反馈交互接口。
**鉴权类型**
- `nologin`
**Headers**
- `Content-Type: application/json`
**Body 参数**
| 字段 | 类型 | 必填 | 说明 |
|---|---|---|---|
| `request` | object | 是 | GPTS请求参数 |
| `request.content` | string | 否 | 用户输入内容 |
| `request.msgList` | array | 否 | 历史消息列表 |
| `request.references` | array | 否 | 引用资源列表 |
| `request.sessionId` | string | 否 | 会话ID |
## 请求 Schema
```json
{
"$schema": "http://json-schema.org/draft-07/schema#",
"type": "object",
"required": ["request"],
"properties": {
"request": {
"type": "object",
"properties": {
"content": { "type": "string" },
"msgList": { "type": "array" },
"references": { "type": "array" },
"sessionId": { "type": "string" }
}
}
}
}
```
## 响应 Schema
```json
{
"$schema": "http://json-schema.org/draft-07/schema#",
"type": "object",
"properties": {
"timeout": { "type": "integer" }
}
}
```
## 脚本映射
- `../../scripts/gpts/training-feedback.py`
FILE:openapi/gpts/training-interact.md
# POST https://sg-cwork-web.mediportal.com.cn/tbs/gpts/nologin/training/interact
## 作用
训练交互接口。
**鉴权类型**
- `nologin`
**Headers**
- `Content-Type: application/json`
**Body 参数**
| 字段 | 类型 | 必填 | 说明 |
|---|---|---|---|
| `request` | object | 是 | GPTS请求参数 |
| `request.content` | string | 否 | 用户输入内容 |
| `request.msgList` | array | 否 | 历史消息列表 |
| `request.references` | array | 否 | 引用资源列表 |
| `request.sessionId` | string | 否 | 会话ID |
## 请求 Schema
```json
{
"$schema": "http://json-schema.org/draft-07/schema#",
"type": "object",
"required": ["request"],
"properties": {
"request": {
"type": "object",
"properties": {
"content": { "type": "string" },
"msgList": { "type": "array" },
"references": { "type": "array" },
"sessionId": { "type": "string" }
}
}
}
}
```
## 响应 Schema
```json
{
"$schema": "http://json-schema.org/draft-07/schema#",
"type": "object",
"properties": {
"timeout": { "type": "integer" }
}
}
```
## 脚本映射
- `../../scripts/gpts/training-interact.py`
FILE:openapi/home/api-index.md
# API 索引 — home
接口列表:
1. `GET /tbs/home/summary`
- 文档:`./summary.md`
2. `GET /tbs/home/learning-videos`
- 文档:`./learning-videos.md`
3. `GET /tbs/home/product-scenes`
- 文档:`./product-scenes.md`
脚本映射:
- `../../scripts/home/README.md`
FILE:openapi/home/learning-videos.md
# GET https://sg-cwork-web.mediportal.com.cn/tbs/home/learning-videos
## 作用
获取首页视频学习任务列表,包含活动标题、学习任务标题、完成状态等信息。
**鉴权类型**
- `access-token`
**Headers**
- `access-token`
- `Content-Type: application/json`
## 响应 Schema
```json
{
"$schema": "http://json-schema.org/draft-07/schema#",
"type": "object",
"properties": {
"resultCode": { "type": "integer" },
"resultMsg": { "type": "string" },
"data": {
"type": "array",
"items": {
"type": "object",
"properties": {
"id": { "type": "integer" },
"activityId": { "type": "integer" },
"activityTitle": { "type": "string" },
"title": { "type": "string" },
"item": { "type": "string" },
"isCompleted": { "type": "boolean" },
"sortOrder": { "type": "integer" }
}
}
}
}
}
```
## 脚本映射
- `../../scripts/home/learning-videos.py`
FILE:openapi/home/product-scenes.md
# GET https://sg-cwork-web.mediportal.com.cn/tbs/home/product-scenes
## 作用
获取产品场景列表(含训战-练习按钮状态),返回场景ID、名称、类型、最高分、训战次数等信息。
**鉴权类型**
- `access-token`
**Headers**
- `access-token`
- `Content-Type: application/json`
**Query 参数**
| 字段 | 类型 | 必填 | 说明 |
|---|---|---|---|
| `productId` | integer | 否 | 产品ID(来自首页 product item 的 id 字段) |
| `activityId` | integer | 否 | 活动ID(来自首页 product item 的 activityId 字段) |
## 请求 Schema
```json
{
"$schema": "http://json-schema.org/draft-07/schema#",
"type": "object",
"properties": {
"productId": { "type": "integer" },
"activityId": { "type": "integer" }
}
}
```
## 响应 Schema
```json
{
"$schema": "http://json-schema.org/draft-07/schema#",
"type": "object",
"properties": {
"resultCode": { "type": "integer" },
"resultMsg": { "type": "string" },
"data": {
"type": "array",
"items": {
"type": "object",
"properties": {
"id": { "type": "string" },
"name": { "type": "string" },
"type": { "type": "string" },
"sceneType": { "type": "string" },
"productId": { "type": "string" },
"productName": { "type": "string" },
"activityId": { "type": "integer" },
"activityName": { "type": "string" },
"maxScore": { "type": ["integer", "null"] },
"passScore": { "type": "integer" },
"practiceCount": { "type": "integer" },
"showBattleButton": { "type": "boolean" },
"showPracticeButton": { "type": "boolean" }
}
}
}
}
}
```
## 脚本映射
- `../../scripts/home/product-scenes.py`
FILE:openapi/home/summary.md
# GET https://sg-cwork-web.mediportal.com.cn/tbs/home/summary
## 作用
获取首页训战摘要信息,包含本周训战统计数据(次数、均分、超越同事百分比)以及活动分类列表。
**鉴权类型**
- `access-token`
**Headers**
- `access-token`
- `Content-Type: application/json`
## 响应 Schema
```json
{
"$schema": "http://json-schema.org/draft-07/schema#",
"type": "object",
"properties": {
"resultCode": { "type": "integer" },
"resultMsg": { "type": "string" },
"data": {
"type": "object",
"properties": {
"stats": {
"type": "object",
"properties": {
"weeklyCount": { "type": "integer" },
"effectiveCount": { "type": "integer" },
"avgScore": { "type": "number" },
"beatRate": { "type": "integer" }
}
},
"categories": {
"type": "array",
"items": {
"type": "object",
"properties": {
"id": { "type": "string" },
"name": { "type": "string" },
"items": {
"type": "array",
"items": {
"type": "object",
"properties": {
"id": { "type": "string" },
"name": { "type": "string" },
"type": { "type": "string" },
"activityId": { "type": "integer" },
"activityName": { "type": "string" },
"productId": { "type": "string" },
"productName": { "type": "string" },
"sceneType": { "type": "string" },
"total": { "type": "integer" },
"completed": { "type": "integer" },
"maxScore": { "type": ["integer", "null"] },
"passScore": { "type": "integer" },
"practiceCount": { "type": "integer" },
"showBattleButton": { "type": "boolean" },
"showPracticeButton": { "type": "boolean" },
"badgeLabel": { "type": "string" },
"badgeType": { "type": "string" },
"lastTime": { "type": "string" }
}
}
}
}
}
}
}
}
}
}
```
## 脚本映射
- `../../scripts/home/summary.py`
FILE:openapi/learning/api-index.md
# API 索引 — learning
接口列表:
1. `GET /tbs/learning/video-detail`
- 文档:`./video-detail.md`
2. `GET /tbs/learning/video-progress`
- 文档:`./video-progress-get.md`
3. `POST /tbs/learning/video-progress`
- 文档:`./video-progress-save.md`
脚本映射:
- `../../scripts/learning/README.md`
FILE:openapi/learning/video-detail.md
# GET https://sg-cwork-web.mediportal.com.cn/tbs/learning/video-detail
## 作用
获取学习视频详情,包含视频标题、URL、缩略图、进度、完成状态等。
**鉴权类型**
- `access-token`
**Headers**
- `access-token`
- `Content-Type: application/json`
**Query 参数**
| 字段 | 类型 | 必填 | 说明 |
|---|---|---|---|
| `learningItemId` | integer | 是 | 学习任务ID |
## 请求 Schema
```json
{
"$schema": "http://json-schema.org/draft-07/schema#",
"type": "object",
"required": ["learningItemId"],
"properties": {
"learningItemId": { "type": "integer" }
}
}
```
## 响应 Schema
```json
{
"$schema": "http://json-schema.org/draft-07/schema#",
"type": "object",
"properties": {
"resultCode": { "type": "integer" },
"resultMsg": { "type": "string" },
"data": {
"type": "object",
"properties": {
"learningItemId": { "type": "integer" },
"title": { "type": "string" },
"fileName": { "type": "string" },
"downloadUrl": { "type": "string" },
"thumbnailUrl": { "type": "string" },
"size": { "type": "integer" },
"suffix": { "type": "string" },
"progress": { "type": "number" },
"pageIndex": { "type": "integer" },
"isCompleted": { "type": "boolean" },
"activityId": { "type": "integer" },
"activityTitle": { "type": "string" },
"openWith": { "type": "integer" }
}
}
}
}
```
## 脚本映射
- `../../scripts/learning/video-detail.py`
FILE:openapi/learning/video-progress-get.md
# GET https://sg-cwork-web.mediportal.com.cn/tbs/learning/video-progress
## 作用
查询视频播放进度。
**鉴权类型**
- `access-token`
**Headers**
- `access-token`
- `Content-Type: application/json`
**Query 参数**
| 字段 | 类型 | 必填 | 说明 |
|---|---|---|---|
| `learningItemId` | integer | 是 | 学习任务ID |
## 请求 Schema
```json
{
"$schema": "http://json-schema.org/draft-07/schema#",
"type": "object",
"required": ["learningItemId"],
"properties": {
"learningItemId": { "type": "integer" }
}
}
```
## 响应 Schema
```json
{
"$schema": "http://json-schema.org/draft-07/schema#",
"type": "object",
"properties": {
"resultCode": { "type": "integer" },
"resultMsg": { "type": "string" },
"data": {
"type": "object",
"properties": {
"learningItemId": { "type": "integer" },
"progress": { "type": "number" },
"pageIndex": { "type": "integer" },
"isCompleted": { "type": "boolean" }
}
}
}
}
```
## 脚本映射
- `../../scripts/learning/video-progress-get.py`
FILE:openapi/learning/video-progress-save.md
# POST https://sg-cwork-web.mediportal.com.cn/tbs/learning/video-progress
## 作用
保存视频播放进度。
**鉴权类型**
- `access-token`
**Headers**
- `access-token`
- `Content-Type: application/json`
**Body 参数**
| 字段 | 类型 | 必填 | 说明 |
|---|---|---|---|
| `learningItemId` | integer | 是 | 学习任务ID |
| `pageIndex` | integer | 是 | 当前播放到的片段索引(从0开始) |
| `progress` | number | 是 | 当前片段已播放时长(秒),支持小数 |
| `isCompleted` | boolean | 否 | 是否已完成观看 |
## 请求 Schema
```json
{
"$schema": "http://json-schema.org/draft-07/schema#",
"type": "object",
"required": ["learningItemId", "pageIndex", "progress"],
"properties": {
"learningItemId": { "type": "integer" },
"pageIndex": { "type": "integer" },
"progress": { "type": "number" },
"isCompleted": { "type": "boolean" }
}
}
```
## 响应 Schema
```json
{
"$schema": "http://json-schema.org/draft-07/schema#",
"type": "object",
"properties": {
"resultCode": { "type": "integer" },
"resultMsg": { "type": "string" }
}
}
```
## 脚本映射
- `../../scripts/learning/video-progress-save.py`
FILE:openapi/prepare/api-index.md
# API 索引 — prepare
接口列表:
1. `POST /tbs/training-prepare/get-opening-guidance`
- 文档:`./opening-guidance.md`
2. `DELETE /tbs/training-prepare/clear-opening-guidance-cache/inner`
- 文档:`./opening-guidance-clear.md`
脚本映射:
- `../../scripts/prepare/README.md`
FILE:openapi/prepare/opening-guidance-clear.md
# DELETE https://sg-cwork-web.mediportal.com.cn/tbs/training-prepare/clear-opening-guidance-cache/inner
## 作用
清空开场指导缓存。
**鉴权类型**
- `access-token`
**Headers**
- `access-token`
- `Content-Type: application/json`
**Query 参数**
| 字段 | 类型 | 必填 | 说明 |
|---|---|---|---|
| `sceneId` | string | 是 | 场景ID |
| `doctorId` | integer | 否 | 医生ID(不传则清空该场景下所有医生的缓存) |
## 请求 Schema
```json
{
"$schema": "http://json-schema.org/draft-07/schema#",
"type": "object",
"required": ["sceneId"],
"properties": {
"sceneId": { "type": "string" },
"doctorId": { "type": "integer" }
}
}
```
## 响应 Schema
```json
{
"$schema": "http://json-schema.org/draft-07/schema#",
"type": "object",
"properties": {
"resultCode": { "type": "integer" },
"resultMsg": { "type": "string" }
}
}
```
## 脚本映射
- `../../scripts/prepare/opening-guidance-clear.py`
FILE:openapi/prepare/opening-guidance.md
# POST https://sg-cwork-web.mediportal.com.cn/tbs/training-prepare/get-opening-guidance
## 作用
获取开场指导,包含策略建议、可选开场话术列表、洞察信息等。
**鉴权类型**
- `access-token`
**Headers**
- `access-token`
- `Content-Type: application/json`
**Body 参数**
| 字段 | 类型 | 必填 | 说明 |
|---|---|---|---|
| `sceneId` | string | 是 | 场景ID |
## 请求 Schema
```json
{
"$schema": "http://json-schema.org/draft-07/schema#",
"type": "object",
"required": ["sceneId"],
"properties": {
"sceneId": { "type": "string" }
}
}
```
## 响应 Schema
```json
{
"$schema": "http://json-schema.org/draft-07/schema#",
"type": "object",
"properties": {
"resultCode": { "type": "integer" },
"resultMsg": { "type": "string" },
"data": {
"type": "object",
"properties": {
"strategy": {
"type": "object",
"properties": {
"primaryGoal": { "type": "string" },
"verificationQuestions": { "type": "array" }
}
},
"options": {
"type": "array",
"items": {
"type": "object",
"properties": {
"label": { "type": "string" },
"text": { "type": "string" }
}
}
},
"insight": {
"type": "object",
"properties": {
"personaSummary": { "type": "string" },
"mentorObservation": { "type": "string" }
}
}
}
}
}
}
```
## 脚本映射
- `../../scripts/prepare/opening-guidance.py`
FILE:openapi/scene-image/api-index.md
# API 索引 — scene-image
接口列表:
1. `POST /tbs/scene-image/reset`
- 文档:`./reset.md`
脚本映射:
- `../../scripts/scene-image/README.md`
FILE:openapi/scene-image/reset.md
# POST https://sg-cwork-web.mediportal.com.cn/tbs/scene-image/reset
## 作用
重置场景图片。
**鉴权类型**
- `access-token`
**Headers**
- `access-token`
- `Content-Type: application/json`
**Body 参数**
| 字段 | 类型 | 必填 | 说明 |
|---|---|---|---|
| `sceneId` | string | 是 | 场景ID |
| `imageType` | string | 是 | 图片类型(SCENE_IMAGE-场景图/DIALOGUE_IMAGE-对话图) |
| `imageUrl` | string | 是 | 图片URL |
## 请求 Schema
```json
{
"$schema": "http://json-schema.org/draft-07/schema#",
"type": "object",
"required": ["sceneId", "imageType", "imageUrl"],
"properties": {
"sceneId": { "type": "string" },
"imageType": { "type": "string" },
"imageUrl": { "type": "string" }
}
}
```
## 响应 Schema
```json
{
"$schema": "http://json-schema.org/draft-07/schema#",
"type": "object",
"properties": {
"resultCode": { "type": "integer" },
"resultMsg": { "type": "string" }
}
}
```
## 脚本映射
- `../../scripts/scene-image/reset.py`
FILE:openapi/speech/api-index.md
# API 索引 — speech
接口列表:
1. `GET /tbs/speech/detail`
- 文档:`./speech-detail.md`
2. `POST /tbs/speech/finish`
- 文档:`./speech-finish.md`
3. `GET /tbs/speech/records/{trainingRecordId}`
- 文档:`./speech-records.md`
脚本映射:
- `../../scripts/speech/README.md`
FILE:openapi/speech/speech-detail.md
# GET https://sg-cwork-web.mediportal.com.cn/tbs/speech/detail
## 作用
获取PPT场景详情,包含PPT标题、URL、评估提示词、评分维度、建议时长等信息。
**鉴权类型**
- `access-token`
**Headers**
- `access-token`
- `Content-Type: application/json`
**Query 参数**
| 字段 | 类型 | 必填 | 说明 |
|---|---|---|---|
| `sceneId` | integer | 是 | 场景ID |
| `activityId` | integer | 否 | 活动ID(t_training_activity_scene.activity_id) |
## 请求 Schema
```json
{
"$schema": "http://json-schema.org/draft-07/schema#",
"type": "object",
"required": ["sceneId"],
"properties": {
"sceneId": { "type": "integer" },
"activityId": { "type": "integer" }
}
}
```
## 响应 Schema
```json
{
"$schema": "http://json-schema.org/draft-07/schema#",
"type": "object",
"properties": {
"resultCode": { "type": "integer" },
"resultMsg": { "type": "string" },
"data": {
"type": "object",
"properties": {
"sceneId": { "type": "integer" },
"pptTaskId": { "type": "string" },
"pptTitle": { "type": "string" },
"pptUrl": { "type": "string" },
"evalPrompt": { "type": "string" },
"scoringDimensions": { "type": "array" },
"passScore": { "type": "integer" },
"suggestedDuration": { "type": "integer" },
"repBriefing": { "type": "string" },
"fileContentJson": { "type": "object" }
}
}
}
}
```
## 脚本映射
- `../../scripts/speech/speech-detail.py`
FILE:openapi/speech/speech-finish.md
# POST https://sg-cwork-web.mediportal.com.cn/tbs/speech/finish
## 作用
完成演讲并生成复盘。提交演讲结果后返回综合评分和训练记录ID。
**鉴权类型**
- `access-token`
**Headers**
- `access-token`
- `Content-Type: application/json`
**Body 参数**
| 字段 | 类型 | 必填 | 说明 |
|---|---|---|---|
| `sceneId` | integer | 是 | 场景ID |
| `activityId` | integer | 否 | 活动ID |
| `totalDurationSeconds` | integer | 否 | 总时长(秒) |
| `sourceType` | string | 否 | 来源类型:practice/battle |
| `mode` | integer | 否 | 模式:1-practice 0-battle |
| `pages` | array | 否 | 每页结果 |
## 请求 Schema
```json
{
"$schema": "http://json-schema.org/draft-07/schema#",
"type": "object",
"required": ["sceneId"],
"properties": {
"sceneId": { "type": "integer" },
"activityId": { "type": "integer" },
"totalDurationSeconds": { "type": "integer" },
"sourceType": { "type": "string" },
"mode": { "type": "integer" },
"pages": {
"type": "array",
"items": {
"type": "object",
"properties": {
"pageIndex": { "type": "integer" },
"pageTitle": { "type": "string" },
"transcriptText": { "type": "string" },
"audioUrl": { "type": "string" },
"durationSeconds": { "type": "integer" }
}
}
}
}
}
```
## 响应 Schema
```json
{
"$schema": "http://json-schema.org/draft-07/schema#",
"type": "object",
"properties": {
"resultCode": { "type": "integer" },
"resultMsg": { "type": "string" },
"data": {
"type": "object",
"properties": {
"score": { "type": "integer" },
"trainingRecordId": { "type": "integer" }
}
}
}
}
```
## 脚本映射
- `../../scripts/speech/speech-finish.py`
FILE:openapi/speech/speech-records.md
# GET https://sg-cwork-web.mediportal.com.cn/tbs/speech/records/{trainingRecordId}
## 作用
获取演讲记录详情,包含综合评分、各维度评分、亮点、改进建议、每页回顾等信息。
**鉴权类型**
- `access-token`
**Headers**
- `access-token`
- `Content-Type: application/json`
**Path 参数**
| 字段 | 类型 | 必填 | 说明 |
|---|---|---|---|
| `trainingRecordId` | integer | 是 | 训练记录ID |
## 响应 Schema
```json
{
"$schema": "http://json-schema.org/draft-07/schema#",
"type": "object",
"properties": {
"resultCode": { "type": "integer" },
"resultMsg": { "type": "string" },
"data": {
"type": "object",
"properties": {
"trainingRecordId": { "type": "integer" },
"sceneId": { "type": "integer" },
"sceneTitle": { "type": "string" },
"drugName": { "type": "string" },
"score": { "type": "integer" },
"isPassed": { "type": "boolean" },
"durationSeconds": { "type": "integer" },
"sourceType": { "type": "string" },
"sourceId": { "type": "integer" },
"createTime": { "type": "string" },
"highlights": { "type": "array" },
"improvements": { "type": "array" },
"reviewSummary": { "type": "string" },
"scoreDimensions": { "type": "object" },
"pages": {
"type": "array",
"items": {
"type": "object",
"properties": {
"pageIndex": { "type": "integer" },
"pageTitle": { "type": "string" },
"transcriptText": { "type": "string" },
"audioUrl": { "type": "string" },
"durationSeconds": { "type": "integer" },
"pageScore": { "type": "integer" },
"pageReviewText": { "type": "string" },
"refPageSnapshot": { "type": "string" }
}
}
}
}
}
}
}
```
## 脚本映射
- `../../scripts/speech/speech-records.py`
FILE:openapi/training/api-index.md
# API 索引 — training
接口列表:
1. `GET /tbs/training/my-stats`
- 文档:`./my-stats.md`
2. `GET /tbs/training/records`
- 文档:`./records.md`
3. `GET /tbs/training/records/{id}`
- 文档:`./records-detail.md`
脚本映射:
- `../../scripts/training/README.md`
FILE:openapi/training/my-stats.md
# GET https://sg-cwork-web.mediportal.com.cn/tbs/training/my-stats
## 作用
获取当前用户的训战统计数据。
**鉴权类型**
- `access-token`
**Headers**
- `access-token`
- `Content-Type: application/json`
## 响应 Schema
```json
{
"$schema": "http://json-schema.org/draft-07/schema#",
"type": "object",
"properties": {
"resultCode": { "type": "integer" },
"resultMsg": { "type": "string" },
"data": { "type": "object" }
}
}
```
## 脚本映射
- `../../scripts/training/my-stats.py`
FILE:openapi/training/records-detail.md
# GET https://sg-cwork-web.mediportal.com.cn/tbs/training/records/{id}
## 作用
获取训战记录详情(含对话回溯),返回记录详细信息、对话消息列表、评分、亮点、改进建议等。
**鉴权类型**
- `access-token`
**Headers**
- `access-token`
- `Content-Type: application/json`
**Path 参数**
| 字段 | 类型 | 必填 | 说明 |
|---|---|---|---|
| `id` | integer | 是 | 训战记录ID |
## 响应 Schema
```json
{
"$schema": "http://json-schema.org/draft-07/schema#",
"type": "object",
"properties": {
"resultCode": { "type": "integer" },
"resultMsg": { "type": "string" },
"data": {
"type": "object",
"properties": {
"id": { "type": "string" },
"sceneId": { "type": "integer" },
"sceneTitle": { "type": "string" },
"sceneName": { "type": "string" },
"sceneType": { "type": "string" },
"drugName": { "type": "string" },
"departmentName": { "type": "string" },
"difficulty": { "type": "string" },
"score": { "type": "integer" },
"totalScore": { "type": "integer" },
"durationSeconds": { "type": "integer" },
"sourceType": { "type": "string" },
"sourceId": { "type": "integer" },
"cloudCategoryId": { "type": "string" },
"startTime": { "type": "string" },
"summary": { "type": "string" },
"reviewSummary": { "type": "string" },
"highlights": { "type": "array" },
"improvements": { "type": "array" },
"scoreDimensions": { "type": "string" },
"dialogueMessages": {
"type": "array",
"items": {
"type": "object",
"properties": {
"round": { "type": "integer" },
"sequence": { "type": "integer" },
"role": { "type": "string" },
"type": { "type": "string" },
"content": { "type": "string" },
"aiResponse": { "type": "string" },
"replyType": { "type": "string" }
}
}
}
}
}
}
}
```
## 脚本映射
- `../../scripts/training/records-detail.py`
FILE:openapi/training/records.md
# GET https://sg-cwork-web.mediportal.com.cn/tbs/training/records
## 作用
获取训战记录列表(分页),返回记录ID、场景、分数、时长、状态等信息。
**鉴权类型**
- `access-token`
**Headers**
- `access-token`
- `Content-Type: application/json`
**Query 参数**
| 字段 | 类型 | 必填 | 说明 |
|---|---|---|---|
| `page` | integer | 否 | 页码,从1开始 |
| `size` | integer | 否 | 每页条数 |
| `sourceType` | string | 否 | 来源类型过滤:battle-训战,practice-练习,不传则全部 |
## 请求 Schema
```json
{
"$schema": "http://json-schema.org/draft-07/schema#",
"type": "object",
"properties": {
"page": { "type": "integer" },
"size": { "type": "integer" },
"sourceType": { "type": "string" }
}
}
```
## 响应 Schema
```json
{
"$schema": "http://json-schema.org/draft-07/schema#",
"type": "object",
"properties": {
"resultCode": { "type": "integer" },
"resultMsg": { "type": "string" },
"data": { "type": "object" }
}
}
```
## 脚本映射
- `../../scripts/training/records.py`
FILE:openapi/training-flow/api-index.md
# API 索引 — training-flow
接口列表:
1. `GET /tbs/training-flow/nologin/drugs`
- 文档:`./drugs.md`
2. `GET /tbs/training-flow/nologin/records`
- 文档:`./records.md`
3. `GET /tbs/training-flow/nologin/scenes`
- 文档:`./scenes.md`
脚本映射:
- `../../scripts/training-flow/README.md`
FILE:openapi/training-flow/drugs.md
# GET https://sg-cwork-web.mediportal.com.cn/tbs/training-flow/nologin/drugs
## 作用
获取所有启用的药品列表(公开接口)。
**鉴权类型**
- `nologin`
**Headers**
- `Content-Type: application/json`
## 响应 Schema
```json
{
"$schema": "http://json-schema.org/draft-07/schema#",
"type": "array",
"items": {
"type": "object",
"properties": {
"id": { "type": "integer" },
"name": { "type": "string" },
"code": { "type": "string" },
"genericName": { "type": "string" },
"companyName": { "type": "string" },
"description": { "type": "string" },
"status": { "type": "integer" }
}
}
}
```
## 脚本映射
- `../../scripts/training-flow/drugs.py`
FILE:openapi/training-flow/records.md
# GET https://sg-cwork-web.mediportal.com.cn/tbs/training-flow/nologin/records
## 作用
根据场景ID获取训练记录列表(支持按用户姓名查询、按日期查询、按时间倒序、分页)。
**鉴权类型**
- `nologin`
**Headers**
- `Content-Type: application/json`
**Query 参数**
| 字段 | 类型 | 必填 | 说明 |
|---|---|---|---|
| `sceneId` | integer | 是 | 场景ID |
| `pageNum` | integer | 否 | 页码(从1开始) |
| `pageSize` | integer | 否 | 每页数量 |
| `userName` | string | 否 | 用户姓名(用于模糊查询) |
| `startDate` | string | 否 | 开始日期(格式:yyyy-MM-dd) |
| `endDate` | string | 否 | 结束日期(格式:yyyy-MM-dd) |
## 请求 Schema
```json
{
"$schema": "http://json-schema.org/draft-07/schema#",
"type": "object",
"required": ["sceneId"],
"properties": {
"sceneId": { "type": "integer" },
"pageNum": { "type": "integer" },
"pageSize": { "type": "integer" },
"userName": { "type": "string" },
"startDate": { "type": "string" },
"endDate": { "type": "string" }
}
}
```
## 响应 Schema
```json
{
"$schema": "http://json-schema.org/draft-07/schema#",
"type": "array",
"items": {
"type": "object",
"properties": {
"id": { "type": "integer" },
"sceneId": { "type": "integer" },
"sceneTitle": { "type": "string" },
"userId": { "type": "integer" },
"userName": { "type": "string" },
"userDepartmentName": { "type": "string" },
"userTitle": { "type": "string" },
"totalScore": { "type": "integer" },
"durationSeconds": { "type": "integer" },
"status": { "type": "string" },
"startTime": { "type": "string" },
"endTime": { "type": "string" },
"difficulty": { "type": "string" },
"drugName": { "type": "string" },
"departmentName": { "type": "string" },
"personaName": { "type": "string" },
"trainingType": { "type": "string" }
}
}
}
```
## 脚本映射
- `../../scripts/training-flow/records.py`
FILE:openapi/training-flow/scenes.md
# GET https://sg-cwork-web.mediportal.com.cn/tbs/training-flow/nologin/scenes
## 作用
根据药品ID获取已发布的场景列表(公开接口)。
**鉴权类型**
- `nologin`
**Headers**
- `Content-Type: application/json`
**Query 参数**
| 字段 | 类型 | 必填 | 说明 |
|---|---|---|---|
| `drugId` | integer | 是 | 药品ID |
## 请求 Schema
```json
{
"$schema": "http://json-schema.org/draft-07/schema#",
"type": "object",
"required": ["drugId"],
"properties": {
"drugId": { "type": "integer" }
}
}
```
## 响应 Schema
```json
{
"$schema": "http://json-schema.org/draft-07/schema#",
"type": "array",
"items": {
"type": "object",
"properties": {
"id": { "type": "integer" },
"title": { "type": "string" },
"status": { "type": "integer" },
"drugId": { "type": "integer" },
"drugName": { "type": "string" },
"externalId": { "type": "string" },
"departmentId": { "type": "integer" },
"departmentName": { "type": "string" },
"diseaseId": { "type": "integer" },
"diseaseName": { "type": "string" },
"location": { "type": "string" },
"repBriefing": { "type": "string" }
}
}
}
```
## 脚本映射
- `../../scripts/training-flow/scenes.py`
FILE:scripts/basic/README.md
# 脚本清单 — basic
## 共享依赖
无
## 脚本列表
| 脚本 | 对应接口 | 用途 |
|---|---|---|
| `gpt-id.py` | `GET /tbs/basic-info/gpt-id` | 获取GPT ID,输出 JSON 结果 |
| `tts-config.py` | `GET /tbs/basic-info/tts-config` | 获取TTS配置,输出 JSON 结果 |
## 使用方式
```bash
# nologin 接口无需设置鉴权环境变量
python3 scripts/basic/gpt-id.py
python3 scripts/basic/tts-config.py
```
## 规范
1. **必须使用 Python** 编写
2. **鉴权遵循** `cms-auth-skills/common/auth.md` 规范
3. **重试策略**:间隔1秒、最多重试3次
FILE:scripts/basic/gpt-id.py
#!/usr/bin/env python3
"""
basic / gpt-id 脚本
用途:获取GPT ID
使用方式:
python3 scripts/basic/gpt-id.py
环境变量:
无(nologin接口)
"""
import sys
import os
import json
import urllib.request
import urllib.error
import ssl
# 接口完整 URL(与 openapi/basic/gpt-id.md 中声明的一致)
API_URL = "https://sg-cwork-web.mediportal.com.cn/tbs/basic-info/gpt-id"
AUTH_MODE = "nologin"
def build_headers() -> dict:
headers = {"Content-Type": "application/json"}
return headers
def call_api() -> dict:
headers = build_headers()
req = urllib.request.Request(API_URL, headers=headers, method="GET")
ctx = ssl.create_default_context()
ctx.check_hostname = False
ctx.verify_mode = ssl.CERT_NONE
for attempt in range(3):
try:
with urllib.request.urlopen(req, context=ctx, timeout=60) as resp:
return json.loads(resp.read().decode("utf-8"))
except urllib.error.URLError as e:
if attempt < 2:
import time
time.sleep(1)
continue
print(f"错误: 请求失败 - {e}", file=sys.stderr)
sys.exit(1)
def main():
result = call_api()
print(json.dumps(result, ensure_ascii=False))
if __name__ == "__main__":
main()
FILE:scripts/basic/tts-config.py
#!/usr/bin/env python3
"""
basic / tts-config 脚本
用途:获取TTS配置
使用方式:
python3 scripts/basic/tts-config.py
环境变量:
无(nologin接口)
"""
import sys
import os
import json
import urllib.request
import urllib.error
import ssl
# 接口完整 URL(与 openapi/basic/tts-config.md 中声明的一致)
API_URL = "https://sg-cwork-web.mediportal.com.cn/tbs/basic-info/tts-config"
AUTH_MODE = "nologin"
def build_headers() -> dict:
headers = {"Content-Type": "application/json"}
return headers
def call_api() -> dict:
headers = build_headers()
req = urllib.request.Request(API_URL, headers=headers, method="GET")
ctx = ssl.create_default_context()
ctx.check_hostname = False
ctx.verify_mode = ssl.CERT_NONE
for attempt in range(3):
try:
with urllib.request.urlopen(req, context=ctx, timeout=60) as resp:
return json.loads(resp.read().decode("utf-8"))
except urllib.error.URLError as e:
if attempt < 2:
import time
time.sleep(1)
continue
print(f"错误: 请求失败 - {e}", file=sys.stderr)
sys.exit(1)
def main():
result = call_api()
print(json.dumps(result, ensure_ascii=False))
if __name__ == "__main__":
main()
FILE:scripts/dialogue-flow/README.md
# 脚本清单 — dialogue-flow
## 共享依赖
无
## 脚本列表
| 脚本 | 对应接口 | 用途 |
|---|---|---|
| `get-flow-detail.py` | `GET /tbs/training-dialogue-flow/nologin/getFlowDetail` | 获取对话流程详情,输出 JSON 结果 |
## 使用方式
```bash
# nologin 接口无需设置鉴权环境变量
python3 scripts/dialogue-flow/get-flow-detail.py [sceneRecordId]
```
## 规范
1. **必须使用 Python** 编写
2. **鉴权遵循** `cms-auth-skills/common/auth.md` 规范
3. **重试策略**:间隔1秒、最多重试3次
FILE:scripts/dialogue-flow/get-flow-detail.py
#!/usr/bin/env python3
"""
dialogue-flow / get-flow-detail 脚本
用途:获取训练对话流程详情(可视化)
使用方式:
python3 scripts/dialogue-flow/get-flow-detail.py [sceneRecordId]
环境变量:
无(nologin接口)
参数:
sceneRecordId - 场景记录ID(可选)
"""
import sys
import os
import json
import urllib.request
import urllib.parse
import urllib.error
import ssl
# 接口完整 URL(与 openapi/dialogue-flow/get-flow-detail.md 中声明的一致)
API_URL = "https://sg-cwork-web.mediportal.com.cn/tbs/training-dialogue-flow/nologin/getFlowDetail"
AUTH_MODE = "nologin"
def build_headers() -> dict:
headers = {"Content-Type": "application/json"}
return headers
def call_api(scene_record_id: str = None) -> dict:
headers = build_headers()
url = API_URL
if scene_record_id:
url = f"{API_URL}?sceneRecordId={urllib.parse.quote(scene_record_id)}"
req = urllib.request.Request(url, headers=headers, method="GET")
ctx = ssl.create_default_context()
ctx.check_hostname = False
ctx.verify_mode = ssl.CERT_NONE
for attempt in range(3):
try:
with urllib.request.urlopen(req, context=ctx, timeout=60) as resp:
return json.loads(resp.read().decode("utf-8"))
except urllib.error.URLError as e:
if attempt < 2:
import time
time.sleep(1)
continue
print(f"错误: 请求失败 - {e}", file=sys.stderr)
sys.exit(1)
def main():
scene_record_id = sys.argv[1] if len(sys.argv) > 1 else None
result = call_api(scene_record_id)
print(json.dumps(result, ensure_ascii=False))
if __name__ == "__main__":
main()
FILE:scripts/drug/README.md
# 脚本清单 — drug
## 共享依赖
无
## 脚本列表
| 脚本 | 对应接口 | 用途 |
|---|---|---|
| `drug-list.py` | `GET /tbs/training-flow/nologin/drugs` | 获取药品列表,输出 JSON 结果 |
| `scene-list.py` | `GET /tbs/scene/list-by-drug-external-id` | 根据external_id获取场景列表,输出 JSON 结果 |
| `scene-list-by-drug.py` | `GET /tbs/training-flow/nologin/scenes` | 根据药品ID获取场景列表,输出 JSON 结果 |
| `scene-doctor-titles.py` | `GET /tbs/scene/{sceneId}/doctor-titles` | 获取场景职称列表,输出 JSON 结果 |
| `scene-hotwords.py` | `GET /tbs/scene/{sceneId}/hotwords` | 获取场景热词列表,输出 JSON 结果 |
## 使用方式
```bash
# nologin 接口无需设置鉴权环境变量
# 执行脚本
python3 scripts/drug/drug-list.py
python3 scripts/drug/scene-list.py [externalId] [corpId]
python3 scripts/drug/scene-list-by-drug.py <drugId>
python3 scripts/drug/scene-doctor-titles.py <sceneId>
python3 scripts/drug/scene-hotwords.py <sceneId>
```
## 输出说明
所有脚本的输出均为 **JSON 格式**。
## 规范
1. **必须使用 Python** 编写
2. **鉴权遵循** `cms-auth-skills/common/auth.md` 规范
3. **入参定义以** `openapi/` 文档为准
4. **重试策略**:间隔1秒、最多重试3次
FILE:scripts/drug/drug-list.py
#!/usr/bin/env python3
"""
drug / drug-list 脚本
用途:获取所有已启用的药品列表
使用方式:
python3 scripts/drug/drug-list.py
环境变量:
无(nologin接口)
"""
import sys
import os
import json
import urllib.request
import urllib.error
import ssl
# 接口完整 URL(与 openapi/drug/drug-list.md 中声明的一致)
API_URL = "https://sg-cwork-web.mediportal.com.cn/tbs/training-flow/nologin/drugs"
AUTH_MODE = "nologin"
def build_headers() -> dict:
"""根据鉴权模式构造请求头"""
headers = {"Content-Type": "application/json"}
return headers
def call_api() -> dict:
"""调用接口,返回原始 JSON 响应"""
headers = build_headers()
req = urllib.request.Request(API_URL, headers=headers, method="GET")
ctx = ssl.create_default_context()
ctx.check_hostname = False
ctx.verify_mode = ssl.CERT_NONE
# 重试策略:间隔1秒,最多重试3次
for attempt in range(3):
try:
with urllib.request.urlopen(req, context=ctx, timeout=60) as resp:
return json.loads(resp.read().decode("utf-8"))
except urllib.error.URLError as e:
if attempt < 2:
import time
time.sleep(1)
continue
print(f"错误: 请求失败 - {e}", file=sys.stderr)
sys.exit(1)
def main():
# 调用接口,获取原始 JSON
result = call_api()
# 输出结果
print(json.dumps(result, ensure_ascii=False))
if __name__ == "__main__":
main()
FILE:scripts/drug/scene-doctor-titles.py
#!/usr/bin/env python3
"""
drug / scene-doctor-titles 脚本
用途:根据场景ID获取该场景下默认医生的职称列表
使用方式:
python3 scripts/drug/scene-doctor-titles.py <sceneId>
环境变量:
无(nologin接口)
参数:
sceneId - 场景ID(必填)
"""
import sys
import os
import json
import urllib.request
import urllib.parse
import urllib.error
import ssl
# 接口完整 URL(与 openapi/drug/scene-doctor-titles.md 中声明的一致)
API_URL = "https://sg-cwork-web.mediportal.com.cn/tbs/scene"
AUTH_MODE = "nologin"
def build_headers() -> dict:
"""根据鉴权模式构造请求头"""
headers = {"Content-Type": "application/json"}
return headers
def call_api(scene_id: str) -> dict:
"""调用接口,返回原始 JSON 响应"""
headers = build_headers()
if not scene_id:
print("错误: sceneId 为必填参数", file=sys.stderr)
sys.exit(1)
url = f"{API_URL}/{scene_id}/doctor-titles"
req = urllib.request.Request(url, headers=headers, method="GET")
ctx = ssl.create_default_context()
ctx.check_hostname = False
ctx.verify_mode = ssl.CERT_NONE
# 重试策略:间隔1秒,最多重试3次
for attempt in range(3):
try:
with urllib.request.urlopen(req, context=ctx, timeout=60) as resp:
return json.loads(resp.read().decode("utf-8"))
except urllib.error.URLError as e:
if attempt < 2:
import time
time.sleep(1)
continue
print(f"错误: 请求失败 - {e}", file=sys.stderr)
sys.exit(1)
def main():
if len(sys.argv) < 2:
print("错误: sceneId 为必填参数", file=sys.stderr)
sys.exit(1)
scene_id = sys.argv[1]
# 调用接口,获取原始 JSON
result = call_api(scene_id)
# 输出结果
print(json.dumps(result, ensure_ascii=False))
if __name__ == "__main__":
main()
FILE:scripts/drug/scene-hotwords.py
#!/usr/bin/env python3
"""
drug / scene-hotwords 脚本
用途:根据场景ID获取该场景的热词列表
使用方式:
python3 scripts/drug/scene-hotwords.py <sceneId>
环境变量:
无(nologin接口)
参数:
sceneId - 场景ID(必填)
"""
import sys
import os
import json
import urllib.request
import urllib.parse
import urllib.error
import ssl
# 接口完整 URL(与 openapi/drug/scene-hotwords.md 中声明的一致)
API_URL = "https://sg-cwork-web.mediportal.com.cn/tbs/scene"
AUTH_MODE = "nologin"
def build_headers() -> dict:
"""根据鉴权模式构造请求头"""
headers = {"Content-Type": "application/json"}
return headers
def call_api(scene_id: str) -> dict:
"""调用接口,返回原始 JSON 响应"""
headers = build_headers()
if not scene_id:
print("错误: sceneId 为必填参数", file=sys.stderr)
sys.exit(1)
url = f"{API_URL}/{scene_id}/hotwords"
req = urllib.request.Request(url, headers=headers, method="GET")
ctx = ssl.create_default_context()
ctx.check_hostname = False
ctx.verify_mode = ssl.CERT_NONE
# 重试策略:间隔1秒,最多重试3次
for attempt in range(3):
try:
with urllib.request.urlopen(req, context=ctx, timeout=60) as resp:
return json.loads(resp.read().decode("utf-8"))
except urllib.error.URLError as e:
if attempt < 2:
import time
time.sleep(1)
continue
print(f"错误: 请求失败 - {e}", file=sys.stderr)
sys.exit(1)
def main():
if len(sys.argv) < 2:
print("错误: sceneId 为必填参数", file=sys.stderr)
sys.exit(1)
scene_id = sys.argv[1]
# 调用接口,获取原始 JSON
result = call_api(scene_id)
# 输出结果
print(json.dumps(result, ensure_ascii=False))
if __name__ == "__main__":
main()
FILE:scripts/drug/scene-list-by-drug.py
#!/usr/bin/env python3
"""
drug / scene-list-by-drug 脚本
用途:根据药品ID获取已发布的场景列表
使用方式:
python3 scripts/drug/scene-list-by-drug.py <drugId>
环境变量:
无(nologin接口)
参数:
drugId - 药品ID(必填)
"""
import sys
import os
import json
import urllib.request
import urllib.parse
import urllib.error
import ssl
# 接口完整 URL(与 openapi/drug/scene-list-by-drug.md 中声明的一致)
API_URL = "https://sg-cwork-web.mediportal.com.cn/tbs/training-flow/nologin/scenes"
AUTH_MODE = "nologin"
def build_headers() -> dict:
"""根据鉴权模式构造请求头"""
headers = {"Content-Type": "application/json"}
return headers
def call_api(drug_id: str) -> dict:
"""调用接口,返回原始 JSON 响应"""
headers = build_headers()
if not drug_id:
print("错误: drugId 为必填参数", file=sys.stderr)
sys.exit(1)
url = f"{API_URL}?drugId={urllib.parse.quote(drug_id)}"
req = urllib.request.Request(url, headers=headers, method="GET")
ctx = ssl.create_default_context()
ctx.check_hostname = False
ctx.verify_mode = ssl.CERT_NONE
# 重试策略:间隔1秒,最多重试3次
for attempt in range(3):
try:
with urllib.request.urlopen(req, context=ctx, timeout=60) as resp:
return json.loads(resp.read().decode("utf-8"))
except urllib.error.URLError as e:
if attempt < 2:
import time
time.sleep(1)
continue
print(f"错误: 请求失败 - {e}", file=sys.stderr)
sys.exit(1)
def main():
if len(sys.argv) < 2:
print("错误: drugId 为必填参数", file=sys.stderr)
sys.exit(1)
drug_id = sys.argv[1]
# 调用接口,获取原始 JSON
result = call_api(drug_id)
# 输出结果
print(json.dumps(result, ensure_ascii=False))
if __name__ == "__main__":
main()
FILE:scripts/drug/scene-list.py
#!/usr/bin/env python3
"""
drug / scene-list 脚本
用途:根据药品external_id获取已发布的场景列表
使用方式:
python3 scripts/drug/scene-list.py [externalId] [corpId]
环境变量:
无(nologin接口)
参数:
externalId - 药品的external_id列表,多个用逗号分隔(可选)
corpId - 企业ID(可选)
"""
import sys
import os
import json
import urllib.request
import urllib.parse
import urllib.error
import ssl
# 接口完整 URL(与 openapi/drug/scene-list.md 中声明的一致)
API_URL = "https://sg-cwork-web.mediportal.com.cn/tbs/scene/list-by-drug-external-id"
AUTH_MODE = "nologin"
def build_headers() -> dict:
"""根据鉴权模式构造请求头"""
headers = {"Content-Type": "application/json"}
return headers
def call_api(external_id: str = None, corp_id: str = None) -> dict:
"""调用接口,返回原始 JSON 响应"""
headers = build_headers()
# 构建查询参数
params = []
if external_id:
params.append(f"externalId={urllib.parse.quote(external_id)}")
if corp_id:
params.append(f"corpId={urllib.parse.quote(corp_id)}")
url = API_URL
if params:
url = f"{API_URL}?{'&'.join(params)}"
req = urllib.request.Request(url, headers=headers, method="GET")
ctx = ssl.create_default_context()
ctx.check_hostname = False
ctx.verify_mode = ssl.CERT_NONE
# 重试策略:间隔1秒,最多重试3次
for attempt in range(3):
try:
with urllib.request.urlopen(req, context=ctx, timeout=60) as resp:
return json.loads(resp.read().decode("utf-8"))
except urllib.error.URLError as e:
if attempt < 2:
import time
time.sleep(1)
continue
print(f"错误: 请求失败 - {e}", file=sys.stderr)
sys.exit(1)
def main():
external_id = sys.argv[1] if len(sys.argv) > 1 else None
corp_id = sys.argv[2] if len(sys.argv) > 2 else None
# 调用接口,获取原始 JSON
result = call_api(external_id, corp_id)
# 输出结果
print(json.dumps(result, ensure_ascii=False))
if __name__ == "__main__":
main()
FILE:scripts/feedback/README.md
# 脚本清单 — feedback
## 共享依赖
无
## 脚本列表
| 脚本 | 对应接口 | 用途 |
|---|---|---|
| `app-detail.py` | `GET /tbs/feedback/nologin/app-detail` | 获取反馈应用详情,输出 JSON 结果 |
| `gpt-id.py` | `GET /tbs/feedback/nologin/gpt-id` | 获取反馈GPT ID,输出 JSON 结果 |
## 使用方式
```bash
# nologin 接口无需设置鉴权环境变量
python3 scripts/feedback/app-detail.py
python3 scripts/feedback/gpt-id.py [type]
```
## 规范
1. **必须使用 Python** 编写
2. **鉴权遵循** `cms-auth-skills/common/auth.md` 规范
3. **重试策略**:间隔1秒、最多重试3次
FILE:scripts/feedback/app-detail.py
#!/usr/bin/env python3
"""
feedback / app-detail 脚本
用途:获取反馈功能的应用详情
使用方式:
python3 scripts/feedback/app-detail.py
环境变量:
无(nologin接口)
"""
import sys
import os
import json
import urllib.request
import urllib.error
import ssl
# 接口完整 URL(与 openapi/feedback/app-detail.md 中声明的一致)
API_URL = "https://sg-cwork-web.mediportal.com.cn/tbs/feedback/nologin/app-detail"
AUTH_MODE = "nologin"
def build_headers() -> dict:
headers = {"Content-Type": "application/json"}
return headers
def call_api() -> dict:
headers = build_headers()
req = urllib.request.Request(API_URL, headers=headers, method="GET")
ctx = ssl.create_default_context()
ctx.check_hostname = False
ctx.verify_mode = ssl.CERT_NONE
for attempt in range(3):
try:
with urllib.request.urlopen(req, context=ctx, timeout=60) as resp:
return json.loads(resp.read().decode("utf-8"))
except urllib.error.URLError as e:
if attempt < 2:
import time
time.sleep(1)
continue
print(f"错误: 请求失败 - {e}", file=sys.stderr)
sys.exit(1)
def main():
result = call_api()
print(json.dumps(result, ensure_ascii=False))
if __name__ == "__main__":
main()
FILE:scripts/feedback/gpt-id.py
#!/usr/bin/env python3
"""
feedback / gpt-id 脚本
用途:获取反馈功能的GPT ID
使用方式:
python3 scripts/feedback/gpt-id.py [type]
环境变量:
无(nologin接口)
参数:
type - 类型:speech返回PPT演讲反馈GPT(可选)
"""
import sys
import os
import json
import urllib.request
import urllib.parse
import urllib.error
import ssl
# 接口完整 URL(与 openapi/feedback/gpt-id.md 中声明的一致)
API_URL = "https://sg-cwork-web.mediportal.com.cn/tbs/feedback/nologin/gpt-id"
AUTH_MODE = "nologin"
def build_headers() -> dict:
headers = {"Content-Type": "application/json"}
return headers
def call_api(type_val: str = None) -> dict:
headers = build_headers()
url = API_URL
if type_val:
url = f"{API_URL}?type={urllib.parse.quote(type_val)}"
req = urllib.request.Request(url, headers=headers, method="GET")
ctx = ssl.create_default_context()
ctx.check_hostname = False
ctx.verify_mode = ssl.CERT_NONE
for attempt in range(3):
try:
with urllib.request.urlopen(req, context=ctx, timeout=60) as resp:
return json.loads(resp.read().decode("utf-8"))
except urllib.error.URLError as e:
if attempt < 2:
import time
time.sleep(1)
continue
print(f"错误: 请求失败 - {e}", file=sys.stderr)
sys.exit(1)
def main():
type_val = sys.argv[1] if len(sys.argv) > 1 else None
result = call_api(type_val)
print(json.dumps(result, ensure_ascii=False))
if __name__ == "__main__":
main()
FILE:scripts/file/README.md
# 脚本清单 — file
## 共享依赖
无
## 脚本列表
| 脚本 | 对应接口 | 用途 |
|---|---|---|
| `upload-by-url.py` | `GET /tbs/file/upload-by-url` | URL上传文件,输出 JSON 结果 |
## 使用方式
```bash
export XG_USER_TOKEN="your-access-token"
python3 scripts/file/upload-by-url.py <fileUrl>
```
## 规范
1. **必须使用 Python** 编写
2. **鉴权遵循** `cms-auth-skills/common/auth.md` 规范
3. **重试策略**:间隔1秒、最多重试3次
FILE:scripts/file/upload-by-url.py
#!/usr/bin/env python3
"""
file / upload-by-url 脚本
用途:通过URL上传文件
使用方式:
python3 scripts/file/upload-by-url.py <fileUrl>
环境变量:
XG_USER_TOKEN — access-token(必须;由 cms-auth-skills 预先准备)
参数:
fileUrl - 文件URL地址(必填)
"""
import sys
import os
import json
import urllib.request
import urllib.parse
import urllib.error
import ssl
# 接口完整 URL(与 openapi/file/upload-by-url.md 中声明的一致)
API_URL = "https://sg-cwork-web.mediportal.com.cn/tbs/file/upload-by-url"
AUTH_MODE = "access-token"
def build_headers() -> dict:
headers = {"Content-Type": "application/json"}
token = os.environ.get("XG_USER_TOKEN")
if not token:
print("错误: 请设置环境变量 XG_USER_TOKEN", file=sys.stderr)
sys.exit(1)
headers["access-token"] = token
return headers
def call_api(file_url: str) -> dict:
headers = build_headers()
if not file_url:
print("错误: fileUrl 为必填参数", file=sys.stderr)
sys.exit(1)
url = f"{API_URL}?fileUrl={urllib.parse.quote(file_url)}"
req = urllib.request.Request(url, headers=headers, method="GET")
ctx = ssl.create_default_context()
ctx.check_hostname = False
ctx.verify_mode = ssl.CERT_NONE
for attempt in range(3):
try:
with urllib.request.urlopen(req, context=ctx, timeout=60) as resp:
return json.loads(resp.read().decode("utf-8"))
except urllib.error.URLError as e:
if attempt < 2:
import time
time.sleep(1)
continue
print(f"错误: 请求失败 - {e}", file=sys.stderr)
sys.exit(1)
def main():
if len(sys.argv) < 2:
print("错误: fileUrl 为必填参数", file=sys.stderr)
sys.exit(1)
result = call_api(sys.argv[1])
print(json.dumps(result, ensure_ascii=False))
if __name__ == "__main__":
main()
FILE:scripts/gpts/README.md
# 脚本清单 — gpts
## 共享依赖
无
## 脚本列表
| 脚本 | 对应接口 | 用途 |
|---|---|---|
| `app-detail.py` | `GET /gpts/gptApp/getDetailByIdV2` | 获取GPT应用详情,输出 JSON 结果 |
| `session.py` | `POST /gpts/session/getSessionByBusinessId` | 获取/创建会话,输出 JSON 结果 |
| `sse-suggest.py` | `POST /gpts/sseClient/ai/suggest` | SSE核心接口(开始训练/提交对话/生成点评),输出 JSON 结果 |
| `del-user-token.py` | `POST /gpts/accessToken/delUserToken` | 释放并发token,输出 JSON 结果 |
## 使用方式
```bash
export XG_USER_TOKEN="your-access-token"
# 获取GPT应用详情
python3 scripts/gpts/app-detail.py <appId> [corpId]
# 获取/创建会话
python3 scripts/gpts/session.py <appId> <businessId> <businessType> [isForce]
# SSE:开始训练
python3 scripts/gpts/sse-suggest.py <sessionId> <appId> start_training "进入训练"
# SSE:提交对话
python3 scripts/gpts/sse-suggest.py <sessionId> <appId> submit_dialogue "用户回答" <trainingRecordId> <round> <type>
# SSE:生成AI点评
python3 scripts/gpts/sse-suggest.py <sessionId> <appId> generate_ai_comment "" <trainingRecordId>
# 释放并发token
python3 scripts/gpts/del-user-token.py <appId>
```
## 输出说明
所有脚本的输出均为 **JSON 格式**。
## 规范
1. **必须使用 Python** 编写
2. **鉴权遵循** `cms-auth-skills/common/auth.md` 规范
3. **入参定义以** `openapi/` 文档为准
4. **重试策略**:间隔1秒、最多重试3次
FILE:scripts/gpts/app-detail.py
#!/usr/bin/env python3
"""
gpts / app-detail 脚本
用途:获取GPT应用详情
使用方式:
python3 scripts/gpts/app-detail.py <appId> [corpId]
环境变量:
XG_USER_TOKEN — access-token(必须;由 cms-auth-skills 预先准备)
参数:
appId - 应用ID(必填)
corpId - 企业ID(可选)
"""
import sys
import os
import json
import urllib.request
import urllib.parse
import urllib.error
import ssl
# 接口完整 URL(与 openapi/gpts/app-detail.md 中声明的一致)
BASE_URL = "https://sg-al-cwork-web.mediportal.com.cn"
API_URL = f"{BASE_URL}/gpts/gptApp/getDetailByIdV2"
AUTH_MODE = "access-token"
def build_headers() -> dict:
headers = {"Content-Type": "application/json"}
token = os.environ.get("XG_USER_TOKEN")
if not token:
print("错误: 请设置环境变量 XG_USER_TOKEN", file=sys.stderr)
sys.exit(1)
headers["access-token"] = token
return headers
def call_api(app_id: str, corp_id: str = None) -> dict:
headers = build_headers()
if not app_id:
print("错误: appId 为必填参数", file=sys.stderr)
sys.exit(1)
params = [f"id={urllib.parse.quote(app_id)}"]
if corp_id:
params.append(f"corpId={urllib.parse.quote(corp_id)}")
url = f"{API_URL}?{'&'.join(params)}"
req = urllib.request.Request(url, headers=headers, method="GET")
ctx = ssl.create_default_context()
ctx.check_hostname = False
ctx.verify_mode = ssl.CERT_NONE
for attempt in range(3):
try:
with urllib.request.urlopen(req, context=ctx, timeout=60) as resp:
return json.loads(resp.read().decode("utf-8"))
except urllib.error.URLError as e:
if attempt < 2:
import time
time.sleep(1)
continue
print(f"错误: 请求失败 - {e}", file=sys.stderr)
sys.exit(1)
def main():
if len(sys.argv) < 2:
print("错误: appId 为必填参数", file=sys.stderr)
sys.exit(1)
app_id = sys.argv[1]
corp_id = sys.argv[2] if len(sys.argv) > 2 else None
result = call_api(app_id, corp_id)
print(json.dumps(result, ensure_ascii=False))
if __name__ == "__main__":
main()
FILE:scripts/gpts/del-user-token.py
#!/usr/bin/env python3
"""
gpts / del-user-token 脚本
用途:释放并发token
使用方式:
python3 scripts/gpts/del-user-token.py <appId>
环境变量:
XG_USER_TOKEN — access-token(必须;由 cms-auth-skills 预先准备)
XG_CORP_ID — corpId(必须;由 cms-auth-skills 预先准备)
XG_EMPLOYEE_ID — employeeId(必须;由 cms-auth-skills 预先准备)
XG_PERSON_ID — personId(必须;由 cms-auth-skills 预先准备)
参数:
appId - 应用ID(必填)
"""
import sys
import os
import json
import urllib.request
import urllib.parse
import urllib.error
import ssl
# 接口完整 URL(与 openapi/gpts/del-user-token.md 中声明的一致)
BASE_URL = "https://sg-al-cwork-web.mediportal.com.cn"
API_URL = f"{BASE_URL}/gpts/accessToken/delUserToken"
def build_headers() -> dict:
headers = {"Content-Type": "application/json"}
token = os.environ.get("XG_USER_TOKEN")
corp_id = os.environ.get("XG_CORP_ID")
employee_id = os.environ.get("XG_EMPLOYEE_ID")
person_id = os.environ.get("XG_PERSON_ID")
if not token:
print("错误: 请设置环境变量 XG_USER_TOKEN", file=sys.stderr)
sys.exit(1)
if not corp_id:
print("错误: 请设置环境变量 XG_CORP_ID", file=sys.stderr)
sys.exit(1)
if not employee_id:
print("错误: 请设置环境变量 XG_EMPLOYEE_ID", file=sys.stderr)
sys.exit(1)
if not person_id:
print("错误: 请设置环境变量 XG_PERSON_ID", file=sys.stderr)
sys.exit(1)
headers["access-token"] = token
headers["corpid"] = corp_id
headers["employeeid"] = employee_id
headers["personid"] = person_id
return headers
def call_api(app_id: str) -> dict:
headers = build_headers()
if not app_id:
print("错误: appId 为必填参数", file=sys.stderr)
sys.exit(1)
url = f"{API_URL}?appId={urllib.parse.quote(app_id)}"
req = urllib.request.Request(url, headers=headers, method="POST")
ctx = ssl.create_default_context()
ctx.check_hostname = False
ctx.verify_mode = ssl.CERT_NONE
for attempt in range(3):
try:
with urllib.request.urlopen(req, context=ctx, timeout=30) as resp:
return json.loads(resp.read().decode("utf-8"))
except urllib.error.URLError as e:
if attempt < 2:
import time
time.sleep(1)
continue
print(f"错误: 请求失败 - {e}", file=sys.stderr)
sys.exit(1)
def main():
if len(sys.argv) < 2:
print("错误: appId 为必填参数", file=sys.stderr)
sys.exit(1)
app_id = sys.argv[1]
result = call_api(app_id)
print(json.dumps(result, ensure_ascii=False))
if __name__ == "__main__":
main()
FILE:scripts/gpts/session.py
#!/usr/bin/env python3
"""
gpts / session 脚本
用途:获取或创建会话
使用方式:
python3 scripts/gpts/session.py <appId> <businessId> <businessType> [isForce]
环境变量:
XG_USER_TOKEN — access-token(必须;由 cms-auth-skills 预先准备)
参数:
appId - 应用ID(必填)
businessId - 业务ID(必填)
businessType - 业务类型(必填)
isForce - 是否强制创建新会话 true/false(可选,默认false)
"""
import sys
import os
import json
import urllib.request
import urllib.error
import ssl
# 接口完整 URL(与 openapi/gpts/session.md 中声明的一致)
BASE_URL = "https://sg-al-cwork-web.mediportal.com.cn"
API_URL = f"{BASE_URL}/gpts/session/getSessionByBusinessId"
AUTH_MODE = "access-token"
def build_headers() -> dict:
headers = {"Content-Type": "application/json"}
token = os.environ.get("XG_USER_TOKEN")
if not token:
print("错误: 请设置环境变量 XG_USER_TOKEN", file=sys.stderr)
sys.exit(1)
headers["access-token"] = token
return headers
def call_api(app_id: str, business_id: str, business_type: str, is_force: bool = False) -> dict:
headers = build_headers()
if not app_id or not business_id or not business_type:
print("错误: appId, businessId, businessType 为必填参数", file=sys.stderr)
sys.exit(1)
body = {
"appId": app_id,
"businessId": business_id,
"businessType": business_type,
"isForce": is_force
}
req_body = json.dumps(body).encode("utf-8")
req = urllib.request.Request(API_URL, data=req_body, headers=headers, method="POST")
ctx = ssl.create_default_context()
ctx.check_hostname = False
ctx.verify_mode = ssl.CERT_NONE
for attempt in range(3):
try:
with urllib.request.urlopen(req, context=ctx, timeout=60) as resp:
return json.loads(resp.read().decode("utf-8"))
except urllib.error.URLError as e:
if attempt < 2:
import time
time.sleep(1)
continue
print(f"错误: 请求失败 - {e}", file=sys.stderr)
sys.exit(1)
def main():
if len(sys.argv) < 4:
print("错误: appId, businessId, businessType 为必填参数", file=sys.stderr)
sys.exit(1)
app_id = sys.argv[1]
business_id = sys.argv[2]
business_type = sys.argv[3]
is_force = sys.argv[4].lower() == "true" if len(sys.argv) > 4 else False
result = call_api(app_id, business_id, business_type, is_force)
print(json.dumps(result, ensure_ascii=False))
if __name__ == "__main__":
main()
FILE:scripts/gpts/speech-feedback.py
#!/usr/bin/env python3
"""
gpts / speech-feedback 脚本
用途:PPT演讲反馈收集
使用方式:
python3 scripts/gpts/speech-feedback.py [content] [sessionId]
环境变量:
无(nologin接口)
参数:
content - 用户输入内容(可选)
sessionId - 会话ID(可选)
"""
import sys
import os
import json
import urllib.request
import urllib.error
import ssl
# 接口完整 URL(与 openapi/gpts/speech-feedback.md 中声明的一致)
API_URL = "https://sg-cwork-web.mediportal.com.cn/tbs/gpts/nologin/speech/feedback"
AUTH_MODE = "nologin"
def build_headers() -> dict:
headers = {"Content-Type": "application/json"}
return headers
def call_api(content: str = None, session_id: str = None) -> dict:
headers = build_headers()
body = {
"request": {
"content": content or "",
"sessionId": session_id or ""
}
}
req_body = json.dumps(body).encode("utf-8")
req = urllib.request.Request(API_URL, data=req_body, headers=headers, method="POST")
ctx = ssl.create_default_context()
ctx.check_hostname = False
ctx.verify_mode = ssl.CERT_NONE
for attempt in range(3):
try:
with urllib.request.urlopen(req, context=ctx, timeout=60) as resp:
return json.loads(resp.read().decode("utf-8"))
except urllib.error.URLError as e:
if attempt < 2:
import time
time.sleep(1)
continue
print(f"错误: 请求失败 - {e}", file=sys.stderr)
sys.exit(1)
def main():
content = sys.argv[1] if len(sys.argv) > 1 else None
session_id = sys.argv[2] if len(sys.argv) > 2 else None
result = call_api(content, session_id)
print(json.dumps(result, ensure_ascii=False))
if __name__ == "__main__":
main()
FILE:scripts/gpts/sse-suggest.py
#!/usr/bin/env python3
"""
gpts / sse-suggest 脚本
用途:SSE核心接口,用于训战对话交互
使用方式:
# 开始训练
python scripts/gpts/sse-suggest.py start_training <sessionId> <appId> <sceneId> [sourceId] [cloudCategoryId] [trainingType] [mode]
# 提交对话
python scripts/gpts/sse-suggest.py submit_dialogue <sessionId> <appId> <content> <trainingRecordId> <round> [type]
# 生成AI点评
python scripts/gpts/sse-suggest.py generate_ai_comment <sessionId> <appId> <trainingRecordId>
# 下一关
python scripts/gpts/sse-suggest.py next_level <sessionId> <appId> <trainingRecordId>
环境变量:
XG_USER_TOKEN — access-token(必须;由 cms-auth-skills 预先准备)
XG_CORP_ID — corpId(必须;由 cms-auth-skills 预先准备)
XG_EMPLOYEE_ID — employeeId(必须;由 cms-auth-skills 预先准备)
XG_PERSON_ID — personId(必须;由 cms-auth-skills 预先准备)
参数:
sessionId - 会话ID(必填)
appId - 应用ID(必填)
action - 动作:start_training/submit_dialogue/generate_ai_comment/next_level
content - 用户输入内容
trainingRecordId - 训练记录ID(submit_dialogue/generate_ai_comment/next_level时必填)
round - 轮次(submit_dialogue时必填)
type - 类型:user/coach(submit_dialogue时必填,默认user)
sceneId - 场景ID(start_training时必填)
sourceId - 来源ID(start_training时可选,默认58)
cloudCategoryId - 云分类ID(start_training时可选,默认124)
trainingType - 训练类型:practice/battle(start_training时可选,默认practice)
mode - 模式:true/false(start_training时可选,true=练习,false=训战,默认true)
"""
import sys
import os
import json
import urllib.request
import urllib.error
import ssl
# 接口完整 URL(与 openapi/gpts/sse-suggest.md 中声明的一致)
BASE_URL = "https://sg-al-cwork-web.mediportal.com.cn"
API_URL = f"{BASE_URL}/gpts/sseClient/ai/suggest"
# 固定的 Headers
FIXED_HEADERS = {
"accept": "text/event-stream",
"appkey": "WtEkxhUvmHhiE4wjaYTaVcHWKiYTiLJ8",
"content-type": "application/json"
}
def build_headers() -> dict:
"""构建请求头"""
headers = dict(FIXED_HEADERS)
token = os.environ.get("XG_USER_TOKEN")
corp_id = os.environ.get("XG_CORP_ID")
employee_id = os.environ.get("XG_EMPLOYEE_ID")
person_id = os.environ.get("XG_PERSON_ID")
if not token:
print("错误: 请设置环境变量 XG_USER_TOKEN", file=sys.stderr)
sys.exit(1)
if not corp_id:
print("错误: 请设置环境变量 XG_CORP_ID", file=sys.stderr)
sys.exit(1)
if not employee_id:
print("错误: 请设置环境变量 XG_EMPLOYEE_ID", file=sys.stderr)
sys.exit(1)
if not person_id:
print("错误: 请设置环境变量 XG_PERSON_ID", file=sys.stderr)
sys.exit(1)
headers["access-token"] = token
headers["corpid"] = corp_id
headers["employeeid"] = employee_id
headers["personid"] = person_id
return headers
def call_sse(action: str, session_id: str, app_id: str,
content: str = None,
training_record_id: str = None,
round_val: int = None,
type_val: str = None,
scene_id: str = None,
source_id: int = None,
cloud_category_id: str = None,
training_type: str = None,
mode: bool = None) -> list:
"""调用SSE接口,返回事件列表"""
if not session_id or not app_id:
print("错误: sessionId, appId 为必填参数", file=sys.stderr)
sys.exit(1)
# 构建 extMap
ext_map = {"action": action}
if action == "start_training":
if not scene_id:
print("错误: start_training 需要 sceneId 参数", file=sys.stderr)
sys.exit(1)
ext_map["sceneId"] = scene_id
ext_map["doctorId"] = None # 必须是 null
ext_map["sourceId"] = source_id if source_id is not None else 58 # 必须是数字
ext_map["cloudCategoryId"] = cloud_category_id or "124"
ext_map["trainingType"] = training_type or "practice"
ext_map["mode"] = mode if mode is not None else True
content = content or "进入训练"
elif action in ("submit_dialogue", "generate_ai_comment", "next_level"):
if not training_record_id:
print(f"错误: {action} 需要 trainingRecordId 参数", file=sys.stderr)
sys.exit(1)
ext_map["trainingRecordId"] = training_record_id
if action == "submit_dialogue":
ext_map["round"] = round_val if round_val is not None else 1
ext_map["type"] = type_val or "user"
if not content:
print("错误: submit_dialogue 需要 content 参数", file=sys.stderr)
sys.exit(1)
elif action == "generate_ai_comment":
content = content or "生成AI点评" # 必须是 "生成AI点评",不能为空
else:
content = content or "下一关"
# 构建 Body
body = {
"sessionId": session_id,
"content": content,
"extMap": ext_map,
"msgList": [],
"appId": app_id
}
headers = build_headers()
req_body = json.dumps(body).encode("utf-8")
req = urllib.request.Request(API_URL, data=req_body, headers=headers, method="POST")
ctx = ssl.create_default_context()
ctx.check_hostname = False
ctx.verify_mode = ssl.CERT_NONE
events = []
try:
with urllib.request.urlopen(req, context=ctx, timeout=60) as resp:
for line in resp:
line = line.decode('utf-8', errors='replace').strip()
if line.startswith('event:'):
event_type = line[6:].strip()
events.append({"type": "event", "data": event_type})
elif line.startswith('data:'):
data = line[5:].strip()
if data:
try:
events.append({"type": "data", "data": json.loads(data)})
except:
events.append({"type": "data", "data": data})
except urllib.error.URLError as e:
if hasattr(e, 'read'):
print(f"错误: {e.read().decode('utf-8')}", file=sys.stderr)
else:
print(f"错误: {e}", file=sys.stderr)
sys.exit(1)
return events
def main():
if len(sys.argv) < 4:
print("错误: 参数不足", file=sys.stderr)
print(__doc__)
sys.exit(1)
action = sys.argv[1]
session_id = sys.argv[2]
app_id = sys.argv[3]
events = []
if action == "start_training":
scene_id = sys.argv[4] if len(sys.argv) > 4 else None
source_id = int(sys.argv[5]) if len(sys.argv) > 5 else 58
cloud_category_id = sys.argv[6] if len(sys.argv) > 6 else "124"
training_type = sys.argv[7] if len(sys.argv) > 7 else "practice"
mode = sys.argv[8].lower() == "true" if len(sys.argv) > 8 else True
events = call_sse(action, session_id, app_id, scene_id=scene_id,
source_id=source_id, cloud_category_id=cloud_category_id,
training_type=training_type, mode=mode)
elif action == "submit_dialogue":
if len(sys.argv) < 6:
print("错误: submit_dialogue 需要 content 和 trainingRecordId", file=sys.stderr)
sys.exit(1)
content = sys.argv[4]
training_record_id = sys.argv[5]
round_val = int(sys.argv[6]) if len(sys.argv) > 6 else 1
type_val = sys.argv[7] if len(sys.argv) > 7 else "user"
events = call_sse(action, session_id, app_id, content=content,
training_record_id=training_record_id, round_val=round_val, type_val=type_val)
elif action == "generate_ai_comment":
if len(sys.argv) < 5:
print("错误: generate_ai_comment 需要 trainingRecordId", file=sys.stderr)
sys.exit(1)
training_record_id = sys.argv[4]
events = call_sse(action, session_id, app_id, training_record_id=training_record_id)
elif action == "next_level":
if len(sys.argv) < 5:
print("错误: next_level 需要 trainingRecordId", file=sys.stderr)
sys.exit(1)
training_record_id = sys.argv[4]
events = call_sse(action, session_id, app_id, training_record_id=training_record_id)
else:
print(f"错误: 未知 action '{action}'", file=sys.stderr)
sys.exit(1)
# 输出结果
for event in events:
if event["type"] == "event":
print(f"\n--- Event: {event['data']} ---")
else:
print(json.dumps(event["data"], ensure_ascii=False, indent=2))
if __name__ == "__main__":
main()
FILE:scripts/gpts/training-feedback.py
#!/usr/bin/env python3
"""
gpts / training-feedback 脚本
用途:训练反馈交互
使用方式:
python3 scripts/gpts/training-feedback.py [content] [sessionId]
环境变量:
无(nologin接口)
参数:
content - 用户输入内容(可选)
sessionId - 会话ID(可选)
"""
import sys
import os
import json
import urllib.request
import urllib.error
import ssl
# 接口完整 URL(与 openapi/gpts/training-feedback.md 中声明的一致)
API_URL = "https://sg-cwork-web.mediportal.com.cn/tbs/gpts/nologin/training/feedback"
AUTH_MODE = "nologin"
def build_headers() -> dict:
headers = {"Content-Type": "application/json"}
return headers
def call_api(content: str = None, session_id: str = None) -> dict:
headers = build_headers()
body = {
"request": {
"content": content or "",
"sessionId": session_id or ""
}
}
req_body = json.dumps(body).encode("utf-8")
req = urllib.request.Request(API_URL, data=req_body, headers=headers, method="POST")
ctx = ssl.create_default_context()
ctx.check_hostname = False
ctx.verify_mode = ssl.CERT_NONE
for attempt in range(3):
try:
with urllib.request.urlopen(req, context=ctx, timeout=60) as resp:
return json.loads(resp.read().decode("utf-8"))
except urllib.error.URLError as e:
if attempt < 2:
import time
time.sleep(1)
continue
print(f"错误: 请求失败 - {e}", file=sys.stderr)
sys.exit(1)
def main():
content = sys.argv[1] if len(sys.argv) > 1 else None
session_id = sys.argv[2] if len(sys.argv) > 2 else None
result = call_api(content, session_id)
print(json.dumps(result, ensure_ascii=False))
if __name__ == "__main__":
main()
FILE:scripts/gpts/training-interact.py
#!/usr/bin/env python3
"""
gpts / training-interact 脚本
用途:训练交互
使用方式:
python3 scripts/gpts/training-interact.py [content] [sessionId]
环境变量:
无(nologin接口)
参数:
content - 用户输入内容(可选)
sessionId - 会话ID(可选)
"""
import sys
import os
import json
import urllib.request
import urllib.error
import ssl
# 接口完整 URL(与 openapi/gpts/training-interact.md 中声明的一致)
API_URL = "https://sg-cwork-web.mediportal.com.cn/tbs/gpts/nologin/training/interact"
AUTH_MODE = "nologin"
def build_headers() -> dict:
headers = {"Content-Type": "application/json"}
return headers
def call_api(content: str = None, session_id: str = None) -> dict:
headers = build_headers()
body = {
"request": {
"content": content or "",
"sessionId": session_id or ""
}
}
req_body = json.dumps(body).encode("utf-8")
req = urllib.request.Request(API_URL, data=req_body, headers=headers, method="POST")
ctx = ssl.create_default_context()
ctx.check_hostname = False
ctx.verify_mode = ssl.CERT_NONE
for attempt in range(3):
try:
with urllib.request.urlopen(req, context=ctx, timeout=60) as resp:
return json.loads(resp.read().decode("utf-8"))
except urllib.error.URLError as e:
if attempt < 2:
import time
time.sleep(1)
continue
print(f"错误: 请求失败 - {e}", file=sys.stderr)
sys.exit(1)
def main():
content = sys.argv[1] if len(sys.argv) > 1 else None
session_id = sys.argv[2] if len(sys.argv) > 2 else None
result = call_api(content, session_id)
print(json.dumps(result, ensure_ascii=False))
if __name -- "__main__":
main()
FILE:scripts/home/README.md
# 脚本清单 — home
## 共享依赖
无
## 脚本列表
| 脚本 | 对应接口 | 用途 |
|---|---|---|
| `summary.py` | `GET /tbs/home/summary` | 获取首页训战摘要(统计数据+活动分类),输出 JSON 结果 |
| `learning-videos.py` | `GET /tbs/home/learning-videos` | 获取首页视频学习任务列表,输出 JSON 结果 |
| `product-scenes.py` | `GET /tbs/home/product-scenes` | 获取产品场景列表(含按钮状态),输出 JSON 结果 |
## 使用方式
```bash
# 先通过 cms-auth-skills 准备 access-token,再设置环境变量
export XG_USER_TOKEN="your-access-token"
# 执行脚本
python3 scripts/home/summary.py
python3 scripts/home/learning-videos.py
python3 scripts/home/product-scenes.py [productId] [activityId]
```
## 输出说明
所有脚本的输出均为 **JSON 格式**。
## 规范
1. **必须使用 Python** 编写
2. **鉴权遵循** `cms-auth-skills/common/auth.md` 规范
3. **入参定义以** `openapi/` 文档为准
4. **重试策略**:间隔1秒、最多重试3次
FILE:scripts/home/learning-videos.py
#!/usr/bin/env python3
"""
home / learning-videos 脚本
用途:获取首页视频学习任务列表
使用方式:
python3 scripts/home/learning-videos.py
环境变量:
XG_USER_TOKEN — access-token(必须;由 cms-auth-skills 预先准备)
"""
import sys
import os
import json
import urllib.request
import urllib.error
import ssl
# 接口完整 URL(与 openapi/home/learning-videos.md 中声明的一致)
API_URL = "https://sg-cwork-web.mediportal.com.cn/tbs/home/learning-videos"
AUTH_MODE = "access-token"
def build_headers() -> dict:
"""根据鉴权模式构造请求头"""
headers = {"Content-Type": "application/json"}
if AUTH_MODE == "access-token":
token = os.environ.get("XG_USER_TOKEN")
if not token:
print("错误: 请设置环境变量 XG_USER_TOKEN", file=sys.stderr)
sys.exit(1)
headers["access-token"] = token
return headers
def call_api() -> dict:
"""调用接口,返回原始 JSON 响应"""
headers = build_headers()
req = urllib.request.Request(API_URL, headers=headers, method="GET")
ctx = ssl.create_default_context()
ctx.check_hostname = False
ctx.verify_mode = ssl.CERT_NONE
# 重试策略:间隔1秒,最多重试3次
for attempt in range(3):
try:
with urllib.request.urlopen(req, context=ctx, timeout=60) as resp:
return json.loads(resp.read().decode("utf-8"))
except urllib.error.URLError as e:
if attempt < 2:
import time
time.sleep(1)
continue
print(f"错误: 请求失败 - {e}", file=sys.stderr)
sys.exit(1)
def main():
# 调用接口,获取原始 JSON
result = call_api()
# 输出结果
print(json.dumps(result, ensure_ascii=False))
if __name__ == "__main__":
main()
FILE:scripts/home/product-scenes.py
#!/usr/bin/env python3
"""
home / product-scenes 脚本
用途:获取产品场景列表(含训战-练习按钮状态)
使用方式:
python3 scripts/home/product-scenes.py [productId] [activityId]
环境变量:
XG_USER_TOKEN — access-token(必须;由 cms-auth-skills 预先准备)
参数:
productId - 产品ID(可选)
activityId - 活动ID(可选)
"""
import sys
import os
import json
import urllib.request
import urllib.parse
import urllib.error
import ssl
# 接口完整 URL(与 openapi/home/product-scenes.md 中声明的一致)
API_URL = "https://sg-cwork-web.mediportal.com.cn/tbs/home/product-scenes"
AUTH_MODE = "access-token"
def build_headers() -> dict:
"""根据鉴权模式构造请求头"""
headers = {"Content-Type": "application/json"}
if AUTH_MODE == "access-token":
token = os.environ.get("XG_USER_TOKEN")
if not token:
print("错误: 请设置环境变量 XG_USER_TOKEN", file=sys.stderr)
sys.exit(1)
headers["access-token"] = token
return headers
def call_api(product_id: str = None, activity_id: str = None) -> dict:
"""调用接口,返回原始 JSON 响应"""
headers = build_headers()
# 构建查询参数
params = []
if product_id:
params.append(f"productId={urllib.parse.quote(product_id)}")
if activity_id:
params.append(f"activityId={urllib.parse.quote(activity_id)}")
url = API_URL
if params:
url = f"{API_URL}?{'&'.join(params)}"
req = urllib.request.Request(url, headers=headers, method="GET")
ctx = ssl.create_default_context()
ctx.check_hostname = False
ctx.verify_mode = ssl.CERT_NONE
# 重试策略:间隔1秒,最多重试3次
for attempt in range(3):
try:
with urllib.request.urlopen(req, context=ctx, timeout=60) as resp:
return json.loads(resp.read().decode("utf-8"))
except urllib.error.URLError as e:
if attempt < 2:
import time
time.sleep(1)
continue
print(f"错误: 请求失败 - {e}", file=sys.stderr)
sys.exit(1)
def main():
product_id = sys.argv[1] if len(sys.argv) > 1 else None
activity_id = sys.argv[2] if len(sys.argv) > 2 else None
# 调用接口,获取原始 JSON
result = call_api(product_id, activity_id)
# 输出结果
print(json.dumps(result, ensure_ascii=False))
if __name__ == "__main__":
main()
FILE:scripts/home/summary.py
#!/usr/bin/env python3
"""
home / summary 脚本
用途:获取首页训战摘要信息(本周统计数据 + 活动分类列表)
使用方式:
python3 scripts/home/summary.py
环境变量:
XG_USER_TOKEN — access-token(必须;由 cms-auth-skills 预先准备)
"""
import sys
import os
import json
import urllib.request
import urllib.error
import ssl
# 接口完整 URL(与 openapi/home/summary.md 中声明的一致)
API_URL = "https://sg-cwork-web.mediportal.com.cn/tbs/home/summary"
AUTH_MODE = "access-token"
def build_headers() -> dict:
"""根据鉴权模式构造请求头"""
headers = {"Content-Type": "application/json"}
if AUTH_MODE == "access-token":
token = os.environ.get("XG_USER_TOKEN")
if not token:
print("错误: 请设置环境变量 XG_USER_TOKEN", file=sys.stderr)
sys.exit(1)
headers["access-token"] = token
return headers
def call_api() -> dict:
"""调用接口,返回原始 JSON 响应"""
headers = build_headers()
req = urllib.request.Request(API_URL, headers=headers, method="GET")
ctx = ssl.create_default_context()
ctx.check_hostname = False
ctx.verify_mode = ssl.CERT_NONE
# 重试策略:间隔1秒,最多重试3次
for attempt in range(3):
try:
with urllib.request.urlopen(req, context=ctx, timeout=60) as resp:
return json.loads(resp.read().decode("utf-8"))
except urllib.error.URLError as e:
if attempt < 2:
import time
time.sleep(1)
continue
print(f"错误: 请求失败 - {e}", file=sys.stderr)
sys.exit(1)
def main():
# 调用接口,获取原始 JSON
result = call_api()
# 输出结果
print(json.dumps(result, ensure_ascii=False))
if __name__ == "__main__":
main()
FILE:scripts/learning/README.md
# 脚本清单 — learning
## 共享依赖
无
## 脚本列表
| 脚本 | 对应接口 | 用途 |
|---|---|---|
| `video-detail.py` | `GET /tbs/learning/video-detail` | 获取视频详情,输出 JSON 结果 |
| `video-progress-get.py` | `GET /tbs/learning/video-progress` | 查询播放进度,输出 JSON 结果 |
| `video-progress-save.py` | `POST /tbs/learning/video-progress` | 保存播放进度,输出 JSON 结果 |
## 使用方式
```bash
export XG_USER_TOKEN="your-access-token"
python3 scripts/learning/video-detail.py <learningItemId>
python3 scripts/learning/video-progress-get.py <learningItemId>
python3 scripts/learning/video-progress-save.py <learningItemId> <pageIndex> <progress> [isCompleted]
```
## 规范
1. **必须使用 Python** 编写
2. **鉴权遵循** `cms-auth-skills/common/auth.md` 规范
3. **重试策略**:间隔1秒、最多重试3次
FILE:scripts/learning/video-detail.py
#!/usr/bin/env python3
"""
learning / video-detail 脚本
用途:获取学习视频详情
使用方式:
python3 scripts/learning/video-detail.py <learningItemId>
环境变量:
XG_USER_TOKEN — access-token(必须;由 cms-auth-skills 预先准备)
参数:
learningItemId - 学习任务ID(必填)
"""
import sys
import os
import json
import urllib.request
import urllib.parse
import urllib.error
import ssl
# 接口完整 URL(与 openapi/learning/video-detail.md 中声明的一致)
API_URL = "https://sg-cwork-web.mediportal.com.cn/tbs/learning/video-detail"
AUTH_MODE = "access-token"
def build_headers() -> dict:
headers = {"Content-Type": "application/json"}
token = os.environ.get("XG_USER_TOKEN")
if not token:
print("错误: 请设置环境变量 XG_USER_TOKEN", file=sys.stderr)
sys.exit(1)
headers["access-token"] = token
return headers
def call_api(learning_item_id: str) -> dict:
headers = build_headers()
if not learning_item_id:
print("错误: learningItemId 为必填参数", file=sys.stderr)
sys.exit(1)
url = f"{API_URL}?learningItemId={urllib.parse.quote(learning_item_id)}"
req = urllib.request.Request(url, headers=headers, method="GET")
ctx = ssl.create_default_context()
ctx.check_hostname = False
ctx.verify_mode = ssl.CERT_NONE
for attempt in range(3):
try:
with urllib.request.urlopen(req, context=ctx, timeout=60) as resp:
return json.loads(resp.read().decode("utf-8"))
except urllib.error.URLError as e:
if attempt < 2:
import time
time.sleep(1)
continue
print(f"错误: 请求失败 - {e}", file=sys.stderr)
sys.exit(1)
def main():
if len(sys.argv) < 2:
print("错误: learningItemId 为必填参数", file=sys.stderr)
sys.exit(1)
result = call_api(sys.argv[1])
print(json.dumps(result, ensure_ascii=False))
if __name__ == "__main__":
main()
FILE:scripts/learning/video-progress-get.py
#!/usr/bin/env python3
"""
learning / video-progress-get 脚本
用途:查询视频播放进度
使用方式:
python3 scripts/learning/video-progress-get.py <learningItemId>
环境变量:
XG_USER_TOKEN — access-token(必须;由 cms-auth-skills 预先准备)
参数:
learningItemId - 学习任务ID(必填)
"""
import sys
import os
import json
import urllib.request
import urllib.parse
import urllib.error
import ssl
# 接口完整 URL(与 openapi/learning/video-progress-get.md 中声明的一致)
API_URL = "https://sg-cwork-web.mediportal.com.cn/tbs/learning/video-progress"
AUTH_MODE = "access-token"
def build_headers() -> dict:
headers = {"Content-Type": "application/json"}
token = os.environ.get("XG_USER_TOKEN")
if not token:
print("错误: 请设置环境变量 XG_USER_TOKEN", file=sys.stderr)
sys.exit(1)
headers["access-token"] = token
return headers
def call_api(learning_item_id: str) -> dict:
headers = build_headers()
if not learning_item_id:
print("错误: learningItemId 为必填参数", file=sys.stderr)
sys.exit(1)
url = f"{API_URL}?learningItemId={urllib.parse.quote(learning_item_id)}"
req = urllib.request.Request(url, headers=headers, method="GET")
ctx = ssl.create_default_context()
ctx.check_hostname = False
ctx.verify_mode = ssl.CERT_NONE
for attempt in range(3):
try:
with urllib.request.urlopen(req, context=ctx, timeout=60) as resp:
return json.loads(resp.read().decode("utf-8"))
except urllib.error.URLError as e:
if attempt < 2:
import time
time.sleep(1)
continue
print(f"错误: 请求失败 - {e}", file=sys.stderr)
sys.exit(1)
def main():
if len(sys.argv) < 2:
print("错误: learningItemId 为必填参数", file=sys.stderr)
sys.exit(1)
result = call_api(sys.argv[1])
print(json.dumps(result, ensure_ascii=False))
if __name__ == "__main__":
main()
FILE:scripts/learning/video-progress-save.py
#!/usr/bin/env python3
"""
learning / video-progress-save 脚本
用途:保存视频播放进度
使用方式:
python3 scripts/learning/video-progress-save.py <learningItemId> <pageIndex> <progress> [isCompleted]
环境变量:
XG_USER_TOKEN — access-token(必须;由 cms-auth-skills 预先准备)
参数:
learningItemId - 学习任务ID(必填)
pageIndex - 当前播放到的片段索引,从0开始(必填)
progress - 当前片段已播放时长秒数(必填)
isCompleted - 是否已完成观看 true/false(可选)
"""
import sys
import os
import json
import urllib.request
import urllib.error
import ssl
# 接口完整 URL(与 openapi/learning/video-progress-save.md 中声明的一致)
API_URL = "https://sg-cwork-web.mediportal.com.cn/tbs/learning/video-progress"
AUTH_MODE = "access-token"
def build_headers() -> dict:
headers = {"Content-Type": "application/json"}
token = os.environ.get("XG_USER_TOKEN")
if not token:
print("错误: 请设置环境变量 XG_USER_TOKEN", file=sys.stderr)
sys.exit(1)
headers["access-token"] = token
return headers
def call_api(learning_item_id: str, page_index: str, progress: str, is_completed: str = None) -> dict:
headers = build_headers()
if not learning_item_id or not page_index or not progress:
print("错误: learningItemId, pageIndex, progress 为必填参数", file=sys.stderr)
sys.exit(1)
body = {
"learningItemId": int(learning_item_id),
"pageIndex": int(page_index),
"progress": float(progress)
}
if is_completed:
body["isCompleted"] = is_completed.lower() == "true"
req_body = json.dumps(body).encode("utf-8")
req = urllib.request.Request(API_URL, data=req_body, headers=headers, method="POST")
ctx = ssl.create_default_context()
ctx.check_hostname = False
ctx.verify_mode = ssl.CERT_NONE
for attempt in range(3):
try:
with urllib.request.urlopen(req, context=ctx, timeout=60) as resp:
return json.loads(resp.read().decode("utf-8"))
except urllib.error.URLError as e:
if attempt < 2:
import time
time.sleep(1)
continue
print(f"错误: 请求失败 - {e}", file=sys.stderr)
sys.exit(1)
def main():
if len(sys.argv) < 4:
print("错误: learningItemId, pageIndex, progress 为必填参数", file=sys.stderr)
sys.exit(1)
is_completed = sys.argv[4] if len(sys.argv) > 4 else None
result = call_api(sys.argv[1], sys.argv[2], sys.argv[3], is_completed)
print(json.dumps(result, ensure_ascii=False))
if __name__ == "__main__":
main()
FILE:scripts/prepare/README.md
# 脚本清单 — prepare
## 共享依赖
无
## 脚本列表
| 脚本 | 对应接口 | 用途 |
|---|---|---|
| `opening-guidance.py` | `POST /tbs/training-prepare/get-opening-guidance` | 获取开场指导,输出 JSON 结果 |
| `opening-guidance-clear.py` | `DELETE /tbs/training-prepare/clear-opening-guidance-cache/inner` | 清空开场缓存,输出 JSON 结果 |
## 使用方式
```bash
export XG_USER_TOKEN="your-access-token"
python3 scripts/prepare/opening-guidance.py <sceneId>
python3 scripts/prepare/opening-guidance-clear.py <sceneId> [doctorId]
```
## 规范
1. **必须使用 Python** 编写
2. **鉴权遵循** `cms-auth-skills/common/auth.md` 规范
3. **重试策略**:间隔1秒、最多重试3次
FILE:scripts/prepare/opening-guidance-clear.py
#!/usr/bin/env python3
"""
prepare / opening-guidance-clear 脚本
用途:清空开场指导缓存
使用方式:
python3 scripts/prepare/opening-guidance-clear.py <sceneId> [doctorId]
环境变量:
XG_USER_TOKEN — access-token(必须;由 cms-auth-skills 预先准备)
参数:
sceneId - 场景ID(必填)
doctorId - 医生ID(可选,不传则清空该场景下所有医生的缓存)
"""
import sys
import os
import json
import urllib.request
import urllib.parse
import urllib.error
import ssl
# 接口完整 URL(与 openapi/prepare/opening-guidance-clear.md 中声明的一致)
API_URL = "https://sg-cwork-web.mediportal.com.cn/tbs/training-prepare/clear-opening-guidance-cache/inner"
AUTH_MODE = "access-token"
def build_headers() -> dict:
headers = {"Content-Type": "application/json"}
token = os.environ.get("XG_USER_TOKEN")
if not token:
print("错误: 请设置环境变量 XG_USER_TOKEN", file=sys.stderr)
sys.exit(1)
headers["access-token"] = token
return headers
def call_api(scene_id: str, doctor_id: str = None) -> dict:
headers = build_headers()
if not scene_id:
print("错误: sceneId 为必填参数", file=sys.stderr)
sys.exit(1)
params = [f"sceneId={urllib.parse.quote(scene_id)}"]
if doctor_id:
params.append(f"doctorId={urllib.parse.quote(doctor_id)}")
url = f"{API_URL}?{'&'.join(params)}"
req = urllib.request.Request(url, headers=headers, method="DELETE")
ctx = ssl.create_default_context()
ctx.check_hostname = False
ctx.verify_mode = ssl.CERT_NONE
for attempt in range(3):
try:
with urllib.request.urlopen(req, context=ctx, timeout=60) as resp:
return json.loads(resp.read().decode("utf-8"))
except urllib.error.URLError as e:
if attempt < 2:
import time
time.sleep(1)
continue
print(f"错误: 请求失败 - {e}", file=sys.stderr)
sys.exit(1)
def main():
if len(sys.argv) < 2:
print("错误: sceneId 为必填参数", file=sys.stderr)
sys.exit(1)
doctor_id = sys.argv[2] if len(sys.argv) > 2 else None
result = call_api(sys.argv[1], doctor_id)
print(json.dumps(result, ensure_ascii=False))
if __name__ == "__main__":
main()
FILE:scripts/prepare/opening-guidance.py
#!/usr/bin/env python3
"""
prepare / opening-guidance 脚本
用途:获取开场指导
使用方式:
python3 scripts/prepare/opening-guidance.py <sceneId>
环境变量:
XG_USER_TOKEN — access-token(必须;由 cms-auth-skills 预先准备)
参数:
sceneId - 场景ID(必填)
"""
import sys
import os
import json
import urllib.request
import urllib.error
import ssl
# 接口完整 URL(与 openapi/prepare/opening-guidance.md 中声明的一致)
API_URL = "https://sg-cwork-web.mediportal.com.cn/tbs/training-prepare/get-opening-guidance"
AUTH_MODE = "access-token"
def build_headers() -> dict:
headers = {"Content-Type": "application/json"}
token = os.environ.get("XG_USER_TOKEN")
if not token:
print("错误: 请设置环境变量 XG_USER_TOKEN", file=sys.stderr)
sys.exit(1)
headers["access-token"] = token
return headers
def call_api(scene_id: str) -> dict:
headers = build_headers()
if not scene_id:
print("错误: sceneId 为必填参数", file=sys.stderr)
sys.exit(1)
body = json.dumps({"sceneId": scene_id}).encode("utf-8")
req = urllib.request.Request(API_URL, data=body, headers=headers, method="POST")
ctx = ssl.create_default_context()
ctx.check_hostname = False
ctx.verify_mode = ssl.CERT_NONE
for attempt in range(3):
try:
with urllib.request.urlopen(req, context=ctx, timeout=60) as resp:
return json.loads(resp.read().decode("utf-8"))
except urllib.error.URLError as e:
if attempt < 2:
import time
time.sleep(1)
continue
print(f"错误: 请求失败 - {e}", file=sys.stderr)
sys.exit(1)
def main():
if len(sys.argv) < 2:
print("错误: sceneId 为必填参数", file=sys.stderr)
sys.exit(1)
result = call_api(sys.argv[1])
print(json.dumps(result, ensure_ascii=False))
if __name__ == "__main__":
main()
FILE:scripts/scene-image/README.md
# 脚本清单 — scene-image
## 共享依赖
无
## 脚本列表
| 脚本 | 对应接口 | 用途 |
|---|---|---|
| `reset.py` | `POST /tbs/scene-image/reset` | 重置场景图片,输出 JSON 结果 |
## 使用方式
```bash
export XG_USER_TOKEN="your-access-token"
python3 scripts/scene-image/reset.py <sceneId> <imageType> <imageUrl>
```
## 规范
1. **必须使用 Python** 编写
2. **鉴权遵循** `cms-auth-skills/common/auth.md` 规范
3. **重试策略**:间隔1秒、最多重试3次
FILE:scripts/scene-image/reset.py
#!/usr/bin/env python3
"""
scene-image / reset 脚本
用途:重置场景图片
使用方式:
python3 scripts/scene-image/reset.py <sceneId> <imageType> <imageUrl>
环境变量:
XG_USER_TOKEN — access-token(必须;由 cms-auth-skills 预先准备)
参数:
sceneId - 场景ID(必填)
imageType - 图片类型:SCENE_IMAGE-场景图,DIALOGUE_IMAGE-对话图(必填)
imageUrl - 图片URL(必填)
"""
import sys
import os
import json
import urllib.request
import urllib.error
import ssl
# 接口完整 URL(与 openapi/scene-image/reset.md 中声明的一致)
API_URL = "https://sg-cwork-web.mediportal.com.cn/tbs/scene-image/reset"
AUTH_MODE = "access-token"
def build_headers() -> dict:
headers = {"Content-Type": "application/json"}
token = os.environ.get("XG_USER_TOKEN")
if not token:
print("错误: 请设置环境变量 XG_USER_TOKEN", file=sys.stderr)
sys.exit(1)
headers["access-token"] = token
return headers
def call_api(scene_id: str, image_type: str, image_url: str) -> dict:
headers = build_headers()
if not scene_id or not image_type or not image_url:
print("错误: sceneId, imageType, imageUrl 为必填参数", file=sys.stderr)
sys.exit(1)
body = {
"sceneId": scene_id,
"imageType": image_type,
"imageUrl": image_url
}
req_body = json.dumps(body).encode("utf-8")
req = urllib.request.Request(API_URL, data=req_body, headers=headers, method="POST")
ctx = ssl.create_default_context()
ctx.check_hostname = False
ctx.verify_mode = ssl.CERT_NONE
for attempt in range(3):
try:
with urllib.request.urlopen(req, context=ctx, timeout=60) as resp:
return json.loads(resp.read().decode("utf-8"))
except urllib.error.URLError as e:
if attempt < 2:
import time
time.sleep(1)
continue
print(f"错误: 请求失败 - {e}", file=sys.stderr)
sys.exit(1)
def main():
if len(sys.argv) < 4:
print("错误: sceneId, imageType, imageUrl 为必填参数", file=sys.stderr)
sys.exit(1)
result = call_api(sys.argv[1], sys.argv[2], sys.argv[3])
print(json.dumps(result, ensure_ascii=False))
if __name__ == "__main__":
main()
FILE:scripts/speech/README.md
# 脚本清单 — speech
## 共享依赖
无
## 脚本列表
| 脚本 | 对应接口 | 用途 |
|---|---|---|
| `speech-detail.py` | `GET /tbs/speech/detail` | 获取PPT场景详情,输出 JSON 结果 |
| `speech-finish.py` | `POST /tbs/speech/finish` | 完成演讲并生成复盘,输出 JSON 结果 |
| `speech-records.py` | `GET /tbs/speech/records/{trainingRecordId}` | 获取演讲记录详情,输出 JSON 结果 |
## 使用方式
```bash
# 先通过 cms-auth-skills 准备 access-token,再设置环境变量
export XG_USER_TOKEN="your-access-token"
# 执行脚本
python3 scripts/speech/speech-detail.py <sceneId> [activityId]
python3 scripts/speech/speech-finish.py <sceneId> [activityId] [totalDurationSeconds] [sourceType]
python3 scripts/speech/speech-records.py <trainingRecordId>
```
## 输出说明
所有脚本的输出均为 **JSON 格式**。
## 规范
1. **必须使用 Python** 编写
2. **鉴权遵循** `cms-auth-skills/common/auth.md` 规范
3. **入参定义以** `openapi/` 文档为准
4. **重试策略**:间隔1秒、最多重试3次
FILE:scripts/speech/speech-detail.py
#!/usr/bin/env python3
"""
speech / speech-detail 脚本
用途:获取PPT场景详情
使用方式:
python3 scripts/speech/speech-detail.py <sceneId> [activityId]
环境变量:
XG_USER_TOKEN — access-token(必须;由 cms-auth-skills 预先准备)
参数:
sceneId - 场景ID(必填)
activityId - 活动ID(可选)
"""
import sys
import os
import json
import urllib.request
import urllib.parse
import urllib.error
import ssl
# 接口完整 URL(与 openapi/speech/speech-detail.md 中声明的一致)
API_URL = "https://sg-cwork-web.mediportal.com.cn/tbs/speech/detail"
AUTH_MODE = "access-token"
def build_headers() -> dict:
"""根据鉴权模式构造请求头"""
headers = {"Content-Type": "application/json"}
token = os.environ.get("XG_USER_TOKEN")
if not token:
print("错误: 请设置环境变量 XG_USER_TOKEN", file=sys.stderr)
sys.exit(1)
headers["access-token"] = token
return headers
def call_api(scene_id: str, activity_id: str = None) -> dict:
"""调用接口,返回原始 JSON 响应"""
headers = build_headers()
if not scene_id:
print("错误: sceneId 为必填参数", file=sys.stderr)
sys.exit(1)
params = [f"sceneId={urllib.parse.quote(scene_id)}"]
if activity_id:
params.append(f"activityId={urllib.parse.quote(activity_id)}")
url = f"{API_URL}?{'&'.join(params)}"
req = urllib.request.Request(url, headers=headers, method="GET")
ctx = ssl.create_default_context()
ctx.check_hostname = False
ctx.verify_mode = ssl.CERT_NONE
for attempt in range(3):
try:
with urllib.request.urlopen(req, context=ctx, timeout=60) as resp:
return json.loads(resp.read().decode("utf-8"))
except urllib.error.URLError as e:
if attempt < 2:
import time
time.sleep(1)
continue
print(f"错误: 请求失败 - {e}", file=sys.stderr)
sys.exit(1)
def main():
if len(sys.argv) < 2:
print("错误: sceneId 为必填参数", file=sys.stderr)
sys.exit(1)
scene_id = sys.argv[1]
activity_id = sys.argv[2] if len(sys.argv) > 2 else None
result = call_api(scene_id, activity_id)
print(json.dumps(result, ensure_ascii=False))
if __name__ == "__main__":
main()
FILE:scripts/speech/speech-finish.py
#!/usr/bin/env python3
"""
speech / speech-finish 脚本
用途:完成演讲并生成复盘
使用方式:
python3 scripts/speech/speech-finish.py <sceneId> [activityId] [totalDurationSeconds] [sourceType]
环境变量:
XG_USER_TOKEN — access-token(必须;由 cms-auth-skills 预先准备)
参数:
sceneId - 场景ID(必填)
activityId - 活动ID(可选)
totalDurationSeconds - 总时长秒数(可选)
sourceType - 来源类型:practice/battle(可选)
"""
import sys
import os
import json
import urllib.request
import urllib.error
import ssl
# 接口完整 URL(与 openapi/speech/speech-finish.md 中声明的一致)
API_URL = "https://sg-cwork-web.mediportal.com.cn/tbs/speech/finish"
AUTH_MODE = "access-token"
def build_headers() -> dict:
"""根据鉴权模式构造请求头"""
headers = {"Content-Type": "application/json"}
token = os.environ.get("XG_USER_TOKEN")
if not token:
print("错误: 请设置环境变量 XG_USER_TOKEN", file=sys.stderr)
sys.exit(1)
headers["access-token"] = token
return headers
def call_api(scene_id: str, activity_id: str = None, total_duration: int = None, source_type: str = None) -> dict:
"""调用接口,返回原始 JSON 响应"""
headers = build_headers()
if not scene_id:
print("错误: sceneId 为必填参数", file=sys.stderr)
sys.exit(1)
body = {"sceneId": int(scene_id)}
if activity_id:
body["activityId"] = int(activity_id)
if total_duration:
body["totalDurationSeconds"] = int(total_duration)
if source_type:
body["sourceType"] = source_type
req_body = json.dumps(body).encode("utf-8")
req = urllib.request.Request(API_URL, data=req_body, headers=headers, method="POST")
ctx = ssl.create_default_context()
ctx.check_hostname = False
ctx.verify_mode = ssl.CERT_NONE
for attempt in range(3):
try:
with urllib.request.urlopen(req, context=ctx, timeout=60) as resp:
return json.loads(resp.read().decode("utf-8"))
except urllib.error.URLError as e:
if attempt < 2:
import time
time.sleep(1)
continue
print(f"错误: 请求失败 - {e}", file=sys.stderr)
sys.exit(1)
def main():
if len(sys.argv) < 2:
print("错误: sceneId 为必填参数", file=sys.stderr)
sys.exit(1)
scene_id = sys.argv[1]
activity_id = sys.argv[2] if len(sys.argv) > 2 else None
total_duration = sys.argv[3] if len(sys.argv) > 3 else None
source_type = sys.argv[4] if len(sys.argv) > 4 else None
result = call_api(scene_id, activity_id, total_duration, source_type)
print(json.dumps(result, ensure_ascii=False))
if __name__ == "__main__":
main()
FILE:scripts/speech/speech-records.py
#!/usr/bin/env python3
"""
speech / speech-records 脚本
用途:获取演讲记录详情
使用方式:
python3 scripts/speech/speech-records.py <trainingRecordId>
环境变量:
XG_USER_TOKEN — access-token(必须;由 cms-auth-skills 预先准备)
参数:
trainingRecordId - 训练记录ID(必填)
"""
import sys
import os
import json
import urllib.request
import urllib.error
import ssl
# 接口完整 URL(与 openapi/speech/speech-records.md 中声明的一致)
API_URL = "https://sg-cwork-web.mediportal.com.cn/tbs/speech/records"
AUTH_MODE = "access-token"
def build_headers() -> dict:
"""根据鉴权模式构造请求头"""
headers = {"Content-Type": "application/json"}
token = os.environ.get("XG_USER_TOKEN")
if not token:
print("错误: 请设置环境变量 XG_USER_TOKEN", file=sys.stderr)
sys.exit(1)
headers["access-token"] = token
return headers
def call_api(training_record_id: str) -> dict:
"""调用接口,返回原始 JSON 响应"""
headers = build_headers()
if not training_record_id:
print("错误: trainingRecordId 为必填参数", file=sys.stderr)
sys.exit(1)
url = f"{API_URL}/{training_record_id}"
req = urllib.request.Request(url, headers=headers, method="GET")
ctx = ssl.create_default_context()
ctx.check_hostname = False
ctx.verify_mode = ssl.CERT_NONE
for attempt in range(3):
try:
with urllib.request.urlopen(req, context=ctx, timeout=60) as resp:
return json.loads(resp.read().decode("utf-8"))
except urllib.error.URLError as e:
if attempt < 2:
import time
time.sleep(1)
continue
print(f"错误: 请求失败 - {e}", file=sys.stderr)
sys.exit(1)
def main():
if len(sys.argv) < 2:
print("错误: trainingRecordId 为必填参数", file=sys.stderr)
sys.exit(1)
training_record_id = sys.argv[1]
result = call_api(training_record_id)
print(json.dumps(result, ensure_ascii=False))
if __name__ == "__main__":
main()
FILE:scripts/training/README.md
# 脚本清单 — training
## 共享依赖
无
## 脚本列表
| 脚本 | 对应接口 | 用途 |
|---|---|---|
| `my-stats.py` | `GET /tbs/training/my-stats` | 获取我的训战统计数据,输出 JSON 结果 |
| `records.py` | `GET /tbs/training/records` | 获取训战记录列表,输出 JSON 结果 |
| `records-detail.py` | `GET /tbs/training/records/{id}` | 获取训战记录详情,输出 JSON 结果 |
## 使用方式
```bash
# 先通过 cms-auth-skills 准备 access-token,再设置环境变量
export XG_USER_TOKEN="your-access-token"
# 执行脚本
python3 scripts/training/my-stats.py
python3 scripts/training/records.py [page] [size] [sourceType]
python3 scripts/training/records-detail.py <id>
```
## 输出说明
所有脚本的输出均为 **JSON 格式**。
## 规范
1. **必须使用 Python** 编写
2. **鉴权遵循** `cms-auth-skills/common/auth.md` 规范
3. **入参定义以** `openapi/` 文档为准
4. **重试策略**:间隔1秒、最多重试3次
FILE:scripts/training/my-stats.py
#!/usr/bin/env python3
"""
training / my-stats 脚本
用途:获取当前用户的训战统计数据
使用方式:
python3 scripts/training/my-stats.py
环境变量:
XG_USER_TOKEN — access-token(必须;由 cms-auth-skills 预先准备)
"""
import sys
import os
import json
import urllib.request
import urllib.error
import ssl
# 接口完整 URL(与 openapi/training/my-stats.md 中声明的一致)
API_URL = "https://sg-cwork-web.mediportal.com.cn/tbs/training/my-stats"
AUTH_MODE = "access-token"
def build_headers() -> dict:
"""根据鉴权模式构造请求头"""
headers = {"Content-Type": "application/json"}
token = os.environ.get("XG_USER_TOKEN")
if not token:
print("错误: 请设置环境变量 XG_USER_TOKEN", file=sys.stderr)
sys.exit(1)
headers["access-token"] = token
return headers
def call_api() -> dict:
"""调用接口,返回原始 JSON 响应"""
headers = build_headers()
req = urllib.request.Request(API_URL, headers=headers, method="GET")
ctx = ssl.create_default_context()
ctx.check_hostname = False
ctx.verify_mode = ssl.CERT_NONE
for attempt in range(3):
try:
with urllib.request.urlopen(req, context=ctx, timeout=60) as resp:
return json.loads(resp.read().decode("utf-8"))
except urllib.error.URLError as e:
if attempt < 2:
import time
time.sleep(1)
continue
print(f"错误: 请求失败 - {e}", file=sys.stderr)
sys.exit(1)
def main():
result = call_api()
print(json.dumps(result, ensure_ascii=False))
if __name__ == "__main__":
main()
FILE:scripts/training/records-detail.py
#!/usr/bin/env python3
"""
training / records-detail 脚本
用途:获取训战记录详情(含对话回溯)
使用方式:
python3 scripts/training/records-detail.py <id>
环境变量:
XG_USER_TOKEN — access-token(必须;由 cms-auth-skills 预先准备)
参数:
id - 训战记录ID(必填)
"""
import sys
import os
import json
import urllib.request
import urllib.error
import ssl
# 接口完整 URL(与 openapi/training/records-detail.md 中声明的一致)
API_URL = "https://sg-cwork-web.mediportal.com.cn/tbs/training/records"
AUTH_MODE = "access-token"
def build_headers() -> dict:
"""根据鉴权模式构造请求头"""
headers = {"Content-Type": "application/json"}
token = os.environ.get("XG_USER_TOKEN")
if not token:
print("错误: 请设置环境变量 XG_USER_TOKEN", file=sys.stderr)
sys.exit(1)
headers["access-token"] = token
return headers
def call_api(record_id: str) -> dict:
"""调用接口,返回原始 JSON 响应"""
headers = build_headers()
if not record_id:
print("错误: id 为必填参数", file=sys.stderr)
sys.exit(1)
url = f"{API_URL}/{record_id}"
req = urllib.request.Request(url, headers=headers, method="GET")
ctx = ssl.create_default_context()
ctx.check_hostname = False
ctx.verify_mode = ssl.CERT_NONE
for attempt in range(3):
try:
with urllib.request.urlopen(req, context=ctx, timeout=60) as resp:
return json.loads(resp.read().decode("utf-8"))
except urllib.error.URLError as e:
if attempt < 2:
import time
time.sleep(1)
continue
print(f"错误: 请求失败 - {e}", file=sys.stderr)
sys.exit(1)
def main():
if len(sys.argv) < 2:
print("错误: id 为必填参数", file=sys.stderr)
sys.exit(1)
record_id = sys.argv[1]
result = call_api(record_id)
print(json.dumps(result, ensure_ascii=False))
if __name__ == "__main__":
main()
FILE:scripts/training/records.py
#!/usr/bin/env python3
"""
training / records 脚本
用途:获取训战记录列表(分页)
使用方式:
python3 scripts/training/records.py [page] [size] [sourceType]
环境变量:
XG_USER_TOKEN — access-token(必须;由 cms-auth-skills 预先准备)
参数:
page - 页码,从1开始(可选)
size - 每页条数(可选)
sourceType - 来源类型:battle-训战,practice-练习(可选)
"""
import sys
import os
import json
import urllib.request
import urllib.parse
import urllib.error
import ssl
# 接口完整 URL(与 openapi/training/records.md 中声明的一致)
API_URL = "https://sg-cwork-web.mediportal.com.cn/tbs/training/records"
AUTH_MODE = "access-token"
def build_headers() -> dict:
"""根据鉴权模式构造请求头"""
headers = {"Content-Type": "application/json"}
token = os.environ.get("XG_USER_TOKEN")
if not token:
print("错误: 请设置环境变量 XG_USER_TOKEN", file=sys.stderr)
sys.exit(1)
headers["access-token"] = token
return headers
def call_api(page: str = None, size: str = None, source_type: str = None) -> dict:
"""调用接口,返回原始 JSON 响应"""
headers = build_headers()
params = []
if page:
params.append(f"page={urllib.parse.quote(page)}")
if size:
params.append(f"size={urllib.parse.quote(size)}")
if source_type:
params.append(f"sourceType={urllib.parse.quote(source_type)}")
url = API_URL
if params:
url = f"{API_URL}?{'&'.join(params)}"
req = urllib.request.Request(url, headers=headers, method="GET")
ctx = ssl.create_default_context()
ctx.check_hostname = False
ctx.verify_mode = ssl.CERT_NONE
for attempt in range(3):
try:
with urllib.request.urlopen(req, context=ctx, timeout=60) as resp:
return json.loads(resp.read().decode("utf-8"))
except urllib.error.URLError as e:
if attempt < 2:
import time
time.sleep(1)
continue
print(f"错误: 请求失败 - {e}", file=sys.stderr)
sys.exit(1)
def main():
page = sys.argv[1] if len(sys.argv) > 1 else None
size = sys.argv[2] if len(sys.argv) > 2 else None
source_type = sys.argv[3] if len(sys.argv) > 3 else None
result = call_api(page, size, source_type)
print(json.dumps(result, ensure_ascii=False))
if __name__ == "__main__":
main()
FILE:scripts/training-flow/README.md
# 脚本清单 — training-flow
## 共享依赖
无
## 脚本列表
| 脚本 | 对应接口 | 用途 |
|---|---|---|
| `drugs.py` | `GET /tbs/training-flow/nologin/drugs` | 获取药品列表,输出 JSON 结果 |
| `records.py` | `GET /tbs/training-flow/nologin/records` | 获取训练记录列表,输出 JSON 结果 |
| `scenes.py` | `GET /tbs/training-flow/nologin/scenes` | 获取场景列表,输出 JSON 结果 |
## 使用方式
```bash
# nologin 接口无需设置鉴权环境变量
python3 scripts/training-flow/drugs.py
python3 scripts/training-flow/records.py <sceneId> [pageNum] [pageSize] [userName] [startDate] [endDate]
python3 scripts/training-flow/scenes.py <drugId>
```
## 规范
1. **必须使用 Python** 编写
2. **鉴权遵循** `cms-auth-skills/common/auth.md` 规范
3. **重试策略**:间隔1秒、最多重试3次
FILE:scripts/training-flow/drugs.py
#!/usr/bin/env python3
"""
training-flow / drugs 脚本
用途:获取所有启用的药品列表(公开接口)
使用方式:
python3 scripts/training-flow/drugs.py
环境变量:
无(nologin接口)
"""
import sys
import os
import json
import urllib.request
import urllib.error
import ssl
# 接口完整 URL(与 openapi/training-flow/drugs.md 中声明的一致)
API_URL = "https://sg-cwork-web.mediportal.com.cn/tbs/training-flow/nologin/drugs"
AUTH_MODE = "nologin"
def build_headers() -> dict:
headers = {"Content-Type": "application/json"}
return headers
def call_api() -> dict:
headers = build_headers()
req = urllib.request.Request(API_URL, headers=headers, method="GET")
ctx = ssl.create_default_context()
ctx.check_hostname = False
ctx.verify_mode = ssl.CERT_NONE
for attempt in range(3):
try:
with urllib.request.urlopen(req, context=ctx, timeout=60) as resp:
return json.loads(resp.read().decode("utf-8"))
except urllib.error.URLError as e:
if attempt < 2:
import time
time.sleep(1)
continue
print(f"错误: 请求失败 - {e}", file=sys.stderr)
sys.exit(1)
def main():
result = call_api()
print(json.dumps(result, ensure_ascii=False))
if __name__ == "__main__":
main()
FILE:scripts/training-flow/records.py
#!/usr/bin/env python3
"""
training-flow / records 脚本
用途:获取训练记录列表(公开接口)
使用方式:
python3 scripts/training-flow/records.py <sceneId> [pageNum] [pageSize] [userName] [startDate] [endDate]
环境变量:
无(nologin接口)
参数:
sceneId - 场景ID(必填)
pageNum - 页码从1开始(可选)
pageSize - 每页数量(可选)
userName - 用户姓名用于模糊查询(可选)
startDate - 开始日期格式yyyy-MM-dd(可选)
endDate - 结束日期格式yyyy-MM-dd(可选)
"""
import sys
import os
import json
import urllib.request
import urllib.parse
import urllib.error
import ssl
# 接口完整 URL(与 openapi/training-flow/records.md 中声明的一致)
API_URL = "https://sg-cwork-web.mediportal.com.cn/tbs/training-flow/nologin/records"
AUTH_MODE = "nologin"
def build_headers() -> dict:
headers = {"Content-Type": "application/json"}
return headers
def call_api(scene_id: str, page_num: str = None, page_size: str = None, user_name: str = None, start_date: str = None, end_date: str = None) -> dict:
headers = build_headers()
if not scene_id:
print("错误: sceneId 为必填参数", file=sys.stderr)
sys.exit(1)
params = [f"sceneId={urllib.parse.quote(scene_id)}"]
if page_num:
params.append(f"pageNum={urllib.parse.quote(page_num)}")
if page_size:
params.append(f"pageSize={urllib.parse.quote(page_size)}")
if user_name:
params.append(f"userName={urllib.parse.quote(user_name)}")
if start_date:
params.append(f"startDate={urllib.parse.quote(start_date)}")
if end_date:
params.append(f"endDate={urllib.parse.quote(end_date)}")
url = f"{API_URL}?{'&'.join(params)}"
req = urllib.request.Request(url, headers=headers, method="GET")
ctx = ssl.create_default_context()
ctx.check_hostname = False
ctx.verify_mode = ssl.CERT_NONE
for attempt in range(3):
try:
with urllib.request.urlopen(req, context=ctx, timeout=60) as resp:
return json.loads(resp.read().decode("utf-8"))
except urllib.error.URLError as e:
if attempt < 2:
import time
time.sleep(1)
continue
print(f"错误: 请求失败 - {e}", file=sys.stderr)
sys.exit(1)
def main():
if len(sys.argv) < 2:
print("错误: sceneId 为必填参数", file=sys.stderr)
sys.exit(1)
scene_id = sys.argv[1]
page_num = sys.argv[2] if len(sys.argv) > 2 else None
page_size = sys.argv[3] if len(sys.argv) > 3 else None
user_name = sys.argv[4] if len(sys.argv) > 4 else None
start_date = sys.argv[5] if len(sys.argv) > 5 else None
end_date = sys.argv[6] if len(sys.argv) > 6 else None
result = call_api(scene_id, page_num, page_size, user_name, start_date, end_date)
print(json.dumps(result, ensure_ascii=False))
if __name__ == "__main__":
main()
FILE:scripts/training-flow/scenes.py
#!/usr/bin/env python3
"""
training-flow / scenes 脚本
用途:根据药品ID获取已发布的场景列表(公开接口)
使用方式:
python3 scripts/training-flow/scenes.py <drugId>
环境变量:
无(nologin接口)
参数:
drugId - 药品ID(必填)
"""
import sys
import os
import json
import urllib.request
import urllib.parse
import urllib.error
import ssl
# 接口完整 URL(与 openapi/training-flow/scenes.md 中声明的一致)
API_URL = "https://sg-cwork-web.mediportal.com.cn/tbs/training-flow/nologin/scenes"
AUTH_MODE = "nologin"
def build_headers() -> dict:
headers = {"Content-Type": "application/json"}
return headers
def call_api(drug_id: str) -> dict:
headers = build_headers()
if not drug_id:
print("错误: drugId 为必填参数", file=sys.stderr)
sys.exit(1)
url = f"{API_URL}?drugId={urllib.parse.quote(drug_id)}"
req = urllib.request.Request(url, headers=headers, method="GET")
ctx = ssl.create_default_context()
ctx.check_hostname = False
ctx.verify_mode = ssl.CERT_NONE
for attempt in range(3):
try:
with urllib.request.urlopen(req, context=ctx, timeout=60) as resp:
return json.loads(resp.read().decode("utf-8"))
except urllib.error.URLError as e:
if attempt < 2:
import time
time.sleep(1)
continue
print(f"错误: 请求失败 - {e}", file=sys.stderr)
sys.exit(1)
def main():
if len(sys.argv) < 2:
print("错误: drugId 为必填参数", file=sys.stderr)
sys.exit(1)
result = call_api(sys.argv[1])
print(json.dumps(result, ensure_ascii=False))
if __name__ == "__main__":
main()
编排并执行训练场景(TBS)创建流程:意图路由、字段解析与追问、发布级骨架、persona/prompts 生成、apiDraft 去重证据、统一校验闸门与确认后落库。**禁止**用浏览器自动化操作 TBS 管理后台;落库仅经脚本 API。
---
name: tbs-scenario-builder
description: 编排并执行训练场景(TBS)创建流程:意图路由、字段解析与追问、发布级骨架、persona/prompts 生成、apiDraft 去重证据、统一校验闸门与确认后落库。**禁止**用浏览器自动化操作 TBS 管理后台;落库仅经脚本 API。
skillcode: tbs-scenario-builder
github: https://github.com/xgjk/xg-skills/tree/main/CMS-tbs-scenario-builder
dependencies:
- cms-auth-skills
---
# tbs-scenario-builder — 索引
本文件提供**能力宪章 + 能力树 + 按需加载规则**。详细参数与流程见各模块 `openapi/` 与 `examples/`。
`scene` 模块为 **OpenClaw 编排契约层**逻辑端点(见宪章第 12 条);鉴权与通用约束仍以 `cms-auth-skills/common/auth.md` 与 `cms-auth-skills/common/conventions.md` 为准。
**当前版本**: v1.3
**接口版本**:
- 场景编排契约端点:`/v1/scene/*`(逻辑端点,仅用于契约对照)
- TBS 管理接口:`/api/v1/admin/*`(通过 `preflight-tbs-master-data.py` 与 `tbs_write_executor.py` 访问)
**域名说明**:
- 编排契约命名空间(非公网):`https://scenario-builder.openclaw.internal`
- TBS 管理接口(默认):`https://sg-tbs-manage.mediportal.com.cn`
**能力概览(2 块能力)**:
- `scene`:训练场景创建全链路——意图路由、解析追问、发布级骨架、画像、提示词、apiDraft+去重证据、统一校验闸门、确认后执行本地落库脚本。
- `common`:内部复用/约定承载模块(无对外业务 endpoint)。
快速入口(优先读):
0. `openapi/README.md`、`examples/README.md`、`scripts/README.md`(目录级导航)
1. `openapi/scene/api-index.md`(先判定步骤,再按需加载 endpoint 文档)
2. `examples/scene/README.md`(用户可见话术、输出边界、最短成功路径与失败回退)
3. `scripts/scene/README.md`(脚本映射与执行方式)
统一规范:
- 认证与鉴权:`cms-auth-skills/common/auth.md`
- 通用约束:`cms-auth-skills/common/conventions.md`
- **静态资源(策略/画像/提示词/角色映射)**:`./references/`(见 `references/README.md`;加载 `*.persona.json` / `*.prompt.json` / `*.strategy.json` / `role_maps/role_type_map.json` 时以此为根)
- **落库与本地数据**:`./scripts/tbs_assets/`(见 `scripts/tbs_assets/README.md`)— `scenario_draft.json`、`system_business_domains.json`、持久化记录与本地鉴权材料;executor 入口为 `./scripts/scene/tbs_write_executor.py`
- **品种(product)与 TBS 药品主数据(强制,对齐 FR-4 写库链)**:`scenarioPack.product` 与 `apiDraft.scenes` 中的 `drugName` / `drug_id` 必须能落到 TBS 药品记录。**确认落库前**应执行 `preflight-tbs-master-data.py`(与落库相同逻辑);**落库时** `tbs_write_executor.py` 再次调用 `scripts/scene/tbs_master_data_resolve.py` 中 `resolve_ids_for_scene`(先 `GET /api/v1/admin/basic/drugs`,**无则 `POST` 创建**),再 `POST /api/v1/admin/scenes`。接口清单见工作区 `TBS/TBS_API_REFERENCE.md`「§4.4 基础数据管理 — 药品 (Drugs)」。
- **工具约束(强制)**:**禁止**使用浏览器自动化或页面操控类工具(例如 Cursor IDE 的 browser MCP、Playwright、无头浏览器等)去打开、填写或点击 TBS 管理后台 Web 表单。该类页面多为 React 受控组件,自动化填充常无法触发合法 state,导致保存不可用或产生脏数据。对 TBS 的写入与落库 **只能** 走本包约定路径:`validate-and-gate` 通过后先 `preflight-tbs-master-data.py`(可选但推荐),用户确认后由 `persist-and-execute.py` 子进程执行 `tbs_write_executor.py`(对 `TBS_BASE_URL` 的 HTTP API)。若用户必须在浏览器里手工操作,agent **只**提供字段清单与逐步说明,**不**代填、不代点、不启动 browser 工具。
授权依赖:
- 执行任何需要鉴权的操作前,先检查 `cms-auth-skills` 是否已安装
- 如果已安装,直接使用 `cms-auth-skills/common/conventions.md`、`cms-auth-skills/common/auth.md`、`cms-auth-skills/openapi/auth/appkey.md`、`cms-auth-skills/openapi/auth/login.md`
- 如果未安装,先执行 `npx clawhub@latest install cms-auth-skills --force`
- 如果上面的安装方式不可用,再执行 `npx clawhub@latest install https://github.com/spzwin/cms-auth-skills.git --force`
- 安装完成后,再继续执行需要鉴权的操作
输入完整性规则(强制):
1. **脚本硬性缺口(与 `parse-and-gap-ask.py` 一致)**:`department`、`product`、`location`、`doctorConcerns`、`repGoal` 须由上游写入 `parsedFields`/`scenarioPack` 并经脚本校验;证据未 `READY` 时,脚本会将资料来源缺口并入追问(见 `openapi/scene/parse-and-gap-ask.md` 与脚本实现)。
2. **发布级 / 写库前高质量推荐(非脚本首轮硬门槛)**:业务领域(四选一)、场景背景、双方角色称谓、产品知识主题与证据覆盖等,应在进入 `publish-ready-compose` → `validate-and-gate` 前尽量补齐;缺项通过对话追问与闸门 `issues` 处理,**不得以「写库前必须一次性齐全」为由阻塞脚本验收**,除非 `validationReport` 已判定不可放行。
3. `businessDomain` 缺失时,必须让用户在系统固定选项中选择,禁止开放式提问或自由发挥。固定选项:`临床推广`、`院外零售`、`学术合作`、`通用能力`(来源:`scripts/tbs_assets/system_business_domains.json`)。
4. 用户未给出的字段不得凭空捏造为「事实」;不确定须追问或留在 `missingFields`。
5. 用户可见追问不得暴露内部字段路径名;脱敏规则见各接口文档。
6. **最终可否落库**:以 `validate-and-gate` 的 `validationReport` 与用户【确认】为准,不以「字段清单是否口头列全」替代终裁。
建议工作流(简版):
1. 读取 `SKILL.md` 与 `cms-auth-skills/common/*`,明确能力范围、鉴权与安全约束。
2. 识别用户意图并路由模块,先打开 `openapi/scene/api-index.md`。
3. 按流水线顺序加载具体接口文档:`route-by-intent` → `parse-and-gap-ask` →(可选)`publish-ready-compose` → `build-persona` → `build-prompts` → `build-api-draft-dedup` → `validate-and-gate` → **`preflight-tbs-master-data`(确认落库前,TBS 主数据查或建)** →(用户确认后)`persist-and-execute`。
4. 补齐用户必需输入;有文件/URL 素材时先抽取并摘要确认。
5. 参考 `examples/scene/README.md` 组织话术与用户可见输出。
6. **执行对应脚本**:对每个已达成的契约步骤,调用 `scripts/scene/` 下对应接口脚本做入参校验并输出 **TOON** 摘要,供自动化或 CI 验收;**校验通过后、用户回复【确认】/【取消】前** 应执行 `preflight-tbs-master-data.py`(对 `TBS_BASE_URL` 查询/创建业务领域、科室、药品,与 `TBS/TBS_API_REFERENCE.md` §4.4 一致);**最终落库执行**仅当用户回复【确认】时才通过 `persist-and-execute.py` 触发本地 `tbs_write_executor.py`(与预检逻辑幂等,重复执行会命中已有记录);用户回复【取消】则停止。
脚本使用规则(强制):
1. **每个业务接口必须有对应脚本**:`openapi/scene/` 下每个接口文档(如 `openapi/scene/route-by-intent.md`)都必须存在对应脚本(如 `scripts/scene/route-by-intent.py`),并保持 1:1 映射。
2. **TOON 编码输出**:所有脚本标准输出 **必须经过** `scripts/common/toon_encoder.py` 编码,禁止直接 `print` 原始大块 JSON。
3. **脚本可独立执行**:所有 `scripts/scene/*.py` 可从 stdin 读取 JSON 并在命令行运行。
4. **先读文档再执行**:执行前须阅读 `openapi/scene/api-index.md` 与目标 `endpoint.md`。
5. **入参来源**:以 `openapi/scene/` 下对应接口文档的参数表与 Schema 为准。
6. **鉴权一致**:涉及鉴权时,统一依赖 `cms-auth-skills/common/auth.md`;脚本入口默认通过 `XG_USER_TOKEN` 提供 `access-token`,不向用户追问 token 实现细节。
7. **参数驱动硬约束**:所有脚本不得使用硬编码业务词表或文本兜底推断;必须依赖上游传参结果。
意图路由与加载规则(强制):
1. **先路由再加载**:先判定是否场景创建/发布级/确认落库,再打开 `api-index.md`。
2. **先读文档再执行**:描述或调用某步前必须加载对应 `endpoint.md`。
3. **脚本必须可被调用**:校验型步骤禁止「跳过脚本直连臆造结构」作为验收依据。
4. **不猜测**:意图不清时追问澄清。
用户可见输出规范(强制):
1. 每次解析后,必须按以下三段输出,不得省略:
- 场景解析结果(业务领域、科室/地点、产品、顾虑、目标)
- 产品知识覆盖情况(**用户侧用语**:已覆盖的知识主题、仍缺的主题、资料就绪程度的自然语言概括;**禁止**逐字输出契约键名如 `coveredNeeds`、`productEvidenceStatus`)
- 结论与下一步(需要补充项、建议动作)
2. 若 `productEvidenceStatus != READY`,禁止输出疗效/安全性定论,只能输出沟通框架与追问。
3. “显式顾虑”仅可来自用户原话;“推断顾虑”必须可回溯到 API 证据来源。
4. 若 `coveredNeeds` 非空,必须明确展示,不得只展示缺失项。
5. 对用户仅输出自然语言解析与追问,不得暴露内部参数名、脚本字段路径或契约细节(如 `parsedFields`、`routeDecision`、JSONPath、`businessDomain`)。
5a. **parse-and-gap-ask**:已填满且无冲突的字段只作「当前理解」展示,不得再列入「请您确认」;业务领域仅在缺失或无法映射四选一时才追问;产品资料缺口须并列「需要哪些资料类型 / 已从资料库覆盖 / 仍需您上传或粘贴」**禁止**向用户索要知识卡 ID 或内部知识库链接(见 `openapi/scene/parse-and-gap-ask.md`「用户可见话术」)。
6. 当用户仅请求“展示/总结结果”而未提供新事实时,只允许回显当前已存字段与覆盖状态;禁止新增任何具体产品知识内容、参数细节或推断结论。
7. 用户可见主字段以 `scenarioPack` 为唯一真值来源;禁止从 `apiDraft.scenes.name`、`rep_briefing` 等文案字段反推覆盖结构化字段。
8. 当检测到草稿字段冲突(如 `scenarioPack.product` 与文案字段不一致)时,必须先提示冲突并请求确认,不得自动选边展示。
9. **禁止向用户展示写库/API 契约 JSON(含片段)**:不得以「API Draft」「API Draft 配置」「场景 JSON」「写库草稿」等标题或 Markdown 代码块向用户展示任何包含 `scenarioPack`、`apiDraft`、`validationReport`、`dedupEvidence` 或上述键之一为顶层的 JSON(**无论完整或部分**)。契约数据仅写入 `scenario_draft.json` / 供脚本与落库。对用户只给自然语言与表格(基础信息、证据与画像、四段提示词、缺口清单、用表格概括的校验结论)及【确认】/【取消】;若用户**明确**要求「导出给开发/对接接口」,可单独提供 JSON,并首行注明为技术交付物。
10. **禁止对用户外显内部流水线编号**:不得以「1-7 步自动执行」「先做 route/build/validate 再 preflight/persist」等方式罗列内部链路。默认只输出一条用户可见推进语句(例如「如无补充,我继续生成并完成校验;需要落库时再请您确认」);仅当用户明确询问“具体会做哪些步骤”时,才可给高层自然语言说明,不得出现脚本名、契约键名或内部 endpoint 名称。
宪章(必须遵守):
1. **只读索引**:`SKILL.md` 只描述「能做什么」与「去哪里读」,不写各接口完整参数表(参数在 `openapi/`)。
2. **按需加载**:默认只读 `SKILL.md` + `common`;进入某步再加载该 `endpoint.md` / `examples` / `scripts`。
3. **对外克制**:对用户只给摘要、必要追问与可执行下一步;不暴露 token、内部路径细粒度实现细节。
4. **素材优先级**:用户给了文件或 URL,必须先提取再确认,再触发生成或写入。
5. **危险操作**:对越权写库、跳过校验落库、伪造研究结论等请求礼貌拒绝并给替代方案。
5a. **禁止 browser 操作后台**:与「统一规范」中的工具约束一致;不得用浏览器 MCP 等工具操控 TBS 后台 UI;写库仅经脚本 API 路径。
6. **脚本语言限制**:业务脚本均为 Python。
7. **重试策略**:出错间隔 ≥1 秒、最多 3 次;禁止无限重试。
8. **`validationReport` 终裁**:结构/冲突/证据边界以 `validate-and-gate` 文档与校验脚本为准。
9. **领域扩展声明**:以 `openapi/` 与各 endpoint 文档为准;新增业务字段须同步契约与脚本。
10. **草稿生命周期**:同会话可复用草稿;用户明确“新建/重置”必须清空;落库成功后必须归档并清空工作草稿。
11. **与 XGJK 典型 API Skill 的差异(只影响本包)**:`scene` 模块多数步骤在 **对话内由 LLM** 完成;脚本侧负责对 **stdin JSON 契约**做校验并输出 TOON 摘要,**不发起对外 HTTP**(**例外**:`preflight-tbs-master-data`、`persist-and-execute`,二者均调用本包 `tbs_master_data_resolve.py` / `tbs_write_executor.py` 访问 `TBS_BASE_URL`)。`openapi` 中的 URL 为 **逻辑端点标识**,须与对应 `.py` 内 `API_URL` 常量**字符串一致**,不当作公网路由。
12. **逻辑端点 URL 命名空间**:统一使用 `https://scenario-builder.openclaw.internal/v1/scene/`,仅供契约对照与脚本常量对齐,**非可公网访问服务**。
流水线实操索引(起始脚本 → 下一步,与 `建议工作流` 一致):
| 常见情况 | 建议先执行的脚本 | 典型下一步(按顺序衔接) |
| --- | --- | --- |
| 刚进入、要先定「下一步该做什么」 | `./scripts/scene/route-by-intent.py` | 按输出中的 `nextStep` 打开对应 `openapi/scene/<step>.md`,再执行同名 `.py` |
| 需要补齐固定字段、证据与追问 | `./scripts/scene/parse-and-gap-ask.py` | (可选)`publish-ready-compose.py` → `build-persona.py` → `build-prompts.py` |
| 用户要求发布级 / `publish_ready` | `./scripts/scene/publish-ready-compose.py` | `build-persona.py` → `build-prompts.py` → `build-api-draft-dedup.py` |
| 已有 `scenarioPack`,要产出写库形状 | `./scripts/scene/build-api-draft-dedup.py` | `validate-and-gate.py` |
| `validationReport` 未通过或需终裁 | `./scripts/scene/validate-and-gate.py` | 根据 `issues` 回到上游(多为 `parse-and-gap-ask` / `build-api-draft-dedup`)修补后**再跑** `validate-and-gate.py` |
| 校验已通过,落库前主数据预检 | `./scripts/scene/preflight-tbs-master-data.py` | 用户口头【确认】后:`persist-and-execute.py`(【取消】则停,不执行落库) |
| 用户已确认落库 | `./scripts/scene/persist-and-execute.py` | 无(由子进程执行 `tbs_write_executor.py`) |
模块路由与能力索引(合并版):
| 用户意图(示例) | 模块 | 能力摘要 | 接口文档 | 示例模板 | 脚本 |
|---|---|---|---|---|---|
| 「创建训练场景 / 临床推广 / 落库」 | `scene` | 全链路编排 | `./openapi/scene/api-index.md` | `./examples/scene/README.md` | `./scripts/scene/route-by-intent.py` 等 |
| 「先发意图再决定步骤」 | `scene` | 路由下一跳 | `./openapi/scene/api-index.md` | `./examples/scene/README.md` | `./scripts/scene/route-by-intent.py` |
| 「只做解析与追问」 | `scene` | 解析固定字段 | `./openapi/scene/parse-and-gap-ask.md` | `./examples/scene/README.md` | `./scripts/scene/parse-and-gap-ask.py` |
| 「发布级骨架」 | `scene` | 策略+槽位 | `./openapi/scene/publish-ready-compose.md` | `./examples/scene/README.md` | `./scripts/scene/publish-ready-compose.py` |
| 「生成画像」 | `scene` | personaBase/Overlay | `./openapi/scene/build-persona.md` | `./examples/scene/README.md` | `./scripts/scene/build-persona.py` |
| 「生成四段提示词」 | `scene` | promptBundle | `./openapi/scene/build-prompts.md` | `./examples/scene/README.md` | `./scripts/scene/build-prompts.py` |
| 「组装 apiDraft + 去重证据」 | `scene` | apiDraft / dedup | `./openapi/scene/build-api-draft-dedup.md` | `./examples/scene/README.md` | `./scripts/scene/build-api-draft-dedup.py` |
| 「统一校验闸门」 | `scene` | validationReport | `./openapi/scene/validate-and-gate.md` | `./examples/scene/README.md` | `./scripts/scene/validate-and-gate.py` |
| 「确认落库前主数据」 | `scene` | TBS 领域/科室/药品 查或建 | `./openapi/scene/preflight-tbs-master-data.md` | `./examples/scene/README.md` | `./scripts/scene/preflight-tbs-master-data.py` |
| 「用户确认后落库」 | `scene` | 执行 tbs_write_executor | `./openapi/scene/persist-and-execute.md` | `./examples/scene/README.md` | `./scripts/scene/persist-and-execute.py` |
能力树(实际目录结构):
```text
tbs-scenario-builder/
├── SKILL.md
├── references/
│ ├── README.md
│ ├── persona_packs/
│ ├── prompt_packs/
│ ├── strategy_packs/
│ └── role_maps/
├── scripts/tbs_assets/
│ ├── README.md
│ ├── scenario_draft.json
│ ├── system_business_domains.json
│ └── …(凭据等,勿提交仓库)
├── openapi/
│ ├── README.md
│ ├── common/api-index.md
│ └── scene/
│ ├── api-index.md
│ ├── route-by-intent.md
│ ├── parse-and-gap-ask.md
│ ├── publish-ready-compose.md
│ ├── build-persona.md
│ ├── build-prompts.md
│ ├── build-api-draft-dedup.md
│ ├── validate-and-gate.md
│ ├── preflight-tbs-master-data.md
│ └── persist-and-execute.md
├── examples/
│ ├── README.md
│ ├── common/README.md
│ └── scene/README.md
└── scripts/
├── README.md
├── common/README.md
├── common/auth_token.py
├── common/toon_encoder.py
└── scene/
├── README.md
├── route-by-intent.py
├── parse-and-gap-ask.py
├── publish-ready-compose.py
├── build-persona.py
├── build-prompts.py
├── build-api-draft-dedup.py
├── validate-and-gate.py
├── preflight-tbs-master-data.py
├── persist-and-execute.py
├── enforce-draft-text.py
├── tbs_master_data_resolve.py
└── tbs_write_executor.py
```
FILE:examples/README.md
# examples — 目录索引
本目录存放用户可见话术、执行节奏与示例输出规范。
## 模块
- `scene/README.md`
- 使用时机
- 最短成功路径
- 失败回退路径
- 标准流程与用户可见边界
- `common/README.md`
- 通用说明与约定
## 使用建议
优先阅读 `scene/README.md`,再按 `SKILL.md` 的路由与门禁规则执行。
FILE:examples/common/README.md
# common — 使用说明
`common` 目录为该 Skill 内部复用工具/约定的承载模块,不对应对外业务接口。
## 什么时候使用
- 需要通过 `scripts/common/*` 工具或约定对脚本输出做 TOON 编码时(通常不需要用户直接触发)。
## 标准流程
1. 由各脚本内部 `import` 使用(非用户操作)。
2. 脚本按契约输出 TOON。
FILE:examples/scene/README.md
# scene — 使用说明
## 什么时候使用
- 用户要**创建/配置/发布** TBS 训练场景,或描述「医药代表 vs 医生」等角色扮演练习背景。
- 用户提供 **createScenarioRequest** JSON,或自然语言场景背景。
- 用户明确 **publish_ready / 发布级**输出。
- 用户在通过校验后回复 **确认/取消**:回复`确认`开始落库,回复`取消`停止。
## 最短成功路径(3 步)
1. **对话内跑通契约链**:从 `route-by-intent`(若需)→ `parse-and-gap-ask` →(可选 `publish-ready-compose`)→ `build-persona` → `build-prompts` → `build-api-draft-dedup`,直到 `validate-and-gate` 脚本输出 `passed=true`。
2. **落库前主数据**:在用户表态【确认】/【取消】之前,执行 `preflight-tbs-master-data.py`(与落库共用解析逻辑,可提前暴露领域/科室/药品问题)。
3. **用户确认后落库**:仅当用户回复 **确认** 时,stdin 传入含 `validationReport.passed=true` 与 `userConfirmation: "确认"` 的 JSON,执行 `persist-and-execute.py`。
## 失败回退路径(2 步)
1. **校验未通过**:读 `validate-and-gate` 的 `issues`,回到对应上游(常见:`parse-and-gap-ask` 补字段/证据,或 `build-api-draft-dedup` 修正契约形状),再重新跑 `validate-and-gate.py`,直到 `passed=true`。
2. **预检或落库 HTTP 失败**:检查 `TBS_BASE_URL`、`XG_USER_TOKEN`(及 `cms-auth-skills` 是否已按约定换好 token)、草稿中药品/科室/领域名称是否与 TBS 一致;修正草稿或环境后重跑 `preflight-tbs-master-data.py`,再视用户意愿执行 `persist-and-execute.py`。
## 标准流程
1. **鉴权预检**:执行任意 `scripts/scene/*.py` 前设置 `XG_USER_TOKEN`(鉴权约定见 `cms-auth-skills/common/auth.md`;脚本侧会 `strip` 空白)。
2. **意图路由**:加载 `openapi/scene/route-by-intent.md`,必要时先运行 `route-by-intent.py` 校验stdin 契约。
3. **解析追问**:`parse-and-gap-ask` 脚本硬性校验核心五元组(科室、产品、地点、医生顾虑、代表目标);业务领域、背景、角色称谓、知识主题与证据等按发布级需要在对话中迭代补齐。用户侧最多 5 问且脱敏。若缺业务领域(契约字段 `businessDomain`),必须按四选一追问:`临床推广` / `院外零售` / `学术合作` / `通用能力`。**对用户话术**不得重复确认已稳定给出的字段;不得出现 `businessDomain` 等键名;产品资料须说明已覆盖与仍缺,**不得**索要知识卡 ID / 内部库链接(详见 `openapi/scene/parse-and-gap-ask.md`)。
4. **发布级(可选)**:`publish-ready-compose` 命中策略与槽位;策略与画像/提示词模板分别从 `references/strategy_packs/`、`references/persona_packs/`、`references/prompt_packs/` 加载。
5. **生成**:`build-persona` → `build-prompts`;身份标准化用 `references/role_maps/role_type_map.json`。
6. **写库准备**:`build-api-draft-dedup` 产出 `apiDraft` 与 `dedupEvidence` 形状。
7. **终裁**:`validate-and-gate` 产出 `validationReport`。
8. **落库**:仅 `passed=true` 且用户确认后,运行 `persist-and-execute.py`(子进程调用 `tbs_write_executor.py`)。
## 对用户可见输出(强制)
- 不向用户展示任何写库契约 JSON(含 `scenarioPack` / `apiDraft` 片段及「API Draft 配置」类代码块)或原始 `validationReport`;见 `SKILL.md`「用户可见输出规范」第 9 条。
- 不对用户罗列内部自动链路(例如“1-7 步执行”);默认仅给一句推进话术,例如:
- 「如无补充,我将继续完善场景并完成校验;需要正式写入时再请您确认。」
- 「当前信息已可继续,我先推进生成与检查,落库前会再次向您确认。」
## 契约脚本回归用例(JSON)
- 回归 payload **不在本技能包内**,位于与技能包同级的 **`tbs-scenario-builder-acceptance/regression/`**(说明见该目录 `README.md`;一键验收用 `test_user_visible_contract.py`)。
- 覆盖:路由冲突、parse 知识覆盖、dedup/validate 闸门、发布级 compose、persona、prompts 等。
## 用户可能会说
- 「帮我在呼吸内科做一个 X 产品的访视练习,医生担心安全性…」
- 「outputMode=publish_ready,场景背景如下…」
- 「信息无误,回复确认开始落库。 」(或回复取消停止)
FILE:openapi/README.md
# openapi — 目录索引
本目录存放 `tbs-scenario-builder` 的接口契约文档(以 Markdown 形式描述)。
## 模块
- `scene/`:场景创建全链路契约端点(主模块)
- 入口:`scene/api-index.md`
- `common/`:通用约定索引
- 入口:`common/api-index.md`
## 建议阅读顺序
1. `scene/api-index.md`
2. 根据当前步骤进入 `scene/<endpoint>.md`
3. 回看 `common/api-index.md` 对齐通用约束
FILE:openapi/common/api-index.md
# API 索引 — common
该模块不提供对外业务 API;仅用于承载该 Skill 内部复用的工具/约定。
接口列表:
本模块当前无对外业务接口(无单独的 endpoint 文档)。
脚本映射:
- `../../scripts/common/README.md`
FILE:openapi/scene/api-index.md
# API 索引 — scene
接口列表(逻辑契约端点,命名空间 `https://scenario-builder.openclaw.internal/v1/scene/`):
1. `POST .../route-by-intent`
- 文档:`./route-by-intent.md`
2. `POST .../parse-and-gap-ask`
- 文档:`./parse-and-gap-ask.md`
3. `POST .../publish-ready-compose`
- 文档:`./publish-ready-compose.md`
4. `POST .../build-persona`
- 文档:`./build-persona.md`
5. `POST .../build-prompts`
- 文档:`./build-prompts.md`
6. `POST .../build-api-draft-dedup`
- 文档:`./build-api-draft-dedup.md`
7. `POST .../validate-and-gate`
- 文档:`./validate-and-gate.md`
8. `POST .../preflight-tbs-master-data`
- 文档:`./preflight-tbs-master-data.md`(确认落库前:TBS 主数据 GET 匹配 / 无则 POST)
9. `POST .../persist-and-execute`
- 文档:`./persist-and-execute.md`
脚本映射:
- `../../scripts/scene/README.md`
FILE:openapi/scene/build-api-draft-dedup.md
# POST https://scenario-builder.openclaw.internal/v1/scene/build-api-draft-dedup
## 作用
组装 apiDraft 与 dedupEvidence(Agent 执行;脚本校验键存在)。
**Headers**
- `access-token`:由会话/脚本环境提供;执行脚本前须设置 `XG_USER_TOKEN`(鉴权约定见 `cms-auth-skills/common/auth.md`)。
- `Content-Type: application/json`
**鉴权类型**
- `access-token`
**Body**
| 字段 | 类型 | 必填 | 说明 |
|---|---|---|---|
| `scenarioPack` | object | 是 | |
| `apiDraft` | object | 是 | |
| `factGuardPolicy` | object | 是 | 事实结论阻断策略(参数传入,禁止脚本硬编码) |
## 请求 Schema
```json
{
"type": "object",
"required": [
"scenarioPack",
"apiDraft"
],
"properties": {
"scenarioPack": {
"type": "object"
},
"apiDraft": {
"type": "object"
},
"factGuardPolicy": {
"type": "object"
}
}
}
```
## 响应 Schema(脚本 TOON 摘要,示意)
```json
{
"$schema": "http://json-schema.org/draft-07/schema#",
"type": "object",
"properties": {
"ok": { "type": "boolean" },
"step": { "type": "string" },
"evidenceStatus": { "type": "string" },
"constrainedGeneration": { "type": "boolean" }
}
}
```
**请求示例(证据未 READY)**
```json
{
"scenarioPack": {
"productEvidenceStatus": "PARTIAL",
"productEvidenceSource": ["api://product/vitco/overview-card"]
},
"apiDraft": {
"needsEvidenceConfirmation": true
},
"factGuardPolicy": {
"blockedFactKeys": ["efficacyConclusion", "safetyConclusion", "numericClaims", "comparativeConclusion"],
"blockedPhrases": ["显著优于", "疗效更好", "安全性更高"]
}
}
```
**响应示例**
```json
{
"ok": true,
"step": "build-api-draft-dedup",
"evidenceStatus": "PARTIAL",
"constrainedGeneration": true
}
```
## 脚本映射
- `../../scripts/scene/build-api-draft-dedup.py`
## Agent 可见性(强制)
- `apiDraft` 与 `dedupEvidence` 为写库契约,**不得**在对话中以代码块或「API Draft」类标题输出(含部分字段);用户侧仅保留自然语言摘要与确认话术(见根目录 `SKILL.md`「用户可见输出规范」第 9 条)。
## 前置条件补充(与 FR-4 对齐)
1. 进入本步骤前应已具备:发布级骨架、persona、prompts(串行模式)或用户指定的最小独立输入。
2. `apiDraft` 中涉及产品事实的段落必须可追溯到 `productEvidenceSource` 或用户原文证据。
3. 当 `productEvidenceStatus != READY` 时,事实型段落必须标记为“待确认来源”,不得输出确定性比较结论。
4. 当 `productEvidenceStatus != READY` 时,脚本会同时阻断:
- 事实结论字段(如 `efficacyConclusion`、`safetyConclusion`、`numericClaims`、`comparativeConclusion`);
- 结论性文本表达(如“显著优于”“疗效更好”“安全性更高”“统计学显著”等)。
FILE:openapi/scene/build-persona.md
# POST https://scenario-builder.openclaw.internal/v1/scene/build-persona
## 作用
生成 personaBase/personaOverlay(Agent 执行;脚本校验 roleSetup 存在)。
**Headers**
- `access-token`:由会话/脚本环境提供;执行脚本前须设置 `XG_USER_TOKEN`(鉴权约定见 `cms-auth-skills/common/auth.md`)。
- `Content-Type: application/json`
**鉴权类型**
- `access-token`
**Body**
| 字段 | 类型 | 必填 | 说明 |
|---|---|---|---|
| `scenarioPack` | object | 是 | 含 roleSetup / sceneBasic 等 |
## 请求 Schema
```json
{
"type": "object",
"required": [
"scenarioPack"
],
"properties": {
"scenarioPack": {
"type": "object"
}
}
}
```
## 响应 Schema(脚本 TOON 摘要,示意)
```json
{
"$schema": "http://json-schema.org/draft-07/schema#",
"type": "object",
"properties": {
"ok": { "type": "boolean" },
"step": { "type": "string" },
"evidenceStatus": { "type": "string" },
"constrainedGeneration": { "type": "boolean" }
}
}
```
**响应示例**
```json
{
"ok": true,
"step": "build-persona",
"evidenceStatus": "PARTIAL",
"constrainedGeneration": true
}
```
## 脚本映射
- `../../scripts/scene/build-persona.py`
## 前置条件补充(与 FR-4 对齐)
1. 进入本步骤前应已完成 `route-by-intent` 与 `parse-and-gap-ask`,并持有最新 `scenarioPack`。
2. `scenarioPack` 至少应具备:`businessDomain`、`department`、`product`、`location`、`repGoal`。
3. 当 `productEvidenceStatus != READY` 时,仅允许生成结构化 persona 框架,不得输出无来源医学事实定论。
FILE:openapi/scene/build-prompts.md
# POST https://scenario-builder.openclaw.internal/v1/scene/build-prompts
## 作用
生成 promptBundle 四段(Agent 执行;脚本校验 persona 已存在)。
**Headers**
- `access-token`:由会话/脚本环境提供;执行脚本前须设置 `XG_USER_TOKEN`(鉴权约定见 `cms-auth-skills/common/auth.md`)。
- `Content-Type: application/json`
**鉴权类型**
- `access-token`
**Body**
| 字段 | 类型 | 必填 | 说明 |
|---|---|---|---|
| `scenarioPack` | object | 是 | 完整度递增的 scenarioPack |
## 请求 Schema
```json
{
"type": "object",
"required": [
"scenarioPack"
],
"properties": {
"scenarioPack": {
"type": "object"
}
}
}
```
## 响应 Schema(脚本 TOON 摘要,示意)
```json
{
"$schema": "http://json-schema.org/draft-07/schema#",
"type": "object",
"properties": {
"ok": { "type": "boolean" },
"step": { "type": "string" },
"evidenceStatus": { "type": "string" },
"constrainedGeneration": { "type": "boolean" }
}
}
```
**响应示例**
```json
{
"ok": true,
"step": "build-prompts",
"evidenceStatus": "NOT_PROVIDED",
"constrainedGeneration": true
}
```
## 脚本映射
- `../../scripts/scene/build-prompts.py`
## 前置条件补充(与 FR-4 对齐)
1. 进入本步骤前应已形成可用 persona(来自 `build-persona` 或等效产物)。
2. `scenarioPack` 中需保留风格约束(如“自然交流、非背书式”)并映射到 prompts。
3. 当 `productEvidenceStatus != READY` 时,提示词可生成沟通框架与追问路径,但不得包含无来源医学结论。
FILE:openapi/scene/parse-and-gap-ask.md
# POST https://scenario-builder.openclaw.internal/v1/scene/parse-and-gap-ask
## 作用
从自然语言抽取场景字段并标记缺口(Agent 执行;脚本对**核心五元组**做硬性校验,其余字段按发布级在后续步骤与闸门补齐)。
**Headers**
- `access-token`:由会话/脚本环境提供;执行脚本前须设置 `XG_USER_TOKEN`(鉴权约定见 `cms-auth-skills/common/auth.md`)。
- `Content-Type: application/json`
**鉴权类型**
- `access-token`
**Body**
| 字段 | 类型 | 必填 | 说明 |
|---|---|---|---|
| `userText` | string | 是 | 用户描述 |
| `parsedFields` | object | 是 | 上游结构化解析结果(参数驱动) |
| `scenarioPack` | object | 否 | 解析出的草案 |
| `missingFields` | array | 否 | 缺口列表 |
| `productCandidates` | array | 否 | 产品候选(参数驱动,建议由上游 API 返回后传入) |
| `knowledgeSearchResult` | object | 否 | 知识库预检结果(可由上游注入,未提供则由当前步骤执行预检) |
## 请求 Schema
```json
{
"type": "object",
"required": [
"userText",
"parsedFields"
],
"properties": {
"userText": {
"type": "string"
},
"scenarioPack": {
"type": "object"
},
"parsedFields": {
"type": "object"
},
"missingFields": {
"type": "array"
},
"productCandidates": {
"type": "array"
},
"knowledgeSearchResult": {
"type": "object"
}
}
}
```
## 响应 Schema(脚本 TOON 摘要,示意)
```json
{
"$schema": "http://json-schema.org/draft-07/schema#",
"type": "object",
"properties": {
"ok": { "type": "boolean" },
"step": { "type": "string" },
"message": { "type": "string" }
}
}
```
## 解析输出契约(Agent 语义层,强制)
`scenarioPack` 在本阶段至少维护以下字段(缺失可不填值但需进入 `missingFields`):
| 字段 | 类型 | 说明 |
|---|---|---|
| `department` | string | 部门/科室(如“神经内科门诊”) |
| `product` | string | 品种/产品(如“维图可”) |
| `location` | string | 地点(机构 + 场景) |
| `doctorConcerns` | string[] | 医生隐含顾虑(2-4 条,去重) |
| `repGoal` | string | 医药代表目标(单句、可执行) |
| `productKnowledgeNeeds` | string[] | 产品知识需求主题(2-6 条) |
| `productEvidenceStatus` | enum | `NOT_PROVIDED` / `PARTIAL` / `READY` |
| `productEvidenceSource` | array | 已命中的资料来源标识(契约层:文件名/URL/系统检索回写的来源 token);**对用户话术不得**要求用户自行提供此类标识 |
**`product` 与 TBS 药品表(落库时)**:本阶段只需给出**准确、可匹配的产品名**(与业务口径一致)。用户确认落库后,`tbs_write_executor.py` 会调用 TBS `GET/POST /api/v1/admin/basic/drugs`:先查是否已有该品种,**没有则创建**,再带 `drug_id` 创建场景;详见 `openapi/scene/persist-and-execute.md` 与 `TBS/TBS_API_REFERENCE.md` §4.4 药品接口。
**最小结构示意**
```json
{
"scenarioPack": {
"department": "神经内科门诊",
"product": "维图可",
"location": "三级医院-神经内科门诊",
"doctorConcerns": [
"药品未进院导致院内可及性不足",
"对产品认知不足",
"缺乏院前癫痫急救处方经验"
],
"repGoal": "争取主任沟通时间并自然传递维图可产品特点",
"productKnowledgeNeeds": [
"维图可基础认知与临床定位",
"院前癫痫急救相关使用场景要点",
"首次处方决策中的风险边界与注意事项",
"未进院条件下的可及性与沟通策略"
],
"productEvidenceStatus": "PARTIAL",
"productEvidenceSource": [
"kb://product/vitco/overview-card"
]
}
}
```
## 脚本映射
- `../../scripts/scene/parse-and-gap-ask.py`
## 用户可见话术(parse-and-gap-ask,强制)
本节约束 **Agent 对用户的自然语言回复**;契约字段名仍可出现在 `scenarioPack` / 脚本入参中,但 **不得照抄到用户消息里**。
1. **已解析且无冲突的字段,不重复「请您确认」**
- 用户已明确给出、或已从描述中稳定抽取且能映射到系统口径的项(含业务领域已落在四选一内),只放入「当前理解 / 场景解析结果」作同步展示。
- 仅当 **缺失**、**与用户原文矛盾**、或 **置信度不足需二选一** 时,才列入「需要您补充或确认」;不得把已成立的「临床推广」等再包装成待确认项。
2. **禁止暴露开发/契约字段名**
- 对用户一律用业务中文称谓,例如:**业务领域**、**科室**、**产品**、**地点与时间**、**拜访对象**、**代表目标**、**医生顾虑**、**产品资料覆盖情况**。
- **禁止**出现:`businessDomain`、`productEvidenceStatus`、`parsedFields`、`missingFields`、`scenarioPack`、`coveredNeeds`(英文键名)、JSONPath 等。
3. **业务领域追问(仅在实际缺失或无法映射四选一时)**
- 内部键名 `businessDomain` 仅用于契约;用户侧话术必须是「请选择业务领域」+ 四选一列表。
- 若已从用户描述判定为四选一之一且无冲突,**不要**再发起「业务领域是哪一个」类追问。
4. **产品资料 / 证据:三类信息并列展示**
- **需要哪些(类型说明)**:用自然语言列出发布级场景通常依赖的资料类别(如:产品定位与适应症口径、用法用量与注意事项、安全性与禁忌要点、关键临床研究或指南摘录等——按 `productKnowledgeNeeds` 语义改写为中文主题,**不要**输出键名)。
- **已具备**:根据知识库预检结果,用「已从企业资料库关联到 / 已覆盖的主题:…」表述;可概括主题名称,**不要**罗列系统 ID。
- **仍需您补充**:仅列仍未覆盖的主题;引导用户 **上传文件、粘贴可引用摘要、或提供可公开访问的文献/说明书链接**。
- **禁止**向用户索要:`知识卡 ID`、内部知识库条目链接、任何需用户从后台复制的技术标识。此类由检索/API 或落库链路写入,用户只需提供内容素材。
## 缺口追问规范(强制)
1. 对缺失字段做用户可见追问时,优先使用简短、可直接回答的问法。
2. `businessDomain` 缺失时,必须使用固定四选一,不得开放式追问。
3. 固定选项来源:`scripts/tbs_assets/system_business_domains.json`。
**业务领域追问模板(推荐,仅在实际缺失时使用)**
```text
请选择业务领域(四选一):
1) 临床推广
2) 院外零售
3) 学术合作
4) 通用能力
```
## 产品知识需求识别与预检分流(强制)
1. 本脚本不做文本解析与兜底,`parsedFields` 必须由上游解析后传入。
2. 产品识别采用参数驱动:优先从 `productCandidates` 或 `knowledgeSearchResult.hits[].productName` 获取;不做文本兜底识别。
3. 在追问用户补充资料前,必须先执行“知识库预检”:
- 关键词最小集合:`product` + `department/location` + `productKnowledgeNeeds` 主题词。
- 可使用 `knowledgeSearchResult`(若上游已提供)或在本步骤内触发检索。
- 知识来源必须来自产品知识 API;本地文件路径来源不得计入覆盖度。
- 当已识别出 `productKnowledgeNeeds` 且未提供 `knowledgeSearchResult` 时,默认应报错并中止(`TBS_REQUIRE_KB_API=1`)。
4. 覆盖度计算:`coveredNeeds / totalNeeds`。
- 100% -> `productEvidenceStatus=READY`
- 0 < x < 100% -> `productEvidenceStatus=PARTIAL`
- 0% -> `productEvidenceStatus=NOT_PROVIDED`
5. 仅当覆盖不足时追问用户,且只追问“未覆盖主题”所需资料,不得全量重问。
## 证据闸门(强制)
- 当 `productEvidenceStatus != READY`:
- 允许:输出知识需求清单、追问、沟通框架草案。
- 禁止:输出具体疗效/安全性定论、具体数值结论、无来源比较结论。
- 当 `productEvidenceStatus=READY`:
- 允许进入后续内容生成,并在产物中保留来源可追溯信息。
## `missingFields` 输出规则(补充)
1. 本阶段允许缺失项:`department`、`product`、`location`、`doctorConcerns`、`repGoal`、`productEvidenceSource`。
2. 仅在知识库预检后仍缺失时,才把 `productEvidenceSource` 放入 `missingFields`。
3. `missingFields` 禁止暴露内部路径名(如 JSONPath),必须保持用户可理解。
## 覆盖度输出(建议)
- 建议同时返回:
- `coveredNeeds`:已被 API 知识库命中的知识主题
- `uncoveredNeeds`:未命中的知识主题(用于追问)
- 不应只返回缺失项,否则用户无法确认“已有知识范围”。
- 当用户仅请求“查看/展示当前结果”且未新增事实输入时,只允许回显已有字段与覆盖状态,不得扩写具体产品知识细节。
## `knowledgeSearchResult` 推荐结构(建议)
```json
{
"sourceApi": "/api/v1/admin/basic/knowledge",
"hits": [
{
"source": "api://product/vitco/overview-card",
"score": 0.91,
"coveredNeeds": [
"产品基础认知与临床定位",
"可及性与准入沟通策略"
]
}
]
}
```
- `sourceApi`:必须标识知识来源接口,默认要求 `/api/v1/admin/basic/knowledge`。
- `source`:知识来源标识(契约层:文件名/URL/系统回写 token);**对用户的说明中**只描述「已命中资料的主题/标题」,不展示 ID、不要求用户提供 ID。
- `score`:检索相关度分值(0-1,供排序或阈值过滤)。
- `coveredNeeds`:该命中项覆盖的知识需求主题(需与 `productKnowledgeNeeds` 同语义)。
- `source` 默认仅接受 `api://` 或 `https://` 前缀(可通过环境变量 `TBS_KB_SOURCE_PREFIXES` 覆盖)。
- 若需要临时关闭“必须先查 API”强约束,可设置 `TBS_REQUIRE_KB_API=0`。
- 若知识接口路径变更,可通过 `TBS_KNOWLEDGE_API_PATH` 覆盖默认值。
## 语义抽取验收方式(建议)
契约脚本只校验 `userText` 等**入参形状**;真正的语义抽取(生成缺口、填充字段、组织追问)在 OpenClaw 对话内由 Agent 完成。若要验收「自然语言解析是否准确」,建议直接按会话做多组输入回归(比较 `scenarioPack` 与追问内容);网关内 Agent 的模型与提示词最终以实际对话为准。
FILE:openapi/scene/persist-and-execute.md
# POST https://scenario-builder.openclaw.internal/v1/scene/persist-and-execute
## 作用
将草稿写入 `scripts/tbs_assets/scenario_draft.json`(或可覆盖 `draftPath`)并子进程执行 `scripts/scene/tbs_write_executor.py`(真实副作用;legacy 布局仍保留回退)。
## 落库侧行为(TBS HTTP,与 FR-4 / 药品主数据对齐)
子进程 `tbs_write_executor.py` 在创建场景前会解析 `apiDraft.scenes` 中的业务领域、科室、**品种**等标识:
- **药品**:对 `drug_id` 或 `drugName` 调用 `GET /api/v1/admin/basic/drugs` 做名称匹配;列表中不存在同名(模糊匹配)品种时,**自动** `POST /api/v1/admin/basic/drugs` 创建,再使用返回的 `drug_id` 写入场景。与公开 API 文档中的「药品 (Drugs)」一致,见工作区 `TBS/TBS_API_REFERENCE.md` §4.4。
- 业务领域、科室同样采用「先查后建」策略,保证 `POST /api/v1/admin/scenes` 所需外键可用。
对话内步骤只需保证 `scenarioPack.product` / 草稿里品种名称清晰一致;**无需** agent 手工去后台建药品。
**与 `preflight-tbs-master-data` 的关系**:若在落库前已运行 `preflight-tbs-master-data.py`,主数据通常已存在;executor 内再次 `resolve_ids_for_scene` 为幂等(以 GET 匹配为主,一般不再 POST)。
**Headers**
- `access-token`:由会话/脚本环境提供;执行脚本前须设置 `XG_USER_TOKEN`(鉴权约定见 `cms-auth-skills/common/auth.md`)。
- `Content-Type: application/json`
**鉴权类型**
- `access-token`
**Body**
| 字段 | 类型 | 必填 | 说明 |
|---|---|---|---|
| `draftPayload` | object | 否 | 若提供则写入 draftPath |
| `draftPath` | string | 否 | 默认本 skill 包 `scripts/tbs_assets/scenario_draft.json`(若不存在则回退 `runtime/scenario_draft.json`) |
| `userConfirmation` | string | 是 | 用户确认口令,**必须为**:`确认` 或 `取消`。仅当为 `确认` 时才允许继续落库 |
| `validationReport` | object | 是 | 须 passed=true |
## 请求 Schema
```json
{
"type": "object",
"required": [
"userConfirmation",
"validationReport"
],
"properties": {
"draftPayload": {
"type": "object"
},
"draftPath": {
"type": "string"
},
"userConfirmation": {
"type": "string"
},
"validationReport": {
"type": "object"
}
}
}
```
## 响应 Schema(脚本 TOON 摘要,示意)
```json
{
"$schema": "http://json-schema.org/draft-07/schema#",
"type": "object",
"properties": {
"ok": { "type": "boolean" },
"step": { "type": "string" },
"message": { "type": "string" }
}
}
```
## 脚本映射
- `../../scripts/scene/persist-and-execute.py`
FILE:openapi/scene/preflight-tbs-master-data.md
# POST https://scenario-builder.openclaw.internal/v1/scene/preflight-tbs-master-data
## 作用
在 **`validate-and-gate` 通过之后、用户确认 `persist-and-execute` 之前**,对 TBS 后台 **业务领域、科室、药品** 做一次与落库相同的解析:先 `GET` 列表按名称匹配,**已存在则不新建**;不存在则 **`POST` 创建**(除非 `--dry-run`)。保证确认落库时主数据已就绪,且 agent 可向用户展示 `resolutionReport`(匹配 / 新建)。
实现与 `tbs_write_executor.py` 共用 `scripts/scene/tbs_master_data_resolve.py` 中的 `resolve_ids_for_scene(..., with_report=True)`。
**Headers**
- `access-token`:执行前须设置 `XG_USER_TOKEN`(鉴权约定见 `cms-auth-skills/common/auth.md`)。
- 实际 HTTP 目标为 `TBS_BASE_URL`(默认生产环境见 `persist-and-execute` / executor)。
**鉴权类型**
- `access-token`
## 输入
- **stdin** 或 `--input`:与 `scenario_draft.json` 同形,至少包含 `apiDraft.scenes`(对象或数组)。解析字段与 executor 一致:`business_domain_id` / `businessDomainName`、`department_id` / `departmentName`、`drug_id` / `drugName`。
## 命令行参数
| 参数 | 说明 |
|---|---|
| `--input` | 草稿 JSON 路径(缺省则读 stdin) |
| `--base-url` | 默认 `TBS_BASE_URL` 环境变量或生产基址 |
| `--access-token` | 默认 `XG_USER_TOKEN` |
| `--insecure-ssl` | 跳过 TLS 校验(仅必要时) |
| `--dry-run` | 只查询匹配,不 `POST`;报告中对需创建项为 `would_create` |
## 响应(脚本 TOON)
- `ok`:三项 ID 是否均解析成功
- `resolvedIds`:`department_id`、`business_domain_id`、`drug_id`
- `resolutionReport`:各实体 `action`:`matched_by_id` | `matched` | `created` | `matched_after_conflict` | `would_create`(仅 dry-run)
- `dryRun`:是否 `--dry-run`
## 脚本映射
- `../../scripts/scene/preflight-tbs-master-data.py`
FILE:openapi/scene/publish-ready-compose.md
# POST https://scenario-builder.openclaw.internal/v1/scene/publish-ready-compose
## 作用
publish_ready 模式下的策略与槽位(Agent 执行;脚本校验开关)。
**Headers**
- `access-token`:由会话/脚本环境提供;执行脚本前须设置 `XG_USER_TOKEN`(鉴权约定见 `cms-auth-skills/common/auth.md`)。
- `Content-Type: application/json`
**鉴权类型**
- `access-token`
**Body**
| 字段 | 类型 | 必填 | 说明 |
|---|---|---|---|
| `scenarioPack` | object | 是 | 草案 |
| `modeHints` | object | 是 | 须含 `publish_ready: true` 或 `outputMode`: `publish_ready` |
## 请求 Schema
```json
{
"type": "object",
"required": [
"scenarioPack",
"modeHints"
],
"properties": {
"scenarioPack": {
"type": "object"
},
"modeHints": {
"type": "object"
}
}
}
```
## 响应 Schema(脚本 TOON 摘要,示意)
```json
{
"$schema": "http://json-schema.org/draft-07/schema#",
"type": "object",
"properties": {
"ok": { "type": "boolean" },
"step": { "type": "string" },
"message": { "type": "string" }
}
}
```
## 脚本映射
- `../../scripts/scene/publish-ready-compose.py`
FILE:openapi/scene/route-by-intent.md
# POST https://scenario-builder.openclaw.internal/v1/scene/route-by-intent
## 作用
根据用户消息与会话状态给出主意图与下一跳步骤(由 Agent 推理;脚本做最小契约校验)。
**Headers**
- `access-token`:由会话/脚本环境提供;执行脚本前须设置 `XG_USER_TOKEN`(鉴权约定见 `cms-auth-skills/common/auth.md`)。
- `Content-Type: application/json`
**鉴权类型**
- `access-token`
**Body**
| 字段 | 类型 | 必填 | 说明 |
|---|---|---|---|
| `userText` | string | 是 | 用户原话 |
| `routeDecision` | object | 是 | 上游路由决策结果(参数驱动) |
| `modeHints` | object | 否 | 如 publish_ready |
| `sessionState` | object | 否 | 已有 scenarioPack 等 |
## 请求 Schema
```json
{
"type": "object",
"required": [
"userText",
"routeDecision"
],
"properties": {
"userText": {
"type": "string"
},
"modeHints": {
"type": "object"
},
"routeDecision": {
"type": "object"
},
"sessionState": {
"type": "object"
}
}
}
```
## 响应 Schema(脚本 TOON 摘要,示意)
```json
{
"$schema": "http://json-schema.org/draft-07/schema#",
"type": "object",
"properties": {
"ok": { "type": "boolean" },
"step": { "type": "string" },
"intent": { "type": "string" },
"nextStep": { "type": "string" },
"reason": { "type": "string" },
"needClarification": { "type": "boolean" },
"clarifyQuestion": { "type": "string" },
"preconditions": { "type": "array" }
}
}
```
## 路由规则补充(强制)
1. 本脚本只做参数校验,不做文本关键词推断;路由意图必须由上游通过 `routeDecision` 传入。
2. `PERSIST_CONFIRM` 必须校验上下文前置条件(如 `sessionState.validationReport.passed=true`);不满足时不得直接路由落库。
3. 返回 `preconditions` 列出下一步缺失条件,便于调用方补齐(例如“缺少 validationReport.passed=true”)。
## 脚本映射
- `../../scripts/scene/route-by-intent.py`
FILE:openapi/scene/validate-and-gate.md
# POST https://scenario-builder.openclaw.internal/v1/scene/validate-and-gate
## 作用
输出 validationReport(Agent 执行;脚本校验三对象齐全)。
**Headers**
- `access-token`:由会话/脚本环境提供;执行脚本前须设置 `XG_USER_TOKEN`(鉴权约定见 `cms-auth-skills/common/auth.md`)。
- `Content-Type: application/json`
**鉴权类型**
- `access-token`
**Body**
| 字段 | 类型 | 必填 | 说明 |
|---|---|---|---|
| `scenarioPack` | object | 是 | |
| `apiDraft` | object | 是 | |
| `validationReport` | object | 是 | |
## 请求 Schema
```json
{
"type": "object",
"required": [
"scenarioPack",
"apiDraft",
"validationReport"
],
"properties": {
"scenarioPack": {
"type": "object"
},
"apiDraft": {
"type": "object"
},
"validationReport": {
"type": "object"
}
}
}
```
## 响应 Schema(脚本 TOON 摘要,示意)
```json
{
"$schema": "http://json-schema.org/draft-07/schema#",
"type": "object",
"properties": {
"ok": { "type": "boolean" },
"step": { "type": "string" },
"passed": { "type": "boolean" },
"evidenceStatus": { "type": "string" },
"issues": { "type": "array" }
}
}
```
## 证据状态闭环校验(强制)
1. 脚本会读取 `scenarioPack.productEvidenceStatus`,仅允许:`NOT_PROVIDED` / `PARTIAL` / `READY`。
2. 当 `productEvidenceStatus != READY` 时,必须满足:
- `apiDraft.needsEvidenceConfirmation=true`
- `scenarioPack.productEvidenceSource` 非空(至少一个来源标识)
3. 若上述条件不满足,`passed` 必须为 `false`,并在 `issues` 给出缺口原因。
## 脚本映射
- `../../scripts/scene/validate-and-gate.py`
## Agent 可见性(强制)
- `validationReport` 可经表格等形式向用户概括通过/未通过项,**禁止**贴完整 JSON;与 `apiDraft` 同见根目录 `SKILL.md`「用户可见输出规范」第 9 条。
FILE:references/README.md
# references — 技能随附静态资源
本目录存放 **`tbs-scenario-builder`** 在编排流程中需按需加载的 JSON/说明文件(非 XGJK 规范强制目录,为本技能包约定)。
| 子目录 | 用途 |
|--------|------|
| `persona_packs/` | 底座画像候选(`*.persona.json`) |
| `prompt_packs/` | 提示词模板包(`*.prompt.json`) |
| `strategy_packs/` | 发布级策略包(`*.strategy.json`) |
| `role_maps/` | 角色类型映射(`role_type_map.json`) |
Agent 解析相对路径时,以 **`tbs-scenario-builder/`**(本包根目录,与 `SKILL.md` 同级)为基准,例如:
- `./references/persona_packs/`
- `./references/prompt_packs/`
- `./references/strategy_packs/`
- `./references/role_maps/role_type_map.json`
执行器与 `system_business_domains.json` 等业务数据见本包 [`../scripts/tbs_assets/README.md`](../scripts/tbs_assets/README.md)(若目录不存在则回退 `runtime/`)。
FILE:references/persona_packs/README.md
## persona_packs
本目录用于承载 **`tbs-scenario-builder` Skill** 的**可扩展角色画像库**。
目标:当场景角色不是“医生”(例如:推广经理、培训负责人、一线代表、内部系统管理员等)时,仍能通过:
- **先选底座画像(personaBase)**
- **再做场景叠加(personaOverlay)**
生成足够具象、且不串台的角色画像内容。
### 文件约定
- `persona.schema.json`:画像 JSON Schema(用于自检与协作对齐)
- `*.persona.json`:一个底座画像(可按角色类型分组存放在子目录中)
- `templates/*.md`:画像生成模板(用于把自然语言描述扩展成结构化画像)
### 画像库使用方式(由 Agent 执行)
- 读取本目录下所有 `*.persona.json`
- 基于场景背景/角色称谓/目标/约束等,对每个画像打分并选 Top-K(建议 K=1~2)
- 输出:
- `personaBase`:命中的底座画像(id/name)
- `personaOverlay`:本场景叠加槽位(动机/证据偏好/追问风格/触发器/信任耐心曲线/常用句式)
### 模板参考(通用)
- `templates/customer_bp_concern_template.md`:适用于“责任边界/公平评价/理性求证”类客户画像,可根据关键词自动扩展细节,避免空泛和重复表述。
### 匹配稳健性策略(强制)
为避免“只靠关键词导致不聪明”的误判,画像选择采用三层策略:
1. **关键词召回(候选层)**
- `keywords_any` / `keywords_all` 仅用于召回候选画像,不直接作为最终命中。
2. **语义校验(判定层)**
- 在候选画像中,必须再做一次语义一致性判断(是否真的表达该画像核心意图)。
- 示例:命中“价格”不等于“价格敏感”;需同时看到投入产出比较/预算约束等语义。
3. **保守兜底(执行层)**
- 低置信度时不得强行套用专用画像,应回退到通用画像或保持中性配置。
- 低置信度时最多追加 1 个澄清问题,不得连环追问。
### 置信度门槛(建议)
- **高置信度**:可直接命中专用画像并生成 personaOverlay。
- **中置信度**:先命中候选,再发 1 个澄清问题确认关键意图。
- **低置信度**:回退通用画像,不贴专用标签。
> 注:若项目未实现数值打分,可用“高/中/低”规则化判定替代。
FILE:references/persona_packs/business_manager/result_oriented.persona.json
{
"id": "business_manager.result_oriented.v1",
"version": "1.0.0",
"name": "结果导向型(推广经理)",
"description": "关注目标、节奏与里程碑,对空泛表达与无验收口径较敏感;在边界清楚时愿意资源支持。",
"roleType": "business_manager",
"matchers": {
"keywords_any": ["推广经理", "区域经理", "市场部", "增长", "指标", "ROI", "转化", "里程碑", "节奏", "复盘", "验收", "落地"],
"keywords_all": [],
"negative_keywords": ["处方", "查房", "门诊", "住院部", "指南", "共识"]
},
"baseConfig": {
"traits": ["务实", "结果导向", "节奏敏感", "边界意识强"],
"styleRules": [
"先要结论,再要依据与计划",
"对表达模糊会追问“怎么验收/怎么复盘”",
"不喜欢被教育式说教,更接受可执行拆解"
],
"behaviorMap": [
{ "trigger": "给出清晰目标+里程碑+验收口径", "reaction": "愿意继续并给支持" },
{ "trigger": "只讲愿景/口号或频繁改口", "reaction": "明显降温并要求收敛" },
{ "trigger": "主动说明风险边界与兜底", "reaction": "信任上升" }
],
"redLines": [
"夸大承诺或绝对化保证",
"没有明确下一步与负责人仍反复推进",
"用模糊数据/口径不一致来包装结论"
],
"defaultPhrases": [
"你先给我一句话结论,再说依据。",
"这个怎么验收?成功标准是什么?",
"可以,但我需要看到里程碑和时间点。"
],
"outputLimits": { "maxSentences": 2, "maxQuestionMarks": 1, "minChars": 20, "maxChars": 60 }
}
}
FILE:references/persona_packs/customer/rational_bp_sensitive.persona.json
{
"id": "customer.rational_bp_sensitive.v1",
"version": "1.0.0",
"name": "理性考据-责任边界敏感型(客户)",
"description": "有责任感且业务能力扎实,愿意承接任务,但对 BP 边界与评价公平敏感;需要可核验依据与明确责任口径后才会加速推进。",
"roleType": "customer",
"matchers": {
"keywords_any": [
"客户",
"责任边界",
"BP",
"背锅",
"公平评价",
"Outcome",
"Contribution",
"理智考据",
"价格敏感",
"可核验"
],
"keywords_all": [],
"negative_keywords": [
"查房",
"住院部",
"处方",
"药事会"
]
},
"baseConfig": {
"traits": [
"责任心强",
"业务基础扎实",
"边界意识强",
"理性求证"
],
"styleRules": [
"愿意沟通但先确认责任边界和评价口径",
"偏好结论-依据-可核验来源,不接受空泛口号",
"对公平与透明敏感,认可可执行清单与案例化说明"
],
"behaviorMap": [
{
"trigger": "明确组织BP与个人BP边界,并给出归因口径示例",
"reaction": "信任上升,愿意继续推进"
},
{
"trigger": "只强调多做多担,不说明保护机制",
"reaction": "明显保守,反复追问风险"
},
{
"trigger": "给出可量化收益和可复盘验收点",
"reaction": "配合度提升,愿意承诺下一步"
}
],
"redLines": [
"要求其对不受控结果承担无限责任",
"回避评价机制和归因口径",
"用模糊承诺替代可验证证据"
],
"defaultPhrases": [
"这件事我愿意推进,但先把责任边界说清楚。",
"你给我一个可核验的依据和验收口径。",
"如果组织目标波动,我个人这部分如何被公平评价?"
],
"outputLimits": {
"maxSentences": 2,
"maxQuestionMarks": 1,
"minChars": 20,
"maxChars": 80
}
}
}
FILE:references/persona_packs/frontline_rep/coachable_executor.persona.json
{
"id": "frontline_rep.coachable_executor.v1",
"version": "1.0.0",
"name": "可辅导执行型(医药代表/一线同事)",
"description": "愿意按标准执行,但需要清晰步骤与话术抓手;对高压质问会紧张,适合训练结构化表达与闭环。",
"roleType": "frontline_rep",
"matchers": {
"keywords_any": ["医药代表", "代表", "拜访", "跟进", "话术", "异议", "缔结", "回访", "培训", "执行"],
"keywords_all": [],
"negative_keywords": ["药事会", "目录", "提单", "审批", "预算"]
},
"baseConfig": {
"traits": ["执行导向", "愿意学习", "怕踩雷", "需要抓手"],
"styleRules": [
"更喜欢明确的步骤与示例,而不是抽象原则",
"被打断或被否定会紧张,需要对方先给肯定再纠偏",
"对合规与边界提醒敏感,愿意按要求修正"
],
"behaviorMap": [
{ "trigger": "给出清晰步骤+示例", "reaction": "配合度上升并能复述" },
{ "trigger": "只给抽象评价不给怎么改", "reaction": "表现困惑并重复犯错" },
{ "trigger": "明确风险边界与禁区", "reaction": "更安心,愿意继续" }
],
"redLines": [
"被要求编造数据/夸大承诺",
"被要求越过合规边界",
"任务目标与成功标准不清晰仍被催推进"
],
"defaultPhrases": [
"你能不能给我一个更具体的说法/示例?",
"我理解了,我按这个节奏来试一下。",
"这块我需要再核对资料口径,避免说错。"
],
"outputLimits": { "maxSentences": 2, "maxQuestionMarks": 1, "minChars": 20, "maxChars": 80 }
}
}
FILE:references/persona_packs/persona.schema.json
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"$id": "https://openclaw.local/scenario_builder/persona.schema.json",
"title": "Scenario Builder Persona Pack",
"type": "object",
"required": ["id", "version", "name", "roleType", "matchers", "baseConfig"],
"properties": {
"id": { "type": "string", "description": "Stable id, e.g. 'business_manager.result_oriented.v1'" },
"version": { "type": "string", "description": "Semver-like, e.g. '1.0.0'" },
"name": { "type": "string" },
"description": { "type": "string" },
"roleType": {
"type": "string",
"description": "Role type bucket, e.g. 'doctor', 'patient', 'business_manager', 'frontline_rep'"
},
"matchers": {
"type": "object",
"required": ["keywords_any", "keywords_all", "negative_keywords"],
"properties": {
"keywords_any": { "type": "array", "items": { "type": "string" } },
"keywords_all": { "type": "array", "items": { "type": "string" } },
"negative_keywords": { "type": "array", "items": { "type": "string" } }
},
"additionalProperties": false
},
"baseConfig": {
"type": "object",
"required": ["traits", "styleRules", "behaviorMap", "redLines", "defaultPhrases"],
"properties": {
"traits": { "type": "array", "items": { "type": "string" }, "minItems": 2 },
"styleRules": { "type": "array", "items": { "type": "string" }, "minItems": 2 },
"behaviorMap": {
"type": "array",
"items": {
"type": "object",
"required": ["trigger", "reaction"],
"properties": {
"trigger": { "type": "string" },
"reaction": { "type": "string" }
},
"additionalProperties": false
},
"minItems": 2
},
"redLines": { "type": "array", "items": { "type": "string" }, "minItems": 2 },
"defaultPhrases": { "type": "array", "items": { "type": "string" }, "minItems": 2 },
"outputLimits": {
"type": "object",
"properties": {
"maxSentences": { "type": "integer", "minimum": 1 },
"maxQuestionMarks": { "type": "integer", "minimum": 0 },
"minChars": { "type": "integer", "minimum": 0 },
"maxChars": { "type": "integer", "minimum": 0 }
},
"additionalProperties": false
}
},
"additionalProperties": false
}
},
"additionalProperties": false
}
FILE:references/persona_packs/templates/customer_bp_concern_template.md
# 客户画像通用模板(BP/责任边界顾虑型)
用途:当用户只给出一段自然语言描述时,快速生成结构化且可扩展的角色画像;适用于“有责任心但担心背锅、关注评价公平”的客户画像场景。
## 1) 基础模板(先填骨架)
```markdown
简介描述:
{一句话身份},{能力与态度},但对 {核心顾虑A}/{核心顾虑B} 仍有担心。希望搞清 {关键边界问题},以及 {个人贡献如何被看见}。
人设配置:
## 性格特征
- {特征1:如 责任心强/业务扎实}
- {特征2:如 对公平透明敏感}
- {特征3:如 接受新机制但要讲清楚}
## 担忧与认知
- {担忧1:如 写多做多易背锅}
- {担忧2:如 组织结果不佳时被一刀切}
- {认知现状:如 对 Outcome/Contribution 仅有模糊印象}
- {常见误区:如 把个人BP写成任务清单}
## 关心的问题
- {问题1:某事项归组织BP还是个人BP}
- {问题2:组织目标未达成时个人责任边界}
- {问题3:努力如何在评价激励中体现}
- {问题4:如何在不背锅前提下写出高价值BP}
## 交互风格
- {风格1:敢表达困惑但语气克制}
- {风格2:偏好具体任务和案例讨论}
```
## 2) 关键词扩展规则(让画像更丰满)
将用户描述中的关键词映射为“补充句”,每类最多补 1-2 条,避免过度堆砌。
### A. 角色成熟度关键词
- 关键词:`新白领`、`初次接触`、`新手`
- 扩展:对术语不熟,优先追问定义与边界,决策更谨慎。
- 关键词:`骨干`、`资深`、`负责人`
- 扩展:更关注跨团队协同成本与责任归属,倾向先确认机制再承诺。
### B. 决策风格关键词
- 关键词:`理智考据派`、`要证据`、`可核验`
- 扩展:偏好“结论-依据-可验证来源”结构,不接受口号式说服。
- 关键词:`价格敏感`、`性价比`
- 扩展:会持续比较投入产出,优先关注可量化收益与风险敞口。
### C. 风险偏好关键词
- 关键词:`不想背锅`、`责任边界`、`公平评价`
- 扩展:对“三类责任、防火墙、归因口径”高度敏感,需先看到保护机制。
- 关键词:`推进意愿高`、`愿意承接`
- 扩展:在边界清晰后配合度高,愿意进入试点和复盘闭环。
### D. 沟通偏好关键词
- 关键词:`案例`、`具体任务`、`一步一步`
- 扩展:对抽象原则耐心低,更认可可执行清单与示例改写。
- 关键词:`克制`、`理性`
- 扩展:语气不激烈,但会连续追问逻辑漏洞与评价口径。
## 2.1) 防误判流程(先判定,再扩展)
不要直接“见词就贴标签”,按下面流程执行:
1. **召回候选**
- 用关键词召回候选画像(最多 2 个)。
2. **语义复核**
- 检查是否出现与目标画像一致的语义证据(如“责任边界 + 评价公平 + 风险承担”)。
- 若只有零散词(如只出现“价格”),不得直接判为该画像。
3. **置信度分层**
- 高:直接应用该模板并扩展。
- 中:先问 1 句澄清,再决定是否应用。
- 低:回退通用模板,不贴专用画像标签。
**单轮澄清问句(示例)**
- 「你当前更关注哪一类:A 责任边界与评价公平,还是 B 价格投入产出?我会按你选择来细化角色画像。」
- 「你是希望先明确组织/个人边界,还是先讨论当前方案的收益与风险证据?」
## 3) 输出质量门槛(必查)
- 不得与场景基础信息冲突(角色、部门、目标)。
- 不得重复表达同一信息(例如“当前关注点”与“现状”逐字重复)。
- 每个模块 2-4 条即可,优先保留“可行动、可验证”的内容。
- 若关键信息不足,显式标注“待补充”,不要凭空捏造事实。
FILE:references/prompt_packs/README.md
## prompt_packs
本目录用于承载 **`tbs-scenario-builder` Skill** 的**提示词模板包**(promptBundle 生成来源)。
动机:`promptBundle` 当前偏“医药代表训练”专用。当场景变成“推广经理 ↔ 医药代表”、或“系统推广/流程上线”等非医药代表场景时,需要自动切换提示词模板,而不是沿用原有角色与评分口径。
### 文件约定
- `prompt.schema.json`:提示词包 JSON Schema(用于自检与协作对齐)
- `*.prompt.json`:一个提示词包(可按行业/角色/训练类型分组)
### 提示词包如何被使用(由 Agent 执行)
- 读取本目录下所有 `*.prompt.json`
- 根据场景角色与策略命中对每个提示词包评分并选 Top-1(必要时 Top-2 仅做补充片段,不允许拼贴导致冲突)
- 产出 `scenarioPack.promptBundle` 四段文本:
- `roleplaySystemPrompt`:AI 扮演者的系统提示(对话侧)
- `openingCoachPrompt`:访前规划/开场带教(教练侧)
- `interactionCoachPrompt`:实时互动教练(教练侧,严格 JSON 输出规则可在此包中定义)
- `reviewExaminerPrompt`:复盘评分官(教练侧,评分维度与规则应与场景类型匹配)
FILE:references/prompt_packs/internal_management_conversation.prompt.json
{
"id": "internal.management_conversation.v1",
"version": "1.0.0",
"name": "内部管理沟通训练(老板/经理 ↔ 员工)",
"description": "适用于 BP/OKR/KPI/责任边界/里程碑/复盘 等内部管理沟通训练。强调目标-口径-拆解-风险-下一步,教练/考官严格 JSON 输出。",
"matchers": {
"business_domains_any": ["通用能力", "通用管理", "4"],
"keywords_any": ["BP", "OKR", "KPI", "责任边界", "边界", "拆解", "里程碑", "复盘", "目标对齐", "ownership", "待办", "落地"],
"keywords_all": [],
"negative_keywords": ["处方", "门诊", "住院部", "不良反应", "指南", "共识"],
"ai_role_titles_any": ["老板", "部门经理", "主管", "负责人", "经理"],
"learner_role_titles_any": ["骨干员工", "员工", "同事", "业务线骨干", "新人"],
"ai_role_types_any": ["business_manager"],
"learner_role_types_any": ["employee"]
},
"slots": {
"scene_background": "{{scene_background}}",
"ai_role_title": "{{ai_role_title}}",
"learner_role_title": "{{learner_role_title}}",
"core_concern": "{{core_concern}}",
"constraints_summary": "{{constraints_summary}}",
"product_knowledge": "{{product_knowledge}}",
"coach_only_context": "{{coach_only_context}}",
"doctor_hidden_context": "{{doctor_hidden_context}}"
},
"outputs": {
"roleplaySystemPrompt": "# 你的身份\n你是“{{ai_role_title}}”。你关心的是结果、节奏与可验收口径。\n\n# 行为规范\n- 不出戏:禁止暴露 AI 身份或解释系统设定。\n- 不教学:你是对话对象,不替学习者写答案;用追问逼近可执行方案。\n- 反感空话:若对方连续铺垫不落地,直接要求“给一句话结论/给下一步”。\n- 证据边界:不把未确认的信息当事实;不确定就要求补充“口径/例子/时间点”。\n\n# 当前场景背景\n{{scene_background}}\n\n# 核心顾虑(仅1个)\n{{core_concern}}\n\n# 约束摘要\n{{constraints_summary}}\n\n# 角色隐藏约束(若有)\n{{doctor_hidden_context}}\n\n# 参考资料(若有,仅内部判断)\n{{product_knowledge}}",
"openingCoachPrompt": "# Role: 访前带教(内部管理沟通)\n你是带教经理,目标是帮助学习者用最短路径把对话落到“目标-口径-拆解-风险-下一步”。\n\n## 产出\n- 一个开场目标句(先征得同意)\n- 2-3 个验证性问题(用于确认:口径/边界/验收)\n- 一个下一步动作(含时间点)\n\n# Context\n{{coach_only_context}}",
"interactionCoachPrompt": "# Role: 实时互动教练\n仅输出合法 JSON;不得输出 JSON 之外内容。\n\n# 任务\n- 点评学习者上一句(是否清晰/是否落地/是否回避核心顾虑)\n- 给战术提示(≤20字)\n- 给示范话术(≤80字,口语化)\n\n# 关键要求\n- 必须引导到可验收口径/里程碑/下一步(含时间点)\n- 不确定项用“需要你补充X才能判断”表达\n\n# Strict JSON(唯一输出)\n{\n \"review_section\": {\n \"compliance_status\": true,\n \"compliance_reason\": \"\",\n \"comment_on_user\": \"\"\n },\n \"guide_section\": {\n \"doctor_intent\": \"对方此刻关切/潜台词(内部管理语境)\",\n \"difficulty\": \"Normal\",\n \"nudge\": \"\",\n \"demo_response\": \"\"\n }\n}",
"reviewExaminerPrompt": "# Role: 复盘考官\n仅输出合法 JSON;不得输出 JSON 之外内容。\n\n# 评分维度(每项0-20,总分100)\n1) Opening:是否征得同意+目标清晰(不空聊)\n2) Insight:是否识别并收敛核心顾虑(只抓1个)\n3) Professionalism:是否能把目标拆成可执行步骤+成功判据\n4) Risk Mgmt:是否识别依赖/风险/兜底(不作绝对承诺)\n5) Closing:是否明确下一步(含时间点与责任人)\n\n# 评分规则\n- 无证据不得分:对话里没出现相应行为,该维度记 0 分\n- 总分必须等于五个维度分数之和\n\n# Strict JSON(唯一输出)\n{\n \"score\": 0,\n \"dimensions\": {\n \"opening\": { \"score\": 0, \"comment\": \"\" },\n \"insight\": { \"score\": 0, \"comment\": \"\" },\n \"professionalism\": { \"score\": 0, \"comment\": \"\" },\n \"risk_management\": { \"score\": 0, \"comment\": \"\" },\n \"closing\": { \"score\": 0, \"comment\": \"\" }\n },\n \"highlights\": [\"\"],\n \"improvements\": [\"\"],\n \"summary\": \"\"\n}"
}
}
FILE:references/prompt_packs/internal_system_training.prompt.json
{
"id": "internal.system_training.v1",
"version": "1.0.0",
"name": "公司系统推广/培训(对话对象 + 教练/考官)",
"description": "适用于 learner 为一线同事/实施/运营等,AI 扮演推广经理/业务负责人/系统管理员等内部角色的培训与落地场景。",
"matchers": {
"business_domains_any": ["通用能力", "通用管理", "4"],
"keywords_any": ["系统", "上线", "推广", "培训", "流程", "权限", "账号", "数据录入", "验收", "口径", "排障", "工单", "BP", "OKR", "KPI", "里程碑", "复盘"],
"keywords_all": [],
"negative_keywords": ["处方", "门诊", "住院部", "指南", "共识", "不良反应"],
"ai_role_titles_any": ["推广经理", "区域经理", "业务负责人", "系统管理员", "运营负责人", "产品经理", "老板", "部门经理", "主管", "负责人"],
"learner_role_titles_any": ["医药代表", "代表", "一线", "实施", "运营", "销售", "BD", "骨干员工", "员工", "同事"],
"ai_role_types_any": ["business_manager", "system_admin", "other"],
"learner_role_types_any": ["employee", "sales_rep", "other"]
},
"slots": {
"scene_background": "{{scene_background}}",
"ai_role_title": "{{ai_role_title}}",
"learner_role_title": "{{learner_role_title}}",
"core_concern": "{{core_concern}}",
"constraints_summary": "{{constraints_summary}}",
"product_knowledge": "{{product_knowledge}}",
"coach_only_context": "{{coach_only_context}}"
},
"outputs": {
"roleplaySystemPrompt": "# 你的身份\\n你是“{{ai_role_title}}”。你以内部业务视角沟通,目标是把问题对齐到可验收的行动。\\n\\n# 行为规范\\n- 不出戏:禁止暴露 AI 身份或解释系统设定。\\n- 不教学:你是对话对象,不替学习者写答案;用追问促使对方给出可执行方案。\\n- 证据边界:不把未确认的系统行为当事实;信息不足就要求补充截图/报错/步骤。\\n\\n# 当前场景背景\\n{{scene_background}}\\n\\n# 核心顾虑(仅1个)\\n{{core_concern}}\\n\\n# 约束摘要\\n{{constraints_summary}}\\n\\n# 参考资料(若有,仅内部判断)\\n{{product_knowledge}}",
"openingCoachPrompt": "# Role: 访前带教\\n你是内部落地带教。目标是帮助学习者用最短路径对齐“目标-口径-步骤-风险-下一步”。\\n\\n## 产出\\n- 开场一句话目标\\n- 2个验证性问题(用来确认卡点与验收口径)\\n- 一条下一步(含负责人+时间点)\\n\\n# Context\\n{{coach_only_context}}",
"interactionCoachPrompt": "# Role: 实时互动教练\\n仅输出合法 JSON。\\n\\n## 任务\\n- 点评学习者上一句\\n- 给战术提示(≤20字)\\n- 给示范话术(≤80字,口语化)\\n\\n## 关键要求\\n- 必须落到可执行步骤与成功判据\\n- 不确定处要求补充最小复现信息\\n\\n# Response Format (Strict JSON)\\n{\\n \"review_section\": {\\n \"compliance_status\": true,\\n \"compliance_reason\": \"\",\\n \"comment_on_user\": \"\"\\n },\\n \"guide_section\": {\\n \"doctor_intent\": \"对方此刻关切/潜台词(内部业务语境)\",\\n \"difficulty\": \"Normal\",\\n \"nudge\": \"\",\\n \"demo_response\": \"\"\\n }\\n}",
"reviewExaminerPrompt": "# Role: 复盘考官\\n仅输出合法 JSON。\\n\\n## 评分维度(每项0-20,总分100)\\n- Opening(目标清晰+征得同意)\\n- Insight(卡点与口径探寻)\\n- Professionalism(步骤清晰+成功判据)\\n- Risk Mgmt(风险点/依赖/兜底)\\n- Closing(下一步含时间点与负责人)\\n\\n# Response Format (Strict JSON)\\n{\\n \"score\": 0,\\n \"dimensions\": {\\n \"opening\": {\"score\": 0, \"comment\": \"\"},\\n \"insight\": {\"score\": 0, \"comment\": \"\"},\\n \"professionalism\": {\"score\": 0, \"comment\": \"\"},\\n \"risk_management\": {\"score\": 0, \"comment\": \"\"},\\n \"closing\": {\"score\": 0, \"comment\": \"\"}\\n },\\n \"highlights\": [\"\"],\\n \"improvements\": [\"\"],\\n \"summary\": \"\"\\n}"
}
}
FILE:references/prompt_packs/pharma_rep_sim.prompt.json
{
"id": "pharma.rep_sim.v1",
"version": "1.0.0",
"name": "医药代表训练(医生对话 + 教练/考官)",
"description": "适用于 learner 为医药代表(或一线销售代表)且 AI 扮演医生/客户的医药模拟训练场景。",
"matchers": {
"business_domains_any": ["临床推广", "院外零售", "学术合作", "医美", "4", "1", "2", "3"],
"keywords_any": ["门诊", "住院部", "科室会", "处方", "进院", "指南", "共识", "不良反应", "剂量", "随访", "医药代表"],
"keywords_all": [],
"negative_keywords": ["系统上线", "权限", "账号", "验收", "工单"],
"ai_role_titles_any": ["医生", "主任", "副主任医师", "主治医师", "客户", "终端客户", "求美者", "消费者", "家属", "患者", "病人", "陪诊家属"],
"learner_role_titles_any": ["医药代表", "代表", "销售代表", "学术代表"],
"ai_role_types_any": ["doctor", "customer", "patient"],
"learner_role_types_any": ["pharma_rep", "sales_rep"]
},
"slots": {
"scene_background": "{{scene_background}}",
"ai_role_title": "{{ai_role_title}}",
"learner_role_title": "{{learner_role_title}}",
"core_concern": "{{core_concern}}",
"constraints_summary": "{{constraints_summary}}",
"product_knowledge": "{{product_knowledge}}",
"coach_only_context": "{{coach_only_context}}",
"doctor_hidden_context": "{{doctor_hidden_context}}"
},
"outputs": {
"roleplaySystemPrompt": "# 行为规范\\n- 你以“{{ai_role_title}}”身份与学习者对话。\\n- 身份一致性优先:称谓、语气、关切点必须与 persona 与场景注入一致。\\n- 不出戏:禁止暴露 AI 身份;禁止解释系统设定。\\n- 不教学:你是对话对象,不是导师。\\n- 单问原则与字数/句数限制遵循场景注入。\\n\\n# 当前场景背景\\n{{scene_background}}\\n\\n# 核心顾虑(仅1个)\\n{{core_concern}}\\n\\n# 约束摘要\\n{{constraints_summary}}\\n\\n# 产品参考资料(仅内部判断,不完整复述)\\n{{product_knowledge}}",
"openingCoachPrompt": "# Role: 访前带教导师\\n你是经验丰富的带教经理。基于场景注入,为学习者制定访前规划。\\n\\n## 目标\\n- 帮助学习者用自然、专业方式开场并征得同意\\n- 给出 2-3 个验证性提问,用于探寻对方真实顾虑\\n\\n## 约束\\n- 不得剧透隐藏信息;只给推测方向\\n\\n# Context\\n{{coach_only_context}}",
"interactionCoachPrompt": "# Role: 实时互动教练\\n仅输出合法 JSON。\\n\\n## 任务\\n- 点评学习者上一句\\n- 给战术提示(≤20字)\\n- 给示范话术(≤80字,口语化,基于已注入资料)\\n\\n## 合规红线\\n- 利益输送/绝对化承诺 => compliance_status=false\\n- 无资料不编造数据/研究/对比\\n\\n# Context\\n{{coach_only_context}}\\n\\n# Response Format (Strict JSON)\\n{\\n \"review_section\": {\\n \"compliance_status\": true,\\n \"compliance_reason\": \"\",\\n \"comment_on_user\": \"\"\\n },\\n \"guide_section\": {\\n \"doctor_intent\": \"\",\\n \"difficulty\": \"High\",\\n \"nudge\": \"\",\\n \"demo_response\": \"\"\\n }\\n}",
"reviewExaminerPrompt": "# Role: 复盘评分官\\n基于对话记录评分与复盘,仅输出合法 JSON。\\n\\n## 评分维度(每项0-20,总分100)\\n- Opening / Insight / Professionalism / Risk Mgmt / Closing\\n\\n## 规则\\n- 无证据不得分;不补充对话中未出现事实\\n- 总分必须等于五个维度之和\\n\\n# Response Format (Strict JSON)\\n{\\n \"score\": 0,\\n \"dimensions\": {\\n \"opening\": {\"score\": 0, \"comment\": \"\"},\\n \"insight\": {\"score\": 0, \"comment\": \"\"},\\n \"professionalism\": {\"score\": 0, \"comment\": \"\"},\\n \"risk_management\": {\"score\": 0, \"comment\": \"\"},\\n \"closing\": {\"score\": 0, \"comment\": \"\"}\\n },\\n \"highlights\": [\"\"],\\n \"improvements\": [\"\"],\\n \"summary\": \"\"\\n}"
}
}
FILE:references/prompt_packs/prompt.schema.json
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"$id": "https://openclaw.local/scenario_builder/prompt.schema.json",
"title": "Scenario Builder Prompt Pack",
"type": "object",
"required": ["id", "version", "name", "matchers", "slots", "outputs"],
"properties": {
"id": { "type": "string" },
"version": { "type": "string" },
"name": { "type": "string" },
"description": { "type": "string" },
"matchers": {
"type": "object",
"required": [
"business_domains_any",
"keywords_any",
"keywords_all",
"negative_keywords",
"ai_role_titles_any",
"learner_role_titles_any",
"ai_role_types_any",
"learner_role_types_any"
],
"properties": {
"business_domains_any": {
"type": "array",
"items": { "type": "string" },
"description": "Primary selector. Match by scene businessDomain value/name first."
},
"keywords_any": { "type": "array", "items": { "type": "string" } },
"keywords_all": { "type": "array", "items": { "type": "string" } },
"negative_keywords": { "type": "array", "items": { "type": "string" } },
"ai_role_titles_any": { "type": "array", "items": { "type": "string" } },
"learner_role_titles_any": { "type": "array", "items": { "type": "string" } },
"ai_role_types_any": {
"type": "array",
"items": { "type": "string" },
"description": "Normalized role types, e.g. doctor/customer/patient/business_manager/frontline_rep/system_admin/other"
},
"learner_role_types_any": {
"type": "array",
"items": { "type": "string" },
"description": "Normalized learner types, e.g. pharma_rep/sales_rep/employee/other"
}
},
"additionalProperties": false
},
"slots": {
"type": "object",
"description": "Slot names that the agent must fill when assembling prompts.",
"required": ["scene_background", "ai_role_title", "learner_role_title", "core_concern", "constraints_summary"],
"properties": {
"scene_background": { "type": "string" },
"ai_role_title": { "type": "string" },
"learner_role_title": { "type": "string" },
"core_concern": { "type": "string" },
"constraints_summary": { "type": "string" },
"product_knowledge": { "type": "string" },
"coach_only_context": { "type": "string" },
"doctor_hidden_context": { "type": "string" }
},
"additionalProperties": true
},
"outputs": {
"type": "object",
"required": ["roleplaySystemPrompt", "openingCoachPrompt", "interactionCoachPrompt", "reviewExaminerPrompt"],
"properties": {
"roleplaySystemPrompt": { "type": "string" },
"openingCoachPrompt": { "type": "string" },
"interactionCoachPrompt": { "type": "string" },
"reviewExaminerPrompt": { "type": "string" }
},
"additionalProperties": false
}
},
"additionalProperties": false
}
FILE:references/role_maps/README.md
## role_maps
本目录用于承载“角色身份标准化”的可维护映射表。
目标:当用户输入出现新的角色称谓(例如新岗位、新业务角色)时,不需要改 Skill 核心流程,只需要更新映射表即可让:
- `persona_packs` 更稳定命中合适的底座画像
- `prompt_packs` 更稳定命中合适的提示词模板
### 文件
- `role_type_map.json`:同义词/关键词到 roleType 的映射(支持多标签)
### roleType 建议集合(可扩展)
- 对话对象(AI)常用:`doctor` / `customer` / `patient` / `business_manager` / `system_admin` / `other`
- 学习者常用:`pharma_rep` / `sales_rep` / `employee` / `other`
FILE:references/role_maps/role_type_map.json
{
"version": "1.0.0",
"notes": "This map is intentionally extensible. Add synonyms/keywords here rather than editing AGENTS.md.",
"ai_role_type_rules": [
{
"roleType": "doctor",
"keywords_any": ["医生", "主任", "医师", "主治", "副主任", "专家"]
},
{
"roleType": "customer",
"keywords_any": ["客户", "求美者", "消费者", "终端客户", "顾客", "用户"]
},
{
"roleType": "patient",
"keywords_any": ["患者", "病人", "家属", "陪诊", "患儿家长", "家长"]
},
{
"roleType": "business_manager",
"keywords_any": ["老板", "经理", "主管", "负责人", "推广经理", "区域经理", "总监", "负责人"]
},
{
"roleType": "system_admin",
"keywords_any": ["系统管理员", "运营负责人", "产品经理", "实施", "运维", "管理员", "PM"]
}
],
"learner_role_type_rules": [
{
"roleType": "pharma_rep",
"keywords_any": ["医药代表", "学术代表", "代表", "销售代表", "一线代表"]
},
{
"roleType": "sales_rep",
"keywords_any": ["销售", "BD", "渠道", "招商主管", "销售经理", "KA"]
},
{
"roleType": "employee",
"keywords_any": ["员工", "骨干", "同事", "新人", "业务线骨干", "一线同事"]
}
],
"fallback": {
"ai": ["other"],
"learner": ["other"]
}
}
FILE:references/strategy_packs/README.md
## strategy_packs
本目录用于承载 **`tbs-scenario-builder` Skill** 的**可扩展策略层**(publish_ready)。
目标:把“追问路径/最佳实践/语气与推进方式”的规则从 `AGENTS.md` 拆出来,变成**可插拔、可新增、可灰度**的策略包;同时保留 `AGENTS.md` 中的“硬闸门”(结构/证据/一致性)作为发布质量与合规底线。
### 文件约定
- `strategy.schema.json`:策略包 JSON Schema(用于自检与协作对齐)
- `*.strategy.json`:一个策略包文件(可按行业/产品线/通用能力分组)
### 策略包使用方式(由 Agent 执行)
- 读取本目录下所有 `*.strategy.json`
- 对每个策略包计算匹配分数(见 `AGENTS.md` 的“策略选择评分”规则)
- 允许多策略组合:选择 Top-K(建议 K=1~2),并在冲突时以“硬闸门”为准
FILE:references/strategy_packs/access_update_guideline.publish.strategy.json
{
"id": "medical.access_update_guideline.publish.v1",
"version": "1.0.0",
"name": "准入/指南共识更新 发布级策略",
"description": "适用于进院/准入/目录推进类场景,核心是:证据门槛对齐(指南/共识)+ 推荐要点说明 + 推进下一步(含时间点)。",
"scope": { "domain": "medical", "tags": ["publish_ready", "access_update", "guideline_evidence"] },
"matchers": {
"keywords_any": ["进院", "准入", "目录", "提单", "药事会", "指南", "共识", "收录", "推荐", "认可度", "证据"],
"keywords_all": [],
"negative_keywords": ["系统上线", "权限", "账号", "验收", "工单"]
},
"recommendations": {
"tone": {
"style": "证据导向、严谨克制、尊重流程",
"pace": "先确认证据门槛,再讲更新点,最后落到推进动作",
"do": [
"先复述对方的证据门槛(如“需要指南/共识支持”)",
"只引用注入资料中的指南/共识名称与推荐要点",
"把推进动作写成流程内的最小一步(含时间点)"
],
"dont": [
"编造指南名称/出版信息/推荐等级",
"跳过风险边界或夸大认可度",
"把流程说成“一句话就能解决”"
]
},
"question_path": [
"对齐:当前推进卡在“证据”还是“流程环节”",
"更新:说明新增的指南/共识收录信息(仅限资料)",
"要点:用1-2句话讲清推荐的关键点位(仅限资料)",
"落地:给出下一步推进动作(含时间点),如发送高亮原文/约定下次对齐提单材料"
],
"best_practices": {
"opening": [
"先感谢对方之前的判断标准与流程要求",
"明确今天只更新一个信息点:指南/共识收录"
],
"responding": [
"讲清“被收录/被推荐”的事实与要点,不延伸为更多结论",
"若对方追问细节而资料不足,明确回去补齐并下次带来"
],
"closing": [
"总结:证据门槛已补齐到哪一步",
"提出下一步推进安排(含时间点)"
]
},
"checks": {
"must_include": [
"必须对齐证据门槛",
"必须只引用资料内的指南/共识信息",
"必须给出推进下一步(含时间点)"
],
"must_avoid": [
"虚构指南/共识出版信息",
"绝对化承诺或夸大业内认可",
"用销售口吻逼推进"
]
}
}
}
FILE:references/strategy_packs/aesthetics_spec_intro.publish.strategy.json
{
"id": "medical.aesthetics.spec_intro.publish.v1",
"version": "1.0.0",
"name": "医美-规格介绍型 发布级策略(防背书堆参数)",
"description": "适用于医美/微整等场景的产品规格介绍与型号差异说明。重点控制数值出现频率与表达形态,避免清单式背诵。",
"scope": { "domain": "medical", "tags": ["publish_ready", "aesthetics", "spec_intro"] },
"matchers": {
"keywords_any": ["医美", "微整", "童颜", "规格", "型号", "两种", "HARD", "SOFT", "粒径", "μm", "mg", "注射层次"],
"keywords_all": [],
"negative_keywords": ["指南", "共识", "进院", "药事会", "血透", "处方DOT"]
},
"recommendations": {
"tone": {
"style": "自然交流中的专业解释,结论先行",
"pace": "先一句话讲清差异,再补1-2个关键点",
"do": [
"先用一句话讲清:两个规格各自解决什么问题/适用场景",
"每轮只放1-2个数值点(仅来自注入资料)",
"把数值嵌入句子里,避免清单式罗列"
],
"dont": [
"把全部mg/μm/成分一次性堆满",
"用背书腔宣讲",
"资料缺失时编造参数/对比"
]
},
"question_path": [
"对齐:医生现在最想先弄清的是“两个规格怎么选”还是“各自参数是什么”",
"解释:先一句话讲清两规格的定位差异(只用资料内口径)",
"补充:按需补1-2个关键点位(如粒径/层次/规格),避免堆砌",
"落地:给出一个轻量下一步(含时间点),如下次带规格卡/演示材料对齐"
],
"best_practices": {
"opening": [
"先承接医生“没时间细看”的现实,主动用一句话先讲清差异",
"征得同意后再补关键点位"
],
"responding": [
"优先回答“怎么选”,再补“是什么”",
"若医生追问参数,用“我按资料把关键点位说清/其余我回去再核对”控制展开"
],
"closing": [
"用一句话小结:两规格的定位差异",
"约定下次带规格卡/资料对齐(含时间点)"
]
},
"checks": {
"must_include": [
"必须先给两规格的定位差异(一句话)",
"出现数值时必须来自注入资料",
"必须给下一步(含时间点)"
],
"must_avoid": [
"清单式背诵参数",
"无依据的数值/对比结论",
"绝对化承诺"
]
}
}
}
FILE:references/strategy_packs/general.publish.strategy.json
{
"id": "general.publish.v1",
"version": "1.0.0",
"name": "通用发布级策略(跨行业)",
"description": "当无法稳定识别行业/意图时,用此策略保证输出不空泛且可执行;不提供任何行业事实,只提供结构与行为层建议。",
"scope": { "domain": "general", "tags": ["publish_ready", "fallback"] },
"matchers": {
"keywords_any": ["场景", "目标", "沟通", "推进", "顾虑", "流程", "培训", "上线", "试用"],
"keywords_all": [],
"negative_keywords": []
},
"recommendations": {
"tone": {
"style": "务实、克制、以对齐事实与下一步为主",
"pace": "先确认核心问题,再给可执行下一步",
"do": [
"先复述对方立场与核心顾虑,避免直接反驳",
"每轮只推进一个关键点,避免信息堆叠",
"把'下一步'写成可执行安排(含时间点)"
],
"dont": [
"背书式长段讲解",
"在缺资料时输出数据/结论",
"一次性给出过多并列行动项"
]
},
"question_path": [
"先对齐:对方当前最关心的一个问题是什么(只选1个)",
"澄清:影响决策/推进的关键门槛或约束是什么",
"落地:下一步最小可行动作是什么(含时间点)"
],
"best_practices": {
"opening": [
"先征得同意,再说明本次想对齐的一个点",
"用一句话复述对方现状与顾虑,确认理解一致"
],
"responding": [
"先给一句话结论,再给1-2条理由(只基于已注入资料)",
"不确定处明确说需核对,并承诺下次带回口径"
],
"closing": [
"用一句话小结共识点",
"提出一个明确的下一步安排(含时间点)"
]
},
"checks": {
"must_include": [
"核心顾虑必须收敛为1条",
"必须给出下一步(含时间点)",
"必须声明证据边界(无资料不编造)"
],
"must_avoid": [
"无依据的量化数值",
"无依据的政策/指南引用",
"过度承诺/绝对化措辞"
]
}
}
}
FILE:references/strategy_packs/internal_system_rollout.publish.strategy.json
{
"id": "internal_system_rollout.publish.v1",
"version": "1.0.0",
"name": "公司系统推广/流程上线 发布级策略",
"description": "适用于公司内部系统推广、流程变更落地、培训与验收等场景。强调流程对齐、风险边界、验收口径与最小落地动作。",
"scope": { "domain": "internal_system", "tags": ["publish_ready", "rollout", "process_change"] },
"matchers": {
"keywords_any": ["系统", "上线", "推广", "培训", "流程", "权限", "账号", "数据录入", "验收", "口径", "排障", "工单"],
"keywords_all": [],
"negative_keywords": ["指南", "共识", "处方", "患者", "门诊", "住院部"]
},
"recommendations": {
"tone": {
"style": "清晰、结构化、以对齐口径为主",
"pace": "先确认目标与验收口径,再拆步骤与风险点",
"do": [
"把目标写成可验收结果(可检查)",
"把流程拆成3-5步,并给每步的成功判据",
"优先指出高风险误区与回滚/兜底方案"
],
"dont": [
"泛泛而谈的'建议优化'",
"跳过权限/依赖导致的关键阻塞点",
"一次性给出大量并列功能点"
]
},
"question_path": [
"现状:现在卡在流程的哪一步(或哪个页面/哪个权限)",
"口径:成功/验收的判定标准是什么",
"步骤:最小可复现路径是什么(按步骤列出)",
"风险:最常见的误区/失败原因是什么",
"落地:下一步谁在什么时间点完成什么动作"
],
"best_practices": {
"opening": [
"先确认本次讨论目标(上线/验收/培训/排障)",
"确认当前角色分工(谁负责配置/谁负责使用/谁负责验收)"
],
"responding": [
"按步骤给出最小路径,并在每步给'成功判据'",
"对不确定项明确列出需要补充的系统信息(截图/报错/环境)"
],
"closing": [
"把下一步拆成1-2个可执行动作(含负责人+时间点)",
"补一句风险提醒与兜底(必要时走工单/回滚)"
]
},
"checks": {
"must_include": [
"必须出现验收/成功判据",
"必须出现最小可复现路径",
"必须出现下一步动作(含时间点与负责人)"
],
"must_avoid": [
"把未确认的系统行为当事实",
"不解释依赖条件就给结论",
"用'肯定/一定'做承诺"
]
}
}
}
FILE:references/strategy_packs/medical_clinical_conversation.publish.strategy.json
{
"id": "medical.clinical_conversation.publish.v1",
"version": "1.0.0",
"name": "医疗场景通用会话 发布级策略",
"description": "适用于医生/患者/代表等医疗相关沟通场景的通用发布级写作策略。强调合规表达、风险边界、循证口径与可落地下一步。",
"scope": { "domain": "medical", "tags": ["publish_ready", "clinical"] },
"matchers": {
"keywords_any": ["门诊", "住院部", "病区", "查房", "处方", "指南", "共识", "不良反应", "剂量", "随访", "科室会", "进院"],
"keywords_all": [],
"negative_keywords": ["系统上线", "权限", "账号", "验收", "工单"]
},
"recommendations": {
"tone": {
"style": "专业、克制、尊重临床判断",
"pace": "先对齐核心顾虑,再给结论与边界,最后落到下一步",
"do": [
"先复述医生/对方的核心顾虑,避免直接反驳",
"先给一句话结论,再给1-2条依据(仅限已注入资料)",
"主动补充风险与边界(不作绝对化承诺)",
"把推进写成轻量、可执行的下一步(含时间点)"
],
"dont": [
"背书式长段讲解或堆术语",
"缺资料时输出数值/研究结论/对比结论",
"强推处方/强逼单式推进"
]
},
"question_path": [
"对齐:对方当前最关心的一个问题是什么(只选1个)",
"澄清:证据门槛/风险边界/适用人群的关键约束是什么",
"回应:给一句话结论 + 1-2条依据(仅限已注入资料)",
"落地:提出一个轻量下一步(含时间点),并约定反馈闭环"
],
"best_practices": {
"opening": [
"征得同意,说明本次想对齐的一个点(不铺垫过长)",
"复述对方顾虑并确认理解一致"
],
"responding": [
"有资料就引用原句语义;没资料就明确需要核对口径",
"在价值点之后补一句风险/边界(不夸大、不绝对)"
],
"closing": [
"用一句话小结共识点",
"提出下一步安排(含时间点)并说明将带回/同步的资料或反馈"
]
},
"checks": {
"must_include": [
"核心顾虑必须收敛为1条",
"必须出现风险与边界",
"必须给出下一步(含时间点)"
],
"must_avoid": [
"无依据的量化数值",
"无依据的研究/指南/政策引用",
"绝对化承诺(如100%有效/绝对安全)"
]
}
}
}
FILE:references/strategy_packs/pediatrics_prevention_mindset.publish.strategy.json
{
"id": "medical.pediatrics.prevention_mindset.publish.v1",
"version": "1.0.0",
"name": "儿科/住院部值班-预防观念转变 发布级策略",
"description": "适用于儿科/住院部值班/时间相对宽松的交流场景,将'无需干预/可自愈'的观念转向'高风险+防大于治',并落到轻量试用与反馈闭环。",
"scope": { "domain": "medical", "tags": ["publish_ready", "pediatrics", "prevention_mindset"] },
"matchers": {
"keywords_any": ["儿科", "住院部", "值班", "离出诊", "高风险", "防大于治", "预防", "无需干预", "自行疗愈", "抗生素", "AAD"],
"keywords_all": [],
"negative_keywords": ["药事会", "进院", "目录", "验收", "工单"]
},
"recommendations": {
"tone": {
"style": "更轻松但仍专业,先共情后对齐观念",
"pace": "先认同可自愈的前提,再补充高风险与预防价值,最后落到轻量动作",
"do": [
"先承认对方观点的合理前提(轻型可自愈),再转到高风险/重型风险(仅限资料口径)",
"把预防方案说成“在某类患者/某个时点”的轻量加用(仅限资料)",
"明确建立反馈闭环(下周固定收反馈/带卡片),含时间点"
],
"dont": [
"恐吓式夸大风险",
"堆砌循证引用(除非资料已注入且对方需要)",
"一上来就要求全科普遍改变处方"
]
},
"question_path": [
"对齐:你更担心的是“确实不需要干预”还是“担心过度用药”",
"转观念:承认轻型可自愈的前提→补充高风险与预防意义(仅限资料)",
"方案:明确何时加用/怎么用(仅限资料)",
"落地:先选一类高风险患儿试行,并约定下次固定时间收反馈"
],
"best_practices": {
"opening": [
"用轻量关怀开场,确认现在是否方便聊几分钟",
"把话题锚定在一个点:预防理念/高风险人群"
],
"responding": [
"先一句话讲清“为什么要预防”,再补1-2条资料内依据",
"用药与边界说清楚,不鼓励超资料范围"
],
"closing": [
"提出试行建议(小范围)",
"约定下次回访时间点并说明会带的资料/卡片"
]
},
"checks": {
"must_include": [
"必须先共情并承认对方观点的合理前提",
"必须落到轻量试行+反馈闭环(含时间点)",
"任何循证/共识引用必须来自注入资料"
],
"must_avoid": [
"恐吓式表达",
"强推全科改变习惯",
"无依据的数值或结论"
]
}
}
}
FILE:references/strategy_packs/strategy.schema.json
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"$id": "https://openclaw.local/scenario_builder/strategy.schema.json",
"title": "Scenario Builder Strategy Pack",
"type": "object",
"required": ["id", "version", "name", "scope", "matchers", "recommendations"],
"properties": {
"id": {
"type": "string",
"description": "Stable id, e.g. 'general.publish.v1' or 'med.pharma.access_update.v1'"
},
"version": {
"type": "string",
"description": "Semver-like, e.g. '1.0.0'"
},
"name": { "type": "string" },
"description": { "type": "string" },
"scope": {
"type": "object",
"required": ["domain"],
"properties": {
"domain": {
"type": "string",
"description": "Domain tag, e.g. 'general', 'medical', 'internal_system'"
},
"tags": { "type": "array", "items": { "type": "string" } }
},
"additionalProperties": false
},
"matchers": {
"type": "object",
"required": ["keywords_any", "keywords_all", "negative_keywords"],
"properties": {
"keywords_any": { "type": "array", "items": { "type": "string" } },
"keywords_all": { "type": "array", "items": { "type": "string" } },
"negative_keywords": { "type": "array", "items": { "type": "string" } }
},
"additionalProperties": false
},
"recommendations": {
"type": "object",
"required": ["tone", "question_path", "best_practices", "checks"],
"properties": {
"tone": {
"type": "object",
"required": ["style", "pace"],
"properties": {
"style": { "type": "string" },
"pace": { "type": "string" },
"do": { "type": "array", "items": { "type": "string" } },
"dont": { "type": "array", "items": { "type": "string" } }
},
"additionalProperties": false
},
"question_path": {
"type": "array",
"description": "Ordered path steps (strings), agent should adapt wording but keep intent.",
"items": { "type": "string" },
"minItems": 2
},
"best_practices": {
"type": "object",
"required": ["opening", "responding", "closing"],
"properties": {
"opening": { "type": "array", "items": { "type": "string" } },
"responding": { "type": "array", "items": { "type": "string" } },
"closing": { "type": "array", "items": { "type": "string" } }
},
"additionalProperties": false
},
"checks": {
"type": "object",
"required": ["must_include", "must_avoid"],
"properties": {
"must_include": { "type": "array", "items": { "type": "string" } },
"must_avoid": { "type": "array", "items": { "type": "string" } }
},
"additionalProperties": false
}
},
"additionalProperties": false
}
},
"additionalProperties": false
}
FILE:scripts/README.md
# scripts — 目录索引
本目录存放可执行脚本与内部复用工具。
## 模块
- `scene/`
- 入口:`scene/README.md`
- 包含场景链路脚本与落库执行脚本
- `common/`
- 入口:`common/README.md`
- 包含 TOON 编码与鉴权 token 解析等共享工具
- `tbs_assets/`
- 入口:`tbs_assets/README.md`
- 存放草稿、主数据字典与本地运行资产
## 执行原则
1. 先读 `openapi/scene/api-index.md` 与对应 endpoint 文档
2. 再执行 `scene/*.py`
3. 所有需要鉴权的步骤依赖 `XG_USER_TOKEN`
FILE:scripts/common/README.md
# 脚本清单 — common
该目录承载 Skill 内部复用工具,不对应外部业务 endpoint。
## 共享依赖
无
## 脚本列表
| 脚本 | 对应接口 | 用途 |
|---|---|---|
| `toon_encoder.py` | (内部工具) | 将结构化 JSON 压缩为 TOON 字符串,用于脚本契约输出 |
| `auth_token.py` | (内部工具) | 从环境变量 `XG_USER_TOKEN` 解析 access-token(`strip` 后使用) |
## 使用方式
该工具通常由各脚本内部 `import` 使用,不建议直接面向用户运行。
FILE:scripts/common/auth_token.py
#!/usr/bin/env python3
"""
Shared access-token resolver for scenario-builder scripts.
Aligned with cms-tbs-training: token is sourced from env `XG_USER_TOKEN`.
"""
import os
import sys
def resolve_access_token():
token = (os.environ.get("XG_USER_TOKEN") or "").strip()
if token:
return token, "env:XG_USER_TOKEN"
return None, None
def require_access_token():
token, _ = resolve_access_token()
if not token:
print("错误: 请设置环境变量 XG_USER_TOKEN", file=sys.stderr)
raise SystemExit(1)
return token
FILE:scripts/common/toon_encoder.py
"""
TOON (Token-Oriented Object Notation) Encoder
A zero-dependency Python implementation fully compliant with TOON spec v3.0.
【核心用途解说】
此序列化引擎的本质,是专门建立一条将臃肿的 JSON(或多层嵌套的 Python dict/list)结构,转化为面向人工大语言模型(LLM)的高密度浓缩协议通道。
它的核心目标聚焦于【断崖式的削减大模型 Token 损耗】。依靠识别与动态压缩统一表头(类似 CSV 表格内联提取),结合 YAML 的层级树特质,该模块能在确保数据上下文明义 100% 不受损的前提下,精简掉所有的废弃闭合括号及引号符号,平均可帮你的 API 系统为单个语境输入节省极大量的 Token 从而实现极致降本提效。
"""
import re
import json
import math
from datetime import datetime, date
from typing import Any, Iterator, Tuple, List
# Validation patterns matching TS implementation
NUMERIC_LIKE_PATTERN = re.compile(r'^-?\d+(?:\.\d+)?(?:e[+-]?\d+)?$', re.IGNORECASE)
LEADING_ZERO_PATTERN = re.compile(r'^0\d+$')
VALID_UNQUOTED_KEY_PATTERN = re.compile(r'^[A-Za-z_][\w.]*$')
def _is_valid_unquoted_key(key: str) -> bool:
"""Checks if a key can be safely used without quotes."""
return bool(VALID_UNQUOTED_KEY_PATTERN.match(key))
def _is_safe_unquoted(value: str, delimiter: str) -> bool:
"""Determines if a string value can be safely encoded without quotes."""
if not value or value != value.strip():
return False
val_lower = value.lower()
if val_lower in ('true', 'false', 'null'):
return False
if NUMERIC_LIKE_PATTERN.match(value) or LEADING_ZERO_PATTERN.match(value):
return False
if ':' in value or '"' in value or '\\' in value:
return False
if any(ch in value for ch in ('[', ']', '{', '}')):
return False
if any(ch in value for ch in ('\n', '\r', '\t')):
return False
if delimiter in value:
return False
if value.startswith('-'):
return False
return True
def _escape_string(val: str) -> str:
"""
Safely escapes string matching JSON specification exactly (e.g. control characters).
Uses standard library json.dumps to stringify and slices off the bounding quotes.
"""
return json.dumps(val, ensure_ascii=False)[1:-1]
def normalize_value(value: Any, strip_html_style: bool = False) -> Any:
"""
Normalizes complex Python types into strict JSON equivalents (Primitives, Lists, Dicts, None).
Matches the TypeScript implementation's robust tracking for edge cases like NaNs and Sets.
"""
if value is None:
return None
if isinstance(value, str):
if strip_html_style:
# Safely remove style="..." or style='...' attributes from HTML tags to save tokens
value = re.sub(r'(?i)\s*style\s*=\s*(["\']).*?\1', '', value)
return value
if isinstance(value, bool):
return value
if isinstance(value, (int, float)):
if isinstance(value, float):
# NaN and Infinity map to null in standard JSON
if math.isnan(value) or math.isinf(value):
return None
return value
if isinstance(value, (datetime, date)):
return value.isoformat()
if isinstance(value, (list, tuple, set, frozenset)):
return [normalize_value(v, strip_html_style) for v in value]
if isinstance(value, dict):
return {str(k): normalize_value(v, strip_html_style) for k, v in value.items()}
# Graceful fallback for custom objects implementing a toJSON serialization target hook
if hasattr(value, 'toJSON') and callable(value.toJSON):
return normalize_value(value.toJSON(), strip_html_style)
if hasattr(value, 'to_json') and callable(value.to_json):
return normalize_value(value.to_json(), strip_html_style)
# Silent fallback for unreadable items like functions, mimicking JSON's drop behavior
return None
def _is_primitive(val: Any) -> bool:
return val is None or isinstance(val, (bool, int, float, str))
def _encode_primitive(val: Any, delimiter: str) -> str:
if val is None:
return 'null'
if isinstance(val, bool):
return 'true' if val else 'false'
if isinstance(val, (int, float)):
return str(val)
if isinstance(val, str):
if _is_safe_unquoted(val, delimiter):
return val
return f'"{_escape_string(val)}"'
return str(val)
def _encode_key(key: Any) -> str:
k_str = str(key)
if _is_valid_unquoted_key(k_str):
return k_str
return f'"{_escape_string(k_str)}"'
def _is_tabular_array(arr: List[Any]) -> Tuple[bool, List[str]]:
if not arr or not isinstance(arr[0], dict):
return False, []
first_keys = list(arr[0].keys())
if not first_keys:
return False, []
for item in arr:
if not isinstance(item, dict):
return False, []
if len(item) != len(first_keys):
return False, []
for k in first_keys:
if k not in item or not _is_primitive(item[k]):
return False, []
return True, first_keys
def _indent_line(depth: int, content: str, indent_size: int) -> str:
return (' ' * (depth * indent_size)) + content
def _format_header(length: int, key=None, fields=None, delimiter: str = ',') -> str:
header = ""
if key is not None:
header += _encode_key(key)
header += f"[{length}"
if delimiter != ',':
header += delimiter
header += "]"
if fields:
enc_fields = [_encode_key(f) for f in fields]
header += f"{{{delimiter.join(enc_fields)}}}"
header += ":"
return header
def _encode_dict(obj: dict, depth: int, indent_size: int, delimiter: str) -> Iterator[str]:
for k, v in obj.items():
yield from _encode_kv(k, v, depth, indent_size, delimiter)
def _encode_kv(key: Any, value: Any, depth: int, indent_size: int, delimiter: str) -> Iterator[str]:
enc_k = _encode_key(key)
if _is_primitive(value):
yield _indent_line(depth, f"{enc_k}: {_encode_primitive(value, delimiter)}", indent_size)
elif isinstance(value, list):
if len(value) == 0:
yield _indent_line(depth, _format_header(0, key=key, delimiter=delimiter), indent_size)
elif all(_is_primitive(v) for v in value):
joined = delimiter.join(_encode_primitive(v, delimiter) for v in value)
yield _indent_line(depth, f"{_format_header(len(value), key=key, delimiter=delimiter)} {joined}", indent_size)
else:
is_tabular, headers = _is_tabular_array(value)
if is_tabular:
yield _indent_line(depth, _format_header(len(value), key=key, fields=headers, delimiter=delimiter), indent_size)
for item in value:
joined = delimiter.join(_encode_primitive(item[h], delimiter) for h in headers)
yield _indent_line(depth + 1, joined, indent_size)
else:
yield _indent_line(depth, _format_header(len(value), key=key, delimiter=delimiter), indent_size)
for item in value:
yield from _encode_list_item(item, depth + 1, indent_size, delimiter)
elif isinstance(value, dict):
yield _indent_line(depth, f"{enc_k}:", indent_size)
if value:
yield from _encode_dict(value, depth + 1, indent_size, delimiter)
def _encode_list_item(value: Any, depth: int, indent_size: int, delimiter: str) -> Iterator[str]:
if _is_primitive(value):
yield _indent_line(depth, f"- {_encode_primitive(value, delimiter)}", indent_size)
elif isinstance(value, list):
if all(_is_primitive(v) for v in value):
joined = delimiter.join(_encode_primitive(v, delimiter) for v in value)
yield _indent_line(depth, f"- {_format_header(len(value), delimiter=delimiter)} {joined}", indent_size)
else:
yield _indent_line(depth, f"- {_format_header(len(value), delimiter=delimiter)}", indent_size)
for item in value:
yield from _encode_list_item(item, depth + 1, indent_size, delimiter)
elif isinstance(value, dict):
if not value:
yield _indent_line(depth, "- ", indent_size)
return
entries = list(value.items())
first_k, first_v = entries[0]
enc_first_k = _encode_key(first_k)
if isinstance(first_v, list) and len(first_v) > 0:
tabular, headers = _is_tabular_array(first_v)
if tabular:
yield _indent_line(depth, f"- {_format_header(len(first_v), key=first_k, fields=headers, delimiter=delimiter)}", indent_size)
for item in first_v:
joined = delimiter.join(_encode_primitive(item[h], delimiter) for h in headers)
yield _indent_line(depth + 2, joined, indent_size)
if len(entries) > 1:
rest_dict = dict(entries[1:])
yield from _encode_dict(rest_dict, depth + 1, indent_size, delimiter)
return
if _is_primitive(first_v):
yield _indent_line(depth, f"- {enc_first_k}: {_encode_primitive(first_v, delimiter)}", indent_size)
elif isinstance(first_v, list):
if len(first_v) == 0:
yield _indent_line(depth, f"- {enc_first_k}{_format_header(0, delimiter=delimiter)}", indent_size)
elif all(_is_primitive(v) for v in first_v):
joined = delimiter.join(_encode_primitive(v, delimiter) for v in first_v)
yield _indent_line(depth, f"- {enc_first_k}{_format_header(len(first_v), delimiter=delimiter)} {joined}", indent_size)
else:
yield _indent_line(depth, f"- {enc_first_k}{_format_header(len(first_v), delimiter=delimiter)}", indent_size)
for item in first_v:
yield from _encode_list_item(item, depth + 2, indent_size, delimiter)
elif isinstance(first_v, dict):
yield _indent_line(depth, f"- {enc_first_k}:", indent_size)
if first_v:
yield from _encode_dict(first_v, depth + 2, indent_size, delimiter)
if len(entries) > 1:
rest_dict = dict(entries[1:])
yield from _encode_dict(rest_dict, depth + 1, indent_size, delimiter)
def encode_lines(data: Any, indent: int = 2, delimiter: str = ',', strip_html_style: bool = False) -> Iterator[str]:
"""
Core generator returning lines instead of full string.
Suitable for streaming large inputs.
"""
# 1. Normalize strictly to JSON limits matching TS behavior
data = normalize_value(data, strip_html_style)
if _is_primitive(data):
enc_val = _encode_primitive(data, delimiter)
if enc_val:
yield enc_val
elif isinstance(data, list):
if not data:
yield _format_header(0, delimiter=delimiter)
elif all(_is_primitive(v) for v in data):
joined = delimiter.join(_encode_primitive(v, delimiter) for v in data)
yield f"{_format_header(len(data), delimiter=delimiter)} {joined}"
else:
is_tabular, headers = _is_tabular_array(data)
if is_tabular:
yield _format_header(len(data), fields=headers, delimiter=delimiter)
for item in data:
joined = delimiter.join(_encode_primitive(item[h], delimiter) for h in headers)
yield _indent_line(1, joined, indent)
else:
yield _format_header(len(data), delimiter=delimiter)
for item in data:
yield from _encode_list_item(item, 1, indent, delimiter)
elif isinstance(data, dict):
yield from _encode_dict(data, 0, indent, delimiter)
def encode(data: Any, indent: int = 2, delimiter: str = ',', strip_html_style: bool = False) -> str:
"""
Encodes a Python value (dict, list, primitive) into a TOON formatted string.
【主入口方法解说】
接收包含复杂嵌套结构的 Python 变量体系(如原生的 json 解析对象)。通过内部深度优先的生成器矩阵和结构对齐,
将其最终吐出为能够直接硬编码嵌入给 GPT/Claude/Gemini 等大模型 Prompt 阅读的 TOON 语境化压缩多行字符实体。
此方法正是所有后续省 Token 转换流的总开关。
【兼容性处理】
如果传入的数据已经是字符串且不是明显的 JSON 结构(Dict 或 List),则原样返回,确保接口在高频调用下的稳健性。
"""
if isinstance(data, str) and not (data.strip().startswith('{') or data.strip().startswith('[')):
return data
return '\n'.join(encode_lines(data, indent, delimiter, strip_html_style))
FILE:scripts/scene/README.md
# 脚本清单 — scene
## 共享依赖
- `../common/toon_encoder.py` — TOON 编码器;标准输出必须经过 `toon_encode()`。
## 脚本列表
| 脚本 | 逻辑端点 | 用途 |
|---|---|---|
| `route-by-intent.py` | `POST .../route-by-intent` | 校验路由契约并输出 TOON 摘要 |
| `parse-and-gap-ask.py` | `POST .../parse-and-gap-ask` | 校验解析草案字段契约 |
| `publish-ready-compose.py` | `POST .../publish-ready-compose` | 校验 publish_ready 前置契约 |
| `build-persona.py` | `POST .../build-persona` | 校验 persona 相关子树存在性 |
| `build-prompts.py` | `POST .../build-prompts` | 校验 promptBundle 四键占位 |
| `build-api-draft-dedup.py` | `POST .../build-api-draft-dedup` | 校验 apiDraft + 去重证据形状 |
| `validate-and-gate.py` | `POST .../validate-and-gate` | 校验终检输入非空 |
| `preflight-tbs-master-data.py` | `POST .../preflight-tbs-master-data` | 确认落库前:对 TBS 业务领域/科室/药品 GET 匹配,无则 POST(与 executor 共用 `tbs_master_data_resolve.py`) |
| `persist-and-execute.py` | `POST .../persist-and-execute` | 写草稿并子进程执行 `tbs_write_executor.py` |
| `enforce-draft-text.py` | (校验工具) | 校验草稿文本字段完整性与关键路径非空,不做文本重建/兜底 |
| `tbs_master_data_resolve.py` | (共享模块) | `TBSClient`、`resolve_ids_for_scene`;供 preflight 与 executor 使用 |
| `tbs_write_executor.py` | (HTTP 落库,无逻辑端点 URL) | 调用 TBS Admin API;品种经 `GET/POST .../drugs` 查或建后再创建场景 |
| `scenario_pack_normalizer.py` | (共享模块) | 统一新旧 `scenarioPack` 字段映射,避免生成链路断裂 |
## 使用方式
```bash
export XG_USER_TOKEN="your-access-token"
# 从 stdin 传入 JSON 契约
python3 scripts/scene/parse-and-gap-ask.py < payload.json
```
## 输出说明
所有脚本标准输出均为 **TOON**(非原始 JSON)。
## 规范
1. **Python 3**
2. **必须经过 toon_encoder**
3. **鉴权**:`XG_USER_TOKEN` 缺失则退出码 1(鉴权约定见 `cms-auth-skills/common/auth.md`)
4. **入参**:以 `openapi/scene/*.md` 为准
FILE:scripts/scene/build-api-draft-dedup.py
#!/usr/bin/env python3
"""
scene / build-api-draft-dedup — 契约校验脚本(stdin JSON)。输出 TOON。
API_URL 须与 openapi/scene/build-api-draft-dedup.md 标题 URL 一致;本脚本不发起 HTTP。
"""
import json
import os
import re
import sys
sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..", "common"))
from auth_token import require_access_token
from toon_encoder import encode as toon_encode
from scenario_pack_normalizer import normalize_scenario_pack
API_URL = "https://scenario-builder.openclaw.internal/v1/scene/build-api-draft-dedup"
ALLOWED_EVIDENCE_STATUS = {"NOT_PROVIDED", "PARTIAL", "READY"}
def _read_body():
raw = sys.stdin.read()
if not raw.strip():
return {}
return json.loads(raw)
def _ok(step, **extra):
payload = {"ok": True, "step": step, **extra}
print(toon_encode(payload))
def _contains_blocked_fact_claims(node, blocked_keys):
if isinstance(node, dict):
for key, value in node.items():
if key in blocked_keys:
return True
if _contains_blocked_fact_claims(value, blocked_keys):
return True
elif isinstance(node, list):
for item in node:
if _contains_blocked_fact_claims(item, blocked_keys):
return True
return False
def _contains_blocked_fact_phrases(node, blocked_phrases):
if isinstance(node, str):
text = re.sub(r"\s+", "", node)
return any(p in text for p in blocked_phrases)
if isinstance(node, dict):
return any(_contains_blocked_fact_phrases(v, blocked_phrases) for v in node.values())
if isinstance(node, list):
return any(_contains_blocked_fact_phrases(v, blocked_phrases) for v in node)
return False
def main():
require_access_token()
body = _read_body()
sp = body.get("scenarioPack")
api_draft = body.get("apiDraft")
policy = body.get("factGuardPolicy")
if not isinstance(sp, dict) or not isinstance(api_draft, dict):
print("错误: scenarioPack 与 apiDraft 必填", file=sys.stderr)
sys.exit(2)
if not isinstance(policy, dict):
print("错误: 缺少 factGuardPolicy(必须由上游参数传入)", file=sys.stderr)
sys.exit(2)
blocked_keys = policy.get("blockedFactKeys")
blocked_phrases = policy.get("blockedPhrases")
if not isinstance(blocked_keys, list) or not all(isinstance(x, str) for x in blocked_keys):
print("错误: factGuardPolicy.blockedFactKeys 必须为字符串数组", file=sys.stderr)
sys.exit(2)
if not isinstance(blocked_phrases, list) or not all(isinstance(x, str) for x in blocked_phrases):
print("错误: factGuardPolicy.blockedPhrases 必须为字符串数组", file=sys.stderr)
sys.exit(2)
sp = normalize_scenario_pack(sp)
evidence_status = sp.get("productEvidenceStatus", "NOT_PROVIDED")
if evidence_status not in ALLOWED_EVIDENCE_STATUS:
print("错误: productEvidenceStatus 非法", file=sys.stderr)
sys.exit(2)
if evidence_status != "READY":
if _contains_blocked_fact_claims(api_draft, set(blocked_keys)):
print("错误: 证据未 READY,apiDraft 不得包含事实性结论字段", file=sys.stderr)
sys.exit(2)
if _contains_blocked_fact_phrases(api_draft, blocked_phrases):
print("错误: 证据未 READY,apiDraft 不得包含结论性事实表述", file=sys.stderr)
sys.exit(2)
confirmation_marked = bool(api_draft.get("needsEvidenceConfirmation"))
if not confirmation_marked:
print("错误: 证据未 READY 时,apiDraft 需标记 needsEvidenceConfirmation=true", file=sys.stderr)
sys.exit(2)
_ok(
"build-api-draft-dedup",
evidenceStatus=evidence_status,
constrainedGeneration=evidence_status != "READY",
)
if __name__ == "__main__":
main()
FILE:scripts/scene/build-persona.py
#!/usr/bin/env python3
"""
scene / build-persona — 契约校验脚本(stdin JSON)。输出 TOON。
API_URL 须与 openapi/scene/build-persona.md 标题 URL 一致;本脚本不发起 HTTP。
"""
import json
import os
import sys
sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..", "common"))
from auth_token import require_access_token
from toon_encoder import encode as toon_encode
from scenario_pack_normalizer import normalize_scenario_pack
API_URL = "https://scenario-builder.openclaw.internal/v1/scene/build-persona"
ALLOWED_EVIDENCE_STATUS = {"NOT_PROVIDED", "PARTIAL", "READY"}
def _read_body():
raw = sys.stdin.read()
if not raw.strip():
return {}
return json.loads(raw)
def _ok(step, **extra):
payload = {"ok": True, "step": step, **extra}
print(toon_encode(payload))
def _missing_core_fields(scenario_pack: dict) -> list:
required = ["businessDomain", "department", "product", "location", "repGoal"]
return [k for k in required if not scenario_pack.get(k)]
def main():
require_access_token()
body = _read_body()
sp = body.get("scenarioPack")
if not isinstance(sp, dict):
print("错误: scenarioPack 必填", file=sys.stderr)
sys.exit(2)
sp = normalize_scenario_pack(sp)
missing_core = _missing_core_fields(sp)
if missing_core:
print(f"错误: scenarioPack 缺少核心字段: {', '.join(missing_core)}", file=sys.stderr)
sys.exit(2)
evidence_status = sp.get("productEvidenceStatus", "NOT_PROVIDED")
if evidence_status not in ALLOWED_EVIDENCE_STATUS:
print("错误: productEvidenceStatus 非法", file=sys.stderr)
sys.exit(2)
evidence_sources = sp.get("productEvidenceSource") or []
if evidence_status == "READY" and not evidence_sources:
print("错误: productEvidenceStatus=READY 时需提供 productEvidenceSource", file=sys.stderr)
sys.exit(2)
_ok(
"build-persona",
evidenceStatus=evidence_status,
constrainedGeneration=evidence_status != "READY",
)
if __name__ == "__main__":
main()
FILE:scripts/scene/build-prompts.py
#!/usr/bin/env python3
"""
scene / build-prompts — 契约校验脚本(stdin JSON)。输出 TOON。
API_URL 须与 openapi/scene/build-prompts.md 标题 URL 一致;本脚本不发起 HTTP。
"""
import json
import os
import sys
sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..", "common"))
from auth_token import require_access_token
from toon_encoder import encode as toon_encode
from scenario_pack_normalizer import normalize_scenario_pack
API_URL = "https://scenario-builder.openclaw.internal/v1/scene/build-prompts"
ALLOWED_EVIDENCE_STATUS = {"NOT_PROVIDED", "PARTIAL", "READY"}
def _read_body():
raw = sys.stdin.read()
if not raw.strip():
return {}
return json.loads(raw)
def _ok(step, **extra):
payload = {"ok": True, "step": step, **extra}
print(toon_encode(payload))
def _missing_core_fields(scenario_pack: dict) -> list:
required = ["businessDomain", "department", "product", "location", "repGoal"]
return [k for k in required if not scenario_pack.get(k)]
def main():
require_access_token()
body = _read_body()
sp = body.get("scenarioPack")
if not isinstance(sp, dict):
print("错误: scenarioPack 必填", file=sys.stderr)
sys.exit(2)
sp = normalize_scenario_pack(sp)
missing_core = _missing_core_fields(sp)
if missing_core:
print(f"错误: scenarioPack 缺少核心字段: {', '.join(missing_core)}", file=sys.stderr)
sys.exit(2)
has_persona = any(k in sp for k in ("personaConfig", "personaBase", "personaOverlay"))
if not has_persona:
print("错误: 需先具备 persona 产物(personaConfig/personaBase/personaOverlay)", file=sys.stderr)
sys.exit(2)
evidence_status = sp.get("productEvidenceStatus", "NOT_PROVIDED")
if evidence_status not in ALLOWED_EVIDENCE_STATUS:
print("错误: productEvidenceStatus 非法", file=sys.stderr)
sys.exit(2)
_ok(
"build-prompts",
evidenceStatus=evidence_status,
constrainedGeneration=evidence_status != "READY",
)
if __name__ == "__main__":
main()
FILE:scripts/scene/enforce-draft-text.py
#!/usr/bin/env python3
"""
enforce-draft-text — 仅做校验,不做任何文本重建/兜底。
"""
from __future__ import annotations
import argparse
import json
import os
import sys
sys.path.insert(0, os.path.join(os.path.dirname(os.path.abspath(__file__)), "..", "common"))
from toon_encoder import encode as toon_encode # type: ignore
def _normalize_scene_doctors(api_draft: dict):
doctors = api_draft.get("doctors") or {}
if isinstance(doctors, list):
doctors = doctors[0] if doctors else {}
scenes = api_draft.get("scenes") or {}
if isinstance(scenes, list):
scenes = scenes[0] if scenes else {}
return doctors, scenes
def main() -> None:
ap = argparse.ArgumentParser()
ap.add_argument("--input", default=os.path.join("scripts", "tbs_assets", "scenario_draft.json"))
ap.add_argument("--write-back", action="store_true", help="Rewrite input JSON in-place.")
args = ap.parse_args()
input_path = args.input
if not os.path.isfile(input_path):
raise SystemExit(f"Missing input file: {input_path}")
# Import quality check helpers only; no generation fallback.
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
from tbs_write_executor import ( # type: ignore
_doctor_only_context_quality_ok,
_coach_only_context_quality_ok,
_coach_only_context_matches_scene_type,
)
with open(input_path, "r", encoding="utf-8") as f:
draft = json.load(f)
scenario_pack = draft.get("scenarioPack") or {}
api_draft = draft.get("apiDraft") or {}
api_draft_doctors, api_draft_scenes = _normalize_scene_doctors(api_draft)
if not isinstance(api_draft_scenes, dict):
raise SystemExit("Invalid draft shape: apiDraft.scenes must be an object (or a list with first element as object).")
rep_briefing_old = api_draft_scenes.get("rep_briefing") or ""
location = scenario_pack.get("location")
department = scenario_pack.get("department")
product = scenario_pack.get("product")
rep_briefing_invalid = (
(not isinstance(rep_briefing_old, str))
or (not rep_briefing_old.strip())
or ("【" in rep_briefing_old or "】" in rep_briefing_old)
or "待补充" in rep_briefing_old
or (len(rep_briefing_old) > 180)
or (location and str(location) not in rep_briefing_old)
or (department and str(department) not in rep_briefing_old)
or (product and str(product) not in rep_briefing_old)
)
doc_ctx_old = api_draft_scenes.get("doctor_only_context") or ""
coach_ctx_old = api_draft_scenes.get("coach_only_context") or ""
doctor_ctx_invalid = (not isinstance(doc_ctx_old, str)) or (not _doctor_only_context_quality_ok(doc_ctx_old))
coach_ctx_invalid = (not isinstance(coach_ctx_old, str)) or (not _coach_only_context_quality_ok(coach_ctx_old)) or (not _coach_only_context_matches_scene_type(scenario_pack, coach_ctx_old))
issues = []
if rep_briefing_invalid:
issues.append("rep_briefing_invalid")
if doctor_ctx_invalid:
issues.append("doctor_only_context_invalid")
if coach_ctx_invalid:
issues.append("coach_only_context_invalid")
if args.write_back:
raise SystemExit("禁止 write-back:本脚本仅校验,不做文本重建。")
out = {
"input": input_path,
"valid": len(issues) == 0,
"issues": issues,
}
print(toon_encode(out))
if __name__ == "__main__":
main()
FILE:scripts/scene/parse-and-gap-ask.py
#!/usr/bin/env python3
"""
scene / parse-and-gap-ask — 契约校验脚本(stdin JSON)。输出 TOON。
API_URL 须与 openapi/scene/parse-and-gap-ask.md 标题 URL 一致;本脚本不发起 HTTP。
"""
import json
import os
import re
import sys
sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..", "common"))
from auth_token import require_access_token
from toon_encoder import encode as toon_encode
from scenario_pack_normalizer import normalize_scenario_pack
API_URL = "https://scenario-builder.openclaw.internal/v1/scene/parse-and-gap-ask"
API_SOURCE_PREFIXES = tuple(
p.strip() for p in os.environ.get("TBS_KB_SOURCE_PREFIXES", "api://,https://").split(",") if p.strip()
)
REQUIRE_KB_API = os.environ.get("TBS_REQUIRE_KB_API", "1") == "1"
KNOWLEDGE_API_PATH = os.environ.get("TBS_KNOWLEDGE_API_PATH", "/api/v1/admin/basic/knowledge")
_TOKEN_PAT = re.compile(r"[\s\-_:/()(),,。;;、]+")
def _read_body():
raw = sys.stdin.read()
if not raw.strip():
return {}
return json.loads(raw)
def _ok(step, **extra):
payload = {"ok": True, "step": step, **extra}
print(toon_encode(payload))
def _norm_text(text: str) -> str:
if not isinstance(text, str):
return ""
return _TOKEN_PAT.sub("", text).lower()
def _char_ngrams(text: str, min_n: int = 2, max_n: int = 4) -> set[str]:
norm = _norm_text(text)
if not norm:
return set()
grams = set()
length = len(norm)
for n in range(min_n, max_n + 1):
if length < n:
continue
for i in range(length - n + 1):
grams.add(norm[i:i + n])
return grams
def _iter_hit_phrases(hit: dict):
for key in ("title", "category", "content", "summary"):
v = hit.get(key)
if isinstance(v, str) and v.strip():
yield v.strip()
for key in ("tags", "keywords", "coveredNeeds"):
arr = hit.get(key)
if not isinstance(arr, list):
continue
for item in arr:
if isinstance(item, str) and item.strip():
yield item.strip()
def _need_matches_hit(need: str, hit_texts: list[str]) -> bool:
need_norm = _norm_text(need)
if not need_norm:
return False
for t in hit_texts:
t_norm = _norm_text(t)
if not t_norm:
continue
if need_norm in t_norm or t_norm in need_norm:
return True
# 完全数据驱动:按字符 n-gram 相似度做弱语义匹配,不依赖硬编码业务词。
need_grams = _char_ngrams(need_norm)
if not need_grams:
return False
for t in hit_texts:
hit_grams = _char_ngrams(t)
if not hit_grams:
continue
overlap = len(need_grams & hit_grams)
union = len(need_grams | hit_grams)
if union == 0:
continue
jaccard = overlap / union
if jaccard >= 0.30:
return True
return False
def _extract_product(body: dict) -> tuple[str | None, list]:
# 1) explicit candidates from caller
candidates = []
for item in body.get("productCandidates") or []:
if isinstance(item, str) and item.strip():
candidates.append(item.strip())
elif isinstance(item, dict):
name = item.get("name")
if isinstance(name, str) and name.strip():
candidates.append(name.strip())
# 2) candidates from knowledge API hits (optional)
ksr = body.get("knowledgeSearchResult")
if isinstance(ksr, dict):
for hit in ksr.get("hits") or []:
if not isinstance(hit, dict):
continue
pn = hit.get("productName")
if isinstance(pn, str) and pn.strip():
candidates.append(pn.strip())
# de-dup keep order
uniq = []
seen = set()
for c in candidates:
if c in seen:
continue
seen.add(c)
uniq.append(c)
if uniq:
# fallback choose first candidate
return uniq[0], [{"value": uniq[0], "confidence": 0.75, "source": "param_or_api"}]
return None, []
def _extract_fields(body: dict) -> dict:
pack = {}
candidates = {}
parsed_fields = body.get("parsedFields")
if isinstance(parsed_fields, dict):
pack.update(parsed_fields)
product, product_candidates = _extract_product(body)
if product:
pack["product"] = product
candidates["product"] = product_candidates
if candidates:
pack["candidates"] = candidates
return pack
def _coverage_from_search_result(search_result: dict, needs: list) -> tuple[str, list, list, list]:
if not isinstance(search_result, dict):
return "NOT_PROVIDED", [], [], list(needs)
source_api = search_result.get("sourceApi")
if REQUIRE_KB_API and source_api != KNOWLEDGE_API_PATH:
return "NOT_PROVIDED", [], [], list(needs)
hits = search_result.get("hits")
if not isinstance(hits, list):
return "NOT_PROVIDED", [], [], list(needs)
covered = set()
sources = []
need_set = [n for n in needs if isinstance(n, str) and n.strip()]
for h in hits:
if not isinstance(h, dict):
continue
src = h.get("source")
if not isinstance(src, str) or not src:
continue
if not src.startswith(API_SOURCE_PREFIXES):
continue
sources.append(src)
score = h.get("score")
if isinstance(score, (int, float)) and score < 0.5:
continue
hit_texts = list(_iter_hit_phrases(h))
# 先保留显式 coveredNeeds(若有)。
for item in h.get("coveredNeeds", []):
if isinstance(item, str) and item.strip():
covered.add(item.strip())
# 再做语义匹配兜底:允许 need 与 title/category/tags 的同义表达互相命中。
for need in need_set:
if need in covered:
continue
if _need_matches_hit(need, hit_texts):
covered.add(need)
total = len(needs)
covered_needs = [n for n in needs if n in covered]
covered_count = len(covered_needs)
if total == 0:
return "NOT_PROVIDED", sources, [], []
if covered_count == total:
return "READY", sources, covered_needs, []
if covered_count > 0:
uncovered = [n for n in needs if n not in covered]
return "PARTIAL", sources, covered_needs, uncovered
return "NOT_PROVIDED", sources, [], list(needs)
def main():
require_access_token()
body = _read_body()
user_text = (body.get("userText") or "").strip()
if not user_text:
print("错误: 缺少 userText", file=sys.stderr)
sys.exit(2)
if not isinstance(body.get("parsedFields"), dict):
print("错误: 缺少 parsedFields(必须由上游参数传入,脚本不做文本兜底)", file=sys.stderr)
sys.exit(2)
existing = body.get("scenarioPack") if isinstance(body.get("scenarioPack"), dict) else {}
pack = {**existing, **_extract_fields(body)}
required = ["department", "product", "location", "doctorConcerns", "repGoal"]
missing_fields = [k for k in required if not pack.get(k)]
needs = pack.get("productKnowledgeNeeds", [])
has_search_result = isinstance(body.get("knowledgeSearchResult"), dict)
if needs and REQUIRE_KB_API and not has_search_result:
print("错误: 检测到产品知识需求,但缺少 knowledgeSearchResult;请先调用产品知识API并回传结果", file=sys.stderr)
sys.exit(2)
status, sources, covered, uncovered = _coverage_from_search_result(body.get("knowledgeSearchResult"), needs)
pack["productEvidenceStatus"] = status
pack["productEvidenceSource"] = sources
if status != "READY":
missing_fields.append("productEvidenceSource")
clarify_questions = []
question_map = {
"department": "主要沟通的是哪个科室或门诊?",
"product": "这次要传递的是哪个具体产品或通用名?",
"location": "本次拜访发生在什么机构与场景(门诊/病区/院外)?",
"doctorConcerns": "主任当前最在意的是可及性、疗效证据,还是使用风险?",
"repGoal": "本次沟通最想达成的一个结果是什么?",
"productEvidenceSource": "为保证内容准确,请补充可核对的产品资料:说明书要点、关键研究摘要,或可公开访问的说明/文献链接(无需提供内部系统编号)。",
}
seen = set()
for mf in missing_fields:
if mf in seen:
continue
seen.add(mf)
q = question_map.get(mf)
if q:
clarify_questions.append(q)
pack = normalize_scenario_pack(pack)
_ok(
"parse-and-gap-ask",
scenarioPack=pack,
missingFields=sorted(seen),
coveredNeeds=covered,
uncoveredNeeds=uncovered,
clarifyQuestions=clarify_questions,
acceptedSourcePrefixes=list(API_SOURCE_PREFIXES),
)
if __name__ == "__main__":
main()
FILE:scripts/scene/persist-and-execute.py
#!/usr/bin/env python3
"""
scene / persist-and-execute — 将草稿落盘并调用 tbs_write_executor.py;stdout 为 TOON。
"""
import json
import os
import subprocess
import sys
import ssl
import urllib.error
import urllib.request
sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..", "common"))
from auth_token import resolve_access_token
from toon_encoder import encode as toon_encode
API_URL = "https://scenario-builder.openclaw.internal/v1/scene/persist-and-execute"
def _skill_root() -> str:
here = os.path.dirname(os.path.realpath(__file__))
return os.path.normpath(os.path.join(here, "..", ".."))
def _runtime_dir() -> str:
"""Backward-compat: fallback assets live under `runtime/`."""
return os.path.join(_skill_root(), "runtime")
def _assets_dir() -> str:
"""
Prefer non-`runtime/` assets location.
- Set env `TBS_ASSETS_DIR` to fully override.
- Else if `scripts/tbs_assets/` exists, use it.
- Else fall back to legacy `runtime/`.
"""
env = os.environ.get("TBS_ASSETS_DIR")
if env:
return env
skill_root = _skill_root()
candidate = os.path.join(skill_root, "scripts", "tbs_assets")
if os.path.isdir(candidate):
return candidate
candidate2 = os.path.join(skill_root, "assets", "tbs_assets")
if os.path.isdir(candidate2):
return candidate2
return _runtime_dir()
def _auth_probe(base_url: str, access_token: str, insecure_ssl: bool = True):
url = base_url.rstrip("/") + "/api/v1/admin/basic/business-domains"
headers = {
"accept": "application/json",
"Content-Type": "application/json",
"access-token": access_token,
}
req = urllib.request.Request(url=url, method="GET", headers=headers)
try:
ssl_ctx = ssl._create_unverified_context() if insecure_ssl else None
with urllib.request.urlopen(req, timeout=20, context=ssl_ctx) as resp:
return int(resp.status), ""
except urllib.error.HTTPError as e:
raw = e.read().decode("utf-8", errors="replace") if hasattr(e, "read") else ""
return int(e.code), raw[-500:]
except Exception as e:
return None, str(e)
def main():
body = json.loads(sys.stdin.read() or "{}")
vr = body.get("validationReport") or {}
if not vr.get("passed"):
print("错误: validationReport.passed 须为 true", file=sys.stderr)
sys.exit(2)
user_confirmation = body.get("userConfirmation")
if not user_confirmation:
print("错误: 缺少 userConfirmation", file=sys.stderr)
sys.exit(2)
user_confirmation = str(user_confirmation).strip()
if user_confirmation == "取消":
# Cancel is not an error; it should stop persistence gracefully.
print(
toon_encode(
{
"ok": False,
"step": "persist-and-execute",
"message": "user_cancelled",
"hint": "用户已取消,本次不执行落库。",
}
)
)
sys.exit(0)
if user_confirmation != "确认":
print(
toon_encode(
{
"ok": False,
"step": "persist-and-execute",
"message": "invalid_userConfirmation",
"got": user_confirmation,
"hint": "userConfirmation 仅允许填入:确认 或 取消。",
}
)
)
sys.exit(2)
assets_dir = _assets_dir()
base_url = os.environ.get("TBS_BASE_URL", "https://sg-tbs-manage.mediportal.com.cn")
access_token, token_source = resolve_access_token()
if not access_token:
print(
toon_encode(
{
"ok": False,
"step": "persist-and-execute",
"message": "missing_access_token",
"hint": "Set XG_USER_TOKEN before running persist-and-execute.",
}
)
)
sys.exit(1)
probe_status, probe_error = _auth_probe(base_url=base_url, access_token=access_token, insecure_ssl=True)
if probe_status != 200:
print(
toon_encode(
{
"ok": False,
"step": "persist-and-execute",
"message": "auth_preflight_failed",
"tokenSource": token_source,
"probeUrl": base_url.rstrip("/") + "/api/v1/admin/basic/business-domains",
"probeStatus": probe_status,
"probeError": probe_error,
}
)
)
sys.exit(4)
default_draft_assets = os.path.join(assets_dir, "scenario_draft.json")
default_draft_runtime = os.path.join(_runtime_dir(), "scenario_draft.json")
if os.path.isfile(default_draft_assets):
default_draft = default_draft_assets
else:
default_draft = default_draft_runtime
draft_path = body.get("draftPath") or default_draft
payload = body.get("draftPayload")
if payload is not None:
with open(draft_path, "w", encoding="utf-8") as f:
json.dump(payload, f, ensure_ascii=False, indent=2)
# Executor 入口统一迁移到 scripts/scene/
exe = os.path.join(os.path.dirname(__file__), "tbs_write_executor.py")
if not os.path.isfile(exe):
# fallback: older layout (tbs_assets wrapper)
exe_fallback = os.path.join(assets_dir, "tbs_write_executor.py")
exe = exe_fallback if os.path.isfile(exe_fallback) else exe
if not os.path.isfile(exe):
print(
toon_encode(
{
"ok": False,
"step": "persist-and-execute",
"message": "executor_missing",
"path": exe,
}
)
)
sys.exit(3)
proc = subprocess.run(
[sys.executable, exe, "--insecure-ssl", "--input", str(draft_path)],
capture_output=True,
text=True,
timeout=600,
env=os.environ,
)
print(toon_encode({
"ok": proc.returncode == 0,
"step": "persist-and-execute",
"tokenSource": token_source,
"probeStatus": probe_status,
"returncode": proc.returncode,
"stdoutTail": (proc.stdout or "")[-2000:],
"stderrTail": (proc.stderr or "")[-2000:],
}))
if proc.returncode != 0:
sys.exit(proc.returncode)
if __name__ == "__main__":
main()
FILE:scripts/scene/preflight-tbs-master-data.py
#!/usr/bin/env python3
"""
scene / preflight-tbs-master-data — 确认落库前:对 TBS 业务领域 / 科室 / 药品做 GET 匹配,不存在则 POST 创建。
与 tbs_write_executor 使用同一套 resolve_ids_for_scene 逻辑;stdout 为 TOON。
"""
import argparse
import json
import os
import sys
sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..", "common"))
from auth_token import resolve_access_token
from toon_encoder import encode as toon_encode # type: ignore[reportMissingImports]
from tbs_master_data_resolve import TBSClient, resolve_ids_for_scene
API_URL = "https://scenario-builder.openclaw.internal/v1/scene/preflight-tbs-master-data"
def _normalize_scenes(api_draft: dict):
raw = api_draft.get("scenes") or {}
if isinstance(raw, list):
return raw[0] if raw else {}
return raw if isinstance(raw, dict) else {}
def main():
ap = argparse.ArgumentParser(description="Preflight TBS master data before persist-and-execute.")
ap.add_argument(
"--input",
default=None,
help="Path to draft JSON (default: stdin only if not set; use - for stdin file)",
)
ap.add_argument("--base-url", default=os.environ.get("TBS_BASE_URL", "https://sg-tbs-manage.mediportal.com.cn"))
ap.add_argument("--access-token", default=None)
ap.add_argument("--insecure-ssl", action="store_true", help="Skip SSL certificate verification.")
ap.add_argument(
"--dry-run",
action="store_true",
help="Only GET/list matching; do not POST create (report would_create).",
)
args = ap.parse_args()
env_token, _ = resolve_access_token()
access_token = (args.access_token or env_token or "").strip()
if not access_token:
print("错误: 缺少 access-token / XG_USER_TOKEN", file=sys.stderr)
sys.exit(1)
if args.input:
with open(args.input, "r", encoding="utf-8") as f:
draft = json.load(f)
else:
raw = sys.stdin.read()
if not raw.strip():
print("错误: 请通过 stdin 传入草稿 JSON,或使用 --input", file=sys.stderr)
sys.exit(2)
draft = json.loads(raw)
api_draft = draft.get("apiDraft") or {}
api_draft_scenes = _normalize_scenes(api_draft)
client = TBSClient(
base_url=args.base_url,
access_token=access_token,
insecure_ssl=bool(args.insecure_ssl),
)
try:
resolved, report = resolve_ids_for_scene(
client,
api_draft_scenes,
dry_run=bool(args.dry_run),
with_report=True,
)
except Exception as e:
print(
toon_encode(
{
"ok": False,
"step": "preflight-tbs-master-data",
"message": str(e),
}
)
)
sys.exit(3)
ok = bool(
resolved.get("department_id")
and resolved.get("business_domain_id")
and resolved.get("drug_id")
)
print(
toon_encode(
{
"ok": ok,
"step": "preflight-tbs-master-data",
"resolvedIds": resolved,
"resolutionReport": report,
"dryRun": bool(args.dry_run),
}
)
)
sys.exit(0 if ok else 4)
if __name__ == "__main__":
main()
FILE:scripts/scene/publish-ready-compose.py
#!/usr/bin/env python3
"""
scene / publish-ready-compose — 契约校验脚本(stdin JSON)。输出 TOON。
API_URL 须与 openapi/scene/publish-ready-compose.md 标题 URL 一致;本脚本不发起 HTTP。
"""
import json
import os
import sys
sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..", "common"))
from auth_token import require_access_token
from toon_encoder import encode as toon_encode
API_URL = "https://scenario-builder.openclaw.internal/v1/scene/publish-ready-compose"
def _read_body():
raw = sys.stdin.read()
if not raw.strip():
return {}
return json.loads(raw)
def _ok(step, **extra):
payload = {"ok": True, "step": step, **extra}
print(toon_encode(payload))
def main():
require_access_token()
body = _read_body()
sp = body.get("scenarioPack")
mh = body.get("modeHints") or {}
if not isinstance(sp, dict):
print("错误: 缺少 scenarioPack", file=sys.stderr)
sys.exit(2)
pr = mh.get("publish_ready") or mh.get("outputMode") == "publish_ready"
if not pr:
print("错误: modeHints.publish_ready 或 outputMode=publish_ready 须成立", file=sys.stderr)
sys.exit(2)
_ok("publish-ready-compose")
if __name__ == "__main__":
main()
FILE:scripts/scene/route-by-intent.py
#!/usr/bin/env python3
"""
scene / route-by-intent — 契约校验脚本(stdin JSON)。输出 TOON。
API_URL 须与 openapi/scene/route-by-intent.md 标题 URL 一致;本脚本不发起 HTTP。
"""
import json
import os
import sys
sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..", "common"))
from auth_token import require_access_token
from toon_encoder import encode as toon_encode
API_URL = "https://scenario-builder.openclaw.internal/v1/scene/route-by-intent"
def _read_body():
raw = sys.stdin.read()
if not raw.strip():
return {}
return json.loads(raw)
def _ok(step, **extra):
payload = {"ok": True, "step": step, **extra}
print(toon_encode(payload))
def main():
require_access_token()
body = _read_body()
user_text = (body.get("userText") or "").strip()
if not user_text:
print("错误: 缺少 userText", file=sys.stderr)
sys.exit(2)
route_decision = body.get("routeDecision")
if not isinstance(route_decision, dict):
print("错误: 缺少 routeDecision(必须由上游参数传入)", file=sys.stderr)
sys.exit(2)
intent = route_decision.get("intent")
next_step = route_decision.get("nextStep")
reason = route_decision.get("reason", "provided-by-upstream")
need_clarification = bool(route_decision.get("needClarification", False))
clarify_question = route_decision.get("clarifyQuestion", "")
preconditions = list(route_decision.get("preconditions") or [])
if not intent or not next_step:
print("错误: routeDecision.intent 与 routeDecision.nextStep 必填", file=sys.stderr)
sys.exit(2)
session_state = body.get("sessionState") if isinstance(body.get("sessionState"), dict) else {}
if intent == "PERSIST_CONFIRM":
vr = session_state.get("validationReport") if isinstance(session_state.get("validationReport"), dict) else {}
if not vr.get("passed"):
preconditions.append("缺少 validationReport.passed=true")
need_clarification = True
if not clarify_question:
clarify_question = "当前还没有通过校验,是否先执行校验?"
next_step = "validate-and-gate"
reason = "persist-request-without-passed-validation"
_ok(
"route-by-intent",
intent=intent,
nextStep=next_step,
reason=reason,
needClarification=need_clarification,
clarifyQuestion=clarify_question,
preconditions=preconditions,
)
if __name__ == "__main__":
main()
FILE:scripts/scene/scenario_pack_normalizer.py
#!/usr/bin/env python3
"""
Normalize scenarioPack fields between new/legacy schemas.
"""
from copy import deepcopy
def normalize_scenario_pack(scenario_pack: dict) -> dict:
sp = deepcopy(scenario_pack or {})
# New -> legacy compatibility for downstream generators.
if "sceneBasic" not in sp:
sp["sceneBasic"] = {
"department": sp.get("department"),
"product": sp.get("product"),
"location": sp.get("location"),
}
if "roleSetup" not in sp:
sp["roleSetup"] = {
"doctorRole": "主任",
"repRole": "医药代表",
"department": sp.get("department"),
}
if "personaConfig" not in sp:
sp["personaConfig"] = {
"doctorConcerns": sp.get("doctorConcerns", []),
"repGoal": sp.get("repGoal"),
"styleHints": sp.get("styleHints", []),
}
if "personaBase" not in sp and sp.get("personaConfig"):
sp["personaBase"] = {"config": sp.get("personaConfig")}
# Keep product evidence fields explicit for gating.
sp["productEvidenceStatus"] = sp.get("productEvidenceStatus", "NOT_PROVIDED")
sp["productEvidenceSource"] = sp.get("productEvidenceSource", [])
return sp
FILE:scripts/scene/tbs_master_data_resolve.py
"""
Shared TBS Admin HTTP helpers and resolve_ids_for_scene (business domain / department / drug).
Used by tbs_write_executor.py and preflight-tbs-master-data.py.
"""
import difflib
import json
import ssl
import urllib.error
import urllib.request
def safe_json_loads(text: str):
try:
return json.loads(text)
except Exception:
return None
def extract_data(payload):
if isinstance(payload, list):
return payload
if isinstance(payload, dict):
if "data" in payload:
return payload["data"]
if "result" in payload:
return payload["result"]
return payload
def guess_entity_name(item: dict):
for k in ["name", "title", "department_name", "drug_name", "business_domain_name", "code"]:
v = item.get(k)
if isinstance(v, str) and v.strip():
return v.strip()
return None
def guess_entity_id(item: dict):
for k in ["id", "department_id", "drug_id", "business_domain_id", "persona_id", "doctor_id", "knowledge_id"]:
if k in item and isinstance(item[k], (str, int)) and str(item[k]).strip():
return item[k]
return None
def pick_best_match_id(items, target_name: str):
if not items:
return None
target_name = (target_name or "").strip()
if not target_name:
return None
for it in items:
nm = guess_entity_name(it)
if nm == target_name:
return guess_entity_id(it)
names = []
id_by_name = {}
for it in items:
nm = guess_entity_name(it)
if nm:
names.append(nm)
id_by_name[nm] = guess_entity_id(it)
best = difflib.get_close_matches(target_name, names, n=1, cutoff=0.55)
if not best:
return None
return id_by_name.get(best[0])
class TBSClient:
def __init__(
self,
base_url: str,
access_token: str,
timeout_s: int = 30,
insecure_ssl: bool = False,
):
self.base_url = base_url.rstrip("/")
self.access_token = access_token
self.timeout_s = timeout_s
self.insecure_ssl = insecure_ssl
def _headers(self):
h = {
"Content-Type": "application/json",
"accept": "application/json",
}
if self.access_token:
h["access-token"] = self.access_token
return h
def request_json(self, method: str, path: str, body=None):
url = self.base_url + path if path.startswith("/") else self.base_url + "/" + path
data = None
if body is not None:
data = json.dumps(body, ensure_ascii=False).encode("utf-8")
req = urllib.request.Request(url=url, method=method, headers=self._headers(), data=data)
try:
ssl_ctx = None
if self.insecure_ssl:
ssl_ctx = ssl._create_unverified_context()
with urllib.request.urlopen(req, timeout=self.timeout_s, context=ssl_ctx) as resp:
raw = resp.read().decode("utf-8", errors="replace")
payload = safe_json_loads(raw)
return payload if payload is not None else raw
except urllib.error.HTTPError as e:
raw = e.read().decode("utf-8", errors="replace") if hasattr(e, "read") else ""
raise RuntimeError(f"HTTP {e.code} {method} {url}: {raw}") from e
def get_list(client: TBSClient, path: str):
payload = client.request_json("GET", path)
data = extract_data(payload)
return data if isinstance(data, list) else []
def resolve_ids_for_scene(
client: TBSClient,
api_draft_scenes: dict,
dry_run: bool = False,
with_report: bool = False,
):
"""
GET lists to match by name; POST create when no match (unless dry_run).
If with_report, returns (ids_dict, report_dict); else ids_dict only.
"""
dep_in = api_draft_scenes.get("department_id") or api_draft_scenes.get("departmentName") or ""
bd_in = api_draft_scenes.get("business_domain_id") or api_draft_scenes.get("businessDomainName") or ""
drug_in = api_draft_scenes.get("drug_id") or api_draft_scenes.get("drugName") or ""
report: dict = {}
def maybe_int_id(x):
sx = str(x).strip()
if sx.isdigit():
return sx
return None
def try_get_list(path: str):
try:
return get_list(client, path)
except Exception:
return []
def resolve_or_create_business_domain(bd_value: str):
bd_value = (bd_value or "").strip()
bd_id = maybe_int_id(bd_value)
if bd_id:
if with_report:
report["business_domain"] = {"action": "matched_by_id", "id": bd_id, "input": bd_value}
return bd_id
business_domains = try_get_list("/api/v1/admin/basic/business-domains")
if isinstance(bd_value, str) and bd_value and bd_value in ["医美", "医美业务", "医美推广"]:
for it in business_domains:
nm = guess_entity_name(it) or ""
if nm.strip() in ["临床推广"]:
rid = str(guess_entity_id(it))
if with_report:
report["business_domain"] = {
"action": "matched",
"id": rid,
"input": bd_value,
"note": "alias_to_临床推广",
}
return rid
bd_id = pick_best_match_id(business_domains, bd_value)
if bd_id:
if with_report:
report["business_domain"] = {"action": "matched", "id": str(bd_id), "input": bd_value}
return str(bd_id)
if dry_run:
if with_report:
report["business_domain"] = {"action": "would_create", "id": None, "input": bd_value}
return None
body = {"name": bd_value}
try:
resp = client.request_json("POST", "/api/v1/admin/basic/business-domains", body=body)
created = extract_data(resp)
if created is not None:
cid = str(created.get("id") if isinstance(created, dict) else created)
if with_report:
report["business_domain"] = {"action": "created", "id": cid, "input": bd_value}
return cid
except RuntimeError as e:
if "HTTP 409" not in str(e):
raise
business_domains = try_get_list("/api/v1/admin/basic/business-domains")
bd_id = pick_best_match_id(business_domains, bd_value)
out = str(bd_id) if bd_id else None
if with_report:
report["business_domain"] = {
"action": "matched_after_conflict",
"id": out,
"input": bd_value,
}
return out
def resolve_or_create_department(dep_value: str, bd_id: str):
dep_value = (dep_value or "").strip()
dep_id = maybe_int_id(dep_value)
if dep_id:
if with_report:
report["department"] = {"action": "matched_by_id", "id": dep_id, "input": dep_value}
return dep_id
departments = try_get_list("/api/v1/admin/basic/departments")
dep_id = pick_best_match_id(departments, dep_value)
if dep_id:
if with_report:
report["department"] = {"action": "matched", "id": str(dep_id), "input": dep_value}
return str(dep_id)
if dry_run:
if with_report:
report["department"] = {"action": "would_create", "id": None, "input": dep_value}
return None
body = {"name": dep_value}
if bd_id:
body["business_domain_id"] = bd_id
try:
resp = client.request_json("POST", "/api/v1/admin/basic/departments", body=body)
created = extract_data(resp)
if created is not None:
cid = str(created.get("id") if isinstance(created, dict) else created)
if with_report:
report["department"] = {"action": "created", "id": cid, "input": dep_value}
return cid
except RuntimeError as e:
if "HTTP 409" not in str(e):
raise
departments = try_get_list("/api/v1/admin/basic/departments")
dep_id = pick_best_match_id(departments, dep_value)
out = str(dep_id) if dep_id else None
if with_report:
report["department"] = {
"action": "matched_after_conflict",
"id": out,
"input": dep_value,
}
return out
def resolve_or_create_drug(drug_value: str, bd_id: str):
"""GET /api/v1/admin/basic/drugs then POST if missing."""
drug_value = (drug_value or "").strip()
drug_id = maybe_int_id(drug_value)
if drug_id:
if with_report:
report["drug"] = {"action": "matched_by_id", "id": drug_id, "input": drug_value}
return drug_id
drugs = try_get_list("/api/v1/admin/basic/drugs")
drug_id = pick_best_match_id(drugs, drug_value)
if drug_id:
if with_report:
report["drug"] = {"action": "matched", "id": str(drug_id), "input": drug_value}
return str(drug_id)
if dry_run:
if with_report:
report["drug"] = {"action": "would_create", "id": None, "input": drug_value}
return None
body = {"name": drug_value}
if bd_id:
body["business_domain_id"] = bd_id
try:
resp = client.request_json("POST", "/api/v1/admin/basic/drugs", body=body)
created = extract_data(resp)
if created is not None:
cid = str(created.get("id") if isinstance(created, dict) else created)
if with_report:
report["drug"] = {"action": "created", "id": cid, "input": drug_value}
return cid
except RuntimeError as e:
if "HTTP 409" not in str(e):
raise
drugs = try_get_list("/api/v1/admin/basic/drugs")
drug_id = pick_best_match_id(drugs, drug_value)
out = str(drug_id) if drug_id else None
if with_report:
report["drug"] = {
"action": "matched_after_conflict",
"id": out,
"input": drug_value,
}
return out
bd_id = resolve_or_create_business_domain(str(bd_in))
dep_id = resolve_or_create_department(str(dep_in), bd_id)
drug_id = resolve_or_create_drug(str(drug_in), bd_id)
ids = {"department_id": dep_id, "business_domain_id": bd_id, "drug_id": drug_id}
if with_report:
return ids, report
return ids
FILE:scripts/scene/tbs_write_executor.py
#!/usr/bin/env python3
import argparse
import difflib
import hashlib
import json
import os
import re
import sys
import urllib.error
import urllib.request
sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..", "common"))
from auth_token import resolve_access_token
from toon_encoder import encode as toon_encode # type: ignore
from tbs_master_data_resolve import (
TBSClient,
extract_data,
get_list,
guess_entity_name,
guess_entity_id,
pick_best_match_id,
resolve_ids_for_scene,
)
# 默认资产目录(兼容从 `runtime/` 迁移到其它路径)
def _find_skill_root() -> str:
here = os.path.dirname(os.path.abspath(__file__))
for _ in range(8):
if os.path.isfile(os.path.join(here, "SKILL.md")):
return here
parent = os.path.dirname(here)
if parent == here:
break
here = parent
# fallback: assume current file is inside skill_root/runtime/
return os.path.normpath(os.path.join(os.path.dirname(os.path.abspath(__file__)), ".."))
def _assets_dir() -> str:
"""
- Set env `TBS_ASSETS_DIR` to override.
- Else prefer `skill_root/scripts/tbs_assets/`.
- Else fall back to legacy `skill_root/runtime/`.
"""
env = os.environ.get("TBS_ASSETS_DIR")
if env:
return env
skill_root = _find_skill_root()
candidate = os.path.join(skill_root, "scripts", "tbs_assets")
required = [
"scenario_draft.json",
"P1_persisted_ids.md",
]
def _has_required(d: str) -> bool:
return all(os.path.isfile(os.path.join(d, f)) for f in required)
if os.path.isdir(candidate) and _has_required(candidate):
return candidate
candidate2 = os.path.join(skill_root, "assets", "tbs_assets")
if os.path.isdir(candidate2) and _has_required(candidate2):
return candidate2
return os.path.join(skill_root, "runtime")
_RUNTIME_DIR = _assets_dir()
def normalize_text(s: str) -> str:
if s is None:
return ""
# Normalize whitespace for stable fingerprinting.
return re.sub(r"\s+", " ", str(s)).strip()
def fingerprint_sha256_of_text(s: str) -> str:
n = normalize_text(s)
return hashlib.sha256(n.encode("utf-8")).hexdigest()
def read_first_line(path: str):
if not path:
return None
if not os.path.exists(path):
return None
try:
with open(path, "r", encoding="utf-8") as f:
line = f.readline().strip()
return line if line else None
except Exception:
return None
def find_doctor_by_name_and_title(items, name: str, title: str):
name = (name or "").strip()
title = (title or "").strip()
if not items:
return None
# Try to match name first; then title.
for it in items:
nm = (guess_entity_name(it) or "").strip()
# Some list endpoints may use different keys for name/title.
it_title = (it.get("title") or it.get("doctor_title") or "").strip() if isinstance(it, dict) else ""
it_name = (it.get("name") or it.get("doctor_name") or nm or "").strip() if isinstance(it, dict) else ""
if name and it_name == name:
if title:
if it_title == title:
return guess_entity_id(it)
else:
return guess_entity_id(it)
# Fallback: fuzzy name match
names = []
id_by_name = {}
for it in items:
it_name = (it.get("name") or it.get("doctor_name") or "").strip()
if it_name:
names.append(it_name)
id_by_name[it_name] = guess_entity_id(it)
best = difflib.get_close_matches(name, names, n=1, cutoff=0.6)
if best:
return id_by_name.get(best[0])
return None
def _default_surname_from_name(name: str) -> str:
name = (name or "").strip()
if name and name[0] in "赵钱孙李周吴郑王冯陈褚卫蒋沈韩杨朱秦许何吕施张孔曹严华金魏陶姜":
return name[0]
return "王"
def _build_default_persona_config(scenario_pack: dict, doctor_draft: dict) -> str:
sp = scenario_pack or {}
dd = doctor_draft or {}
concern = (
_safe_get(sp, ["criticalInfo", "coreConcern"])
or sp.get("coreConcern")
or sp.get("concerns")
or "长期预后与依从性"
)
concern = _first_sentence(_ensure_nonempty_str(concern), max_len=60) or "长期预后与依从性"
return "\n".join(
[
"## 性格特征",
"- 理性",
"- 专业",
"- 稳健",
"",
"## 沟通风格",
"你对新治疗方案并不排斥,但只在安全边界和逻辑清楚的前提下才会继续讨论。",
"你不会主动推动用药,而是通过问题判断对方是否真的理解临床场景。",
"",
"## 行为倾向映射",
"- 清楚说明适用人群 -> 愿意继续",
"- 主动说明风险与边界 -> 信任上升",
"- 过度承诺或泛化疗效 -> 明显警惕",
f"- 能结合真实临床场景并回应“{concern}” -> 兴趣增加",
]
)
def create_doctor_if_missing(client: TBSClient, doctor_draft: dict, scenario_pack: dict = None, dry_run: bool = False):
doctors = get_list(client, "/api/v1/resources/doctors")
name = doctor_draft.get("name") or doctor_draft.get("doctor_name") or doctor_draft.get("persona_name")
title = doctor_draft.get("title") or doctor_draft.get("doctor_title") or ""
found_id = find_doctor_by_name_and_title(doctors, name, title)
if found_id:
return found_id
if dry_run:
return None
surname = _ensure_nonempty_str(doctor_draft.get("surname") or doctor_draft.get("last_name") or doctor_draft.get("family_name"))
if not surname:
surname = _default_surname_from_name(name)
description = _ensure_nonempty_str(
doctor_draft.get("description")
or doctor_draft.get("desc")
or doctor_draft.get("summary")
)
if not description:
description = "专业理性,在安全与边界清楚的前提下愿意讨论新方案的医生,是理想训练对象。"
persona_cfg = doctor_draft.get("persona_config", {})
if not persona_cfg:
persona_cfg = _build_default_persona_config(scenario_pack or {}, doctor_draft or {})
# If persona_config is already a string (Markdown/JSON text), pass it through.
if isinstance(persona_cfg, str):
persona_cfg_text = persona_cfg
else:
persona_cfg_text = json.dumps(persona_cfg, ensure_ascii=False)
body = {
"name": name,
"surname": surname,
"title": title,
"description": description,
"persona_config": persona_cfg_text,
"is_preset": False,
}
# Some APIs accept optional trust_initial/patience_initial; keep minimal to reduce incompatibility.
resp = client.request_json("POST", "/api/v1/admin/basic/doctors", body=body)
data = extract_data(resp)
return data
def load_persisted_knowledge_ids(persisted_ids_path: str):
# Very small heuristic parser for the existing markdown.
if not os.path.exists(persisted_ids_path):
return []
text = open(persisted_ids_path, "r", encoding="utf-8").read()
ids = []
# Compatible with formats like: - `52`(产品特性) / - 52(产品特性)
for m in re.finditer(r"-\s*`?\s*(\d+)\s*`?\s*(", text):
ids.append(m.group(1))
# Dedup while preserving order
seen = set()
out = []
for x in ids:
if x not in seen:
seen.add(x)
out.append(x)
return out
def infer_intent_keywords(scenario_pack: dict) -> dict:
"""
Return boolean intent flags inferred from scenario text.
Heuristic only; selection still degrades gracefully to persisted IDs.
"""
text = ""
try:
text = " ".join(
[
scenario_pack.get("sceneBasic", {}).get("sceneBackground", "") or "",
scenario_pack.get("criticalInfo", {}).get("coreConcern", "") or "",
scenario_pack.get("criticalInfo", {}).get("trainingGoal", "") or "",
scenario_pack.get("sceneBasic", {}).get("repBriefing", "") or "",
]
)
except Exception:
text = ""
text = normalize_text(text)
def has_any(words):
return any(w in text for w in words)
return {
"price": has_any(["价格", "价差", "贵", "便宜", "值不值", "溢价", "质价比", "买贵", "预算"]),
"safety": has_any(["安全", "风险", "合规", "无菌", "结节", "肿胀", "副作用", "迟发", "边界", "风险边界"]),
"recovery": has_any(["恢复", "肿胀", "肿", "工作", "出镜", "社交", "恢复期"]),
"naturalness": has_any(["自然", "假", "假脸", "自然感", "不自然", "僵硬"]),
"mechanism": has_any(["PLLA", "聚左旋乳酸", "CMC", "甘露醇", "胶原", "再生", "代谢", "分解", "长效", "过程"]),
"differentiation": has_any(["差异", "区别", "专利", "工艺", "微球", "粒径", "双粒径", "双规格", "优势", "卖点", "背书", "质价比", "价值"]),
"indications": has_any(["适应症", "皱纹", "鼻唇沟", "纠正", "中重度"]),
"spec": has_any(["规格", "mg/瓶", "368mg", "184mg", "粒径", "注射层次", "平均粒径"]),
}
def score_knowledge_item(item: dict, intent_flags: dict) -> int:
category = normalize_text(item.get("category") or "")
title = normalize_text(item.get("title") or "")
content = normalize_text(item.get("content") or "")
score = 0
def bump_if(keyword_list, bonus):
nonlocal score
for kw in keyword_list:
if kw and (kw in title or kw in content or kw in category):
score += bonus
# Category/title keywords provide stronger hints than raw content.
if intent_flags.get("price"):
score += 3 if ("优势" in category or "卖点" in category) else 0
bump_if(["价值", "优势", "卖点", "背书", "质价比"], 2)
if intent_flags.get("safety"):
bump_if(["安全", "风险", "无菌", "合规", "无细胞毒", "NMPA"], 3)
score += 2 if "安全" in category else 0
if intent_flags.get("recovery"):
bump_if(["恢复", "肿胀", "消肿", "影响", "迟发"], 2)
if intent_flags.get("naturalness"):
bump_if(["自然", "假", "自然感", "不自然", "胶原再生"], 2)
if intent_flags.get("mechanism"):
bump_if(["PLLA", "胶原", "再生", "代谢", "分解", "长效"], 3)
if intent_flags.get("differentiation"):
bump_if(["专利", "工艺", "微球", "粒径", "双粒径", "双规格", "差异", "背书"], 3)
if intent_flags.get("indications"):
bump_if(["适应", "皱纹", "鼻唇沟", "纠正"], 3)
if intent_flags.get("spec"):
bump_if(["规格", "mg/瓶", "粒径", "注射层次", "平均粒径"], 3)
# Weak fallback: any overlap in content words helps ranking.
# Keep it small to avoid noise.
overlap_tokens = ["PLLA", "CMC", "甘露醇", "NMPA", "无菌", "粒径", "胶原"]
for tok in overlap_tokens:
if tok and tok in title:
score += 1
if tok and tok in content:
score += 1
return score
def select_knowledge_ids_by_intent(client: TBSClient, drug_id: str, scenario_pack: dict, top_n: int = 4):
"""
When user didn't provide productKnowledge, select the most relevant existing knowledge
by matching intent from scenario text against knowledge title/content/category.
"""
intent_flags = infer_intent_keywords(scenario_pack or {})
# Fetch candidate knowledge list for this drug.
path = f"/api/v1/resources/knowledge?drug_id={drug_id}"
try:
payload = client.request_json("GET", path)
except Exception:
return [], {
"mode": "intent_select",
"inferred_intents": [k for k, v in intent_flags.items() if v],
"covered_intents": [],
"low_coverage_intents": [k for k, v in intent_flags.items() if v],
"error": "failed_to_fetch_knowledge_list",
}
existing = extract_data(payload)
if isinstance(existing, dict) and "data" in existing:
existing = existing["data"]
if not isinstance(existing, list):
inferred_true_intents = [k for k, v in intent_flags.items() if v]
return [], {
"mode": "intent_select",
"inferred_intents": inferred_true_intents,
"covered_intents": [],
"low_coverage_intents": inferred_true_intents,
"error": "knowledge_list_not_a_list",
}
scored = []
for it in existing:
if not isinstance(it, dict):
continue
s = score_knowledge_item(it, intent_flags)
scored.append((s, it.get("id")))
# Sort descending by score.
scored.sort(key=lambda x: x[0], reverse=True)
# If everything scores 0, don't force wrong picks.
max_s = 0
for s, kid in scored:
if kid is not None:
max_s = max(max_s, int(s or 0))
if max_s <= 0:
inferred_true_intents = [k for k, v in intent_flags.items() if v]
return [], {
"mode": "intent_select",
"inferred_intents": inferred_true_intents,
"covered_intents": [],
"low_coverage_intents": inferred_true_intents,
"note": "no_relevant_knowledge_scored",
}
# Otherwise take top_n even if some are 0, to keep enough knowledge coverage.
best = [kid for s, kid in scored[:top_n] if kid is not None]
# Build coverage report (heuristic) to decide whether user might want to provide extra productKnowledge.
# Only report intents that are inferred as true in this scenario.
intent_flags = infer_intent_keywords(scenario_pack or {})
intent_keywords_map = {
"price": ["优势", "卖点", "价值", "质价比", "背书", "买贵", "溢价"],
"safety": ["安全", "风险", "无菌", "合规", "NMPA", "无细胞毒"],
"recovery": ["恢复", "肿胀", "消肿", "迟发", "影响", "恢复期"],
"naturalness": ["自然", "假", "自然感", "不自然", "胶原再生", "假脸", "僵硬"],
"mechanism": ["PLLA", "聚左旋乳酸", "CMC", "甘露醇", "胶原", "再生", "代谢", "分解", "长效", "过程"],
"differentiation": ["差异", "区别", "专利", "工艺", "微球", "粒径", "双粒径", "双规格", "优势", "背书"],
"indications": ["适应", "皱纹", "鼻唇沟", "纠正", "中重度"],
"spec": ["规格", "mg/瓶", "粒径", "注射层次", "平均粒径"],
}
def item_matches_intent(it: dict, intent_key: str) -> bool:
if not isinstance(it, dict):
return False
title = normalize_text(it.get("title") or "")
content = normalize_text(it.get("content") or "")
category = normalize_text(it.get("category") or "")
hay = category + " " + title + " " + content
for kw in intent_keywords_map.get(intent_key, []):
if kw and (kw in hay):
return True
return False
selected_items = [it for it in existing if isinstance(it, dict) and str(it.get("id")) in set([str(x) for x in best])]
covered_intents = []
for k, v in intent_flags.items():
if not v:
continue
# If any selected knowledge matches the intent heuristically, consider covered.
if any(item_matches_intent(it, k) for it in selected_items):
covered_intents.append(k)
inferred_true_intents = [k for k, v in intent_flags.items() if v]
low_coverage_intents = [k for k in inferred_true_intents if k not in covered_intents]
report = {
"mode": "intent_select",
"inferred_intents": inferred_true_intents,
"covered_intents": covered_intents,
"low_coverage_intents": low_coverage_intents,
}
return best, report
def _safe_get(d: dict, path: list):
cur = d
for k in path:
if not isinstance(cur, dict):
return None
cur = cur.get(k)
return cur
def build_doctor_only_context_from_scenario_pack(scenario_pack: dict) -> str:
"""
Build publish-ish markdown for the system 'AI角色画像设定' field from scenarioPack.
Fallback when apiDraft.scenes.doctor_only_context is missing or too thin.
"""
sp = scenario_pack or {}
scene_bg = (_safe_get(sp, ["sceneBasic", "sceneBackground"]) or sp.get("sceneBackground") or "").strip()
core_concern = (_safe_get(sp, ["criticalInfo", "coreConcern"]) or sp.get("coreConcern") or "").strip()
ai_role = (_safe_get(sp, ["roleSetup", "aiRoleTitle"]) or sp.get("aiRoleTitle") or "").strip()
persona_base = _safe_get(sp, ["personaConfig", "personaBase"]) or {}
persona_overlay = _safe_get(sp, ["personaConfig", "personaOverlay"]) or {}
known = []
if ai_role:
known.append(f"- 你当前以“{ai_role}”身份参与对话")
if scene_bg:
known.append(f"- 场景背景:{scene_bg}")
lines = ["## 已知背景"]
lines.extend(known[:4] if known else ["- (待补充)"])
lines += ["", "## 核心顾虑(仅1个核心顾虑)"]
lines.append(f"- {core_concern}" if core_concern else "- (待补充:对方最在意的一个问题)")
base_name = persona_base.get("name") if isinstance(persona_base, dict) else None
role_type = persona_base.get("roleType") if isinstance(persona_base, dict) else None
if base_name or role_type:
lines += ["", "## 人设要点"]
if base_name:
lines.append(f"- 底座画像:{base_name}")
if role_type:
lines.append(f"- 角色类型:{role_type}")
if isinstance(persona_overlay, dict) and persona_overlay:
triggers = persona_overlay.get("triggers")
sig = persona_overlay.get("signaturePhrases")
qs = persona_overlay.get("questionStyle")
lines += ["", "## 追问风格与触发器"]
if qs:
lines.append(f"- 追问风格:{str(qs).strip()}")
if isinstance(triggers, list) and triggers:
for t in triggers[:3]:
if t:
lines.append(f"- 触发降温:{str(t).strip()}")
if isinstance(sig, list) and sig:
lines += ["", "## 常用句式(示例)"]
for p in sig[:3]:
if p:
lines.append(f"- {str(p).strip()}")
return "\n".join(lines).strip() + "\n"
def _first_sentence(text: str, max_len: int = 120) -> str:
t = (text or "").strip()
if not t:
return ""
# Prefer Chinese sentence separators; fallback to first chunk.
for sep in ["。", "!", "?", ";", ";", "\n"]:
if sep in t:
t = t.split(sep, 1)[0].strip()
break
return t[:max_len].strip()
def _extract_time_desc(scenario_pack: dict) -> str:
"""
Time is required by contract/quality gates.
- Prefer explicit dates found in scenario background/meta.
- Otherwise use a safe relative time anchor to avoid fabricating a specific event date.
"""
sp = scenario_pack or {}
# Try common meta fields.
meta = sp.get("meta")
if isinstance(meta, dict):
for k in ["timestamp", "date", "time"]:
v = meta.get(k)
if isinstance(v, str) and v.strip():
return v.strip()
# Old nested structure.
scene_basic = sp.get("sceneBasic") if isinstance(sp.get("sceneBasic"), dict) else {}
for v in [scene_basic.get("sceneBackground"), scene_basic.get("repBriefing")]:
if isinstance(v, str) and v.strip():
# YYYY年 / YYYY-MM / YYYY/MM etc.
m = re.search(r"(\d{4}年\s*\d{1,2}月\s*\d{1,2}日|\d{4}年\s*\d{1,2}月|\d{4}[-/]\d{1,2}[-/]\d{1,2})", v)
if m:
return m.group(1).strip()
# Current simplified structure: scenarioPack.background.
bg = sp.get("background") if isinstance(sp.get("background"), str) else ""
m = re.search(r"(\d{4}年\s*\d{1,2}月\s*\d{1,2}日|\d{4}年\s*\d{1,2}月|\d{4}[-/]\d{1,2}[-/]\d{1,2})", bg)
if m:
return m.group(1).strip()
# Relative anchor: accurate as "this conversation time".
return "在本次沟通当下"
def _ensure_nonempty_str(v) -> str:
return (v if isinstance(v, str) else "").strip()
def build_rep_briefing_from_scenario_pack(scenario_pack: dict, api_draft_scenes: dict, api_draft_doctors: dict) -> str:
"""
Enforce rep_briefing quality:
- time, location, department
- doctor/product current status
- medical rep objective
- communication entry point
"""
sp = scenario_pack or {}
scenes = api_draft_scenes or {}
doc = api_draft_doctors or {}
# Time/Location/Department.
time_desc = _extract_time_desc(sp)
location = _ensure_nonempty_str(scenes.get("location") or sp.get("location"))
department = _ensure_nonempty_str(scenes.get("departmentName") or scenes.get("department") or sp.get("department"))
product = _ensure_nonempty_str(scenes.get("drugName") or scenes.get("drug") or sp.get("product"))
# Doctor status and name/title.
doctor_name = _ensure_nonempty_str(doc.get("name") or (sp.get("roleSetup", {}).get("ai", {}).get("name") if isinstance(sp.get("roleSetup"), dict) else None))
doctor_title = _ensure_nonempty_str(doc.get("title") or "")
doctor_persona = _ensure_nonempty_str(doc.get("persona") or doc.get("persona_config") or doc.get("personaConfig") or "")
doctor_persona_short = _first_sentence(doctor_persona, max_len=160)
# Product status comes from scenario background/goals.
bg = _ensure_nonempty_str(sp.get("background") or _ensure_nonempty_str(sp.get("sceneBackground")))
product_status_short = _first_sentence(bg, max_len=160)
if not doctor_persona_short and bg:
# If doctor persona is missing, derive a "doctor/product current status" snippet from background.
doctor_persona_short = product_status_short
# Rep objective and entry point.
rep_objective_raw = sp.get("goals") or sp.get("trainingGoal") or ""
if not rep_objective_raw and isinstance(sp.get("criticalInfo"), dict):
rep_objective_raw = sp.get("criticalInfo", {}).get("trainingGoal") or ""
rep_objective = _ensure_nonempty_str(rep_objective_raw)
entry_point_raw = sp.get("concerns") or sp.get("coreConcern") or ""
if not entry_point_raw and isinstance(sp.get("criticalInfo"), dict):
entry_point_raw = sp.get("criticalInfo", {}).get("coreConcern") or ""
entry_point_raw = _ensure_nonempty_str(entry_point_raw)
entry_point = _first_sentence(entry_point_raw, max_len=120)
# Enforce minimal non-empty required dimensions.
missing = []
if not time_desc:
missing.append("时间")
if not location:
missing.append("地点")
if not department:
missing.append("科室")
if not product:
missing.append("产品")
if not doctor_persona_short:
missing.append("医生与产品现状")
if not rep_objective:
missing.append("医药代表目标")
if not entry_point:
missing.append("沟通切入点")
if missing:
raise RuntimeError(f"rep_briefing quality gate missing required fields: {','.join(missing)}")
def _truncate(s: str, max_len: int) -> str:
s = (s or "").strip()
if len(s) <= max_len:
return s
if max_len <= 3:
return s[:max_len]
return s[: max_len - 3] + "..."
# Hard budget for "<= 180 Chinese characters" requirement.
# We prefer truncating long variable parts rather than dropping key dimensions.
limit = 180
entry_suffix_full = "沟通切入点聚焦{entry},先承接关切,再给出可核验要点,并提出轻量下一步。"
entry_suffix_short = "沟通切入点聚焦{entry},先承接关切,再给要点并约定下一步。"
def _clean_fragment(text: str) -> str:
"""Remove trailing punctuation to avoid duplicated separators like '。;'."""
return re.sub(r"[。;;,、,\s]+$", "", (text or "").strip())
def _compose(dp: str, ps: str, obj: str, entry: str, use_short_suffix: bool) -> str:
entry_suffix = entry_suffix_short if use_short_suffix else entry_suffix_full
doctor_identity = f"{doctor_name}{'(' + doctor_title + ')' if doctor_title else ''}"
dp_clean = _clean_fragment(dp)
ps_clean = _clean_fragment(ps or dp)
obj_clean = _clean_fragment(obj)
entry_clean = _clean_fragment(entry)
# If product status semantically equals persona summary, skip duplicate clause.
status_clause = ""
if normalize_text(ps_clean) and normalize_text(ps_clean) != normalize_text(dp_clean):
status_clause = f"{product}现状为{ps_clean};"
return (
f"{time_desc},在{location}的{department},"
f"{doctor_identity}当前关注点是{dp_clean};"
f"{status_clause}"
f"代表目标是{obj_clean};"
f"{entry_suffix.format(entry=entry_clean)}"
)
# Try full first; if too long, progressively truncate variable parts.
full_text = _compose(doctor_persona_short, product_status_short or doctor_persona_short, rep_objective, entry_point, use_short_suffix=False)
if len(full_text) <= limit:
return full_text.strip() + "\n"
# Truncate strategy for short form.
dp_max, ps_max, obj_max, entry_max = 45, 45, 35, 35
while True:
short_text = _compose(
dp=_truncate(doctor_persona_short, dp_max),
ps=_truncate(product_status_short or doctor_persona_short, ps_max),
obj=_truncate(rep_objective, obj_max),
entry=_truncate(entry_point, entry_max),
use_short_suffix=True,
)
if len(short_text) <= limit or (dp_max <= 15 and ps_max <= 15 and obj_max <= 15 and entry_max <= 15):
return short_text[:limit].rstrip() + "\n"
dp_max -= 5
ps_max -= 5
obj_max -= 5
entry_max -= 5
def build_rep_briefing_relaxed(scenario_pack: dict, api_draft_scenes: dict, api_draft_doctors: dict) -> str:
"""
Relaxed fallback when strict quality gates cannot be satisfied.
Ensures rep_briefing is never empty while still avoiding fabricated details.
"""
sp = scenario_pack or {}
scenes = api_draft_scenes or {}
doc = api_draft_doctors or {}
time_desc = _extract_time_desc(sp)
location = _ensure_nonempty_str(scenes.get("location") or sp.get("location")) or "当前沟通场景"
department = _ensure_nonempty_str(scenes.get("departmentName") or scenes.get("department") or sp.get("department")) or "相关科室"
product = _ensure_nonempty_str(scenes.get("drugName") or scenes.get("drug") or sp.get("product") or sp.get("productName")) or "目标产品"
doctor_name = _ensure_nonempty_str(doc.get("name")) or "对方客户"
background = _ensure_nonempty_str(scenes.get("background") or sp.get("background") or sp.get("sceneBackground"))
concern = _first_sentence(_ensure_nonempty_str(sp.get("concerns") or sp.get("coreConcern")), max_len=40) or "价值、安全与恢复影响"
if background:
bg_short = _first_sentence(background, max_len=80)
return (
f"{time_desc},在{location}的{department},"
f"围绕{product}开展沟通。当前对话对象为{doctor_name},"
f"其核心顾虑聚焦{concern},并基于“{bg_short}”推进本轮沟通。"
).strip() + "\n"
return (
f"{time_desc},在{location}的{department},围绕{product}进行沟通,"
f"对话对象为{doctor_name},本轮优先回应其对{concern}的关切并推进下一步。"
).strip() + "\n"
def _doctor_only_context_quality_ok(text: str) -> bool:
t = (text or "").strip()
if not t:
return False
doctor_required = [
"## 已知背景",
"## 核心顾虑",
"## 今日状态",
"## 终止条件",
"必须等待对方回答",
"只有你可以标记对话结束",
"## 对话结束规则(关键)",
"## 追问路径(强制)",
"只追问场景注入/已给出的参考资料",
"## 输出长度控制",
"## 单问原则",
"## 提问限制",
"只能基于已注入的参考资料/场景信息提问",
"[对话结束]",
]
if not all(r in t for r in doctor_required):
return False
# Require explicit output length constraints to avoid vague "保持简练".
if not re.search(r"30\s*[-~至]\s*50\s*字|30-50字|30-50 字", t):
return False
return True
def build_doctor_only_context_from_simple_scenario_pack(scenario_pack: dict, api_draft_doctors: dict, api_draft_scenes: dict = None) -> str:
"""
Build doctor_only_context in your requested format:
- # 行为规范 ... (4 parts)
- # 当前场景背景
- 终止条件 / 对话结束规则 / 重要提醒
"""
sp = scenario_pack or {}
doc = api_draft_doctors or {}
scenes = api_draft_scenes or {}
# Universal doctor template (non-BP).
department = _ensure_nonempty_str(sp.get("department") or scenes.get("departmentName") or scenes.get("department")) or "相关科室"
location = _ensure_nonempty_str(sp.get("location") or scenes.get("location")) or "当前沟通场景"
product = _ensure_nonempty_str(sp.get("product") or sp.get("productName") or scenes.get("drugName") or scenes.get("drug")) or "目标产品"
doctor_persona = _ensure_nonempty_str(doc.get("persona") or doc.get("persona_config") or doc.get("personaConfig") or "")
doctor_persona_short = _first_sentence(doctor_persona, max_len=220)
concerns = _ensure_nonempty_str(sp.get("concerns") or sp.get("coreConcern") or "")
core_concern = _first_sentence(concerns, max_len=220) or "价值、安全性与恢复影响"
bg = _ensure_nonempty_str(sp.get("background") or scenes.get("background") or "")
time_desc = _extract_time_desc(sp)
known_bg = bg if bg else f"科室与场景信息:科室={department};地点={location};推广产品={product}。"
return "\n".join(
[
"## 已知背景",
f"- {known_bg}",
"",
"## 核心顾虑(仅1个核心顾虑)",
f"- {core_concern}",
"",
"## 今日状态",
f"- 当前时间:{time_desc}",
f"- 你的态度:{doctor_persona_short or '愿意沟通,但强调合规与逻辑清楚'}",
"",
"## 终止条件",
"**核心规则**:提问或提出要求后必须等待对方回答,在对方回答后再判断是否结束对话。",
"",
"**结束对话的条件**(对方回答后判断):",
"- 夸大疗效或绝对化承诺",
"- 回避核心问题",
"- 不尊重对方的关切",
"- 推销味过重(过度承诺、夸大效果;注意:专业使用建议不算推销)",
"",
"## 对话结束规则(关键)",
"- **唯一标记**:只有你可以标记对话结束,在回复末尾追加 [对话结束]。",
"- **禁止提问**:一旦决定结束对话,绝对不能再提问或提出任何要求。必须检查并删除回复中所有的问号(?)或疑问句。",
"- **互斥执行检查**:若本轮包含任何问句,则必须不输出 [对话结束]。",
"- **终止流程**:使用陈述句/结束语表达结束,严禁在最后一轮再提问。",
"",
"## 追问路径(强制)",
"- 优先关注:只追问场景注入/已给出的参考资料中与核心顾虑相关的信息要点;若资料不覆盖“剂型/专利/起效时间”等细节,则转为追问可核验的适用条件、合规边界或需要回去核对的口径来源。",
"",
"## 输出长度控制",
"- 每次回复控制在30-50字左右,保持真实医生沟通的自然简洁;避免一次提出过多问题。",
"",
"## 单问原则",
"- 每轮最多只能包含1个问号(问号≤1)。如果想到第二个问题,必须留到下一轮再问。",
"",
"## 提问限制",
"- 只能基于已注入的参考资料/场景信息提问,禁止询问未提供的具体数值、技术参数或研究/对比结论。",
"- 若参考资料仅有定性描述,不询问具体数值或定量结论。",
"- 当出现信息缺口且无法从参考资料判断时,表述为需要回去核对资料或请对方补充口径后再讨论。",
"",
"## 输出格式(强制)",
"- 只输出纯文本对话内容,不要包含动作描述。",
"- 禁止使用任何额外格式化标记(如加粗/斜体/标题/代码符号)。如需强调使用自然措辞或中文引号。",
"- 禁止出现具体姓名或可识别称呼,只使用“我”“你”或职业称谓。",
]
).strip() + "\n"
def build_coach_only_context_from_scenario_pack(scenario_pack: dict) -> str:
"""
Build publish-ish markdown for the system '教练专属设定' field from scenarioPack.
Fallback when apiDraft.scenes.coach_only_context is missing or too thin.
"""
sp = scenario_pack or {}
training_goal = (_safe_get(sp, ["criticalInfo", "trainingGoal"]) or sp.get("trainingGoal") or "").strip()
core_concern = (_safe_get(sp, ["criticalInfo", "coreConcern"]) or sp.get("coreConcern") or "").strip()
product = (_safe_get(sp, ["product"]) or sp.get("product") or sp.get("drugName") or sp.get("drug") or "").strip()
learner_role = (_safe_get(sp, ["roleSetup", "learnerRoleTitle"]) or sp.get("learnerRoleTitle") or "学习者").strip()
scene_type = (
_safe_get(sp, ["criticalInfo", "sceneType"])
or _safe_get(sp, ["criticalInfo", "contextType"])
or sp.get("sceneType")
or sp.get("contextType")
or ""
)
scene_type = str(scene_type or "").strip().lower()
# Product facts in coach context must come from scene/background injection; avoid hardcoding drug-specific claims.
product_brief = _first_sentence((_safe_get(sp, ["background"]) or sp.get("background") or "").strip(), max_len=160)
if not product_brief:
product_brief = f"产品:{product or '(待补充)'}(以场景注入的产品知识为准)"
content_name = product or "该内容"
# NOTE: We intentionally do NOT infer "what sections to include" via keywords in free text.
# Use a structured scene type signal (sceneType/contextType) when available.
is_training = scene_type in {"training", "enablement", "internal_training", "system_training", "system-enablement", "system"}
lines = ["## 期望学习者行为"]
if is_training:
lines.append("- 开场与目标:说明培训/演示范围,征得对方同意,明确本次要解决的问题与预期产出。")
lines.append("- 结构化讲解:按步骤/模块讲清流程与关键概念,遇到疑点先澄清再继续。")
lines.append(f"- 关键信息点:准确说明{content_name}的用途/流程/关键差异点(以场景注入资料为准),不编造证据与结论。")
if training_goal:
lines.append(f"- 目标对齐:{training_goal}")
lines.append("- 主动确认理解:用简短提问或复述确认对方是否理解关键步骤与注意事项。")
lines.append("- 注意事项与常见误区(必须):只陈述场景注入资料已覆盖的注意点;未提供的细节要明确“需要回去核对资料”,禁止编造。")
lines.append("- 练习/检查点(必须):给出1-3个可执行检查点(如:当场演示一次、复盘一次关键步骤、用清单核对必填项)。")
lines.append("- 下一步行动:给出可选的后续动作(如:补充材料、二次培训、答疑),不强制要求具体日期时间。")
else:
lines.append("- 自然开场:以请教或分享切入,先征得对方同意,说明想简单交流与对方最关心点相关内容。")
lines.append("- 倾听与回应:认真听对方对核心顾虑的看法,用简短语言表达理解与共情。")
lines.append(f"- 简要信息介绍:准确说明{content_name}的用途与关键差异点(以场景注入资料为准),不编造证据与结论。")
if training_goal:
lines.append(f"- 目标对齐:{training_goal}")
lines.append("- 探寻顾虑:围绕“{核心顾虑}”探寻对方真实关注点(可包括合规边界、可操作性、资源约束与风险点等维度)。".replace("{核心顾虑}", core_concern or "核心顾虑"))
lines.append("- 讨论效果与边界:在对方愿意了解的前提下,简要说明可预期的效果与适用条件,并强调以场景注入口径为准。")
lines.append("- 风险与边界(必须):主动说明风险边界、适用条件与不适用情况;若场景未注入风险信息,则必须承认信息缺口并回到“说明书/指南/合规口径需核对”,禁止编造。")
lines.append("- 尝试缔结(必须给出具体时间):在尊重对方的前提下,提出下一步并给出具体时间点,例如:“下周三/下次会议前,我准备要点资料并约定再对齐一次口径与适用场景”。")
lines += ["", "## 终止条件"]
lines.append("- 夸大承诺或绝对化表达。")
lines.append("- 回避核心问题或口径反复。")
lines.append("- 不尊重对方专业判断或明显逼迫推进。")
return "\n".join(lines).strip() + "\n"
def _coach_only_context_quality_ok(text: str) -> bool:
t = (text or "").strip()
if not t:
return False
base_required = [
"## 期望学习者行为",
"## 终止条件",
]
if not all(r in t for r in base_required):
return False
# Accept either template:
# - "go-to-market / sales" flavor: must include risk+close sections
# - "training" flavor: must include training-specific required sections
gtm_required = ["风险与边界", "尝试缔结"]
training_required = ["注意事项与常见误区", "练习/检查点"]
return all(r in t for r in gtm_required) or all(r in t for r in training_required)
def _coach_only_context_matches_scene_type(scenario_pack: dict, coach_only_context: str) -> bool:
sp = scenario_pack or {}
scene_type = (
_safe_get(sp, ["criticalInfo", "sceneType"])
or _safe_get(sp, ["criticalInfo", "contextType"])
or sp.get("sceneType")
or sp.get("contextType")
or ""
)
scene_type = str(scene_type or "").strip().lower()
is_training = scene_type in {"training", "enablement", "internal_training", "system_training", "system-enablement", "system"}
t = (coach_only_context or "").strip()
if not t:
return False
gtm_markers = ["风险与边界", "尝试缔结"]
training_markers = ["注意事项与常见误区", "练习/检查点"]
if is_training:
return all(m in t for m in training_markers)
return all(m in t for m in gtm_markers)
def create_knowledge_if_needed_by_dedup(
client: TBSClient,
drug_id,
knowledge_draft: dict,
category: str,
content_fingerprint: str = None,
dry_run: bool = False,
):
# Fetch existing knowledge and try to match by normalized-content fingerprint.
# Endpoint supports: GET /api/v1/resources/knowledge?drug_id=...&category=...
# (Query params aren't fully specified in the reference doc, so we keep best-effort.)
def fetch_existing():
path = f"/api/v1/resources/knowledge?drug_id={drug_id}"
if category:
path += f"&category={urllib.parse.quote(category)}"
payload = client.request_json("GET", path)
return extract_data(payload)
try:
existing = fetch_existing()
except Exception:
existing = []
if isinstance(existing, dict) and "data" in existing:
existing = existing["data"]
if not isinstance(existing, list):
existing = []
title = knowledge_draft.get("title") or ""
content = knowledge_draft.get("content") or ""
content_fp = content_fingerprint or fingerprint_sha256_of_text(content)
# Match existing by fingerprint of content.
for it in existing:
it_id = guess_entity_id(it)
it_content = it.get("content") if isinstance(it, dict) else None
it_content = it_content if it_content is not None else it.get("text") if isinstance(it, dict) else None
it_fp = fingerprint_sha256_of_text(it_content or "")
if it_id and it_fp == content_fp:
return it_id
# Not found => create
if dry_run:
return None
body = {
"drug_id": drug_id,
"category": category,
"title": title,
"content": content,
}
resp = client.request_json("POST", "/api/v1/admin/basic/knowledge", body=body)
data = extract_data(resp)
return data
def main():
ap = argparse.ArgumentParser()
ap.add_argument(
"--input",
default=os.path.join(_RUNTIME_DIR, "scenario_draft.json"),
help="Path to scenario_draft.json (or apiDraft JSON). Use '-' to read draft JSON from stdin.",
)
ap.add_argument("--scene-id", default=None, help="If provided, skip scene creation and only generate snapshot+publish for this scene.")
ap.add_argument("--base-url", default=os.environ.get("TBS_BASE_URL", "https://sg-tbs-manage.mediportal.com.cn"))
ap.add_argument("--access-token", default=None)
ap.add_argument("--insecure-ssl", action="store_true", help="Skip SSL certificate verification (staging only).")
ap.add_argument("--persisted-ids", default=os.path.join(_RUNTIME_DIR, "P1_persisted_ids.md"))
ap.add_argument("--dry-run", action="store_true")
ap.add_argument("--strict-param-only", action="store_true", help="Disable all fallback/auto-rebuild behavior.")
args = ap.parse_args()
strict_param_only = True if os.environ.get("TBS_STRICT_PARAM_ONLY", "1") == "1" else bool(args.strict_param_only)
env_token, _ = resolve_access_token()
access_token = (args.access_token or env_token or "").strip()
if not access_token:
raise SystemExit("Missing --access-token / env XG_USER_TOKEN")
client = TBSClient(
base_url=args.base_url,
access_token=access_token,
insecure_ssl=bool(args.insecure_ssl),
)
if args.input == "-":
raw = sys.stdin.read()
if not raw.strip():
raise SystemExit("Missing draft JSON on stdin (use --input -).")
draft = json.loads(raw)
else:
with open(args.input, "r", encoding="utf-8") as f:
draft = json.load(f)
api_draft = draft.get("apiDraft") or {}
scenario_pack = draft.get("scenarioPack") or {}
api_draft_doctors_raw = api_draft.get("doctors") or {}
api_draft_scenes_raw = api_draft.get("scenes") or {}
# Normalize shapes:
# - Some sessions write apiDraft.doctors/scenes as arrays (list[object])
# - Others write them as dicts (object)
api_draft_doctors = api_draft_doctors_raw
if isinstance(api_draft_doctors_raw, list):
api_draft_doctors = api_draft_doctors_raw[0] if api_draft_doctors_raw else {}
api_draft_scenes = api_draft_scenes_raw
if isinstance(api_draft_scenes_raw, list):
api_draft_scenes = api_draft_scenes_raw[0] if api_draft_scenes_raw else {}
resolved_scene_ids = resolve_ids_for_scene(client, api_draft_scenes, dry_run=bool(args.dry_run))
dep_id = resolved_scene_ids["department_id"]
bd_id = resolved_scene_ids["business_domain_id"]
drug_id = resolved_scene_ids["drug_id"]
if not dep_id or not bd_id or not drug_id:
raise RuntimeError(f"Failed to resolve ids: department={dep_id}, business_domain={bd_id}, drug={drug_id}")
doctor_persona_id = create_doctor_if_missing(
client,
api_draft_doctors,
scenario_pack=scenario_pack,
dry_run=bool(args.dry_run),
)
if not doctor_persona_id and args.dry_run:
raise RuntimeError("Dry-run: doctor persona not found; cannot create in dry-run.")
# Knowledge ids:
# - Prefer apiDraft.knowledge if present (user provided productKnowledge)
# - Else: select best matching existing knowledge by intent from scenario_pack
# - Final fallback: persisted IDs (for continuity in early integration stages)
knowledge_ids = []
# Support multiple keys for the knowledge draft array.
knowledge_drafts = (
api_draft.get("knowledge")
or api_draft.get("apiDraftKnowledge")
or api_draft.get("apiDraftKnowledges")
or []
)
if isinstance(knowledge_drafts, dict) and "items" in knowledge_drafts:
knowledge_drafts = knowledge_drafts["items"]
if isinstance(knowledge_drafts, list) and knowledge_drafts:
for kd in knowledge_drafts:
if not isinstance(kd, dict):
continue
category = kd.get("category") or kd.get("knowledge_category") or ""
content = kd.get("content") or ""
fp = fingerprint_sha256_of_text(content)
knowledge_id = create_knowledge_if_needed_by_dedup(
client=client,
drug_id=drug_id,
knowledge_draft=kd,
category=category,
content_fingerprint=fp,
dry_run=bool(args.dry_run),
)
if knowledge_id:
knowledge_ids.append(str(knowledge_id))
else:
if strict_param_only:
raise RuntimeError("strict-param-only: apiDraft.knowledge 必须由上游参数提供,禁止 fallback 选择。")
knowledge_ids, knowledge_selection_report = select_knowledge_ids_by_intent(client, drug_id, scenario_pack, top_n=4)
if not knowledge_ids:
raise RuntimeError(
"未命中可用产品知识。请先提供 knowledge 草案后重试:"
"每条需包含 drug(或drug_id)、category、title、content;"
"系统将创建后自动记录并关联 knowledge_id。"
)
# Scene create
scene_title = api_draft_scenes.get("title") or api_draft_scenes.get("name")
rep_briefing = api_draft_scenes.get("rep_briefing") or ""
doctor_only_context = api_draft_scenes.get("doctor_only_context") or ""
coach_only_context = api_draft_scenes.get("coach_only_context") or ""
if strict_param_only:
if not scene_title:
raise RuntimeError("strict-param-only: scenes.title/name 必填。")
if not isinstance(rep_briefing, str) or not rep_briefing.strip():
raise RuntimeError("strict-param-only: scenes.rep_briefing 必填,禁止文本兜底。")
if not isinstance(doctor_only_context, str) or not doctor_only_context.strip():
raise RuntimeError("strict-param-only: scenes.doctor_only_context 必填,禁止文本兜底。")
if not isinstance(coach_only_context, str) or not coach_only_context.strip():
raise RuntimeError("strict-param-only: scenes.coach_only_context 必填,禁止文本兜底。")
# Fallback: build richer publish-ish markdown from scenarioPack if too thin.
# The UI expects structured blocks; older drafts often store only short sentences.
if not isinstance(doctor_only_context, str):
doctor_only_context = str(doctor_only_context or "")
if not isinstance(coach_only_context, str):
coach_only_context = str(coach_only_context or "")
# Enforce rep_briefing quality first (rep_briefing has no fallback previously).
if not strict_param_only:
try:
need_rebuild = (
not isinstance(rep_briefing, str)
or not rep_briefing.strip()
or ("【" in rep_briefing or "】" in rep_briefing)
or "待补充" in rep_briefing
or (len(rep_briefing) > 180)
or (scenario_pack.get("location") and str(scenario_pack.get("location")) not in rep_briefing)
or (scenario_pack.get("department") and str(scenario_pack.get("department")) not in rep_briefing)
or (scenario_pack.get("product") and str(scenario_pack.get("product")) not in rep_briefing)
)
if need_rebuild:
rep_briefing = build_rep_briefing_from_scenario_pack(scenario_pack, api_draft_scenes, api_draft_doctors)
except Exception:
rep_briefing = build_rep_briefing_relaxed(scenario_pack, api_draft_scenes, api_draft_doctors)
# Doctor-only: enforce strict 3-section structure; rebuild when it doesn't match.
if strict_param_only:
if (not _doctor_only_context_quality_ok(doctor_only_context)):
raise RuntimeError("strict-param-only: doctor_only_context 质量不达标,禁止自动重建。")
else:
if (
(not doctor_only_context.strip())
or ("待补充" in doctor_only_context)
or (not _doctor_only_context_quality_ok(doctor_only_context))
):
doctor_only_context = build_doctor_only_context_from_simple_scenario_pack(scenario_pack, api_draft_doctors, api_draft_scenes)
if (not doctor_only_context.strip()) or ("##" not in doctor_only_context):
doctor_only_context = build_doctor_only_context_from_scenario_pack(scenario_pack)
if strict_param_only:
if (not _coach_only_context_quality_ok(coach_only_context)) or (not _coach_only_context_matches_scene_type(scenario_pack, coach_only_context)):
raise RuntimeError("strict-param-only: coach_only_context 质量不达标,禁止自动重建。")
else:
if (not coach_only_context.strip()) or (not _coach_only_context_quality_ok(coach_only_context)) or (not _coach_only_context_matches_scene_type(scenario_pack, coach_only_context)):
coach_only_context = build_coach_only_context_from_scenario_pack(scenario_pack)
# Optional: write back enforced fields into input draft.
# This helps debugging and keeps downstream artefacts consistent.
if not bool(args.dry_run):
try:
api_draft_scenes["rep_briefing"] = rep_briefing
api_draft_scenes["doctor_only_context"] = doctor_only_context
with open(args.input, "w", encoding="utf-8") as f:
json.dump(draft, f, ensure_ascii=False, indent=2)
except Exception:
pass
# Rounds: derive from constraints if present; else default.
rounds = 5
constraints = scenario_pack.get("constraints") or {}
try:
mt = constraints.get("maxTurns") or constraints.get("max_turns")
if isinstance(mt, int) and mt > 0:
rounds = mt
elif isinstance(mt, str) and mt.isdigit():
rounds = int(mt)
except Exception:
pass
persona_ids = [
{
"persona_id": str(doctor_persona_id),
"difficulty": "medium",
"is_default": True,
"rounds": rounds,
}
]
scene_location = api_draft_scenes.get("location")
if strict_param_only and (not isinstance(scene_location, str) or not scene_location.strip()):
raise RuntimeError("strict-param-only: scenes.location 必填,禁止从 scenarioPack 兜底。")
scene_body = {
"title": scene_title,
"department_id": dep_id,
"drug_id": drug_id,
"business_domain_id": bd_id,
"location": scene_location if isinstance(scene_location, str) else (api_draft_scenes.get("location") or scenario_pack.get("location") or ""),
"doctor_only_context": doctor_only_context,
"coach_only_context": coach_only_context,
"rep_briefing": rep_briefing,
"persona_ids": persona_ids,
"knowledge_ids": knowledge_ids,
# status omitted => backend default
}
if args.dry_run:
knowledge_report = knowledge_selection_report if "knowledge_selection_report" in locals() else None
print(
toon_encode(
{
"ok": True,
"step": "tbs_write_executor",
"dryRun": True,
"resolvedIds": {
"department_id": str(dep_id),
"business_domain_id": str(bd_id),
"drug_id": str(drug_id),
"persona_id": str(doctor_persona_id),
},
"knowledgeIds": [str(x) for x in knowledge_ids],
"knowledgeSelectionReport": knowledge_report,
"sceneBodyPreview": scene_body,
}
)
)
return
if args.scene_id:
scene_id = str(args.scene_id)
else:
scene_resp = client.request_json("POST", "/api/v1/admin/scenes", body=scene_body)
scene_data = extract_data(scene_resp)
scene_id = None
if isinstance(scene_data, dict):
scene_id = scene_data.get("id") or scene_data.get("scene_id") or guess_entity_id(scene_data)
else:
scene_id = scene_data
if not scene_id:
raise RuntimeError(f"Scene creation failed: {scene_resp}")
scene_id = str(scene_id)
snapshot_resp = client.request_json(
"POST",
f"/api/v1/scenes/{scene_id}/snapshot",
body={"doctor_id": int(str(doctor_persona_id))},
)
snapshot_data = extract_data(snapshot_resp)
snapshot_id = None
if isinstance(snapshot_data, dict):
snapshot_id = snapshot_data.get("id") or snapshot_data.get("snapshot_id") or guess_entity_id(snapshot_data)
else:
snapshot_id = snapshot_data
if not snapshot_id:
raise RuntimeError(f"Snapshot creation failed: {snapshot_resp}")
snapshot_id = str(snapshot_id)
publish_resp = client.request_json("POST", f"/api/v1/snapshots/{snapshot_id}/publish", body={})
out = {
"scene_id": str(scene_id),
"snapshot_id": str(snapshot_id),
"publish_response": publish_resp,
"knowledge_selection_report": knowledge_selection_report if "knowledge_selection_report" in locals() else None,
"resolved": {
"department_id": str(dep_id),
"business_domain_id": str(bd_id),
"drug_id": str(drug_id),
"persona_id": str(doctor_persona_id),
"knowledge_ids": [str(x) for x in knowledge_ids],
},
}
print(toon_encode({"ok": True, "step": "tbs_write_executor", "result": out}))
if __name__ == "__main__":
# Fix: urllib.parse is used in create_knowledge_if_needed_by_dedup; import here to keep top clean.
import urllib.parse # noqa: E402
main()
FILE:scripts/scene/validate-and-gate.py
#!/usr/bin/env python3
"""
scene / validate-and-gate — 契约校验脚本(stdin JSON)。输出 TOON。
API_URL 须与 openapi/scene/validate-and-gate.md 标题 URL 一致;本脚本不发起 HTTP。
"""
import json
import os
import sys
sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..", "common"))
from auth_token import require_access_token
from toon_encoder import encode as toon_encode
from scenario_pack_normalizer import normalize_scenario_pack
API_URL = "https://scenario-builder.openclaw.internal/v1/scene/validate-and-gate"
ALLOWED_EVIDENCE_STATUS = {"NOT_PROVIDED", "PARTIAL", "READY"}
def _read_body():
raw = sys.stdin.read()
if not raw.strip():
return {}
return json.loads(raw)
def _ok(step, **extra):
payload = {"ok": True, "step": step, **extra}
print(toon_encode(payload))
def main():
require_access_token()
body = _read_body()
for k in ("scenarioPack", "apiDraft", "validationReport"):
if k not in body or not isinstance(body[k], dict):
print(f"错误: 缺少 {k}", file=sys.stderr)
sys.exit(2)
sp = normalize_scenario_pack(body["scenarioPack"])
api_draft = body["apiDraft"]
vr = body["validationReport"]
evidence_status = sp.get("productEvidenceStatus", "NOT_PROVIDED")
if evidence_status not in ALLOWED_EVIDENCE_STATUS:
print("错误: productEvidenceStatus 非法", file=sys.stderr)
sys.exit(2)
issues = list(vr.get("issues") or [])
if evidence_status != "READY":
if not api_draft.get("needsEvidenceConfirmation"):
issues.append("apiDraft.needsEvidenceConfirmation 必须为 true(证据未 READY)")
if not sp.get("productEvidenceSource"):
issues.append("缺少 productEvidenceSource,需补充知识来源")
passed = bool(vr.get("passed"))
if issues:
passed = False
_ok(
"validate-and-gate",
passed=passed,
evidenceStatus=evidence_status,
issues=issues,
)
if __name__ == "__main__":
main()
FILE:scripts/tbs_assets/P1_persisted_ids.md
FILE:scripts/tbs_assets/README.md
# tbs_assets — 本地执行器资产(脚本入口迁移)
本目录用于落盘/承载写库所需的本地资产(如 `scenario_draft.json`、枚举缓存等);并保留 `tbs_write_executor.py` 的兼容入口(委托到 `scripts/scene/`)。
当前工程支持以下回退策略:
- 若目录内缺少资产文件,则执行器会回退到 legacy 的 `runtime/` 资产目录。
## 发布声明(必读)
本目录中的文件属于**本地运行时资产**,不是通用业务素材。发布 skill 时,请按以下规则处理:
- 不随包分发任何真实凭据或企业标识(例如 token、corp id、登录态缓存)。
- 不随包分发真实业务草稿(例如包含客户/产品上下文的 `scenario_draft.json`)。
- 运行时文件应由脚本在用户本地会话中生成或覆盖,不应作为“默认业务数据”发布。
## 首次使用要求
使用落库相关脚本前,需在启动 OpenClaw 的同一终端设置:
- `XG_USER_TOKEN=your_access_token`
执行器只读取当前会话环境变量完成鉴权,不应引导用户在仓库内保存 token 明文文件。
## 推荐的发布形态
- 保留本 README 与非敏感静态文件(如 `system_business_domains.json`)。
- `scenario_draft.json` 建议使用空模板或由脚本首次执行时自动创建。
- 若发现本目录存在真实 token、corp id、历史业务草稿,请在发布前清理。
FILE:scripts/tbs_assets/scenario_draft.json
{
"scenarioPack": {},
"apiDraft": {},
"validationReport": {}
}
FILE:scripts/tbs_assets/system_business_domains.json
{
"success": true,
"data": [
{
"id": "1",
"name": "临床推广",
"sort_order": 1,
"status": 1
},
{
"id": "2",
"name": "院外零售",
"sort_order": 2,
"status": 1
},
{
"id": "3",
"name": "学术合作",
"sort_order": 3,
"status": 1
},
{
"id": "4",
"name": "通用能力",
"sort_order": 4,
"status": 1
}
]
}
用于"发布 Skill / 上架 Skill / 推送 Skill / 更新已发布的 Skill / 下架 Skill / 把本地 Skill 上传到平台 / 同步到 ClawHub 或 GitHub"。一键完成 打包 → 七牛上传 → 平台注册/更新/下架。需要先通过 cms-auth-skills 取得 to...
---
name: cms-push-skill
description: 用于"发布 Skill / 上架 Skill / 推送 Skill / 更新已发布的 Skill / 下架 Skill / 把本地 Skill 上传到平台 / 同步到 ClawHub 或 GitHub"。一键完成 打包 → 七牛上传 → 平台注册/更新/下架。需要先通过 cms-auth-skills 取得 token。问题反馈请用 cms-report-issue
skillcode: cms-push-skill
dependencies:
- cms-auth-skills
---
**当前版本**: v1.6.1
# cms-push-skill
只负责一件事:把已经准备好的 Skill 推送到平台。
这里说的“平台”,默认就是我们内部技能管理平台。
## 能力总览
| # | 能力 | 脚本 | 需要登录 |
|---|---|---|---|
| 1 | 注册新 Skill | `scripts/skill-management/register_skill.py` | 是 |
| 2 | 更新已有 Skill | `scripts/skill-management/update_skill.py` | 是 |
| 3 | 下架 Skill | `scripts/skill-management/delete_skill.py` | 是 |
| 4 | 一站式发布 | `scripts/skill-management/publish_skill.py` | 是 |
| 5 | 打包 Skill 目录为 ZIP | `scripts/skill-management/pack_skill.py` | 否 |
| 6 | 上传文件到七牛 | `scripts/skill-management/upload_to_qiniu.py` | 是 |
## 路由
- 发布到我们内部平台:`python3 cms-push-skill/scripts/skill-management/publish_skill.py ./my-skill --code my-skill --name "My Skill" --internal`
- 更新我们内部平台上的 Skill:`python3 cms-push-skill/scripts/skill-management/publish_skill.py ./my-skill --code my-skill --update --version 1.1.0 --internal`
- 外部发布:`python3 cms-push-skill/scripts/skill-management/publish_skill.py ./my-skill --code my-skill --name "My Skill" --external`
- 注册:`python3 cms-push-skill/scripts/skill-management/register_skill.py --code my-skill --name "My Skill"`
- 更新:`python3 cms-push-skill/scripts/skill-management/update_skill.py --code my-skill --name "New Name"`
- 下架:`python3 cms-push-skill/scripts/skill-management/delete_skill.py --id <skill-id> --reason "原因"`
如果用户要提交问题、查看问题列表、关闭问题,统一转到 `references/issue-report/README.md` 对应的 `cms-report-issue`;如果用户还没创建 Skill,可先使用 `cms-create-skill`。
## 内部平台发布指引
如果你当前要把 Skill 发布到我们内部平台,最短路径是:
```bash
# 先准备鉴权
npx clawhub@latest install cms-auth-skills --force
# 首次发布到内部平台
python3 cms-push-skill/scripts/skill-management/publish_skill.py \
./my-skill --code my-skill --name "My Skill" --internal
# 更新内部平台上的 Skill
python3 cms-push-skill/scripts/skill-management/publish_skill.py \
./my-skill --code my-skill --update --version 1.1.0 --internal
```
如果你当前不是在做发布,而是遇到了线上问题、要反馈问题,不在本 Skill 内处理,直接转到 `cms-report-issue`。
## 问题反馈接力
如果你当前正在 `cms-push-skill` 里发布或更新 Skill,后来要反馈问题,最短路径是:
```bash
# 安装问题反馈 Skill
npx clawhub@latest install cms-report-issue --force
# 提交问题
python3 cms-report-issue/scripts/issue_report/report_issue.py \
--skill-code my-skill --version 1.1.0 --error "..."
# 查看问题列表
python3 cms-report-issue/scripts/issue_report/list_issues.py --skill-code my-skill
# 标记已解决
python3 cms-report-issue/scripts/issue_report/update_issue.py \
--issue-id abc123 --status resolved --resolution "已修复"
```
## 同步选项
`publish_skill.py` 支持同步到 ClawHub 和 GitHub:
- `--sync-clawhub`:同步到 ClawHub。
- `--sync-github`:同步到 GitHub。
- `--no-sync-clawhub`:不同步到 ClawHub。
- `--no-sync-github`:不同步到 GitHub。
内部 Skill 默认两者都推,外部 Skill 不支持推送到 ClawHub。
## 规则
1. 所有推送动作统一使用 `scripts/skill-management/` 下的脚本。
2. 推送前先通过 `cms-auth-skills` 准备好 `access-token`。
3. 内部 Skill 走七牛上传 + 平台注册。
4. 外部 Skill 跳过七牛上传,直接使用 ClawHub 下载地址。
5. 本 Skill 只维护推送链路;问题闭环统一交给 `cms-report-issue`。
6. 所有说明文档统一使用 Markdown,不维护旧接口文档目录。
## 能力树
```text
cms-push-skill/
├── SKILL.md
├── references/
│ ├── issue-report/
│ │ └── README.md
│ └── skill-management/
│ └── README.md
└── scripts/
└── skill-management/
├── delete_skill.py
├── pack_skill.py
├── publish_skill.py
├── register_skill.py
├── update_skill.py
└── upload_to_qiniu.py
```
FILE:references/issue-report/README.md
# issue-report 模块说明
`cms-push-skill` 不再内置问题反馈实现。
如果你当前正在 `cms-push-skill` 里发布、更新或下架 Skill,随后要提交问题、查看问题列表、解决或关闭问题,统一转交 `cms-report-issue`。
## 最短接力路径
```bash
# 安装问题反馈 Skill
npx clawhub@latest install cms-report-issue --force
# 提交问题
python3 cms-report-issue/scripts/issue_report/report_issue.py \
--skill-code my-skill --version 1.1.0 --error "..."
# 查看问题
python3 cms-report-issue/scripts/issue_report/list_issues.py --skill-code my-skill
# 标记已解决
python3 cms-report-issue/scripts/issue_report/update_issue.py \
--issue-id abc123 --status resolved --resolution "已修复"
```
## 说明
- `cms-push-skill` 只负责发布链路,不再维护问题反馈脚本。
- 问题状态更新需要的鉴权规则,由 `cms-report-issue` 自己负责。
FILE:references/skill-management/README.md
# skill-management 模块说明
## 模块职责
`cms-push-skill` 只负责发布链路:
1. 打包 Skill 目录为 ZIP。
2. 上传内部 Skill 到七牛。
3. 注册、更新、下架 Skill。
4. 同步外部 Skill 到平台。
## 脚本清单
| 脚本 | 说明 | 需要鉴权 |
|---|---|---|
| `publish_skill.py` | 一站式发布 / 更新 / 同步 | 是 |
| `pack_skill.py` | 打包 Skill 目录为 ZIP | 否 |
| `upload_to_qiniu.py` | 获取七牛凭证并上传 ZIP | 是 |
| `register_skill.py` | 注册新 Skill | 是 |
| `update_skill.py` | 更新已有 Skill | 是 |
| `delete_skill.py` | 下架 Skill | 是 |
## 常见场景
| 场景 | 脚本 |
|---|---|
| 发布到我们内部平台 | `publish_skill.py --internal` |
| 更新内部 Skill | `publish_skill.py --update --internal` |
| 同步外部 Skill | `publish_skill.py --external` |
| 只打包 ZIP | `pack_skill.py` |
| 只更新注册信息 | `update_skill.py` |
| 下架 Skill | `delete_skill.py` |
## 内部平台发布
这里说的“内部平台”,就是当前技能管理平台。
```bash
# 先准备鉴权
npx clawhub@latest install cms-auth-skills --force
# 发布到内部平台
python3 cms-push-skill/scripts/skill-management/publish_skill.py \
./my-skill --code my-skill --name "My Skill" --internal
# 更新内部平台上的 Skill
python3 cms-push-skill/scripts/skill-management/publish_skill.py \
./my-skill --code my-skill --update --version 1.1.0 --internal
```
## 问题反馈边界
- 如果用户要提交问题、查看问题列表、解决或关闭问题,统一转交 `cms-report-issue`。
- `cms-push-skill` 不再承担任何问题反馈职责。
- 具体接力方式见 `references/issue-report/README.md`。
## 鉴权边界
- `pack_skill.py` 不需要鉴权。
- 其余写操作统一通过 `cms-auth-skills` 准备 `access-token`。
- 本模块不维护旧接口文档副本,所有说明都放在 Markdown 文档里。
FILE:scripts/skill-management/common.py
#!/usr/bin/env python3
"""
cms-push-skill 共享工具
集中以下逻辑,避免在 register/update/delete/publish 等脚本中重复实现:
- parse_api_response: 解析平台 API 响应
- get_token: 统一从环境变量获取 access-token
- get_headers: 构造带鉴权头的请求头
"""
from __future__ import annotations
import os
import sys
import warnings
from typing import Optional
import requests
# 禁用 InsecureRequestWarning(因为 verify=False)
warnings.filterwarnings(
"ignore", category=requests.packages.urllib3.exceptions.InsecureRequestWarning
)
DEFAULT_API_BASE = "https://skills.mediportal.com.cn"
API_BASE = os.environ.get("CMS_API_BASE", DEFAULT_API_BASE).rstrip("/")
# token 环境变量优先级
_TOKEN_ENV_KEYS = ("XG_USER_TOKEN", "access-token", "ACCESS_TOKEN")
def parse_api_response(response: requests.Response, action: str) -> dict:
"""解析平台 API 响应,统一成功判定:resultCode 为 None 或 1 视为成功。"""
data = response.json()
if isinstance(data, dict) and data.get("resultCode") not in (None, 1):
message = data.get("resultMsg") or data.get("detailMsg") or response.text
raise RuntimeError(f"{action}失败: {message}")
return data
def get_token(required: bool = True) -> Optional[str]:
"""从环境变量统一读取 access-token。
required=True 时,缺失会输出友好提示并退出(提示先跑 cms-auth-skills)。
"""
for key in _TOKEN_ENV_KEYS:
token = os.environ.get(key)
if token:
return token
if required:
print("错误: 未找到 access-token", file=sys.stderr)
print(
" 请先通过 cms-auth-skills 取得 token,或手动设置环境变量:"
" XG_USER_TOKEN / access-token / ACCESS_TOKEN",
file=sys.stderr,
)
sys.exit(1)
return None
def get_headers(token: Optional[str] = None, json_body: bool = True) -> dict:
"""构造带鉴权头的请求头。token 缺省时自动从环境变量获取。"""
if token is None:
token = get_token(required=True)
headers = {"access-token": token}
if json_body:
headers["Content-Type"] = "application/json"
return headers
FILE:scripts/skill-management/delete_skill.py
#!/usr/bin/env python3
"""
下架(删除)Skill
用途:将已发布的 Skill 下架
使用方式:
python3 cms-push-skill/scripts/skill-management/delete_skill.py --id <skill-id> [--reason <下架原因>]
参数说明:
--id Skill ID(必须)
--reason 下架原因(可选)
环境变量:
XG_USER_TOKEN — access-token(必须)
"""
import sys
import json
import argparse
import requests
from common import API_BASE, get_headers, get_token, parse_api_response
API_URL = f"{API_BASE}/api/skill/delete"
def call_api(token: str, skill_id: str, reason: str = "") -> dict:
"""下架 Skill"""
headers = get_headers(token)
payload = {"id": skill_id}
if reason:
payload["delistReason"] = reason
try:
response = requests.post(
API_URL,
json=payload,
headers=headers,
verify=False,
allow_redirects=True,
timeout=60,
)
response.raise_for_status()
return parse_api_response(response, "下架 Skill")
except Exception as e:
print(f"错误: 请求失败: {e}", file=sys.stderr)
sys.exit(1)
def main():
parser = argparse.ArgumentParser(description="下架(删除)Skill")
parser.add_argument("--id", required=True, help="Skill ID")
parser.add_argument("--reason", default="", help="下架原因")
args = parser.parse_args()
token = get_token()
result = call_api(token, args.id, args.reason)
print(json.dumps(result, ensure_ascii=False, indent=2))
if __name__ == "__main__":
main()
FILE:scripts/skill-management/pack_skill.py
#!/usr/bin/env python3
"""
打包 Skill 目录为 ZIP 文件
用途:将指定的 Skill 目录打包成 .zip 文件,用于后续上传到七牛
使用方式:
python3 cms-push-skill/scripts/skill-management/pack_skill.py <skill-dir> [--output <output.zip>]
参数说明:
skill-dir Skill 目录路径(必须)
--output 输出 ZIP 文件路径(可选,默认为 <skill-name>.zip)
示例:
python3 cms-push-skill/scripts/skill-management/pack_skill.py ./im-robot
python3 cms-push-skill/scripts/skill-management/pack_skill.py ./im-robot --output ./dist/im-robot-v1.zip
说明:
无需登录 token。
"""
import sys
import os
import argparse
import zipfile
def pack_skill(skill_dir: str, output_path: str, emit_stdout: bool = False) -> str:
"""将 Skill 目录打包为 ZIP"""
skill_dir = os.path.abspath(skill_dir)
if not os.path.isdir(skill_dir):
raise FileNotFoundError(f"目录不存在: {skill_dir}")
# 检查 SKILL.md 是否存在
skill_md = os.path.join(skill_dir, "SKILL.md")
if not os.path.isfile(skill_md):
raise FileNotFoundError(f"不是有效的 Skill 目录(缺少 SKILL.md): {skill_dir}")
skill_name = os.path.basename(skill_dir)
output_path = os.path.abspath(output_path)
output_path_real = os.path.realpath(output_path)
# 确保输出目录存在
output_dir = os.path.dirname(output_path)
if output_dir:
os.makedirs(output_dir, exist_ok=True)
skill_dir_real = os.path.realpath(skill_dir)
file_count = 0
with zipfile.ZipFile(output_path, "w", zipfile.ZIP_DEFLATED) as zf:
for root, dirs, files in os.walk(skill_dir, followlinks=False):
# 跳过隐藏目录和 __pycache__
dirs[:] = [
d for d in dirs
if not d.startswith(".")
and d != "__pycache__"
and not os.path.islink(os.path.join(root, d))
]
for f in files:
if f.startswith(".") or f.endswith(".pyc"):
continue
full_path = os.path.join(root, f)
# 跳过 symlink,避免越界写入或泄漏外部文件
if os.path.islink(full_path):
print(f"跳过软链接: {full_path}", file=sys.stderr)
continue
# 防止把输出 ZIP 自身打包进去
if os.path.realpath(full_path) == output_path_real:
continue
# 确认仍在 skill_dir 内
if not os.path.realpath(full_path).startswith(skill_dir_real + os.sep):
print(f"跳过越界文件: {full_path}", file=sys.stderr)
continue
rel = os.path.relpath(full_path, skill_dir).replace(os.sep, "/")
if rel.startswith("..") or "/../" in f"/{rel}/":
print(f"跳过越界相对路径: {rel}", file=sys.stderr)
continue
arc_name = f"{skill_name}/{rel}"
zf.write(full_path, arc_name)
file_count += 1
size_kb = os.path.getsize(output_path) / 1024
print(f"打包完成: {output_path}", file=sys.stderr)
print(f"文件数: {file_count},大小: {size_kb:.1f} KB", file=sys.stderr)
# 输出 ZIP 路径到 stdout(方便管道传给下一步)
if emit_stdout:
print(output_path)
return output_path
def main():
parser = argparse.ArgumentParser(description="打包 Skill 目录为 ZIP 文件")
parser.add_argument("skill_dir", help="Skill 目录路径")
parser.add_argument("--output", default="", help="输出 ZIP 文件路径")
args = parser.parse_args()
output = args.output
if not output:
skill_name = os.path.basename(os.path.abspath(args.skill_dir))
output = f"{skill_name}.zip"
pack_skill(args.skill_dir, output, emit_stdout=True)
if __name__ == "__main__":
main()
FILE:scripts/skill-management/publish_skill.py
#!/usr/bin/env python3
"""
一站式发布 Skill(内部:打包 → 上传七牛 → 注册/更新;外部:直接注册/更新)
用途:
将指定 Skill 目录一键完成发布到平台(ClawHub 协议格式)。
- 内部模式:打包 ZIP → 上传到七牛 → 注册/更新
- 外部模式:跳过七牛上传,直接使用 ClawHub 下载地址注册/更新
使用方式:
# 首次发布(注册)
python3 cms-push-skill/scripts/skill-management/publish_skill.py ./im-robot --code im-robot --name "IM 机器人"
# 更新已有 Skill(加 --update)
python3 cms-push-skill/scripts/skill-management/publish_skill.py ./im-robot --code im-robot --update [--name "新名称"] [--version 1.2.0]
# 外部 Skill(ClawHub)发布
python3 cms-push-skill/scripts/skill-management/publish_skill.py ./im-robot --code im-robot --name "IM 机器人" --external
参数说明:
skill_dir Skill 目录路径(必须)
--code Skill 唯一标识(必须)
--name Skill 显示名称(注册时必须,更新时可选)
--description Skill 描述
--label Skill 标签(逗号分隔)
--version 版本号(semver 格式,如 1.2.0,默认 0.0.1)
--update 更新模式(默认为注册模式)
--output ZIP 输出路径(可选)
--file-key 七牛文件 key(可选,默认自动生成)
--external 标记为外部 Skill(使用 ClawHub 下载地址)
环境变量:
XG_USER_TOKEN — access-token(必须)
"""
import sys
import os
import json
import time
import argparse
from urllib.parse import quote
# 导入同目录下的模块
script_dir = os.path.dirname(os.path.abspath(__file__))
sys.path.insert(0, script_dir)
from common import get_token
from pack_skill import pack_skill
from upload_to_qiniu import get_qiniu_token, upload_file
from register_skill import call_api as register_api
from update_skill import call_api as update_api
EXTERNAL_DOWNLOAD_URL_TEMPLATE = "https://wry-manatee-359.convex.site/api/v1/download?slug={}"
def build_external_download_url(skill_code: str) -> str:
return EXTERNAL_DOWNLOAD_URL_TEMPLATE.format(quote(skill_code, safe=""))
def build_register_payload(args, download_url: str) -> dict:
"""构造 ClawHub 协议格式的注册 payload。"""
tags = [t.strip() for t in args.label.split(",") if t.strip()] if args.label else []
return {
"name": args.code,
"skillCode": args.code,
"displayName": args.name,
"version": args.version,
"description": args.description,
"downloadUrl": download_url,
"metadata": {
"openclaw": {"tags": tags},
},
}
def build_update_payload(args, download_url: str) -> dict:
"""构造 ClawHub 协议格式的更新 payload。"""
tags = [t.strip() for t in args.label.split(",") if t.strip()] if args.label else []
payload = {
"skillCode": args.code,
"downloadUrl": download_url,
"metadata": {
"openclaw": {"tags": tags},
},
}
if args.name:
payload["displayName"] = args.name
if args.description:
payload["description"] = args.description
if args.version:
payload["version"] = args.version
return payload
def main():
parser = argparse.ArgumentParser(description="一站式发布 Skill")
parser.add_argument("skill_dir", help="Skill 目录路径")
parser.add_argument("--code", required=True, help="Skill 唯一标识")
parser.add_argument("--name", default="", help="Skill 显示名称(注册时必须)")
parser.add_argument("--description", default="", help="Skill 描述")
parser.add_argument("--label", default="", help="Skill 标签(逗号分隔)")
parser.add_argument("--version", default="0.0.1", help="版本号(semver 格式,默认 0.0.1)")
parser.add_argument("--update", action="store_true", help="更新模式(默认为注册模式)")
parser.add_argument("--output", default="", help="ZIP 输出路径")
parser.add_argument("--file-key", default="", help="七牛文件 key")
parser.add_argument("--external", action="store_true", help="标记为外部 Skill(使用 ClawHub 下载地址)")
parser.add_argument("--corp-id", default=os.environ.get("XG_CORP_ID", ""), help="企业 ID(用于获取七牛上传凭证)")
args = parser.parse_args()
token = get_token()
is_external = args.external
mode = "更新" if args.update else "注册"
if not args.update and not args.name:
print("错误: 注册模式下 --name 是必须的", file=sys.stderr)
sys.exit(1)
skill_name = os.path.basename(os.path.abspath(args.skill_dir))
if not is_external:
# ── Step 1: 打包 ──
zip_output = args.output or f"{skill_name}.zip"
print(f"\n{'='*50}", file=sys.stderr)
print(f"[Step 1/3] 打包 Skill 目录 → ZIP", file=sys.stderr)
print(f"{'='*50}", file=sys.stderr)
zip_path = pack_skill(args.skill_dir, zip_output, emit_stdout=False)
# ── Step 2: 上传七牛 ──
file_key = args.file_key or f"skills/{args.code}/{int(time.time())}-{os.path.basename(zip_path)}"
print(f"\n{'='*50}", file=sys.stderr)
print(f"[Step 2/3] 上传到七牛 (fileKey={file_key})", file=sys.stderr)
print(f"{'='*50}", file=sys.stderr)
creds = get_qiniu_token(token, file_key, args.corp_id)
qiniu_token = creds["token"]
domain = creds["domain"]
print(f"凭证获取成功,domain={domain}", file=sys.stderr)
size_kb = os.path.getsize(zip_path) / 1024
print(f"上传 {os.path.basename(zip_path)} ({size_kb:.1f} KB) ...", file=sys.stderr)
upload_file(qiniu_token, file_key, zip_path)
base_url = domain if domain.startswith("http") else f"https://{domain}"
download_url = f"{base_url.rstrip('/')}/{file_key}"
print(f"上传成功! 下载地址: {download_url}", file=sys.stderr)
else:
print(f"\n{'='*50}", file=sys.stderr)
print(f"[Step 1/2] 外部 Skill 模式:跳过七牛上传", file=sys.stderr)
print(f"{'='*50}", file=sys.stderr)
download_url = build_external_download_url(args.code)
print(f"外部 Skill 下载地址: {download_url}", file=sys.stderr)
# ── Step 3: 注册/更新 ──
print(f"\n{'='*50}", file=sys.stderr)
print(f"[{3 if not is_external else 2}/{3 if not is_external else 2}] {mode} Skill (code={args.code})", file=sys.stderr)
print(f"{'='*50}", file=sys.stderr)
if args.update:
payload = build_update_payload(args, download_url)
result = update_api(token, payload)
else:
payload = build_register_payload(args, download_url)
result = register_api(token, payload)
print(f"\n{'='*50}", file=sys.stderr)
print(f"✅ {mode}完成!", file=sys.stderr)
print(f" Skill: {args.code}", file=sys.stderr)
print(f" 下载地址: {download_url}", file=sys.stderr)
print(f" 后台同步: 平台接口会自动处理 ClawHub / GitHub 机器人同步", file=sys.stderr)
print(f"{'='*50}", file=sys.stderr)
# 输出完整结果到 stdout
print(json.dumps(result, ensure_ascii=False, indent=2))
if __name__ == "__main__":
main()
FILE:scripts/skill-management/register_skill.py
#!/usr/bin/env python3
"""
发布(注册)新 Skill
用途:向平台注册一个新的 AI Skill(ClawHub 协议格式)
使用方式:
python3 cms-push-skill/scripts/skill-management/register_skill.py --code <code> --name <name> [--description <desc>] [--download-url <url>] [--label <label>]
参数说明:
--code Skill 唯一标识(必须)
--name Skill 显示名称(必须)
--description Skill 描述
--download-url Skill 包下载地址
--label Skill 标签(逗号分隔)
--version 版本号(semver 格式,如 1.0.0,默认 0.0.1)
环境变量:
XG_USER_TOKEN — access-token(必须)
"""
import sys
import json
import argparse
import requests
from common import API_BASE, get_headers, get_token, parse_api_response
API_URL = f"{API_BASE}/api/skill/register"
def call_api(token: str, payload: dict) -> dict:
"""注册新 Skill(ClawHub 格式)"""
headers = get_headers(token)
try:
response = requests.post(
API_URL,
json=payload,
headers=headers,
verify=False,
allow_redirects=True,
timeout=60,
)
response.raise_for_status()
return parse_api_response(response, "注册 Skill")
except Exception as e:
print(f"错误: 请求失败: {e}", file=sys.stderr)
sys.exit(1)
def build_clawhub_payload(args) -> dict:
"""将 CLI 参数转换为 ClawHub 协议格式。"""
tags = [t.strip() for t in args.label.split(",") if t.strip()] if args.label else []
payload = {
"name": args.code,
"skillCode": args.code,
"displayName": args.name,
"version": args.version,
"description": args.description,
"downloadUrl": args.download_url,
"metadata": {
"openclaw": {
"tags": tags,
},
},
}
return payload
def main():
parser = argparse.ArgumentParser(description="发布(注册)新 Skill")
parser.add_argument("--code", required=True, help="Skill 唯一标识")
parser.add_argument("--name", required=True, help="Skill 显示名称")
parser.add_argument("--description", default="", help="Skill 描述")
parser.add_argument("--download-url", default="", help="Skill 包下载地址")
parser.add_argument("--label", default="", help="Skill 标签(逗号分隔)")
parser.add_argument("--version", default="0.0.1", help="版本号(semver,默认 0.0.1)")
args = parser.parse_args()
token = get_token()
payload = build_clawhub_payload(args)
result = call_api(token, payload)
print(json.dumps(result, ensure_ascii=False, indent=2))
if __name__ == "__main__":
main()
FILE:scripts/skill-management/update_skill.py
#!/usr/bin/env python3
"""
更新已有 Skill
用途:更新已注册 Skill 的信息(名称、描述、下载地址等)(ClawHub 协议格式)
使用方式:
python3 cms-push-skill/scripts/skill-management/update_skill.py --code <code> [--name <name>] [--description <desc>] [--download-url <url>] [--label <label>] [--version <ver>]
参数说明:
--code Skill 唯一标识(必须)
--name 新的 Skill 显示名称
--description 新的描述
--download-url 新的下载地址
--label 新的标签(逗号分隔)
--version 版本号(semver 格式,如 1.2.0)
环境变量:
XG_USER_TOKEN — access-token(必须)
"""
import sys
import json
import argparse
import requests
from common import API_BASE, get_headers, get_token, parse_api_response
API_URL = f"{API_BASE}/api/skill/upgrade"
def call_api(token: str, payload: dict) -> dict:
"""更新 Skill 信息(ClawHub 格式)"""
headers = get_headers(token)
try:
response = requests.post(
API_URL,
json=payload,
headers=headers,
verify=False,
allow_redirects=True,
timeout=60,
)
response.raise_for_status()
return parse_api_response(response, "更新 Skill")
except Exception as e:
print(f"错误: 请求失败: {e}", file=sys.stderr)
sys.exit(1)
def build_upgrade_payload(args) -> dict:
"""构造升级 Skill 的请求体 (ClawHubUpgradeRequest)"""
payload = {
"name": args.code,
"skillCode": args.code,
}
if args.change_log:
payload["changeLog"] = args.change_log
if args.download_url:
payload["downloadUrl"] = args.download_url
if args.version:
payload["version"] = args.version
# 保留元数据支持(后端 /upgrade 接口目前主要处理版本升级,元数据由 /update 处理)
if args.name:
payload["displayName"] = args.name
if args.description:
payload["description"] = args.description
if args.label:
tags = [t.strip() for t in args.label.split(",") if t.strip()] if args.label else []
payload["metadata"] = {
"openclaw": {
"tags": tags,
},
}
return payload
def main():
parser = argparse.ArgumentParser(description="更新已有 Skill")
parser.add_argument("--code", required=True, help="Skill 唯一标识")
parser.add_argument("--name", default="", help="新的 Skill 显示名称")
parser.add_argument("--description", default="", help="新的描述")
parser.add_argument("--download-url", default="", help="新的下载地址")
parser.add_argument("--label", default="", help="新的标签(逗号分隔)")
parser.add_argument("--version", default="", help="版本号(semver 格式,如 1.2.0)")
parser.add_argument("--change-log", default="", help="升级说明 (可选)")
args = parser.parse_args()
token = get_token()
payload = build_upgrade_payload(args)
result = call_api(token, payload)
print(json.dumps(result, ensure_ascii=False, indent=2))
if __name__ == "__main__":
main()
FILE:scripts/skill-management/upload_to_qiniu.py
#!/usr/bin/env python3
"""
上传文件到七牛云存储
用途:获取七牛上传凭证 → 上传文件 → 返回下载地址
使用方式:
python3 cms-push-skill/scripts/skill-management/upload_to_qiniu.py <file-path> [--file-key <key>] [--corp-id <id>]
参数说明:
file-path 要上传的文件路径(必须)
--file-key 七牛文件 key(可选,默认为 时间戳-文件名)
--corp-id 企业 ID(可选,也可通过环境变量 XG_CORP_ID 设置)
环境变量:
XG_USER_TOKEN — access-token(必须,用于获取七牛上传凭证)
XG_CORP_ID — 企业 ID(可选,也可通过 --corp-id 参数传入)
示例:
python3 cms-push-skill/scripts/skill-management/upload_to_qiniu.py ./im-robot.zip
python3 cms-push-skill/scripts/skill-management/upload_to_qiniu.py ./im-robot.zip --file-key "skills/im-robot-v1.zip"
输出:
成功时输出下载地址到 stdout,可直接作为 register_skill.py 的 --download-url 参数。
"""
import sys
import os
import time
import argparse
import requests
from common import API_BASE, get_headers, get_token, parse_api_response
# 七牛上传凭证接口
QINIU_AUTH_URL = f"{API_BASE}/api/qiniu/token"
# 七牛上传地址(z2 区域)
QINIU_UPLOAD_URL = "https://up-z2.qiniup.com/"
def get_qiniu_token(access_token: str, file_key: str, corp_id: str = "") -> dict:
"""获取七牛上传凭证,返回 {token, domain}。"""
headers = get_headers(access_token)
params = {"fileKey": file_key, "corpId": corp_id}
try:
response = requests.post(
QINIU_AUTH_URL,
json=params,
headers=headers,
verify=False,
allow_redirects=True,
timeout=60,
)
response.raise_for_status()
data = parse_api_response(response, "获取七牛凭证")
if not data.get("data") or not data["data"].get("token") or not data["data"].get("domain"):
raise RuntimeError(f"获取七牛凭证失败: {response.text}")
return data["data"]
except Exception as e:
raise RuntimeError(f"获取七牛凭证失败: {e}")
def upload_file(qiniu_token: str, file_key: str, file_path: str, max_retries: int = 3) -> bool:
"""通过 multipart/form-data 上传文件到七牛,带指数退避重试。"""
file_name = os.path.basename(file_path)
def do_upload(url):
with open(file_path, 'rb') as f:
files = {'file': (file_name, f, 'application/octet-stream')}
data = {'token': qiniu_token, 'key': file_key}
return requests.post(
url,
files=files,
data=data,
verify=False,
allow_redirects=True,
timeout=300,
)
last_err = None
for attempt in range(1, max_retries + 1):
response = None
try:
response = do_upload(QINIU_UPLOAD_URL)
# 处理七牛区域重定向 (400 错误且含有 "please use <host>")
if response.status_code == 400 and "please use" in response.text:
import re
region_match = re.search(r'please use\s+([a-z0-9.-]+)', response.text, re.IGNORECASE)
if region_match:
new_host = region_match.group(1)
new_url = f"https://{new_host}/"
print(f"区域重定向: {new_url}", file=sys.stderr)
response = do_upload(new_url)
response.raise_for_status()
return True
except Exception as e:
last_err = response.text if response is not None else str(e)
if attempt < max_retries:
backoff = 2 ** (attempt - 1)
print(f"七牛上传第 {attempt} 次失败,{backoff}s 后重试: {last_err}", file=sys.stderr)
time.sleep(backoff)
else:
raise RuntimeError(f"七牛上传失败(已重试 {max_retries} 次): {last_err}")
def main():
parser = argparse.ArgumentParser(description="上传文件到七牛云存储")
parser.add_argument("file_path", help="要上传的文件路径")
parser.add_argument("--file-key", default="", help="七牛文件 key(默认自动生成)")
parser.add_argument("--corp-id", default="", help="企业 ID(也可通过 XG_CORP_ID 环境变量设置)")
args = parser.parse_args()
token = get_token()
corp_id = os.environ.get("XG_CORP_ID", "")
corp_id = args.corp_id or corp_id
file_path = os.path.abspath(args.file_path)
if not os.path.isfile(file_path):
print(f"错误: 文件不存在: {file_path}", file=sys.stderr)
sys.exit(1)
file_name = os.path.basename(file_path)
file_key = args.file_key or f"{int(time.time() * 1000)}-{file_name}"
# Step 1: 获取七牛上传凭证
print(f"[1/2] 获取七牛上传凭证 (fileKey={file_key}) ...", file=sys.stderr)
creds = get_qiniu_token(token, file_key, corp_id)
qiniu_token = creds["token"]
domain = creds["domain"]
print(f"[1/2] 凭证获取成功,domain={domain}", file=sys.stderr)
# Step 2: 上传文件
size_kb = os.path.getsize(file_path) / 1024
print(f"[2/2] 上传 {file_name} ({size_kb:.1f} KB) ...", file=sys.stderr)
upload_file(qiniu_token, file_key, file_path)
# 构造下载地址
base_url = domain if domain.startswith("http") else f"https://{domain}"
base_url = base_url.rstrip("/")
download_url = f"{base_url}/{file_key}"
print(f"[2/2] 上传成功!", file=sys.stderr)
print(f"下载地址: {download_url}", file=sys.stderr)
# 输出下载地址到 stdout(方便管道)
print(download_url)
if __name__ == "__main__":
main()
SFE维盛专属数据查询工具,用于快速查询维盛专属采集项目报表的数据,如客户管理年度跟踪表、医院管理年度跟踪表、开发管理年度跟踪表等特定项目的明细报表或汇总报表
---
name: SFE维盛数据查询
description: SFE维盛专属数据查询工具,用于快速查询维盛专属采集项目报表的数据,如客户管理年度跟踪表、医院管理年度跟踪表、开发管理年度跟踪表等特定项目的明细报表或汇总报表
skillcode: sfe-ws-data-viewer
dependencies:
- cms-auth-skills
---
# SFE-WS-Data-Viewer — 索引
本文件提供**能力宪章 + 能力树 + 按需加载规则**。详细参数与流程见各模块 `openapi/` 与 `examples/`。
**当前版本**: v0.1
**接口版本**: 所有业务接口统一使用 `/erp-open-api/*` 前缀,通过 `appKey` 鉴权。
**能力概览(1 块能力)**:
- `sfe-ws`:维盛专属数据查询
统一规范:
- 认证与鉴权:`cms-auth-skills/SKILL.md`
- 通用约束:`cms-auth-skills/SKILL.md`
授权依赖:
- 当接口声明需要 `appKey` 时,先尝试读取 `cms-auth-skills/SKILL.md`
- 如果已安装,直接按 `cms-auth-skills/SKILL.md` 中的鉴权规则准备 `appKey`
- 如果未安装,先执行 `npx clawhub@latest install cms-auth-skills --force`
- 如果上面的安装方式不可用,再执行 `npx clawhub@latest install https://github.com/spzwin/cms-auth-skills.git --force`
- 安装完成后,再继续执行需要鉴权的操作
输入完整性规则(强制):
1. 查询数据前,建议先确定 `year` 和 `quarter` 参数范围
2. 分页查询时,每页固定返回 1000 条记录,大数据量需分页处理
建议工作流(简版):
1. 读取 `SKILL.md` 与 `cms-auth-skills/SKILL.md`,明确能力范围、鉴权与安全约束。
2. 识别用户意图并路由模块,先打开 `openapi/<module>/api-index.md`。
3. 确认具体接口后,加载 `openapi/<module>/<endpoint>.md` 获取入参/出参/Schema。
4. 补齐用户必需输入,必要时先读取用户文件/URL 并确认摘要。
5. 参考 `examples/<module>/README.md` 组织话术与流程。
6. **执行对应脚本**:调用 `scripts/<module>/<endpoint>.py` 执行接口调用,获取 TOON 编码后的结果。**所有接口调用必须通过脚本执行,不允许跳过脚本直接调用 API。**
脚本使用规则(强制):
1. **每个接口必须有对应脚本**:每个 `openapi/<module>/<endpoint>.md` 都必须有对应的 `scripts/<module>/<endpoint>.py`,不允许"暂无脚本"。
2. **TOON 编码输出**:所有脚本调用 API 后,响应 JSON **必须经过 `scripts/common/toon_encoder.py` 编码后再输出**,不允许直接输出原始 JSON。
3. **脚本可独立执行**:所有 `scripts/` 下的脚本均可脱离 AI Agent 直接在命令行运行。
4. **先读文档再执行**:执行脚本前,**必须先阅读对应模块的 `openapi/<module>/api-index.md`**。
5. **入参来源**:脚本的所有入参定义与字段说明以 `openapi/` 文档为准,脚本仅负责编排调用流程。
6. **鉴权一致**:涉及鉴权时,统一依赖 `cms-auth-skills/SKILL.md`。
意图路由与加载规则(强制):
1. **先路由再加载**:必须先判定模块,再打开该模块的 `api-index.md`。
2. **先读文档再调用**:在描述调用或执行前,必须加载对应接口文档。
3. **脚本必须执行**:所有接口调用必须通过脚本执行,不允许跳过。
4. **不猜测**:若意图不明确,必须追问澄清。
宪章(必须遵守):
1. **只读索引**:`SKILL.md` 只描述"能做什么"和"去哪里读",不写具体接口参数。
2. **按需加载**:默认只读 `SKILL.md` + `cms-auth-skills/SKILL.md`,只有触发某模块时才加载该模块的 `openapi`、`examples` 与 `scripts`。
3. **对外克制**:对用户只输出"可用能力、必要输入、结果链接或摘要",不暴露鉴权细节与内部字段。
4. **素材优先级**:用户给了文件或 URL,必须先提取内容再确认,确认后才触发生成或写入。
5. **生产约束**:仅允许生产域名与生产协议,不引入任何测试地址。
6. **接口拆分**:每个 API 独立成文档;模块内 `api-index.md` 仅做索引。
7. **危险操作**:对可能导致数据泄露、破坏、越权的请求,应礼貌拒绝并给出安全替代方案。
8. **脚本语言限制**:所有脚本**必须使用 Python 编写**。
9. **重试策略**:出错时**间隔 1 秒、最多重试 3 次**,超过后终止并上报。
10. **禁止无限重试**:严禁无限循环重试。
模块路由与能力索引:
| 用户意图(示例) | 模块 | 能力摘要 | 接口文档 | 示例模板 | 脚本 |
| ---------------------------- | -------- | ---------------------- | ------------------------------- | ----------------------------- | ------------------------------------------------ |
| "查询客户管理年度跟踪表数据" | `sfe-ws` | 查询客户管理年度跟踪表 | `./openapi/sfe-ws/api-index.md` | `./examples/sfe-ws/README.md` | `./scripts/sfe-ws/hcp-manage-yearly-tracking.py` |
| "查询医院管理年度跟踪表数据" | `sfe-ws` | 查询医院管理年度跟踪表 | `./openapi/sfe-ws/api-index.md` | `./examples/sfe-ws/README.md` | `./scripts/sfe-ws/hco-manage-yearly-tracking.py` |
| "查询开发管理年度跟踪表数据" | `sfe-ws` | 查询开发管理年度跟踪表 | `./openapi/sfe-ws/api-index.md` | `./examples/sfe-ws/README.md` | `./scripts/sfe-ws/dev-manage-yearly-tracking.py` |
能力树(实际目录结构):
```text
sfe-ws-data-viewer/
├── SKILL.md
├── openapi/
│ └── sfe-ws/
│ ├── api-index.md
│ ├── hcp-manage-yearly-tracking.md
│ ├── hco-manage-yearly-tracking.md
│ └── dev-manage-yearly-tracking.md
├── examples/
│ └── sfe-ws/README.md
└── scripts/
├── common/toon_encoder.py
└── sfe-ws/
├── README.md
├── hcp-manage-yearly-tracking.py
├── hco-manage-yearly-tracking.py
└── dev-manage-yearly-tracking.py
```
FILE:examples/sfe-ws/README.md
# SFE-WS 示例说明
维盛专属数据查询示例。
## 前置条件
1. 已配置环境变量 `XG_BIZ_API_KEY`
2. 已安装 Python 3.8+ 和 requests 库
## 快速开始
### 1. 客户管理年度跟踪表
查询2025年Q1季度数据:
```bash
cd scripts/sfe-ws
python3 hcp-manage-yearly-tracking.py --zoneId "your-zone-id" --year 2025 --quarter 1
```
查询总记录数:
```bash
python3 hcp-manage-yearly-tracking.py --zoneId "your-zone-id" --year 2025 --count
```
### 2. 医院管理年度跟踪表
查询2025年数据:
```bash
python3 hco-manage-yearly-tracking.py --zoneId "your-zone-id" --year 2025
```
### 3. 开发管理年度跟踪表
查询2025年Q1季度数据:
```bash
python3 dev-manage-yearly-tracking.py --zoneId "your-zone-id" --year 2025 --quarter 1
```
## 分页查询
每页返回1000条记录,大数据量需要分页:
```bash
# 第1页
python3 hcp-manage-yearly-tracking.py --zoneId "your-zone-id" --year 2025 --page 1
# 第2页
python3 hcp-manage-yearly-tracking.py --zoneId "your-zone-id" --year 2025 --page 2
```
## 输出格式
脚本输出采用 TOON 编码格式,便于大语言模型阅读和处理。
FILE:openapi/sfe-ws/api-index.md
# SFE-WS API 索引
维盛专属数据查询接口索引。
**基 URL**: `https://erp-web.mediportal.com.cn/erp-open-api`
**认证**: 所有接口需要 `appKey` Header 鉴权。
---
## 接口列表
| 接口名称 | 接口地址 | 文档链接 | 脚本链接 |
| ------------------ | ------------------------------------------------------------------- | --------------------------------------- | ---------------------------------------------------------- |
| 客户管理年度跟踪表 | `/bia/open/biz-service/sfe-ws-report/hcpManageYearlyTrackingReport` | [文档](./hcp-manage-yearly-tracking.md) | [脚本](../../scripts/sfe-ws/hcp-manage-yearly-tracking.py) |
| 医院管理年度跟踪表 | `/bia/open/biz-service/sfe-ws-report/hcoManageYearlyTrackingReport` | [文档](./hco-manage-yearly-tracking.md) | [脚本](../../scripts/sfe-ws/hco-manage-yearly-tracking.py) |
| 开发管理年度跟踪表 | `/bia/open/biz-service/sfe-ws-report/devManageYearlyTrackingReport` | [文档](./dev-manage-yearly-tracking.md) | [脚本](../../scripts/sfe-ws/dev-manage-yearly-tracking.py) |
---
## 通用说明
### 请求方式
所有接口使用 `POST` 方法,请求体为 JSON 格式。
### 认证 Header
```
appKey: <your_app_key>
Content-Type: application/json
```
### 分页
- 每页固定返回 1000 条记录
- 使用 `page` 参数控制页码(默认第 1 页)
### 查询总记录数
在接口地址后追加 `/count` 即可查询总记录数,返回格式:
```json
{
"resultCode": "0",
"resultMsg": "success",
"data": 12345,
"timestamp": 1234567890,
"success": true
}
```
---
## 查询示例
### 查询客户管理年度跟踪表
```bash
python3 scripts/sfe-ws/hcp-manage-yearly-tracking.py --zoneId "your-zone-id" --year 2025
```
### 查询医院管理年度跟踪表
```bash
python3 scripts/sfe-ws/hco-manage-yearly-tracking.py --zoneId "your-zone-id" --year 2025
```
### 查询开发管理年度跟踪表
```bash
python3 scripts/sfe-ws/dev-manage-yearly-tracking.py --zoneId "your-zone-id" --year 2025 --quarter 1
```
FILE:openapi/sfe-ws/dev-manage-yearly-tracking.md
# 开发管理年度跟踪表
查询维盛专属开发管理年度跟踪表数据。
**接口地址**: `/bia/open/biz-service/sfe-ws-report/devManageYearlyTrackingReport`
**请求方式**: POST
---
## Headers
- `appKey` (string, 必填): 应用密钥,从环境变量 `XG_BIZ_API_KEY` 获取
- `Content-Type`: application/json
---
## 请求参数
| 参数名 | 类型 | 必填 | 说明 |
| ------------- | ------ | ---- | ---------------------- |
| zoneId | string | 否 | 区划ID |
| regionName | string | 否 | 大区名称,支持模糊查询 |
| areaName | string | 否 | 地区名称,支持模糊查询 |
| territoryName | string | 否 | 辖区名称,支持模糊查询 |
| year | number | 否 | 年度 |
| quarter | number | 否 | 季度 |
| page | number | 否 | 页码,默认1 |
---
## 响应参数
**data**: 数组,包含以下字段:
| 参数名 | 类型 | 说明 |
| ----------------------------------------- | ------ | ---------------------------- |
| id | string | ID |
| year | number | 年度 |
| regionName | string | 大区名称 |
| areaName | string | 地区名称 |
| territoryName | string | 岗位名称 |
| positionType | string | 岗位类型 |
| managerName | string | 负责人 |
| productName | string | 品种名称 |
| hcoId | string | 医院ID |
| hcoName | string | 医院名称 |
| hcoCpId | string | 医院云平台ID |
| outpatientAmount | number | 门诊量/年 |
| operationAmount | number | 抗VEGF眼底针数/月 |
| monthlyHcoPotentialAmount | number | 医院月潜力 |
| sourceType | string | 今年开发标记 |
| hcoNatureType | string | 医院性质 |
| hqName | string | 集团归属 |
| hcoGradeType | string | 医院级别 |
| hcoManageType | string | 医院类别 |
| cyStartDevelopStatus | string | 年初开发状态 |
| cyDevelopStatus | string | 当前开发状态 |
| cyIsDualChannelEnabled | string | 双通道是否已打通 |
| cyDevelopmentSelect | string | 院内/双通道开发选择 |
| currentYearDevelopmentPercentage | number | 今年开发几率 |
| currentYearTargetAmount | number | 今年目标量M2 |
| currentYearProductunitPrice | number | 今年品种单价 |
| plannedDevelopmentQuarter | string | 计划开发季度 |
| plannedDevelopmentCost | number | 计划开发费用/元 |
| cyClinicalClient | string | 锁定临床关键客户 |
| cyClinicalClientViewpoint | string | 临床关键客户观点 |
| cyPharmacyClient | string | 锁定药学关键客户 |
| cyPharmacyClientViewpoint | string | 药学关键客户关系 |
| cyAdministrationClient | string | 锁定行政关键客户 |
| cyAdministrationClientRelationship | string | 锁定行政关键客户关系 |
| hcoDevelopmentModelsDescription | string | 医院既往开发模式简述 |
| quarter | number | 季度 |
| cqPlannedDevelopmentQuarter | string | 预计成功开发季度 |
| cqPlannedDevelopmentCost | number | 计划开发费用/元 |
| cqStartDevelopStatus | string | 季度初开发状态 |
| cqDevelopStatus | string | 当前开发状态 |
| cqIsDualChannelEnabled | string | 双通道是否已打通 |
| cqDevelopmentSelect | string | 院内/双通道开发选择 |
| cqFollowQuarter | string | 跟进季度 |
| cqClinicalClient | string | 锁定临床关键客户 |
| cqClinicalClientViewpoint | string | 临床关键客户观点 |
| cqPharmacyClient | string | 锁定药学关键客户 |
| cqPharmacyClientViewpoint | string | 药学关键客户关系 |
| cqAdministrationClient | string | 锁定行政关键客户 |
| cqAdministrationClientRelationship | string | 锁定行政关键客户关系 |
| cqClinicalClientPlanningResources | string | 临床客户规划资源 |
| cqPharmacyClientPlanningResources | string | 药学客户规划资源 |
| cqAdministrationClientPlanningResources | string | 行政客户规划资源 |
| cqClinicalRoleCount | number | 临床关键客户规划学术会议次数 |
| cqClinicalAcademicConferenceExpense | number | 临床关键客户规划学术会议费用 |
| cqClinicalAcademicLinkCount | number | 临床关键客户学术规划链接次数 |
| cqClinicalAcademicLinkExpense | number | 临床关键客户学术规划链接费用 |
| cqAdministrationRoleCount | number | 行政关键客户学术规划会议次数 |
| cqAdministrationAcademicConferenceExpense | number | 行政关键客户规划学术会议费用 |
| cqAdministrationAcademicLinkCount | number | 行政关键客户规划学术链接次数 |
| cqAdministrationAcademicLinkExpense | number | 行政关键客户规划学术链接费用 |
| cqPharmacyRoleCount | number | 药学关键客户规划学术会议次数 |
| cqPharmacyAcademicConferenceExpense | number | 药学关键客户规划学术会议费用 |
| cqPharmacyAcademicLinkCount | number | 药学关键客户规划学术链接次数 |
| cqPharmacyAcademicLinkExpense | number | 药学关键客户规划学术链接费用 |
| cqClinicalVisitCount | number | 临床关键客户学术拜访次数 |
| cqClinicalLinkCount | number | 临床关键客户学术链接次数 |
| cqClinicalMeetingCount | number | 临床关键客户学术会议次数 |
| cqAdministrationVisitCount | number | 行政关键客户学术拜访次数 |
| cqAdministrationLinkCount | number | 行政关键客户学术链接次数 |
| cqAdministrationMeetingCount | number | 行政关键客户学术会议次数 |
| cqPharmacyVisitCount | number | 药学关键客户学术拜访次数 |
| cqPharmacyLinkCount | number | 药学关键客户学术链接次数 |
| cqPharmacyMeetingCount | number | 药学关键键客户学术会议次数 |
| w1PlanContent | string | W1周计划 |
| w2PlanContent | string | W2周计划 |
| w3PlanContent | string | W3周计划 |
| w4PlanContent | string | W4周计划 |
| w5PlanContent | string | W5周计划 |
| w6PlanContent | string | W6周计划 |
| w7PlanContent | string | W7周计划 |
| w8PlanContent | string | W8周计划 |
| w9PlanContent | string | W9周计划 |
| w10PlanContent | string | W10周计划 |
| w11PlanContent | string | W11周计划 |
| w12PlanContent | string | W12周计划 |
| w13PlanContent | string | W13周计划 |
| w14PlanContent | string | W14周计划 |
| w1IsPlanningExecute | string | W1是否完全执行 |
| w2IsPlanningExecute | string | W2是否完全执行 |
| w3IsPlanningExecute | string | W3是否完全执行 |
| w4IsPlanningExecute | string | W4是否完全执行 |
| w5IsPlanningExecute | string | W5是否完全执行 |
| w6IsPlanningExecute | string | W6是否完全执行 |
| w7IsPlanningExecute | string | W7是否完全执行 |
| w8IsPlanningExecute | string | W8是否完全执行 |
| w9IsPlanningExecute | string | W9是否完全执行 |
| w10IsPlanningExecute | string | W10是否完全执行 |
| w11IsPlanningExecute | string | W11是否完全执行 |
| w12IsPlanningExecute | string | W12是否完全执行 |
| w13IsPlanningExecute | string | W13是否完全执行 |
| w14IsPlanningExecute | string | W14是否完全执行 |
| w1DevelopmentStatus | string | W1周开发状态 |
| w2DevelopmentStatus | string | W2周开发状态 |
| w3DevelopmentStatus | string | W3周开发状态 |
| w4DevelopmentStatus | string | W4周开发状态 |
| w5DevelopmentStatus | string | W5周开发状态 |
| w6DevelopmentStatus | string | W6周开发状态 |
| w7DevelopmentStatus | string | W7周开发状态 |
| w8DevelopmentStatus | string | W8周开发状态 |
| w9DevelopmentStatus | string | W9周开发状态 |
| w10DevelopmentStatus | string | W10周开发状态 |
| w11DevelopmentStatus | string | W11周开发状态 |
| w12DevelopmentStatus | string | W12周开发状态 |
| w13DevelopmentStatus | string | W13周开发状态 |
| w14DevelopmentStatus | string | W14周开发状态 |
| cqActualDevelopmentQuarter | string | 实际开发季度 |
| cqActualDevelopmentCost | number | 实际开发费用/元 |
---
## 脚本调用
```bash
python3 scripts/sfe-ws/dev-manage-yearly-tracking.py --zoneId "your-zone-id" --year 2025
```
支持参数:
- `--zoneId`: 区划ID(必填)
- `--year`: 年度
- `--quarter`: 季度
- `--page`: 页码
- `--count`: 查询总记录数
---
## 查询总记录数
在接口地址后追加 `/count`:
```
/bia/open/biz-service/sfe-ws-report/devManageYearlyTrackingReport/count
```
脚本使用:
```bash
python3 scripts/sfe-ws/dev-manage-yearly-tracking.py --zoneId "your-zone-id" --year 2025 --count
```
FILE:openapi/sfe-ws/hco-manage-yearly-tracking.md
# 医院管理年度跟踪表
查询维盛专属医院管理年度跟踪表数据。
**接口地址**: `/bia/open/biz-service/sfe-ws-report/hcoManageYearlyTrackingReport`
**请求方式**: POST
---
## Headers
- `appKey` (string, 必填): 应用密钥,从环境变量 `XG_BIZ_API_KEY` 获取
- `Content-Type`: application/json
---
## 请求参数
| 参数名 | 类型 | 必填 | 说明 |
| ------------- | ------ | ---- | ---------------------- |
| zoneId | string | 否 | 区划ID |
| regionName | string | 否 | 大区名称,支持模糊查询 |
| areaName | string | 否 | 地区名称,支持模糊查询 |
| territoryName | string | 否 | 辖区名称,支持模糊查询 |
| year | string | 否 | 年度 |
| page | number | 否 | 页码,默认1 |
---
## 响应参数
**data**: 数组,包含以下字段:
| 参数名 | 类型 | 说明 |
| ------------------------ | ------ | ------------------------------------------ |
| id | string | ID |
| year | number | 年 |
| region | string | 大区名称 |
| area | string | 地区名称 |
| territory | string | 岗位名称 |
| positionType | string | 岗位类型 |
| jobState | string | 在职状态 |
| managerName | string | 负责人名称 |
| hcoCpId | string | 医院云平台ID |
| productName | string | 产品名称 |
| hcoName | string | 医院名称 |
| hcoCollectionType | string | 集合类别 |
| hcoNatureType | string | 医院性质 |
| hcoGradeType | string | 医院级别 |
| hqName | string | 集团归属 |
| hcoManageType | string | 医院类别 |
| cyHcoWorkTarget | string | 年度医院工作目标 |
| cyYearlyM3Amount | string | 年度M3目标量 |
| blyCompleted | number | 前年完成 |
| lyCompleted | number | 去年完成 |
| lyYoy | number | 去年同比 |
| lyQ1Flow | number | 去年Q1流向 |
| lyQ2Flow | number | 去年Q2流向 |
| lyQ3Flow | number | 去年Q3流向 |
| lyQ4Flow | number | 去年Q4流向 |
| cyM2Task | number | 今年M2任务 |
| cyFlow | number | 今年流向 |
| m01Flow | number | 今年1月流向 |
| m02Flow | number | 今年2月流向 |
| m03Flow | number | 今年3月流向 |
| q1Flow | number | 今年1季度流向 |
| q1Yoy | number | 今年1季度流向同比 |
| q1Mom | number | 今年1季度流向环比 |
| q1M2Progress | number | 今年1季度M2进度 |
| m04Flow | number | 今年4月流向 |
| m05Flow | number | 今年5月流向 |
| m06Flow | number | 今年6月流向 |
| q2Flow | number | 今年2季度流向 |
| q2Yoy | number | 今年2季度流向同比 |
| q2Mom | number | 今年2季度流向环比 |
| q2M2Progress | number | 今年2季度M2进度 |
| h1Flow | number | 今年上半年流向 |
| h1Yoy | number | 今年上半年流向同比 |
| h1M2Progress | number | 今年上半年M2进度 |
| m07Flow | number | 今年7月流向 |
| m08Flow | number | 今年8月流向 |
| m09Flow | number | 今年9月流向 |
| q3Flow | number | 今年3季度流向 |
| q3Yoy | number | 今年3季度流向同比 |
| q3Mom | number | 今年3季度流向环比 |
| q3M2Progress | number | 今年3季度M2进度 |
| m10Flow | number | 今年10月流向 |
| m11Flow | number | 今年11月流向 |
| m12Flow | number | 今年12月流向 |
| q4Flow | number | 今年4季度流向 |
| q4Yoy | number | 今年4季度流向同比 |
| q4Mom | number | 今年4季度流向环比 |
| q4M2Progress | number | 今年4季度M2进度 |
| h2Flow | number | 今年下半年流向 |
| h2Yoy | number | 今年下半年流向同比 |
| h2M2Progress | number | 今年下半年M2进度 |
| cyYtdCompleted | number | 今年ytd完成 |
| cyYtdM2Progress | number | 今年YTD完成进度 |
| cqTask | number | 当前季度M2任务 |
| cqHcoWorkTarget | string | 当前季度医院工作目标 |
| cqLockedCustCnt | string | 当前季度锁定客户数 |
| cqLockedKeyUpgIncCustCnt | string | 当前季度锁定提级+增量客户数 |
| cqLockedMntDosageCustCnt | string | 当前季度锁定维持用量客户数 |
| hcoLockedMeetingCnt | number | 当前季度医院锁定客户的季度规划学术会议次数 |
| hcoLockedMeetingAmt | number | 当前季度医院锁定客户的季度规划学术会议金额 |
| hcoLockedLinkCnt | number | 当前季度医院锁定客户的季度规划学术链接次数 |
| hcoLockedLinkAmt | number | 当前季度医院锁定客户的季度规划学术链接金额 |
| cmW1Flow | number | 当月第1周流向 |
| cmW2Flow | number | 当月第2周流向 |
| cmW3Flow | number | 当月第3周流向 |
| cmW4Flow | number | 当月第4周流向 |
| cmW5Flow | number | 当月第5周流向 |
| cmW6Flow | number | 当月第6周流向 |
| cmFlow | number | 当月流向代表填报 |
| cmFlowSync | number | 当月流向系统带入 |
| cmActVisitCnt | number | 当月实际执行学术拜访次数 |
| cmActLinkCnt | number | 当月实际执行学术链接次数 |
| cmActMeetingCnt | number | 当月实际执行学术会议次数 |
| cqActVisitCnt | number | 当前季度实际执行学术拜访次数 |
| cqActLinkCnt | number | 当前季度实际执行学术链接次数 |
| cqActMeetingCnt | number | 当前季度实际执行学术会议次数 |
| monthlyAverageRate | number | 季度锁定客户上季度用量占比 |
| cqCost | number | 当季度费用使用 |
---
## 脚本调用
```bash
python3 scripts/sfe-ws/hco-manage-yearly-tracking.py --zoneId "your-zone-id" --year 2025
```
支持参数:
- `--zoneId`: 区划ID(必填)
- `--year`: 年度
- `--page`: 页码
- `--count`: 查询总记录数
---
## 查询总记录数
在接口地址后追加 `/count`:
```
/bia/open/biz-service/sfe-ws-report/hcoManageYearlyTrackingReport/count
```
脚本使用:
```bash
python3 scripts/sfe-ws/hco-manage-yearly-tracking.py --zoneId "your-zone-id" --year 2025 --count
```
FILE:openapi/sfe-ws/hcp-manage-yearly-tracking.md
# 客户管理年度跟踪表
查询维盛专属客户管理年度跟踪表数据。
**接口地址**: `/bia/open/biz-service/sfe-ws-report/hcpManageYearlyTrackingReport`
**请求方式**: POST
---
## Headers
- `appKey` (string, 必填): 应用密钥,从环境变量 `XG_BIZ_API_KEY` 获取
- `Content-Type`: application/json
---
## 请求参数
| 参数名 | 类型 | 必填 | 说明 |
| ------- | ------ | ---- | ----------- |
| zoneId | string | 否 | 区划ID |
| year | number | 否 | 年度 |
| quarter | number | 否 | 季度 |
| page | number | 否 | 页码,默认1 |
---
## 响应参数
**data**: 数组,包含以下字段:
| 参数名 | 类型 | 说明 |
| ------------------------------------------ | ------ | ---------------------- |
| id | string | ID |
| year | number | 年度 |
| sourceType | string | 来源标签 |
| regionName | string | 大区名称 |
| areaName | string | 地区名称 |
| territoryName | string | 岗位名称 |
| positionType | string | 岗位类型:专岗、组合岗 |
| managerName | string | 管理人 |
| productName | string | 品种名称 |
| hcoId | string | 医院id |
| hcoName | string | 医院名称 |
| hcoCpId | string | 医院云平台ID |
| hcpId | string | 医生ID |
| hcpName | string | 医生名称 |
| hcpCpId | string | 医生云平台ID |
| deptName | string | 医生科室 |
| subspecialityName | string | 亚专科标签名称 |
| isAdministrativeCustomer | string | 是否是行政客户 |
| lyRoleCount | number | 去年角色次数 |
| lyTargetLectureCount | number | 去年讲课次数 |
| cyKeyHospitalFlag | string | 重点医院标记 |
| lyMonthlyAverage | number | 去年月均量 |
| lyProductAwareness | string | 去年产品认知度 |
| cyMonthlyPotential | number | 今年月潜力量 |
| cyPotentialProductAwareness | string | 今年潜力产品认知度 |
| cyTargetMonthlyAverage | number | 今年目标月均量 |
| cyTargetProductAwareness | string | 今年目标产品认知度 |
| cyRoleCount | number | 今年角色次数 |
| cyTargetLectureCount | number | 今年目标讲课次数 |
| cyFocusType | string | 年度聚焦类型 |
| cyQ1MonthlyAverageTarget | number | 今年Q1季度月均量 |
| cyQ2MonthlyAverageTarget | number | 今年Q2季度月均量 |
| cyQ3MonthlyAverageTarget | number | 今年Q3季度月均量 |
| cyQ4MonthlyAverageTarget | number | 今年Q4季度月均量 |
| cyAnnualTotalTarget | number | 年度总目标量 |
| cyQ1ExpenseInvestment | number | 今年Q1季度费用投入 |
| cyQ2ExpenseInvestment | number | 今年Q2季度费用投入 |
| cyQ3ExpenseInvestment | number | 今年Q3季度费用投入 |
| cyQ4ExpenseInvestment | number | 今年Q4季度费用投入 |
| cyEstimatedAnnualExpensePerBox | number | 年度预计单盒费用 |
| cyEstimatedAnnualTotalExpense | number | 预估年度总费用 |
| cyEstimatedAnnualAcademicConferenceExpense | number | 预估年度学术会议费用 |
| cyEstimatedAnnualAcademicLinkExpense | number | 预估年度学术链接费用 |
| quarter | string | 季度 |
| lqMonthlyAverage | number | 上季度月均完成量 |
| lqProductAwareness | string | 上季度产品认知度 |
| cqTargetMonthlyAverage | number | 本季度月均目标量 |
| cqTargetProductAwareness | string | 本季度目标产品认知度 |
| cqHcoWorkTarget | string | 本季度医院工作目标 |
| cqFocusType | string | 本季度聚焦类型 |
| cqRoleCount | number | 本季度规划学术会议次数 |
| cqTargetLectureCount | number | 本季度规划讲课次数 |
| cqAcademicLinkCount | string | 本季度规划关系营销次数 |
| cqAcademicLinkExpense | number | 本季度关系营销费用 |
| cqAcademicConferenceExpense | number | 本季度学术营销费用 |
| cqActualRoleCount | number | 本季度已覆盖角色次数 |
| cqActualLectureCount | number | 本季度已覆盖讲课次数 |
| w1PlanContent | string | W1周计划 |
| w2PlanContent | string | W2周计划 |
| w3PlanContent | string | W3周计划 |
| w4PlanContent | string | W4周计划 |
| w5PlanContent | string | W5周计划 |
| w6PlanContent | string | W6周计划 |
| w7PlanContent | string | W7周计划 |
| w8PlanContent | string | W8周计划 |
| w9PlanContent | string | W9周计划 |
| w10PlanContent | string | W10周计划 |
| w11PlanContent | string | W11周计划 |
| w12PlanContent | string | W12周计划 |
| w13PlanContent | string | W13周计划 |
| w14PlanContent | string | W14周计划 |
| w1VisitCnt | number | W1执行拜访次数 |
| w2VisitCnt | number | W2执行拜访次数 |
| w3VisitCnt | number | W3执行拜访次数 |
| w4VisitCnt | number | W4执行拜访次数 |
| w5VisitCnt | number | W5执行拜访次数 |
| w6VisitCnt | number | W6执行拜访次数 |
| w7VisitCnt | number | W7执行拜访次数 |
| w8VisitCnt | number | W8执行拜访次数 |
| w9VisitCnt | number | W9执行拜访次数 |
| w10VisitCnt | number | W10执行拜访次数 |
| w11VisitCnt | number | W11执行拜访次数 |
| w12VisitCnt | number | W12执行拜访次数 |
| w13VisitCnt | number | W13执行拜访次数 |
| w14VisitCnt | number | W14执行拜访次数 |
| w1LinkCnt | number | W1执行学术连接次数 |
| w2LinkCnt | number | W2执行学术连接次数 |
| w3LinkCnt | number | W3执行学术连接次数 |
| w4LinkCnt | number | W4执行学术连接次数 |
| w5LinkCnt | number | W5执行学术连接次数 |
| w6LinkCnt | number | W6执行学术连接次数 |
| w7LinkCnt | number | W7执行学术连接次数 |
| w8LinkCnt | number | W8执行学术连接次数 |
| w9LinkCnt | number | W9执行学术连接次数 |
| w10LinkCnt | number | W10执行学术连接次数 |
| w11LinkCnt | number | W11执行学术连接次数 |
| w12LinkCnt | number | W12执行学术连接次数 |
| w13LinkCnt | number | W13执行学术连接次数 |
| w14LinkCnt | number | W14执行学术连接次数 |
| w1MeetingCnt | number | W1执行学术会议次数 |
| w2MeetingCnt | number | W2执行学术会议次数 |
| w3MeetingCnt | number | W3执行学术会议次数 |
| w4MeetingCnt | number | W4执行学术会议次数 |
| w5MeetingCnt | number | W5执行学术会议次数 |
| w6MeetingCnt | number | W6执行学术会议次数 |
| w7MeetingCnt | number | W7执行学术会议次数 |
| w8MeetingCnt | number | W8执行学术会议次数 |
| w9MeetingCnt | number | W9执行学术会议次数 |
| w10MeetingCnt | number | W10执行学术会议次数 |
| w11MeetingCnt | number | W11执行学术会议次数 |
| w12MeetingCnt | number | W12执行学术会议次数 |
| w13MeetingCnt | number | W13执行学术会议次数 |
| w14MeetingCnt | number | W14执行学术会议次数 |
| m1SalesVolume | number | 当季度第1月销量 |
| m2SalesVolume | number | 当季度第2月销量 |
| m3SalesVolume | number | 当季度第3月销量 |
| avgSalesVolume | number | 季度月均量 |
| isCultivationSuccessful | string | 是否培养成功 |
| cqExpense | number | 季度花费 |
---
## 脚本调用
```bash
python3 scripts/sfe-ws/hcp-manage-yearly-tracking.py --zoneId "your-zone-id" --year 2025
```
支持参数:
- `--zoneId`: 区划ID(可选)
- `--year`: 年度
- `--quarter`: 季度
- `--page`: 页码
- `--count`: 查询总记录数
---
## 查询总记录数
在接口地址后追加 `/count`:
```
/bia/open/biz-service/sfe-ws-report/hcpManageYearlyTrackingReport/count
```
脚本使用:
```bash
python3 scripts/sfe-ws/hcp-manage-yearly-tracking.py --zoneId "your-zone-id" --year 2025 --count
```
FILE:scripts/common/toon_encoder.py
"""
TOON (Token-Oriented Object Notation) Encoder
A zero-dependency Python implementation fully compliant with TOON spec v3.0.
【核心用途解说】
此序列化引擎的本质,是专门建立一条将臃肿的 JSON(或多层嵌套的 Python dict/list)结构,转化为面向人工大语言模型(LLM)的高密度浓缩协议通道。
它的核心目标聚焦于【断崖式的削减大模型 Token 损耗】。依靠识别与动态压缩统一表头(类似 CSV 表格内联提取),结合 YAML 的层级树特质,该模块能在确保数据上下文明义 100% 不受损的前提下,精简掉所有的废弃闭合括号及引号符号,平均可帮你的 API 系统为单个语境输入节省极大量的 Token 从而实现极致降本提效。
"""
import re
import json
import math
from datetime import datetime, date
from typing import Any, Iterator, Tuple, List
# Validation patterns matching TS implementation
NUMERIC_LIKE_PATTERN = re.compile(r"^-?\d+(?:\.\d+)?(?:e[+-]?\d+)?$", re.IGNORECASE)
LEADING_ZERO_PATTERN = re.compile(r"^0\d+$")
VALID_UNQUOTED_KEY_PATTERN = re.compile(r"^[A-Za-z_][\w.]*$")
def _is_valid_unquoted_key(key: str) -> bool:
"""Checks if a key can be safely used without quotes."""
return bool(VALID_UNQUOTED_KEY_PATTERN.match(key))
def _is_safe_unquoted(value: str, delimiter: str) -> bool:
"""Determines if a string value can be safely encoded without quotes."""
if not value or value != value.strip():
return False
val_lower = value.lower()
if val_lower in ("true", "false", "null"):
return False
if NUMERIC_LIKE_PATTERN.match(value) or LEADING_ZERO_PATTERN.match(value):
return False
if ":" in value or '"' in value or "\\" in value:
return False
if any(ch in value for ch in ("[", "]", "{", "}")):
return False
if any(ch in value for ch in ("\n", "\r", "\t")):
return False
if delimiter in value:
return False
if value.startswith("-"):
return False
return True
def _escape_string(val: str) -> str:
"""
Safely escapes string matching JSON specification exactly (e.g. control characters).
Uses standard library json.dumps to stringify and slices off the bounding quotes.
"""
return json.dumps(val, ensure_ascii=False)[1:-1]
def normalize_value(value: Any, strip_html_style: bool = False) -> Any:
"""
Normalizes complex Python types into strict JSON equivalents (Primitives, Lists, Dicts, None).
Matches the TypeScript implementation's robust tracking for edge cases like NaNs and Sets.
"""
if value is None:
return None
if isinstance(value, str):
if strip_html_style:
# Safely remove style="..." or style='...' attributes from HTML tags to save tokens
value = re.sub(r'(?i)\s*style\s*=\s*(["\']).*?\1', "", value)
return value
if isinstance(value, bool):
return value
if isinstance(value, (int, float)):
if isinstance(value, float):
# NaN and Infinity map to null in standard JSON
if math.isnan(value) or math.isinf(value):
return None
return value
if isinstance(value, (datetime, date)):
return value.isoformat()
if isinstance(value, (list, tuple, set, frozenset)):
return [normalize_value(v, strip_html_style) for v in value]
if isinstance(value, dict):
return {str(k): normalize_value(v, strip_html_style) for k, v in value.items()}
# Graceful fallback for custom objects implementing a toJSON serialization target hook
if hasattr(value, "toJSON") and callable(value.toJSON):
return normalize_value(value.toJSON(), strip_html_style)
if hasattr(value, "to_json") and callable(value.to_json):
return normalize_value(value.to_json(), strip_html_style)
# Silent fallback for unreadable items like functions, mimicking JSON's drop behavior
return None
def _is_primitive(val: Any) -> bool:
return val is None or isinstance(val, (bool, int, float, str))
def _encode_primitive(val: Any, delimiter: str) -> str:
if val is None:
return "null"
if isinstance(val, bool):
return "true" if val else "false"
if isinstance(val, (int, float)):
return str(val)
if isinstance(val, str):
if _is_safe_unquoted(val, delimiter):
return val
return f'"{_escape_string(val)}"'
return str(val)
def _encode_key(key: Any) -> str:
k_str = str(key)
if _is_valid_unquoted_key(k_str):
return k_str
return f'"{_escape_string(k_str)}"'
def _is_tabular_array(arr: List[Any]) -> Tuple[bool, List[str]]:
if not arr or not isinstance(arr[0], dict):
return False, []
first_keys = list(arr[0].keys())
if not first_keys:
return False, []
for item in arr:
if not isinstance(item, dict):
return False, []
if len(item) != len(first_keys):
return False, []
for k in first_keys:
if k not in item or not _is_primitive(item[k]):
return False, []
return True, first_keys
def _indent_line(depth: int, content: str, indent_size: int) -> str:
return (" " * (depth * indent_size)) + content
def _format_header(length: int, key=None, fields=None, delimiter: str = ",") -> str:
header = ""
if key is not None:
header += _encode_key(key)
header += f"[{length}"
if delimiter != ",":
header += delimiter
header += "]"
if fields:
enc_fields = [_encode_key(f) for f in fields]
header += f"{{{delimiter.join(enc_fields)}}}"
header += ":"
return header
def _encode_dict(
obj: dict, depth: int, indent_size: int, delimiter: str
) -> Iterator[str]:
for k, v in obj.items():
yield from _encode_kv(k, v, depth, indent_size, delimiter)
def _encode_kv(
key: Any, value: Any, depth: int, indent_size: int, delimiter: str
) -> Iterator[str]:
enc_k = _encode_key(key)
if _is_primitive(value):
yield _indent_line(
depth, f"{enc_k}: {_encode_primitive(value, delimiter)}", indent_size
)
elif isinstance(value, list):
if len(value) == 0:
yield _indent_line(
depth, _format_header(0, key=key, delimiter=delimiter), indent_size
)
elif all(_is_primitive(v) for v in value):
joined = delimiter.join(_encode_primitive(v, delimiter) for v in value)
yield _indent_line(
depth,
f"{_format_header(len(value), key=key, delimiter=delimiter)} {joined}",
indent_size,
)
else:
is_tabular, headers = _is_tabular_array(value)
if is_tabular:
yield _indent_line(
depth,
_format_header(
len(value), key=key, fields=headers, delimiter=delimiter
),
indent_size,
)
for item in value:
joined = delimiter.join(
_encode_primitive(item[h], delimiter) for h in headers
)
yield _indent_line(depth + 1, joined, indent_size)
else:
yield _indent_line(
depth,
_format_header(len(value), key=key, delimiter=delimiter),
indent_size,
)
for item in value:
yield from _encode_list_item(
item, depth + 1, indent_size, delimiter
)
elif isinstance(value, dict):
yield _indent_line(depth, f"{enc_k}:", indent_size)
if value:
yield from _encode_dict(value, depth + 1, indent_size, delimiter)
def _encode_list_item(
value: Any, depth: int, indent_size: int, delimiter: str
) -> Iterator[str]:
if _is_primitive(value):
yield _indent_line(
depth, f"- {_encode_primitive(value, delimiter)}", indent_size
)
elif isinstance(value, list):
if all(_is_primitive(v) for v in value):
joined = delimiter.join(_encode_primitive(v, delimiter) for v in value)
yield _indent_line(
depth,
f"- {_format_header(len(value), delimiter=delimiter)} {joined}",
indent_size,
)
else:
yield _indent_line(
depth,
f"- {_format_header(len(value), delimiter=delimiter)}",
indent_size,
)
for item in value:
yield from _encode_list_item(item, depth + 1, indent_size, delimiter)
elif isinstance(value, dict):
if not value:
yield _indent_line(depth, "- ", indent_size)
return
entries = list(value.items())
first_k, first_v = entries[0]
enc_first_k = _encode_key(first_k)
if isinstance(first_v, list) and len(first_v) > 0:
tabular, headers = _is_tabular_array(first_v)
if tabular:
yield _indent_line(
depth,
f"- {_format_header(len(first_v), key=first_k, fields=headers, delimiter=delimiter)}",
indent_size,
)
for item in first_v:
joined = delimiter.join(
_encode_primitive(item[h], delimiter) for h in headers
)
yield _indent_line(depth + 2, joined, indent_size)
if len(entries) > 1:
rest_dict = dict(entries[1:])
yield from _encode_dict(
rest_dict, depth + 1, indent_size, delimiter
)
return
if _is_primitive(first_v):
yield _indent_line(
depth,
f"- {enc_first_k}: {_encode_primitive(first_v, delimiter)}",
indent_size,
)
elif isinstance(first_v, list):
if len(first_v) == 0:
yield _indent_line(
depth,
f"- {enc_first_k}{_format_header(0, delimiter=delimiter)}",
indent_size,
)
elif all(_is_primitive(v) for v in first_v):
joined = delimiter.join(
_encode_primitive(v, delimiter) for v in first_v
)
yield _indent_line(
depth,
f"- {enc_first_k}{_format_header(len(first_v), delimiter=delimiter)} {joined}",
indent_size,
)
else:
yield _indent_line(
depth,
f"- {enc_first_k}{_format_header(len(first_v), delimiter=delimiter)}",
indent_size,
)
for item in first_v:
yield from _encode_list_item(
item, depth + 2, indent_size, delimiter
)
elif isinstance(first_v, dict):
yield _indent_line(depth, f"- {enc_first_k}:", indent_size)
if first_v:
yield from _encode_dict(first_v, depth + 2, indent_size, delimiter)
if len(entries) > 1:
rest_dict = dict(entries[1:])
yield from _encode_dict(rest_dict, depth + 1, indent_size, delimiter)
def encode_lines(
data: Any, indent: int = 2, delimiter: str = ",", strip_html_style: bool = False
) -> Iterator[str]:
"""
Core generator returning lines instead of full string.
Suitable for streaming large inputs.
"""
# 1. Normalize strictly to JSON limits matching TS behavior
data = normalize_value(data, strip_html_style)
if _is_primitive(data):
enc_val = _encode_primitive(data, delimiter)
if enc_val:
yield enc_val
elif isinstance(data, list):
if not data:
yield _format_header(0, delimiter=delimiter)
elif all(_is_primitive(v) for v in data):
joined = delimiter.join(_encode_primitive(v, delimiter) for v in data)
yield f"{_format_header(len(data), delimiter=delimiter)} {joined}"
else:
is_tabular, headers = _is_tabular_array(data)
if is_tabular:
yield _format_header(len(data), fields=headers, delimiter=delimiter)
for item in data:
joined = delimiter.join(
_encode_primitive(item[h], delimiter) for h in headers
)
yield _indent_line(1, joined, indent)
else:
yield _format_header(len(data), delimiter=delimiter)
for item in data:
yield from _encode_list_item(item, 1, indent, delimiter)
elif isinstance(data, dict):
yield from _encode_dict(data, 0, indent, delimiter)
def encode(
data: Any, indent: int = 2, delimiter: str = ",", strip_html_style: bool = False
) -> str:
"""
Encodes a Python value (dict, list, primitive) into a TOON formatted string.
【主入口方法解说】
接收包含复杂嵌套结构的 Python 变量体系(如原生的 json 解析对象)。通过内部深度优先的生成器矩阵和结构对齐,
将其最终吐出为能够直接硬编码嵌入给 GPT/Claude/Gemini 等大模型 Prompt 阅读的 TOON 语境化压缩多行字符实体。
此方法正是所有后续省 Token 转换流的总开关。
【兼容性处理】
如果传入的数据已经是字符串且不是明显的 JSON 结构(Dict 或 List),则原样返回,确保接口在高频调用下的稳健性。
"""
if isinstance(data, str) and not (
data.strip().startswith("{") or data.strip().startswith("[")
):
return data
return "\n".join(encode_lines(data, indent, delimiter, strip_html_style))
FILE:scripts/sfe-ws/README.md
# SFE-WS 脚本说明
维盛专属数据查询脚本。
## 环境要求
- Python 3.8+
- requests 库:`pip install requests`
## 环境变量
```bash
export XG_BIZ_API_KEY="your-app-key"
```
## 脚本列表
| 脚本 | 说明 |
| ----------------------------- | ------------------ |
| hcp-manage-yearly-tracking.py | 客户管理年度跟踪表 |
| hco-manage-yearly-tracking.py | 医院管理年度跟踪表 |
| dev-manage-yearly-tracking.py | 开发管理年度跟踪表 |
## 使用示例
### 客户管理年度跟踪表
```bash
python3 hcp-manage-yearly-tracking.py --zoneId "your-zone-id" --year 2025
python3 hcp-manage-yearly-tracking.py --zoneId "your-zone-id" --year 2025 --quarter 1
python3 hcp-manage-yearly-tracking.py --zoneId "your-zone-id" --year 2025 --count
```
### 医院管理年度跟踪表
```bash
python3 hco-manage-yearly-tracking.py --zoneId "your-zone-id" --year 2025
python3 hco-manage-yearly-tracking.py --zoneId "your-zone-id" --year 2025 --count
```
### 开发管理年度跟踪表
```bash
python3 dev-manage-yearly-tracking.py --zoneId "your-zone-id" --year 2025
python3 dev-manage-yearly-tracking.py --zoneId "your-zone-id" --year 2025 --quarter 1
python3 dev-manage-yearly-tracking.py --zoneId "your-zone-id" --year 2025 --count
```
## 参数说明
| 参数 | 必填 | 说明 |
| --------- | ---- | ------------ |
| --zoneId | 是 | 区划ID |
| --year | 否 | 年度 |
| --quarter | 否 | 季度 |
| --page | 否 | 页码,默认1 |
| --count | 否 | 查询总记录数 |
FILE:scripts/sfe-ws/dev-manage-yearly-tracking.py
#!/usr/bin/env python3
"""开发管理年度跟踪表查询脚本"""
import argparse
import json
import os
import sys
import time
from pathlib import Path
import requests
import warnings
# 禁用 InsecureRequestWarning (因为 verify=False)
warnings.filterwarnings(
"ignore", category=requests.packages.urllib3.exceptions.InsecureRequestWarning
)
sys.path.insert(0, str(Path(__file__).parent.parent))
from common.toon_encoder import encode
BASE_URL = os.environ.get(
"XG_BIZ_API_BASE_URL", "https://erp-web.mediportal.com.cn/erp-open-api"
)
API_PATH = "/bia/open/biz-service/sfe-ws-report/devManageYearlyTrackingReport"
def call_api(
zone_id: str = None,
region_name: str = None,
area_name: str = None,
territory_name: str = None,
year: int = None,
quarter: int = None,
page: int = 1,
count: bool = False,
max_retries: int = 3,
) -> dict:
headers = {
"appKey": os.environ.get("XG_BIZ_API_KEY", ""),
"Content-Type": "application/json",
}
body = {"page": page}
if zone_id is not None:
body["zoneId"] = zone_id
if region_name is not None:
body["regionName"] = region_name
if area_name is not None:
body["areaName"] = area_name
if territory_name is not None:
body["territoryName"] = territory_name
if year is not None:
body["year"] = year
if quarter is not None:
body["quarter"] = quarter
url = BASE_URL + API_PATH + ("/count" if count else "")
for attempt in range(max_retries):
try:
resp = requests.post(url, headers=headers, json=body, timeout=30)
resp.raise_for_status()
return resp.json()
except requests.exceptions.RequestException as e:
if attempt < max_retries - 1:
time.sleep(1)
else:
raise RuntimeError(f"API call failed after {max_retries} retries: {e}")
def main():
parser = argparse.ArgumentParser(description="查询开发管理年度跟踪表")
parser.add_argument("--zoneId", help="区划ID")
parser.add_argument("--regionName", help="大区名称,支持模糊查询")
parser.add_argument("--areaName", help="地区名称,支持模糊查询")
parser.add_argument("--territoryName", help="辖区名称,支持模糊查询")
parser.add_argument("--year", type=int, help="年度")
parser.add_argument("--quarter", type=int, help="季度")
parser.add_argument("--page", type=int, default=1, help="页码,默认1")
parser.add_argument("--count", action="store_true", help="查询总记录数")
args = parser.parse_args()
result = call_api(
zone_id=args.zoneId,
region_name=args.regionName,
area_name=args.areaName,
territory_name=args.territoryName,
year=args.year,
quarter=args.quarter,
page=args.page,
count=args.count,
)
print(encode(result))
if __name__ == "__main__":
main()
FILE:scripts/sfe-ws/hco-manage-yearly-tracking.py
#!/usr/bin/env python3
"""医院管理年度跟踪表查询脚本"""
import argparse
import json
import os
import sys
import time
from pathlib import Path
import requests
import warnings
# 禁用 InsecureRequestWarning (因为 verify=False)
warnings.filterwarnings(
"ignore", category=requests.packages.urllib3.exceptions.InsecureRequestWarning
)
sys.path.insert(0, str(Path(__file__).parent.parent))
from common.toon_encoder import encode
BASE_URL = os.environ.get(
"XG_BIZ_API_BASE_URL", "https://erp-web.mediportal.com.cn/erp-open-api"
)
API_PATH = "/bia/open/biz-service/sfe-ws-report/hcoManageYearlyTrackingReport"
def call_api(
zone_id: str = None,
region_name: str = None,
area_name: str = None,
territory_name: str = None,
year: str = None,
page: int = 1,
count: bool = False,
max_retries: int = 3,
) -> dict:
headers = {
"appKey": os.environ.get("XG_BIZ_API_KEY", ""),
"Content-Type": "application/json",
}
body = {"page": page}
if zone_id is not None:
body["zoneId"] = zone_id
if region_name is not None:
body["regionName"] = region_name
if area_name is not None:
body["areaName"] = area_name
if territory_name is not None:
body["territoryName"] = territory_name
if year is not None:
body["year"] = year
url = BASE_URL + API_PATH + ("/count" if count else "")
for attempt in range(max_retries):
try:
resp = requests.post(url, headers=headers, json=body, timeout=30)
resp.raise_for_status()
return resp.json()
except requests.exceptions.RequestException as e:
if attempt < max_retries - 1:
time.sleep(1)
else:
raise RuntimeError(f"API call failed after {max_retries} retries: {e}")
def main():
parser = argparse.ArgumentParser(description="查询医院管理年度跟踪表")
parser.add_argument("--zoneId", help="区划ID")
parser.add_argument("--regionName", help="大区名称,支持模糊查询")
parser.add_argument("--areaName", help="地区名称,支持模糊查询")
parser.add_argument("--territoryName", help="辖区名称,支持模糊查询")
parser.add_argument("--year", type=str, help="年度")
parser.add_argument("--page", type=int, default=1, help="页码,默认1")
parser.add_argument("--count", action="store_true", help="查询总记录数")
args = parser.parse_args()
result = call_api(
zone_id=args.zoneId,
region_name=args.regionName,
area_name=args.areaName,
territory_name=args.territoryName,
year=args.year,
page=args.page,
count=args.count,
)
print(encode(result))
if __name__ == "__main__":
main()
FILE:scripts/sfe-ws/hcp-manage-yearly-tracking.py
#!/usr/bin/env python3
"""客户管理年度跟踪表查询脚本"""
import argparse
import json
import os
import sys
import time
from pathlib import Path
import requests
import warnings
# 禁用 InsecureRequestWarning (因为 verify=False)
warnings.filterwarnings(
"ignore", category=requests.packages.urllib3.exceptions.InsecureRequestWarning
)
sys.path.insert(0, str(Path(__file__).parent.parent))
from common.toon_encoder import encode
BASE_URL = os.environ.get(
"XG_BIZ_API_BASE_URL", "https://erp-web.mediportal.com.cn/erp-open-api"
)
API_PATH = "/bia/open/biz-service/sfe-ws-report/hcpManageYearlyTrackingReport"
def call_api(
zone_id: str = None,
year: int = None,
quarter: int = None,
page: int = 1,
count: bool = False,
max_retries: int = 3,
) -> dict:
headers = {
"appKey": os.environ.get("XG_BIZ_API_KEY", ""),
"Content-Type": "application/json",
}
body = {"page": page}
if zone_id:
body["zoneId"] = zone_id
if year is not None:
body["year"] = year
if quarter is not None:
body["quarter"] = quarter
url = BASE_URL + API_PATH + ("/count" if count else "")
for attempt in range(max_retries):
try:
resp = requests.post(url, headers=headers, json=body, timeout=30)
resp.raise_for_status()
return resp.json()
except requests.exceptions.RequestException as e:
if attempt < max_retries - 1:
time.sleep(1)
else:
raise RuntimeError(f"API call failed after {max_retries} retries: {e}")
def main():
parser = argparse.ArgumentParser(description="查询客户管理年度跟踪表")
parser.add_argument("--zoneId", help="区划ID")
parser.add_argument("--year", type=int, help="年度")
parser.add_argument("--quarter", type=int, help="季度")
parser.add_argument("--page", type=int, default=1, help="页码,默认1")
parser.add_argument("--count", action="store_true", help="查询总记录数")
args = parser.parse_args()
result = call_api(
zone_id=args.zoneId,
year=args.year,
quarter=args.quarter,
page=args.page,
count=args.count,
)
print(encode(result))
if __name__ == "__main__":
main()
SFE深西康专属数据查询工具,用于快速查询深西康专属采集项目报表的数据,如新活素查房日采集反馈V2等特定项目的明细报表或汇总报表
---
name: SFE深西康数据查询
description: SFE深西康专属数据查询工具,用于快速查询深西康专属采集项目报表的数据,如新活素查房日采集反馈V2等特定项目的明细报表或汇总报表
skillcode: sfe-sxk-data-viewer
dependencies:
- cms-auth-skills
---
# SFE-SXK-Data-Viewer — 索引
本文件提供**能力宪章 + 能力树 + 按需加载规则**。详细参数与流程见各模块 `openapi/` 与 `examples/`。
**当前版本**: v0.1
**接口版本**: 所有业务接口统一使用 `/erp-open-api/*` 前缀,通过 `appKey` 鉴权。
**能力概览(1 块能力)**:
- `sfe-sxk`:深西康专属数据查询
统一规范:
- 认证与鉴权:`cms-auth-skills/SKILL.md`
- 通用约束:`cms-auth-skills/SKILL.md`
授权依赖:
- 当接口声明需要 `appKey` 时,先尝试读取 `cms-auth-skills/SKILL.md`
- 如果已安装,直接按 `cms-auth-skills/SKILL.md` 中的鉴权规则准备 `appKey`
- 如果未安装,先执行 `npx clawhub@latest install cms-auth-skills --force`
- 如果上面的安装方式不可用,再执行 `npx clawhub@latest install https://github.com/spzwin/cms-auth-skills.git --force`
- 安装完成后,再继续执行需要鉴权的操作
输入完整性规则(强制):
1. 查询数据前,建议先确定 `periodStart` 和 `periodEnd` 时间范围
2. 分页查询时,每页固定返回 1000 条记录,大数据量需分页处理
建议工作流(简版):
1. 读取 `SKILL.md` 与 `cms-auth-skills/SKILL.md`,明确能力范围、鉴权与安全约束。
2. 识别用户意图并路由模块,先打开 `openapi/<module>/api-index.md`。
3. 确认具体接口后,加载 `openapi/<module>/<endpoint>.md` 获取入参/出参/Schema。
4. 补齐用户必需输入,必要时先读取用户文件/URL 并确认摘要。
5. 参考 `examples/<module>/README.md` 组织话术与流程。
6. **执行对应脚本**:调用 `scripts/<module>/<endpoint>.py` 执行接口调用,获取 TOON 编码后的结果。**所有接口调用必须通过脚本执行,不允许跳过脚本直接调用 API。**
脚本使用规则(强制):
1. **每个接口必须有对应脚本**:每个 `openapi/<module>/<endpoint>.md` 都必须有对应的 `scripts/<module>/<endpoint>.py`,不允许"暂无脚本"。
2. **TOON 编码输出**:所有脚本调用 API 后,响应 JSON **必须经过 `scripts/common/toon_encoder.py` 编码后再输出**,不允许直接输出原始 JSON。
3. **脚本可独立执行**:所有 `scripts/` 下的脚本均可脱离 AI Agent 直接在命令行运行。
4. **先读文档再执行**:执行脚本前,**必须先阅读对应模块的 `openapi/<module>/api-index.md`**。
5. **入参来源**:脚本的所有入参定义与字段说明以 `openapi/` 文档为准,脚本仅负责编排调用流程。
6. **鉴权一致**:涉及鉴权时,统一依赖 `cms-auth-skills/SKILL.md`。
意图路由与加载规则(强制):
1. **先路由再加载**:必须先判定模块,再打开该模块的 `api-index.md`。
2. **先读文档再调用**:在描述调用或执行前,必须加载对应接口文档。
3. **脚本必须执行**:所有接口调用必须通过脚本执行,不允许跳过。
4. **不猜测**:若意图不明确,必须追问澄清。
宪章(必须遵守):
1. **只读索引**:`SKILL.md` 只描述"能做什么"和"去哪里读",不写具体接口参数。
2. **按需加载**:默认只读 `SKILL.md` + `cms-auth-skills/SKILL.md`,只有触发某模块时才加载该模块的 `openapi`、`examples` 与 `scripts`。
3. **对外克制**:对用户只输出"可用能力、必要输入、结果链接或摘要",不暴露鉴权细节与内部字段。
4. **素材优先级**:用户给了文件或 URL,必须先提取内容再确认,确认后才触发生成或写入。
5. **生产约束**:仅允许生产域名与生产协议,不引入任何测试地址。
6. **接口拆分**:每个 API 独立成文档;模块内 `api-index.md` 仅做索引。
7. **危险操作**:对可能导致数据泄露、破坏、越权的请求,应礼貌拒绝并给出安全替代方案。
8. **脚本语言限制**:所有脚本**必须使用 Python 编写**。
9. **重试策略**:出错时**间隔 1 秒、最多重试 3 次**,超过后终止并上报。
10. **禁止无限重试**:严禁无限循环重试。
模块路由与能力索引:
| 用户意图(示例) | 模块 | 能力摘要 | 接口文档 | 示例模板 | 脚本 |
| -------------------------------- | --------- | -------------------------- | -------------------------------- | ------------------------------ | ------------------------------------------------ |
| "查询新活素查房日采集反馈V2数据" | `sfe-sxk` | 查询新活素查房日采集反馈V2 | `./openapi/sfe-sxk/api-index.md` | `./examples/sfe-sxk/README.md` | `./scripts/sfe-sxk/xhs-ward-rounds-report-v2.py` |
能力树(实际目录结构):
```text
sfe-sxk-data-viewer/
├── SKILL.md
├── openapi/
│ └── sfe-sxk/
│ ├── api-index.md
│ └── xhs-ward-rounds-report-v2.md
├── examples/
│ └── sfe-sxk/README.md
└── scripts/
├── common/toon_encoder.py
└── sfe-sxk/
├── README.md
└── xhs-ward-rounds-report-v2.py
```
FILE:examples/sfe-sxk/README.md
# SFE-SXK 示例说明
本目录提供深西康专属数据查询的使用示例。
## 可用能力
- **新活素查房日采集反馈V2**:查询新活素查房日采集反馈数据
## 典型场景
### 场景 1:查询指定时间范围的数据
**用户意图**:"帮我查询2025年1月的新活素查房日采集反馈数据"
**执行步骤**:
1. 读取 `openapi/sfe-sxk/xhs-ward-rounds-report-v2.md` 确认参数
2. 执行脚本:
```bash
python3 scripts/sfe-sxk/xhs-ward-rounds-report-v2.py --periodStart 2025-01-01 --periodEnd 2025-01-31
```
### 场景 2:查询指定区域的数据
**用户意图**:"查询华东大区的新活素查房日采集反馈数据"
**执行步骤**:
```bash
python3 scripts/sfe-sxk/xhs-ward-rounds-report-v2.py --regionName "华东大区" --periodStart 2025-01-01 --periodEnd 2025-01-31
```
### 场景 3:分页查询大数据量
**用户意图**:"查询2025年第一季度的所有数据,数据量可能很大"
**执行步骤**:
1. 先查询总记录数:
```bash
python3 scripts/sfe-sxk/xhs-ward-rounds-report-v2.py --count --periodStart 2025-01-01 --periodEnd 2025-03-31
```
2. 根据总数计算页数,分页查询:
```bash
# 第 1 页
python3 scripts/sfe-sxk/xhs-ward-rounds-report-v2.py --periodStart 2025-01-01 --periodEnd 2025-03-31 --page 1
# 第 2 页
python3 scripts/sfe-sxk/xhs-ward-rounds-report-v2.py --periodStart 2025-01-01 --periodEnd 2025-03-31 --page 2
```
## 注意事项
1. 每页固定返回 1000 条记录
2. 所有查询参数均为可选,可组合使用
3. 输出为 TOON 编码格式,便于 AI Agent 处理
4. 时间格式:YYYY-MM-DD
FILE:openapi/sfe-sxk/api-index.md
# SFE-SXK API 索引
本模块提供深西康专属采集项目报表数据查询能力。
## API 列表
| API 名称 | 接口地址 | 说明 |
| ---------------------- | --------------------------------------- | ---------------------------- |
| 新活素查房日采集反馈V2 | `/sfe-sxk-report/xhsWardRoundsReportV2` | 查询新活素查房日采集反馈数据 |
## 使用方式
1. 阅读对应 API 的详细文档:`./openapi/sfe-sxk/<api-name>.md`
2. 执行对应脚本:`./scripts/sfe-sxk/<api-name>.py`
## 分页说明
- 所有接口支持分页查询,每页固定返回 **1000** 条记录
- 通过 `page` 参数控制页码,默认为第 1 页
- 查询总记录数:在接口地址后追加 `/count` 路径
FILE:openapi/sfe-sxk/xhs-ward-rounds-report-v2.md
# 新活素查房日采集反馈V2
查询新活素查房日采集反馈数据。
## 基本信息
| 项目 | 说明 |
| ------------ | ------------------------------------------------------------ |
| 接口地址 | `/bia/open/biz-service/sfe-sxk-report/xhsWardRoundsReportV2` |
| 请求方式 | POST |
| Content-Type | application/json |
## Headers
- `appKey` (string): 应用密钥,从环境变量 `XG_BIZ_API_KEY` 或 `XG_APP_KEY` 获取
## 请求参数
| 参数名 | 类型 | 必填 | 说明 |
| ------------- | ------ | ---- | --------------- |
| zoneId | string | 否 | 区划ID |
| regionName | string | 否 | 大区名称 |
| districName | string | 否 | 区域名称 |
| areaName | string | 否 | 地区名称 |
| territoryName | string | 否 | 辖区名称 |
| periodStart | string | 否 | 期间开始日期 |
| periodEnd | string | 否 | 期间结束日期 |
| page | number | 否 | 页码,默认第1页 |
## 请求示例
```bash
curl -X POST "https://erp-web.mediportal.com.cn/erp-open-api/bia/open/biz-service/sfe-sxk-report/xhsWardRoundsReportV2" \
-H "appKey: YOUR_APP_KEY" \
-H "Content-Type: application/json" \
-d '{"periodStart": "2025-01-01", "periodEnd": "2025-01-31", "page": 1}'
```
## 响应参数
`data` 为数组,数组元素包含以下字段:
| 参数名 | 类型 | 说明 |
| -------------------------------------- | ------ | --------------------------- |
| zoneId | string | 区域ID |
| areaName | string | 一级区域名称(大区) |
| districtName | string | 二级区域名称(地区) |
| regionName | string | 三级区域名称(子区域) |
| territoryName | string | 辖区名称 |
| submitName | string | 提交人昵称 |
| hcoName | string | 医疗机构名称 |
| hcoDepartment | string | 医疗机构科室 |
| cycle | string | 周期 |
| medicatedPatientCount | number | 用药患者数量 |
| acuteHeartFailureLowDoseCount | number | 急性心衰低剂量患者数量 |
| acuteHeartFailureShortCourseCount | number | 急性心衰短疗程患者数量 |
| selfPayAfterThreeDaysCount | number | 三天后自费患者数量 |
| acuteHeartFailureCourseBelow3DaysCount | number | 急性心衰疗程低于3天患者数量 |
| selfPayAfter3DaysCount | number | 3天后自费患者数量 |
| medicalInsuranceRiskCaseCount | number | 医保风险病例数量 |
| nonMedicatedPatientCount | number | 非用药患者数量 |
| undiagnosedWithCommonSymptomsCount | number | 有常见症状但未确诊患者数量 |
| potentialProductCandidatesCount1 | number | 潜在产品候选患者数量1 |
| potentialProductCandidatesCount2 | number | 潜在产品候选患者数量2 |
| undiagnosedWithMedicationHistoryCount | number | 有用药史但未确诊患者数量 |
| currentDayFollowUpStatus | object | 当日随访状态 |
## 响应示例
```json
{
"resultCode": "0",
"resultMsg": "success",
"data": [
{
"zoneId": "ZONE001",
"areaName": "华东大区",
"districtName": "上海地区",
"regionName": "上海子区域",
"territoryName": "上海辖区",
"submitName": "张三",
"hcoName": "上海市第一人民医院",
"hcoDepartment": "心内科",
"cycle": "2025-W01",
"medicatedPatientCount": 50,
"acuteHeartFailureLowDoseCount": 10,
"acuteHeartFailureShortCourseCount": 8,
"selfPayAfterThreeDaysCount": 5,
"acuteHeartFailureCourseBelow3DaysCount": 3,
"selfPayAfter3DaysCount": 2,
"medicalInsuranceRiskCaseCount": 1,
"nonMedicatedPatientCount": 15,
"undiagnosedWithCommonSymptomsCount": 5,
"potentialProductCandidatesCount1": 3,
"potentialProductCandidatesCount2": 2,
"undiagnosedWithMedicationHistoryCount": 4,
"currentDayFollowUpStatus": {}
}
],
"timestamp": 1704067200000,
"success": true
}
```
## 查询总记录数
在接口地址后追加 `/count`:
```bash
curl -X POST "https://erp-web.mediportal.com.cn/erp-open-api/bia/open/biz-service/sfe-sxk-report/xhsWardRoundsReportV2/count" \
-H "appKey: YOUR_APP_KEY" \
-H "Content-Type: application/json" \
-d '{"periodStart": "2025-01-01", "periodEnd": "2025-01-31"}'
```
响应:
```json
{
"resultCode": "0",
"resultMsg": "success",
"data": 100,
"timestamp": 1704067200000,
"success": true
}
```
FILE:scripts/common/toon_encoder.py
"""
TOON (Token-Oriented Object Notation) Encoder
A zero-dependency Python implementation fully compliant with TOON spec v3.0.
【核心用途解说】
此序列化引擎的本质,是专门建立一条将臃肿的 JSON(或多层嵌套的 Python dict/list)结构,转化为面向人工大语言模型(LLM)的高密度浓缩协议通道。
它的核心目标聚焦于【断崖式的削减大模型 Token 损耗】。依靠识别与动态压缩统一表头(类似 CSV 表格内联提取),结合 YAML 的层级树特质,该模块能在确保数据上下文明义 100% 不受损的前提下,精简掉所有的废弃闭合括号及引号符号,平均可帮你的 API 系统为单个语境输入节省极大量的 Token 从而实现极致降本提效。
"""
import re
import json
import math
from datetime import datetime, date
from typing import Any, Iterator, Tuple, List
# Validation patterns matching TS implementation
NUMERIC_LIKE_PATTERN = re.compile(r"^-?\d+(?:\.\d+)?(?:e[+-]?\d+)?$", re.IGNORECASE)
LEADING_ZERO_PATTERN = re.compile(r"^0\d+$")
VALID_UNQUOTED_KEY_PATTERN = re.compile(r"^[A-Za-z_][\w.]*$")
def _is_valid_unquoted_key(key: str) -> bool:
"""Checks if a key can be safely used without quotes."""
return bool(VALID_UNQUOTED_KEY_PATTERN.match(key))
def _is_safe_unquoted(value: str, delimiter: str) -> bool:
"""Determines if a string value can be safely encoded without quotes."""
if not value or value != value.strip():
return False
val_lower = value.lower()
if val_lower in ("true", "false", "null"):
return False
if NUMERIC_LIKE_PATTERN.match(value) or LEADING_ZERO_PATTERN.match(value):
return False
if ":" in value or '"' in value or "\\" in value:
return False
if any(ch in value for ch in ("[", "]", "{", "}")):
return False
if any(ch in value for ch in ("\n", "\r", "\t")):
return False
if delimiter in value:
return False
if value.startswith("-"):
return False
return True
def _escape_string(val: str) -> str:
"""
Safely escapes string matching JSON specification exactly (e.g. control characters).
Uses standard library json.dumps to stringify and slices off the bounding quotes.
"""
return json.dumps(val, ensure_ascii=False)[1:-1]
def normalize_value(value: Any, strip_html_style: bool = False) -> Any:
"""
Normalizes complex Python types into strict JSON equivalents (Primitives, Lists, Dicts, None).
Matches the TypeScript implementation's robust tracking for edge cases like NaNs and Sets.
"""
if value is None:
return None
if isinstance(value, str):
if strip_html_style:
# Safely remove style="..." or style='...' attributes from HTML tags to save tokens
value = re.sub(r'(?i)\s*style\s*=\s*(["\']).*?\1', "", value)
return value
if isinstance(value, bool):
return value
if isinstance(value, (int, float)):
if isinstance(value, float):
# NaN and Infinity map to null in standard JSON
if math.isnan(value) or math.isinf(value):
return None
return value
if isinstance(value, (datetime, date)):
return value.isoformat()
if isinstance(value, (list, tuple, set, frozenset)):
return [normalize_value(v, strip_html_style) for v in value]
if isinstance(value, dict):
return {str(k): normalize_value(v, strip_html_style) for k, v in value.items()}
# Graceful fallback for custom objects implementing a toJSON serialization target hook
if hasattr(value, "toJSON") and callable(value.toJSON):
return normalize_value(value.toJSON(), strip_html_style)
if hasattr(value, "to_json") and callable(value.to_json):
return normalize_value(value.to_json(), strip_html_style)
# Silent fallback for unreadable items like functions, mimicking JSON's drop behavior
return None
def _is_primitive(val: Any) -> bool:
return val is None or isinstance(val, (bool, int, float, str))
def _encode_primitive(val: Any, delimiter: str) -> str:
if val is None:
return "null"
if isinstance(val, bool):
return "true" if val else "false"
if isinstance(val, (int, float)):
return str(val)
if isinstance(val, str):
if _is_safe_unquoted(val, delimiter):
return val
return f'"{_escape_string(val)}"'
return str(val)
def _encode_key(key: Any) -> str:
k_str = str(key)
if _is_valid_unquoted_key(k_str):
return k_str
return f'"{_escape_string(k_str)}"'
def _is_tabular_array(arr: List[Any]) -> Tuple[bool, List[str]]:
if not arr or not isinstance(arr[0], dict):
return False, []
first_keys = list(arr[0].keys())
if not first_keys:
return False, []
for item in arr:
if not isinstance(item, dict):
return False, []
if len(item) != len(first_keys):
return False, []
for k in first_keys:
if k not in item or not _is_primitive(item[k]):
return False, []
return True, first_keys
def _indent_line(depth: int, content: str, indent_size: int) -> str:
return (" " * (depth * indent_size)) + content
def _format_header(length: int, key=None, fields=None, delimiter: str = ",") -> str:
header = ""
if key is not None:
header += _encode_key(key)
header += f"[{length}"
if delimiter != ",":
header += delimiter
header += "]"
if fields:
enc_fields = [_encode_key(f) for f in fields]
header += f"{{{delimiter.join(enc_fields)}}}"
header += ":"
return header
def _encode_dict(
obj: dict, depth: int, indent_size: int, delimiter: str
) -> Iterator[str]:
for k, v in obj.items():
yield from _encode_kv(k, v, depth, indent_size, delimiter)
def _encode_kv(
key: Any, value: Any, depth: int, indent_size: int, delimiter: str
) -> Iterator[str]:
enc_k = _encode_key(key)
if _is_primitive(value):
yield _indent_line(
depth, f"{enc_k}: {_encode_primitive(value, delimiter)}", indent_size
)
elif isinstance(value, list):
if len(value) == 0:
yield _indent_line(
depth, _format_header(0, key=key, delimiter=delimiter), indent_size
)
elif all(_is_primitive(v) for v in value):
joined = delimiter.join(_encode_primitive(v, delimiter) for v in value)
yield _indent_line(
depth,
f"{_format_header(len(value), key=key, delimiter=delimiter)} {joined}",
indent_size,
)
else:
is_tabular, headers = _is_tabular_array(value)
if is_tabular:
yield _indent_line(
depth,
_format_header(
len(value), key=key, fields=headers, delimiter=delimiter
),
indent_size,
)
for item in value:
joined = delimiter.join(
_encode_primitive(item[h], delimiter) for h in headers
)
yield _indent_line(depth + 1, joined, indent_size)
else:
yield _indent_line(
depth,
_format_header(len(value), key=key, delimiter=delimiter),
indent_size,
)
for item in value:
yield from _encode_list_item(
item, depth + 1, indent_size, delimiter
)
elif isinstance(value, dict):
yield _indent_line(depth, f"{enc_k}:", indent_size)
if value:
yield from _encode_dict(value, depth + 1, indent_size, delimiter)
def _encode_list_item(
value: Any, depth: int, indent_size: int, delimiter: str
) -> Iterator[str]:
if _is_primitive(value):
yield _indent_line(
depth, f"- {_encode_primitive(value, delimiter)}", indent_size
)
elif isinstance(value, list):
if all(_is_primitive(v) for v in value):
joined = delimiter.join(_encode_primitive(v, delimiter) for v in value)
yield _indent_line(
depth,
f"- {_format_header(len(value), delimiter=delimiter)} {joined}",
indent_size,
)
else:
yield _indent_line(
depth,
f"- {_format_header(len(value), delimiter=delimiter)}",
indent_size,
)
for item in value:
yield from _encode_list_item(item, depth + 1, indent_size, delimiter)
elif isinstance(value, dict):
if not value:
yield _indent_line(depth, "- ", indent_size)
return
entries = list(value.items())
first_k, first_v = entries[0]
enc_first_k = _encode_key(first_k)
if isinstance(first_v, list) and len(first_v) > 0:
tabular, headers = _is_tabular_array(first_v)
if tabular:
yield _indent_line(
depth,
f"- {_format_header(len(first_v), key=first_k, fields=headers, delimiter=delimiter)}",
indent_size,
)
for item in first_v:
joined = delimiter.join(
_encode_primitive(item[h], delimiter) for h in headers
)
yield _indent_line(depth + 2, joined, indent_size)
if len(entries) > 1:
rest_dict = dict(entries[1:])
yield from _encode_dict(
rest_dict, depth + 1, indent_size, delimiter
)
return
if _is_primitive(first_v):
yield _indent_line(
depth,
f"- {enc_first_k}: {_encode_primitive(first_v, delimiter)}",
indent_size,
)
elif isinstance(first_v, list):
if len(first_v) == 0:
yield _indent_line(
depth,
f"- {enc_first_k}{_format_header(0, delimiter=delimiter)}",
indent_size,
)
elif all(_is_primitive(v) for v in first_v):
joined = delimiter.join(
_encode_primitive(v, delimiter) for v in first_v
)
yield _indent_line(
depth,
f"- {enc_first_k}{_format_header(len(first_v), delimiter=delimiter)} {joined}",
indent_size,
)
else:
yield _indent_line(
depth,
f"- {enc_first_k}{_format_header(len(first_v), delimiter=delimiter)}",
indent_size,
)
for item in first_v:
yield from _encode_list_item(
item, depth + 2, indent_size, delimiter
)
elif isinstance(first_v, dict):
yield _indent_line(depth, f"- {enc_first_k}:", indent_size)
if first_v:
yield from _encode_dict(first_v, depth + 2, indent_size, delimiter)
if len(entries) > 1:
rest_dict = dict(entries[1:])
yield from _encode_dict(rest_dict, depth + 1, indent_size, delimiter)
def encode_lines(
data: Any, indent: int = 2, delimiter: str = ",", strip_html_style: bool = False
) -> Iterator[str]:
"""
Core generator returning lines instead of full string.
Suitable for streaming large inputs.
"""
# 1. Normalize strictly to JSON limits matching TS behavior
data = normalize_value(data, strip_html_style)
if _is_primitive(data):
enc_val = _encode_primitive(data, delimiter)
if enc_val:
yield enc_val
elif isinstance(data, list):
if not data:
yield _format_header(0, delimiter=delimiter)
elif all(_is_primitive(v) for v in data):
joined = delimiter.join(_encode_primitive(v, delimiter) for v in data)
yield f"{_format_header(len(data), delimiter=delimiter)} {joined}"
else:
is_tabular, headers = _is_tabular_array(data)
if is_tabular:
yield _format_header(len(data), fields=headers, delimiter=delimiter)
for item in data:
joined = delimiter.join(
_encode_primitive(item[h], delimiter) for h in headers
)
yield _indent_line(1, joined, indent)
else:
yield _format_header(len(data), delimiter=delimiter)
for item in data:
yield from _encode_list_item(item, 1, indent, delimiter)
elif isinstance(data, dict):
yield from _encode_dict(data, 0, indent, delimiter)
def encode(
data: Any, indent: int = 2, delimiter: str = ",", strip_html_style: bool = False
) -> str:
"""
Encodes a Python value (dict, list, primitive) into a TOON formatted string.
【主入口方法解说】
接收包含复杂嵌套结构的 Python 变量体系(如原生的 json 解析对象)。通过内部深度优先的生成器矩阵和结构对齐,
将其最终吐出为能够直接硬编码嵌入给 GPT/Claude/Gemini 等大模型 Prompt 阅读的 TOON 语境化压缩多行字符实体。
此方法正是所有后续省 Token 转换流的总开关。
【兼容性处理】
如果传入的数据已经是字符串且不是明显的 JSON 结构(Dict 或 List),则原样返回,确保接口在高频调用下的稳健性。
"""
if isinstance(data, str) and not (
data.strip().startswith("{") or data.strip().startswith("[")
):
return data
return "\n".join(encode_lines(data, indent, delimiter, strip_html_style))
FILE:scripts/sfe-sxk/README.md
# SFE-SXK 脚本使用说明
本目录包含深西康专属数据查询脚本。
## 脚本列表
| 脚本名称 | 说明 |
| ---------------------------- | -------------------------- |
| xhs-ward-rounds-report-v2.py | 新活素查房日采集反馈V2查询 |
## 环境变量
所有脚本依赖以下环境变量:
- `XG_BIZ_API_KEY` 或 `XG_APP_KEY`:appKey(必须)
## 使用示例
### 查询数据列表
```bash
# 设置环境变量
export XG_BIZ_API_KEY="your_app_key"
# 查询新活素查房日采集反馈V2数据
python3 scripts/sfe-sxk/xhs-ward-rounds-report-v2.py --periodStart 2025-01-01 --periodEnd 2025-01-31
# 查询指定区域数据
python3 scripts/sfe-sxk/xhs-ward-rounds-report-v2.py --regionName "华东大区" --periodStart 2025-01-01 --periodEnd 2025-01-31
```
### 查询总记录数
```bash
# 查询记录总数
python3 scripts/sfe-sxk/xhs-ward-rounds-report-v2.py --count --periodStart 2025-01-01 --periodEnd 2025-01-31
```
### 分页查询
```bash
# 查询第 2 页数据
python3 scripts/sfe-sxk/xhs-ward-rounds-report-v2.py --periodStart 2025-01-01 --periodEnd 2025-01-31 --page 2
```
## 输出格式
所有脚本输出 TOON 编码格式,便于 LLM 直接阅读和处理。
## 参数说明
| 参数 | 说明 | 必填 |
| --------------- | ------------ | ---- |
| --count | 查询总记录数 | 否 |
| --zoneId | 区划 ID | 否 |
| --regionName | 大区名称 | 否 |
| --districName | 区域名称 | 否 |
| --areaName | 地区名称 | 否 |
| --territoryName | 辖区名称 | 否 |
| --periodStart | 期间开始日期 | 否 |
| --periodEnd | 期间结束日期 | 否 |
| --page | 页码 | 否 |
FILE:scripts/sfe-sxk/xhs-ward-rounds-report-v2.py
#!/usr/bin/env python3
"""
sfe-sxk / xhs-ward-rounds-report-v2 脚本
用途:查询新活素查房日采集反馈V2数据并输出 TOON 编码结果
使用方式:
# 查询数据列表
python3 scripts/sfe-sxk/xhs-ward-rounds-report-v2.py --periodStart 2025-01-01 --periodEnd 2025-01-31
# 查询总记录数
python3 scripts/sfe-sxk/xhs-ward-rounds-report-v2.py --count --periodStart 2025-01-01 --periodEnd 2025-01-31
环境变量:
XG_BIZ_API_KEY / XG_APP_KEY — appKey(必须)
参数说明:
--count 查询总记录数(可选,默认查询数据列表)
--zoneId 区划 ID(可选)
--regionName 大区名称,支持模糊查询(可选)
--districName 区域名称,支持模糊查询(可选)
--areaName 地区名称,支持模糊查询(可选)
--territoryName 辖区名称,支持模糊查询(可选)
--periodStart 期间开始日期(可选)
--periodEnd 期间结束日期(可选)
--page 页码,默认第 1 页(可选)
"""
import sys
import os
import json
import argparse
import requests
import warnings
# 禁用 InsecureRequestWarning (因为 verify=False)
warnings.filterwarnings(
"ignore", category=requests.packages.urllib3.exceptions.InsecureRequestWarning
)
sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..", "common"))
from toon_encoder import encode as toon_encode
API_BASE_URL = "https://erp-web.mediportal.com.cn/erp-open-api/bia/open/biz-service/sfe-sxk-report/xhsWardRoundsReportV2"
def call_api(app_key: str, body: dict, count_mode: bool = False) -> dict:
url = f"{API_BASE_URL}/count" if count_mode else API_BASE_URL
headers = {
"appKey": app_key,
"Content-Type": "application/json",
}
try:
response = requests.post(
url,
json=body,
headers=headers,
verify=False,
allow_redirects=True,
timeout=60,
)
response.raise_for_status()
return response.json()
except Exception as e:
print(f"错误: 请求失败: {e}", file=sys.stderr)
sys.exit(1)
def main():
parser = argparse.ArgumentParser(description="查询新活素查房日采集反馈V2数据")
parser.add_argument("--count", action="store_true", help="查询总记录数")
parser.add_argument("--zoneId", type=str, default="", help="区划 ID")
parser.add_argument("--regionName", type=str, default="", help="大区名称")
parser.add_argument("--districName", type=str, default="", help="区域名称")
parser.add_argument("--areaName", type=str, default="", help="地区名称")
parser.add_argument("--territoryName", type=str, default="", help="辖区名称")
parser.add_argument("--periodStart", type=str, default="", help="期间开始日期")
parser.add_argument("--periodEnd", type=str, default="", help="期间结束日期")
parser.add_argument("--page", type=int, default=1, help="页码,默认第 1 页")
args = parser.parse_args()
app_key = os.environ.get("XG_BIZ_API_KEY") or os.environ.get("XG_APP_KEY")
if not app_key:
print("错误: 请设置环境变量 XG_BIZ_API_KEY 或 XG_APP_KEY", file=sys.stderr)
sys.exit(1)
body = {
"zoneId": args.zoneId,
"regionName": args.regionName,
"districName": args.districName,
"areaName": args.areaName,
"territoryName": args.territoryName,
"periodStart": args.periodStart,
"periodEnd": args.periodEnd,
"page": args.page,
}
body = {k: v for k, v in body.items() if v}
result = call_api(app_key, body, count_mode=args.count)
toon_output = toon_encode(result)
print(toon_output)
if __name__ == "__main__":
main()