@vocmarket/voc-skill 0.3.34 → 0.3.35
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.claude-plugin/plugin.json +1 -1
- package/README.md +16 -0
- package/docs/capability-map.md +5 -1
- package/docs/voc-cost-metering-and-balance-implementation-spec.md +56 -5
- package/mcp/src/features/voc-cost/cost-calibration.js +135 -0
- package/mcp/src/features/voc-cost/skill-planner-adapter.js +178 -0
- package/mcp/src/features/voc-cost/workflow-planner.js +9 -0
- package/mcp/src/server.js +8 -1
- package/mcp/src/tools/voc-cost-estimate-run.js +15 -1
- package/mcp/src/tools/voc-usage-report-run.js +24 -2
- package/package.json +2 -1
- package/scripts/acceptance.js +1 -0
- package/scripts/smoke-cost-calibration.js +65 -0
- package/skill-package-manifest.json +1 -1
- package/skills/voc-cost-controller/SKILL.md +4 -0
- package/skills/voc-cost-controller/references/workflow-cost-models.md +22 -0
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "voc-intelligence",
|
|
3
3
|
"description": "VOC intelligence skills for Claude Code: end-to-end VOC business workflows, Xiaohongshu and Douyin trend reports, live social VOC collection, Fmode Doubao image analysis, token/recharge checks, evidence-card outputs, prioritized issue pools, single-problem deep dives, competitor maps, 7-day content plans, VOC-backed speaking script co-creation, boss/operator action plans, and multi-turn memory.",
|
|
4
|
-
"version": "0.3.
|
|
4
|
+
"version": "0.3.35",
|
|
5
5
|
"author": {
|
|
6
6
|
"name": "nkkj-BrainHack"
|
|
7
7
|
}
|
package/README.md
CHANGED
|
@@ -14,6 +14,7 @@
|
|
|
14
14
|
- VOC 口播脚本共创:适合把一条 VOC 选题继续打磨成 60 秒稿、30 秒稿、提词器版,并支持多轮改稿和定稿记忆。
|
|
15
15
|
- VOC 接口目录:适合按平台、关键词和通道查找 1,344 个已登记接口,并读取参数后完成通用调用。
|
|
16
16
|
- VOC 成本控制:适合执行前比较经济版/标准版/完整版,检查余额与预算,执行后查看接口频次和额度消耗。
|
|
17
|
+
- 历史 Skill 成本适配:可直接读取 OpenClaw `api-config.json` 的批量 pipeline 生成报价,并用实际账单校准下一轮估算。
|
|
17
18
|
|
|
18
19
|
这套技能包不覆盖创始人故事、品牌自传或老板个人 IP 长文。口播脚本要做,但必须来自 VOC 用户问题、竞品评论差异、问题池 Top 问题或单点深挖动作。
|
|
19
20
|
|
|
@@ -40,6 +41,7 @@
|
|
|
40
41
|
- “跑一遍完整 VOC 经营闭环”
|
|
41
42
|
- “这个复杂任务预计要花多少钱,经济版和完整版差多少”
|
|
42
43
|
- “查一下这份报告每个接口调用了多少次、消耗了多少额度”
|
|
44
|
+
- “读取这个历史 Skill 的 api-config.json,先估算整批任务要花多少钱”
|
|
43
45
|
- “查商品详情或评论,先从接口清单里找合适的接口”
|
|
44
46
|
|
|
45
47
|
## 能交付什么
|
|
@@ -220,6 +222,20 @@ MCP 工具:
|
|
|
220
222
|
- `voc_cost_estimate`
|
|
221
223
|
- `voc_usage_report`
|
|
222
224
|
|
|
225
|
+
历史批处理 Skill 报价示例:
|
|
226
|
+
|
|
227
|
+
```json
|
|
228
|
+
{
|
|
229
|
+
"skillConfigPath": "E:/workspace/openclaw-voc-skill/openclaw-skills/xiaohongshu/xiaohongshu-mini-voc/api-config.json",
|
|
230
|
+
"keywords": ["全屋定制", "橱柜"],
|
|
231
|
+
"notesPerKeyword": 5,
|
|
232
|
+
"maxCommentPages": 2,
|
|
233
|
+
"mode": "standard"
|
|
234
|
+
}
|
|
235
|
+
```
|
|
236
|
+
|
|
237
|
+
报价执行后会在 `outputDir/cost-estimate.json` 保存快照。把该文件和实际用量一起传给 `voc_usage_report` 的 `estimatePath`,并指定本地 `calibrationPath`,即可看到预计/实际偏差和接口级差异。
|
|
238
|
+
|
|
223
239
|
命令行入口:
|
|
224
240
|
|
|
225
241
|
```powershell
|
package/docs/capability-map.md
CHANGED
|
@@ -24,6 +24,8 @@
|
|
|
24
24
|
| 查找商品、评论、创作者或选品接口 | `voc-api-catalog` | `voc_api_search` → `voc_api_doc` → `voc_api_call` | 接口、通道、参数、返回数据和本地媒体证据 |
|
|
25
25
|
| 复杂任务执行前做成本和余额测算 | `voc-cost-controller` | `voc_cost_estimate` / `voc_billing_query` | 经济版/标准版/完整版、调用计划、预计额度、差额和建议充值 |
|
|
26
26
|
| 查询已完成任务的真实消耗 | `voc-cost-controller` | `voc_usage_report` | 报告/模块/接口维度的频次、quota、用户扣费和成本拆分 |
|
|
27
|
+
| 读取历史 OpenClaw Skill 的批处理定义后报价 | `voc-cost-controller` | `voc_cost_estimate`(`skillConfigPath`) | 从 `api-config.json` 的 pipeline 自动推导批量、分页和可选步骤 |
|
|
28
|
+
| 用实际账单校准下一轮报价 | `voc-cost-controller` | `voc_usage_report`(`estimatePath` + `calibrationPath`) | 预计/实际金额、偏差方向和接口级差异 |
|
|
27
29
|
|
|
28
30
|
## Fmode Image Analysis Add-on
|
|
29
31
|
|
|
@@ -167,7 +169,7 @@ voc_api_search -> voc_api_doc -> voc_api_call
|
|
|
167
169
|
| `skills/*/SKILL.md` | Claude Code 触发规则和操作约束 |
|
|
168
170
|
| `mcp/src/server.js` | MCP 工具注册、schema、工具说明 |
|
|
169
171
|
| `mcp/src/tools/*` | CLI/MCP 可调用工具入口 |
|
|
170
|
-
| `mcp/src/features/*` |
|
|
172
|
+
| `mcp/src/features/*` | 趋势报告、单点深挖、记忆、信号提取、历史 Skill 规划和成本校准 |
|
|
171
173
|
| `mcp/catalog/voc-social-endpoints.json` | 1,344 个接口的稳定 ID、通道、参数索引和版本建议 |
|
|
172
174
|
| `mcp/catalog/params/*.json` | 按平台懒加载的详细参数文档 |
|
|
173
175
|
| `mcp/src/core/credentials.js` | token 读取口径 |
|
|
@@ -175,6 +177,8 @@ voc_api_search -> voc_api_doc -> voc_api_call
|
|
|
175
177
|
| `mcp/src/core/billing-client.js` / `mcp/src/tools/voc-billing-query-run.js` | 按 Agent 操作计划读取服务端价目、余额、quota 差额和建议充值金额 |
|
|
176
178
|
| `mcp/src/tools/voc-cost-estimate-run.js` | 生成执行前的多档采集计划和成本区间 |
|
|
177
179
|
| `mcp/src/tools/voc-usage-report-run.js` | 查询实际计费日志并按报告、模块、接口拆分 |
|
|
180
|
+
| `mcp/src/features/voc-cost/skill-planner-adapter.js` | 将历史 Skill `api-config.json` 转为报价 operations,不执行历史任务 |
|
|
181
|
+
| `mcp/src/features/voc-cost/cost-calibration.js` | 比较预计/实际金额并保存本地校准样本 |
|
|
178
182
|
| `mcp/src/core/evidence-quality.js` | 证据质量门槛、无样本降级建议 |
|
|
179
183
|
| `docs/live-manual-acceptance-checklist.md` | live 采集人工验收 |
|
|
180
184
|
| `docs/claude-code-troubleshooting-playbook.md` | Claude Code 故障定位、误报修复、关键词修复和发散排查 |
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# VOC 用量核算、成本预估与余额看板实现方案
|
|
2
2
|
|
|
3
|
-
> 文档状态:P0 计费、用量展示、现有支付回调链路和工具级续跑已完成;此前为旧钱包负余额增加的 NewAPI
|
|
3
|
+
> 文档状态:P0 计费、用量展示、现有支付回调链路和工具级续跑已完成;此前为旧钱包负余额增加的 NewAPI 直入账补丁已撤销,生产源代码已包含撤销提交。历史 Skill 规划适配和估算偏差校准已在技能包完成并纳入下一版发布;跨会话唤醒仍属于 Agent runtime/任务队列范围。2026-09-05 无扣费探针显示公网 `nova-server` 仍加载旧运行包,生产支付回调到账复核仍需在实际宿主完成。
|
|
4
4
|
> 编写日期:2026-09-01;最近复核:2026-09-05
|
|
5
5
|
> 涉及仓库:`claude-code-voc-intelligence`、`future-server`、`fmode-studio`、`voc-profit-web`
|
|
6
6
|
|
|
@@ -2359,9 +2359,9 @@ Studio 已有 `/balance`、Token 免登录、NewAPI 余额、支付二维码、
|
|
|
2359
2359
|
3. 使用已有检查点续跑,确认已完成业务键的 `fetchCalls=0`;完成后将 P0 账户级支付门禁标记为通过。
|
|
2360
2360
|
4. P1 仍为历史批处理 Skill 规划适配、跨会话自动唤醒、更多媒体入口覆盖和报价偏差校准。
|
|
2361
2361
|
|
|
2362
|
-
### 21.33 2026-09-05 技能包 0.3.34
|
|
2362
|
+
### 21.33 2026-09-05 技能包 0.3.34 发布与线上验收
|
|
2363
2363
|
|
|
2364
|
-
|
|
2364
|
+
本轮完成技能包交付和 npm 正式发布。版本字段已在 `package.json`、`package-lock.json`、`.claude-plugin/plugin.json`、`skill-package-manifest.json` 和 MCP server 中统一为 `0.3.34`。
|
|
2365
2365
|
|
|
2366
2366
|
| 检查项 | 结果 | 证据/结论 |
|
|
2367
2367
|
| --- | --- | --- |
|
|
@@ -2370,11 +2370,62 @@ Studio 已有 `/balance`、Token 免登录、NewAPI 余额、支付二维码、
|
|
|
2370
2370
|
| 包级全量验收 | 通过 | `claude-voc:acceptance` 覆盖样例、证据卡、本地媒体、偏好记忆、MCP、安装器和 workspace。 |
|
|
2371
2371
|
| 计费与成本 smoke | 通过 | billing、cost estimate、budget、trend/checkpoint、request shaping、media、cross-industry、MCP 全部通过。 |
|
|
2372
2372
|
| npm tarball | 通过 | `dist/npm/vocmarket-voc-skill-0.3.34.tgz`,166 个文件,npm 安装、npx 安装和 workspace 安装 smoke 全通过。 |
|
|
2373
|
-
| 发布元数据 | 通过 | `npm publish --dry-run --access public --ignore-scripts`
|
|
2373
|
+
| 发布元数据 | 通过 | `npm publish --dry-run --access public --ignore-scripts` 与正式 `npm publish` 均成功;公网查询返回 `0.3.34`。 |
|
|
2374
2374
|
| 旧路径与媒体策略 | 通过 | VOC 402 只返回 Tokenized Balance/显式二维码;媒体只写本地 `assets/`,不调用线上 Storage 上传链路。 |
|
|
2375
|
+
| 发布后线上安装 | 通过 | 直接从 npm registry 执行 `npx --yes @vocmarket/voc-skill@0.3.34 workspace --smoke`,样例、MCP 和 workspace 文件检查均通过。 |
|
|
2375
2376
|
|
|
2376
2377
|
发布时使用 `Get-FileHash -Algorithm SHA256` 计算 ZIP 和 npm tarball 的校验值;由于本规格文档会随包发布,不在包内固化自身哈希,最终值以构建完成后的命令输出为准。
|
|
2377
2378
|
|
|
2378
2379
|
#### 发布门禁
|
|
2379
2380
|
|
|
2380
|
-
|
|
2381
|
+
技能包已完成正式发布,线上版本为 `@vocmarket/voc-skill@0.3.34`。fmode-studio、future-server 的生产支付门禁仍按 21.32 的独立清单推进,不属于 npm 包发布动作。
|
|
2382
|
+
|
|
2383
|
+
### 21.34 2026-09-06 P1 历史批处理报价适配与偏差校准
|
|
2384
|
+
|
|
2385
|
+
本轮继续推进技能包侧 P1,新增内容已经在源码和测试中闭环;由于已发布的 `0.3.34` 不可覆盖,以下改动随下一版 `0.3.35` 发布。
|
|
2386
|
+
|
|
2387
|
+
| 工作项 | 状态 | 落地点 |
|
|
2388
|
+
| --- | --- | --- |
|
|
2389
|
+
| 历史批处理 Skill 规划适配 | 已完成 | `mcp/src/features/voc-cost/skill-planner-adapter.js` 读取本地目录或 `api-config.json`,解析 `parameters` 默认值、`pipeline[].api`、`forEach` 集合和分页上限,转换成 `voc_cost_estimate` operations。 |
|
|
2390
|
+
| 经济/标准/完整版策略 | 已完成 | 经济版剔除带 `condition` 的可选步骤;标准版和完整版沿用历史配置声明的范围,不虚构额外调用、不执行历史 Skill。 |
|
|
2391
|
+
| 报价快照 | 已完成 | `voc_cost_estimate` 指定 `outputDir` 时写入 `cost-estimate.json`,记录选定方案、接口步骤和当前服务端价目结果。 |
|
|
2392
|
+
| 预计/实际偏差校准 | 已完成 | `mcp/src/features/voc-cost/cost-calibration.js` 比较 `estimatePath` 与 `voc_usage_report` 实际账单,返回总额和接口级偏差;`calibrationPath` 追加本地样本并提供按工作流汇总。 |
|
|
2393
|
+
| 旧支付、旧钱包和媒体上传逻辑 | 保持不变 | 本轮未新增支付接口、任务级预付款、微信回调改动或线上 Storage 上传;实际调用仍逐次扣 NewAPI,媒体仍只写本地任务目录。 |
|
|
2394
|
+
|
|
2395
|
+
#### Agent 使用方式
|
|
2396
|
+
|
|
2397
|
+
历史 Skill 只需要把配置路径作为报价入参,Agent 仍负责向用户解释假设和选择方案:
|
|
2398
|
+
|
|
2399
|
+
```json
|
|
2400
|
+
{
|
|
2401
|
+
"skillConfigPath": "E:/workspace/openclaw-voc-skill/openclaw-skills/xiaohongshu/xiaohongshu-mini-voc/api-config.json",
|
|
2402
|
+
"keywords": ["全屋定制", "橱柜"],
|
|
2403
|
+
"notesPerKeyword": 5,
|
|
2404
|
+
"maxCommentPages": 2,
|
|
2405
|
+
"mode": "standard"
|
|
2406
|
+
}
|
|
2407
|
+
```
|
|
2408
|
+
|
|
2409
|
+
执行后将估算输出目录中的 `cost-estimate.json` 和实际用量查询结果传给 `voc_usage_report`:
|
|
2410
|
+
|
|
2411
|
+
```json
|
|
2412
|
+
{
|
|
2413
|
+
"usageTraceId": "usage_xxx",
|
|
2414
|
+
"estimatePath": "outputs/usage_xxx/cost-estimate.json",
|
|
2415
|
+
"calibrationPath": "outputs/cost-calibration.json"
|
|
2416
|
+
}
|
|
2417
|
+
```
|
|
2418
|
+
|
|
2419
|
+
`calibrationPath` 只保存预计金额、实际金额、接口标识和偏差,不保存 Token、原始请求或媒体内容。校准结果只影响下一轮 Agent 规划,不改变后端价目和 NewAPI 实际扣费。
|
|
2420
|
+
|
|
2421
|
+
#### 本轮验证
|
|
2422
|
+
|
|
2423
|
+
- `npm run smoke:cost-calibration`:通过,覆盖小红书历史 Skill 配置、关键词/笔记/评论分页计数、可选作者画像剔除和偏差汇总。
|
|
2424
|
+
- `npm run smoke:cost`:通过,既有报价和用量报告回归保持通过。
|
|
2425
|
+
- `npm run audit:index`、`npm run acceptance`:需在版本升至 `0.3.35` 后重跑,确认新测试和文档进入发布包。
|
|
2426
|
+
|
|
2427
|
+
#### 尚未由技能包单独完成的事项
|
|
2428
|
+
|
|
2429
|
+
1. `nova-server` 实际宿主加载撤销版本后的生产支付到账、Balance 刷新和 checkpoint 续跑,仍需后端发布与账户级小额支付回归。
|
|
2430
|
+
2. 跨会话自动唤醒仍需 Agent runtime/callback/持久任务队列提供执行租约和支付事件唤醒;技能包继续提供 checkpoint、`usageTraceId` 和恢复参数,不在本包伪造后台执行能力。
|
|
2431
|
+
3. 更多采集入口的媒体覆盖依赖各入口把原始响应交给统一本地媒体流水线;当前通用 `voc_api_call`、小红书、抖音入口已覆盖,新增入口按同一契约接入。
|
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const fs = require('fs');
|
|
4
|
+
const path = require('path');
|
|
5
|
+
|
|
6
|
+
function number(value, fallback = 0) {
|
|
7
|
+
const parsed = Number(value);
|
|
8
|
+
return Number.isFinite(parsed) ? parsed : fallback;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
function round(value, digits = 6) {
|
|
12
|
+
const factor = 10 ** digits;
|
|
13
|
+
return Math.round(number(value) * factor) / factor;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
function rowKey(row = {}) {
|
|
17
|
+
return String(row.endpointId || row.endpoint || row.path || row.apigId || row.model || '').trim();
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
function actualRows(actual = {}) {
|
|
21
|
+
const usage = actual.usage || actual.data?.usage || actual.data || actual;
|
|
22
|
+
return Array.isArray(usage.groups) ? usage.groups
|
|
23
|
+
: Array.isArray(usage.items) ? usage.items
|
|
24
|
+
: Array.isArray(usage.rows) ? usage.rows : [];
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function estimateRows(estimate = {}) {
|
|
28
|
+
const source = estimate.data || estimate;
|
|
29
|
+
const plans = Array.isArray(source.plans) ? source.plans : [];
|
|
30
|
+
if (plans.length) {
|
|
31
|
+
const selected = source.selectedMode ? plans.filter(plan => plan.mode === source.selectedMode) : plans;
|
|
32
|
+
return selected.flatMap(plan => (plan.operations || []).map(operation => ({ ...operation, mode: plan.mode })));
|
|
33
|
+
}
|
|
34
|
+
return Array.isArray(source.operations) ? source.operations : [];
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function compareEstimateToActual(estimate = {}, actual = {}) {
|
|
38
|
+
const estimateSource = estimate.data || estimate;
|
|
39
|
+
const actualSource = actual.data || actual;
|
|
40
|
+
const expectedCny = number(estimateSource.selectedMode
|
|
41
|
+
? (estimateSource.plans || []).find(plan => plan.mode === estimateSource.selectedMode)?.expectedCny
|
|
42
|
+
: estimateSource.expectedCny ?? estimateSource.amountCny);
|
|
43
|
+
const actualTotals = actualSource.totals || actualSource.usage?.totals || {};
|
|
44
|
+
const actualCny = number(actualTotals.costCny ?? actualTotals.amountCny ?? actualTotals.actualCostCny);
|
|
45
|
+
const deltaCny = round(actualCny - expectedCny);
|
|
46
|
+
const deltaPct = expectedCny > 0 ? round((deltaCny / expectedCny) * 100, 2) : null;
|
|
47
|
+
const expectedByPath = new Map();
|
|
48
|
+
for (const row of estimateRows(estimate)) {
|
|
49
|
+
const key = rowKey(row);
|
|
50
|
+
if (!key) continue;
|
|
51
|
+
const amount = number(row.costCny ?? row.amountCny ?? number(row.priceCnyPerCall) * number(row.count));
|
|
52
|
+
expectedByPath.set(key, (expectedByPath.get(key) || 0) + amount);
|
|
53
|
+
}
|
|
54
|
+
const actualByPath = new Map();
|
|
55
|
+
for (const row of actualRows(actual)) {
|
|
56
|
+
const key = rowKey(row);
|
|
57
|
+
if (!key) continue;
|
|
58
|
+
const amount = number(row.costCny ?? row.amountCny ?? row.actualCostCny);
|
|
59
|
+
actualByPath.set(key, (actualByPath.get(key) || 0) + amount);
|
|
60
|
+
}
|
|
61
|
+
const keys = new Set([...expectedByPath.keys(), ...actualByPath.keys()]);
|
|
62
|
+
const operations = [...keys].map(endpoint => {
|
|
63
|
+
const expected = round(expectedByPath.get(endpoint) || 0);
|
|
64
|
+
const actualValue = round(actualByPath.get(endpoint) || 0);
|
|
65
|
+
return {
|
|
66
|
+
endpoint,
|
|
67
|
+
expectedCny: expected,
|
|
68
|
+
actualCny: actualValue,
|
|
69
|
+
deltaCny: round(actualValue - expected),
|
|
70
|
+
deltaPct: expected > 0 ? round(((actualValue - expected) / expected) * 100, 2) : null
|
|
71
|
+
};
|
|
72
|
+
}).sort((a, b) => Math.abs(b.deltaCny) - Math.abs(a.deltaCny));
|
|
73
|
+
return {
|
|
74
|
+
status: expectedCny > 0 || actualCny > 0 ? 'calibrated' : 'insufficient_data',
|
|
75
|
+
workflowType: estimateSource.workflowType || actualSource.workflowType || null,
|
|
76
|
+
mode: estimateSource.selectedMode || null,
|
|
77
|
+
expectedCny: round(expectedCny),
|
|
78
|
+
actualCny: round(actualCny),
|
|
79
|
+
deltaCny,
|
|
80
|
+
deltaPct,
|
|
81
|
+
direction: deltaCny > 0 ? 'underestimated' : deltaCny < 0 ? 'overestimated' : 'aligned',
|
|
82
|
+
operations,
|
|
83
|
+
observedAt: new Date().toISOString()
|
|
84
|
+
};
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
function readHistory(filePath) {
|
|
88
|
+
if (!filePath || !fs.existsSync(filePath)) return [];
|
|
89
|
+
const value = JSON.parse(fs.readFileSync(filePath, 'utf8').replace(/^\uFEFF/, ''));
|
|
90
|
+
return Array.isArray(value) ? value : Array.isArray(value.samples) ? value.samples : [];
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
function appendCalibrationSample(filePath, sample, options = {}) {
|
|
94
|
+
if (!filePath) return null;
|
|
95
|
+
const target = path.resolve(filePath);
|
|
96
|
+
fs.mkdirSync(path.dirname(target), { recursive: true });
|
|
97
|
+
const history = readHistory(target);
|
|
98
|
+
const maxSamples = Math.max(1, Number(options.maxSamples) || 500);
|
|
99
|
+
history.push(sample);
|
|
100
|
+
fs.writeFileSync(target, JSON.stringify(history.slice(-maxSamples), null, 2));
|
|
101
|
+
return target;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
function summarizeCalibration(history = []) {
|
|
105
|
+
const samples = history.filter(item => item && item.status === 'calibrated');
|
|
106
|
+
const totalExpected = samples.reduce((sum, item) => sum + number(item.expectedCny), 0);
|
|
107
|
+
const totalActual = samples.reduce((sum, item) => sum + number(item.actualCny), 0);
|
|
108
|
+
const deltaCny = round(totalActual - totalExpected);
|
|
109
|
+
return {
|
|
110
|
+
sampleCount: samples.length,
|
|
111
|
+
expectedCny: round(totalExpected),
|
|
112
|
+
actualCny: round(totalActual),
|
|
113
|
+
deltaCny,
|
|
114
|
+
deltaPct: totalExpected > 0 ? round((deltaCny / totalExpected) * 100, 2) : null,
|
|
115
|
+
direction: deltaCny > 0 ? 'underestimated' : deltaCny < 0 ? 'overestimated' : 'aligned',
|
|
116
|
+
byWorkflowType: samples.reduce((result, item) => {
|
|
117
|
+
const key = item.workflowType || 'unknown';
|
|
118
|
+
const bucket = result[key] || { sampleCount: 0, expectedCny: 0, actualCny: 0 };
|
|
119
|
+
bucket.sampleCount += 1;
|
|
120
|
+
bucket.expectedCny = round(bucket.expectedCny + number(item.expectedCny));
|
|
121
|
+
bucket.actualCny = round(bucket.actualCny + number(item.actualCny));
|
|
122
|
+
bucket.deltaCny = round(bucket.actualCny - bucket.expectedCny);
|
|
123
|
+
bucket.deltaPct = bucket.expectedCny > 0 ? round((bucket.deltaCny / bucket.expectedCny) * 100, 2) : null;
|
|
124
|
+
result[key] = bucket;
|
|
125
|
+
return result;
|
|
126
|
+
}, {})
|
|
127
|
+
};
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
module.exports = {
|
|
131
|
+
compareEstimateToActual,
|
|
132
|
+
readHistory,
|
|
133
|
+
appendCalibrationSample,
|
|
134
|
+
summarizeCalibration
|
|
135
|
+
};
|
|
@@ -0,0 +1,178 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const fs = require('fs');
|
|
4
|
+
const path = require('path');
|
|
5
|
+
const { cleanLabel } = require('../../core/usage-context');
|
|
6
|
+
|
|
7
|
+
function readJson(filePath) {
|
|
8
|
+
return JSON.parse(fs.readFileSync(filePath, 'utf8').replace(/^\uFEFF/, ''));
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
function resolveConfigPath(input = {}) {
|
|
12
|
+
const value = input.skillConfigPath || input.legacySkillPath || input.skillPath || input.apiConfigPath;
|
|
13
|
+
if (!value) return null;
|
|
14
|
+
const candidate = path.resolve(String(value));
|
|
15
|
+
if (!fs.existsSync(candidate)) throw new Error(`历史 Skill 配置不存在:${candidate}`);
|
|
16
|
+
const stat = fs.statSync(candidate);
|
|
17
|
+
if (stat.isDirectory()) {
|
|
18
|
+
const nested = path.join(candidate, 'api-config.json');
|
|
19
|
+
if (!fs.existsSync(nested)) throw new Error(`历史 Skill 目录缺少 api-config.json:${candidate}`);
|
|
20
|
+
return nested;
|
|
21
|
+
}
|
|
22
|
+
return candidate;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
function loadSkillConfig(input = {}) {
|
|
26
|
+
if (input.skillConfig && typeof input.skillConfig === 'object') {
|
|
27
|
+
return { config: input.skillConfig, path: null };
|
|
28
|
+
}
|
|
29
|
+
const configPath = resolveConfigPath(input);
|
|
30
|
+
if (!configPath) return null;
|
|
31
|
+
return { config: readJson(configPath), path: configPath };
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function defaultsFromConfig(config = {}) {
|
|
35
|
+
const defaults = {};
|
|
36
|
+
for (const [name, definition] of Object.entries(config.parameters || {})) {
|
|
37
|
+
if (definition && definition.default !== undefined) defaults[name] = definition.default;
|
|
38
|
+
}
|
|
39
|
+
return defaults;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function valueFromTemplate(value, input, fallback = 0) {
|
|
43
|
+
if (typeof value === 'number') return Number.isFinite(value) ? value : fallback;
|
|
44
|
+
const text = String(value == null ? '' : value).trim();
|
|
45
|
+
const match = text.match(/^\$\{([^}]+)\}$/);
|
|
46
|
+
const key = match ? match[1].split(/[.[\]]/)[0] : text;
|
|
47
|
+
const raw = key && input[key] !== undefined ? input[key] : undefined;
|
|
48
|
+
const number = Number(raw);
|
|
49
|
+
return Number.isFinite(number) ? number : fallback;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function arrayLength(value) {
|
|
53
|
+
return Array.isArray(value) ? value.length : 0;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function keywordCount(input) {
|
|
57
|
+
return arrayLength(input.keywords) || arrayLength(input.keywordMatrix) || Number(input.keywordCount) || 1;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
function accountCount(input) {
|
|
61
|
+
return arrayLength(input.accounts) || Number(input.accountCount) || Number(input.asinCount) || 1;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
function selectedItemCount(input) {
|
|
65
|
+
const keywords = keywordCount(input);
|
|
66
|
+
const perKeyword = Number(input.notesPerKeyword ?? input.itemsPerKeyword ?? input.videosPerKeyword ?? 1);
|
|
67
|
+
return Math.max(1, keywords * (Number.isFinite(perKeyword) && perKeyword > 0 ? perKeyword : 1));
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
function countForEach(forEach, input) {
|
|
71
|
+
const text = String(forEach || '').toLowerCase();
|
|
72
|
+
if (!text) return 1;
|
|
73
|
+
if (text.includes('asin') && (text.includes('page') || text.includes('×') || text.includes('x'))) {
|
|
74
|
+
const pages = Number(input.maxPagesPerAsin ?? input.pagesPerAsin ?? 1);
|
|
75
|
+
return accountCount(input) * Math.max(1, Number.isFinite(pages) ? pages : 1);
|
|
76
|
+
}
|
|
77
|
+
if (text.includes('asin')) return accountCount(input);
|
|
78
|
+
if (text.includes('keyword') || text.includes('关键词')) return keywordCount(input);
|
|
79
|
+
if (text.includes('selectednote') || text.includes('selectednotes') || text.includes('入选笔记')) return selectedItemCount(input);
|
|
80
|
+
if (text.includes('selectedvideo') || text.includes('selectedvideos') || text.includes('入选视频')) return selectedItemCount(input);
|
|
81
|
+
if (text.includes('note') || text.includes('笔记')) return selectedItemCount(input);
|
|
82
|
+
if (text.includes('video') || text.includes('视频')) return selectedItemCount(input);
|
|
83
|
+
if (text.includes('product') || text.includes('商品') || text.includes('sku')) {
|
|
84
|
+
return Math.max(1, Number(input.productCount ?? input.skuCount ?? input.itemCount ?? 1));
|
|
85
|
+
}
|
|
86
|
+
if (text.includes('user') || text.includes('作者') || text.includes('账号')) return accountCount(input);
|
|
87
|
+
return 1;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
function stepCount(step, input) {
|
|
91
|
+
let count = countForEach(step.forEach, input);
|
|
92
|
+
const pagination = step.pagination;
|
|
93
|
+
if (pagination && !String(step.forEach || '').toLowerCase().includes('page')) {
|
|
94
|
+
const maxPages = valueFromTemplate(pagination.maxPages, input, 1);
|
|
95
|
+
if (maxPages > 1) count *= maxPages;
|
|
96
|
+
}
|
|
97
|
+
return Math.max(1, Math.floor(count));
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
function modelForConfig(config, api = {}) {
|
|
101
|
+
const explicit = api.model || api.channel;
|
|
102
|
+
if (explicit) return cleanLabel(explicit);
|
|
103
|
+
const source = `${config.category || ''} ${api.forwardUrl || ''} ${api.service || ''} ${api.skill || ''}`.toLowerCase();
|
|
104
|
+
if (source.includes('overseas') || source.includes('amazon') || source.includes('跨境')) return 'voc-overseas';
|
|
105
|
+
if (source.includes('ecom') || source.includes('taobao') || source.includes('amazon') || source.includes('电商')) return 'voc-ecom';
|
|
106
|
+
return 'voc-social';
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
function pathForApi(api = {}, step = {}) {
|
|
110
|
+
const value = api.proxyPath || api.path || api.endpoint || api.forwardPath || api.service || api.skill || step.name || 'legacy_skill_step';
|
|
111
|
+
return cleanLabel(value, 'legacy_skill_step');
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
function operationFromStep(config, step, input, index) {
|
|
115
|
+
if (!step || step.type === 'compute' || !step.api) return null;
|
|
116
|
+
const api = Array.isArray(step.api) ? step.api[0] : step.api;
|
|
117
|
+
const condition = String(step.condition || '').trim();
|
|
118
|
+
return {
|
|
119
|
+
model: modelForConfig(config, api),
|
|
120
|
+
apigId: cleanLabel(api.apigId || api.apig_id),
|
|
121
|
+
path: pathForApi(api, step),
|
|
122
|
+
count: stepCount(step, input),
|
|
123
|
+
label: cleanLabel(step.name, `历史 Skill 步骤 ${index + 1}`),
|
|
124
|
+
valueScore: condition ? 2 : 4,
|
|
125
|
+
required: !condition,
|
|
126
|
+
sourceSkill: cleanLabel(api.skill),
|
|
127
|
+
method: cleanLabel(api.method || 'GET').toUpperCase(),
|
|
128
|
+
...(condition ? { condition } : {})
|
|
129
|
+
};
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
function buildLegacySkillPlans(input = {}) {
|
|
133
|
+
const loaded = loadSkillConfig(input);
|
|
134
|
+
if (!loaded) return [];
|
|
135
|
+
const config = loaded.config || {};
|
|
136
|
+
const values = { ...defaultsFromConfig(config), ...input };
|
|
137
|
+
const steps = Array.isArray(config.pipeline) ? config.pipeline : [];
|
|
138
|
+
const operations = steps.map((step, index) => operationFromStep(config, step, values, index)).filter(Boolean);
|
|
139
|
+
if (!operations.length) return [];
|
|
140
|
+
const name = cleanLabel(config.name || (loaded.path && path.basename(path.dirname(loaded.path))) || 'legacy_skill');
|
|
141
|
+
const workflowType = `legacy_skill:${name}`;
|
|
142
|
+
const build = (mode) => {
|
|
143
|
+
const selected = mode === 'economy' ? operations.filter(operation => operation.required) : operations;
|
|
144
|
+
return {
|
|
145
|
+
id: `${name}:${mode}`,
|
|
146
|
+
mode,
|
|
147
|
+
label: ({ economy: '经济版', standard: '标准版', complete: '完整版' })[mode],
|
|
148
|
+
workflowType,
|
|
149
|
+
assumptions: {
|
|
150
|
+
skillName: name,
|
|
151
|
+
skillConfigPath: loaded.path,
|
|
152
|
+
parameterDefaults: defaultsFromConfig(config),
|
|
153
|
+
modePolicy: mode === 'economy' ? '仅保留 pipeline 中无 condition 的必需接口步骤。' : '沿用历史 Skill pipeline 声明的接口步骤和分页上限。'
|
|
154
|
+
},
|
|
155
|
+
operations: selected.map(operation => ({ ...operation })),
|
|
156
|
+
strategy: mode === 'economy'
|
|
157
|
+
? '沿用历史 Skill 的主链路,跳过可选画像、补充评论等步骤。'
|
|
158
|
+
: mode === 'standard'
|
|
159
|
+
? '按历史 Skill 默认参数执行全部声明步骤。'
|
|
160
|
+
: '按历史 Skill 声明的分页和批量范围完整覆盖,不虚构额外调用。'
|
|
161
|
+
};
|
|
162
|
+
};
|
|
163
|
+
const modes = input.mode && input.mode !== 'all'
|
|
164
|
+
? [String(input.mode).toLowerCase()]
|
|
165
|
+
: ['economy', 'standard', 'complete'];
|
|
166
|
+
const normalized = modes.map(mode => mode === 'low' ? 'economy' : mode === 'full' ? 'complete' : mode)
|
|
167
|
+
.filter(mode => ['economy', 'standard', 'complete'].includes(mode));
|
|
168
|
+
return (normalized.length ? normalized : ['standard']).map(build);
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
module.exports = {
|
|
172
|
+
resolveConfigPath,
|
|
173
|
+
loadSkillConfig,
|
|
174
|
+
defaultsFromConfig,
|
|
175
|
+
countForEach,
|
|
176
|
+
stepCount,
|
|
177
|
+
buildLegacySkillPlans
|
|
178
|
+
};
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
'use strict';
|
|
2
2
|
|
|
3
3
|
const { cleanLabel } = require('../../core/usage-context');
|
|
4
|
+
const { buildLegacySkillPlans } = require('./skill-planner-adapter');
|
|
4
5
|
|
|
5
6
|
const MODES = ['economy', 'standard', 'complete'];
|
|
6
7
|
|
|
@@ -120,6 +121,14 @@ function planCustom(input, mode) {
|
|
|
120
121
|
}
|
|
121
122
|
|
|
122
123
|
function buildWorkflowPlans(input = {}) {
|
|
124
|
+
// Historical OpenClaw orchestration Skills describe their real batch shape
|
|
125
|
+
// in api-config.json. Reuse that declaration when the caller supplies it,
|
|
126
|
+
// so the Agent does not have to hand-copy every pipeline step into an
|
|
127
|
+
// operations array before asking for a quote.
|
|
128
|
+
if (input.skillConfigPath || input.legacySkillPath || input.skillPath || input.apiConfigPath || input.skillConfig) {
|
|
129
|
+
const legacyPlans = buildLegacySkillPlans(input);
|
|
130
|
+
if (legacyPlans.length) return legacyPlans;
|
|
131
|
+
}
|
|
123
132
|
const type = String(input.workflowType || '').toLowerCase();
|
|
124
133
|
const xhs = type.includes('xhs') || type.includes('xiaohongshu');
|
|
125
134
|
const douyin = type.includes('douyin');
|
package/mcp/src/server.js
CHANGED
|
@@ -66,7 +66,7 @@ function normalizeToolResult(result = {}) {
|
|
|
66
66
|
function createServer() {
|
|
67
67
|
const server = new McpServer({
|
|
68
68
|
name: 'voc-intelligence',
|
|
69
|
-
version: '0.3.
|
|
69
|
+
version: '0.3.35'
|
|
70
70
|
});
|
|
71
71
|
|
|
72
72
|
server.registerTool(
|
|
@@ -907,6 +907,10 @@ function createServer() {
|
|
|
907
907
|
inputSchema: {
|
|
908
908
|
task: z.string().max(500).optional(),
|
|
909
909
|
workflowType: z.string().max(100).optional(),
|
|
910
|
+
skillConfigPath: z.string().max(1000).optional().describe('本地历史 OpenClaw Skill 目录或 api-config.json 路径;只读取规划声明,不执行 Skill。'),
|
|
911
|
+
legacySkillPath: z.string().max(1000).optional().describe('skillConfigPath 的兼容别名。'),
|
|
912
|
+
apiConfigPath: z.string().max(1000).optional().describe('api-config.json 的兼容别名。'),
|
|
913
|
+
skillConfig: z.object({}).passthrough().optional().describe('已读取的历史 Skill 配置对象;不包含凭证。'),
|
|
910
914
|
mode: z.enum(['economy', 'standard', 'complete', 'low', 'full', 'all']).optional(),
|
|
911
915
|
accountCount: z.number().int().min(1).max(1000000).optional(),
|
|
912
916
|
keywordCount: z.number().int().min(1).max(1000000).optional(),
|
|
@@ -984,6 +988,9 @@ function createServer() {
|
|
|
984
988
|
operations: z.array(z.object({ model: z.string().min(1), apigId: z.string().optional(), path: z.string().optional(), count: z.number().int().min(1).max(1000000).optional() }).passthrough()).max(100).optional(),
|
|
985
989
|
reportPath: z.string().optional(),
|
|
986
990
|
usagePath: z.string().optional(),
|
|
991
|
+
estimatePath: z.string().optional().describe('执行前 voc_cost_estimate 输出的 cost-estimate.json,用于偏差校准。'),
|
|
992
|
+
calibrationPath: z.string().optional().describe('本地估算校准历史 JSON 路径;只写入估算与实际金额汇总。'),
|
|
993
|
+
maxCalibrationSamples: z.number().int().min(1).max(5000).optional(),
|
|
987
994
|
refresh: z.boolean().optional(),
|
|
988
995
|
baseUrl: z.string().url().optional(),
|
|
989
996
|
newapiToken: z.string().optional(),
|
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
'use strict';
|
|
2
2
|
|
|
3
|
+
const fs = require('fs');
|
|
4
|
+
const path = require('path');
|
|
3
5
|
const { queryBilling } = require('../core/billing-client');
|
|
4
6
|
const { contextForTool, writeUsageContext } = require('../core/usage-context');
|
|
5
7
|
const { buildWorkflowPlans, flattenPlans } = require('../features/voc-cost/workflow-planner');
|
|
@@ -8,7 +10,15 @@ const { errorResult } = require('../core/result-envelope');
|
|
|
8
10
|
|
|
9
11
|
async function runVocCostEstimate(input = {}) {
|
|
10
12
|
const context = contextForTool({ ...input, skillId: input.skillId || 'voc-cost-controller' });
|
|
11
|
-
|
|
13
|
+
let plans;
|
|
14
|
+
try {
|
|
15
|
+
plans = buildWorkflowPlans(input);
|
|
16
|
+
} catch (error) {
|
|
17
|
+
return errorResult(`历史 Skill 规划配置无效:${error.message || String(error)}`, {
|
|
18
|
+
summary: { errorKind: 'invalid_skill_config' },
|
|
19
|
+
data: { context }
|
|
20
|
+
});
|
|
21
|
+
}
|
|
12
22
|
const flat = flattenPlans(plans);
|
|
13
23
|
if (!flat.length) return errorResult('请提供 operations 或可识别的 workflowType 与任务规模。', { summary: { errorKind: 'missing_plan' } });
|
|
14
24
|
const query = await queryBilling({
|
|
@@ -55,6 +65,10 @@ async function runVocCostEstimate(input = {}) {
|
|
|
55
65
|
if (input.outputDir) {
|
|
56
66
|
const filePath = writeUsageContext(context, input.outputDir);
|
|
57
67
|
if (filePath) files.push(filePath);
|
|
68
|
+
const estimatePath = path.join(path.resolve(String(input.outputDir)), 'cost-estimate.json');
|
|
69
|
+
fs.mkdirSync(path.dirname(estimatePath), { recursive: true });
|
|
70
|
+
fs.writeFileSync(estimatePath, JSON.stringify({ ...report, status: 'ok' }, null, 2));
|
|
71
|
+
files.push(estimatePath);
|
|
58
72
|
}
|
|
59
73
|
return {
|
|
60
74
|
status: 'ok',
|
|
@@ -5,6 +5,7 @@ const path = require('path');
|
|
|
5
5
|
const { queryBilling } = require('../core/billing-client');
|
|
6
6
|
const { contextForTool } = require('../core/usage-context');
|
|
7
7
|
const { errorResult } = require('../core/result-envelope');
|
|
8
|
+
const { compareEstimateToActual, appendCalibrationSample, readHistory, summarizeCalibration } = require('../features/voc-cost/cost-calibration');
|
|
8
9
|
|
|
9
10
|
function readJson(filePath) {
|
|
10
11
|
try {
|
|
@@ -14,6 +15,10 @@ function readJson(filePath) {
|
|
|
14
15
|
}
|
|
15
16
|
}
|
|
16
17
|
|
|
18
|
+
function readOptionalJson(filePath) {
|
|
19
|
+
return filePath ? readJson(filePath) : null;
|
|
20
|
+
}
|
|
21
|
+
|
|
17
22
|
function extractUsage(source = {}) {
|
|
18
23
|
const usage = source.usage || source.data?.usage || source.data?.billing || source.billing || source;
|
|
19
24
|
const rows = Array.isArray(usage.groups) ? usage.groups : Array.isArray(usage.items) ? usage.items : Array.isArray(usage.rows) ? usage.rows : Array.isArray(usage.operations) ? usage.operations : [];
|
|
@@ -68,13 +73,30 @@ async function runVocUsageReport(input = {}) {
|
|
|
68
73
|
balance: source.balance || null,
|
|
69
74
|
pricing: source.catalog || source.pricing || []
|
|
70
75
|
};
|
|
76
|
+
const estimate = readOptionalJson(input.estimatePath);
|
|
77
|
+
let calibration = null;
|
|
78
|
+
let calibrationFile = null;
|
|
79
|
+
if (estimate) {
|
|
80
|
+
calibration = compareEstimateToActual(estimate, report);
|
|
81
|
+
if (input.calibrationPath) {
|
|
82
|
+
calibrationFile = appendCalibrationSample(input.calibrationPath, calibration, { maxSamples: input.maxCalibrationSamples });
|
|
83
|
+
calibration = { ...calibration, history: summarizeCalibration(readHistory(calibrationFile)), file: calibrationFile };
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
if (calibration) report.calibration = calibration;
|
|
87
|
+
const calibrationLine = calibration
|
|
88
|
+
? `估算校准:预计 ¥${calibration.expectedCny.toFixed(2)},实际 ¥${calibration.actualCny.toFixed(2)},偏差 ${calibration.deltaPct == null ? '暂无' : `${calibration.deltaPct}%`}(${calibration.direction})。`
|
|
89
|
+
: '';
|
|
71
90
|
return {
|
|
72
91
|
status: 'ok',
|
|
73
|
-
assistantMessage: formatMessage(report),
|
|
92
|
+
assistantMessage: [formatMessage(report), calibrationLine].filter(Boolean).join('\n'),
|
|
74
93
|
summary: { usageTraceId: report.usageTraceId, reportId: report.reportId, calls: extracted.calls, billedCalls: extracted.billedCalls, quota: extracted.quota, costCny: extracted.costCny, quality: report.quality },
|
|
75
94
|
data: report,
|
|
76
95
|
recharge: null,
|
|
77
|
-
files:
|
|
96
|
+
files: [
|
|
97
|
+
...(input.reportPath || input.usagePath ? [path.resolve(input.reportPath || input.usagePath)] : []),
|
|
98
|
+
...(calibrationFile ? [calibrationFile] : [])
|
|
99
|
+
],
|
|
78
100
|
nextActions: [],
|
|
79
101
|
warnings: query?.warnings || [],
|
|
80
102
|
errors: []
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@vocmarket/voc-skill",
|
|
3
|
-
"version": "0.3.
|
|
3
|
+
"version": "0.3.35",
|
|
4
4
|
"description": "Claude Code VOC intelligence skills for Xiaohongshu and Douyin trend reports, live social VOC collection, Fmode image analysis, single-issue deep dives, boss/operator action plans, and multi-turn memory.",
|
|
5
5
|
"type": "commonjs",
|
|
6
6
|
"bin": {
|
|
@@ -17,6 +17,7 @@
|
|
|
17
17
|
"smoke:budget": "node scripts/smoke-budget-guard.js",
|
|
18
18
|
"smoke:trend-budget": "node scripts/smoke-trend-budget.js",
|
|
19
19
|
"smoke:cost": "node scripts/smoke-cost-estimate.js",
|
|
20
|
+
"smoke:cost-calibration": "node scripts/smoke-cost-calibration.js",
|
|
20
21
|
"smoke:media": "node scripts/smoke-media-pipeline.js",
|
|
21
22
|
"smoke:request": "node scripts/smoke-voc-request-shaping.js",
|
|
22
23
|
"audit:index": "node scripts/audit-index.js",
|
package/scripts/acceptance.js
CHANGED
|
@@ -13,6 +13,7 @@ function main() {
|
|
|
13
13
|
run('npm', ['run', 'audit:index'], ROOT);
|
|
14
14
|
run('npm', ['run', 'fmode:image:smoke'], ROOT);
|
|
15
15
|
run('npm', ['run', 'smoke:budget'], ROOT);
|
|
16
|
+
run('npm', ['run', 'smoke:cost-calibration'], ROOT);
|
|
16
17
|
run('npm', ['run', 'smoke:trend-budget'], ROOT);
|
|
17
18
|
run('npm', ['run', 'smoke:package'], ROOT);
|
|
18
19
|
run('npm', ['run', 'mcp:smoke'], ROOT);
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const assert = require('node:assert/strict');
|
|
4
|
+
const fs = require('fs');
|
|
5
|
+
const os = require('os');
|
|
6
|
+
const path = require('path');
|
|
7
|
+
const { buildLegacySkillPlans } = require('../mcp/src/features/voc-cost/skill-planner-adapter');
|
|
8
|
+
const {
|
|
9
|
+
compareEstimateToActual,
|
|
10
|
+
appendCalibrationSample,
|
|
11
|
+
readHistory,
|
|
12
|
+
summarizeCalibration
|
|
13
|
+
} = require('../mcp/src/features/voc-cost/cost-calibration');
|
|
14
|
+
|
|
15
|
+
function main() {
|
|
16
|
+
const root = path.resolve(__dirname, '..', '..', '..');
|
|
17
|
+
const xhsConfig = path.join(root, 'openclaw-skills', 'xiaohongshu', 'xiaohongshu-mini-voc', 'api-config.json');
|
|
18
|
+
const plans = buildLegacySkillPlans({
|
|
19
|
+
skillConfigPath: xhsConfig,
|
|
20
|
+
keywords: ['甲', '乙', '丙'],
|
|
21
|
+
notesPerKeyword: 2,
|
|
22
|
+
maxCommentPages: 2,
|
|
23
|
+
includeUserInfo: true
|
|
24
|
+
});
|
|
25
|
+
assert.equal(plans.length, 3);
|
|
26
|
+
assert.equal(plans[0].operations.find(operation => operation.path.includes('search-notes')).count, 3);
|
|
27
|
+
assert.equal(plans[1].operations.find(operation => operation.path.includes('note-detail')).count, 6);
|
|
28
|
+
assert.equal(plans[1].operations.find(operation => operation.path.includes('note-comments')).count, 12);
|
|
29
|
+
assert.equal(plans[0].operations.some(operation => operation.path.includes('user-info')), false);
|
|
30
|
+
assert.equal(plans[1].operations.some(operation => operation.path.includes('user-info')), true);
|
|
31
|
+
|
|
32
|
+
const estimate = {
|
|
33
|
+
workflowType: 'legacy_skill:xiaohongshu-mini-voc',
|
|
34
|
+
selectedMode: 'standard',
|
|
35
|
+
plans: [{
|
|
36
|
+
mode: 'standard',
|
|
37
|
+
expectedCny: 10,
|
|
38
|
+
operations: [{ path: 'xhs.search', costCny: 4 }, { path: 'xhs.detail', costCny: 6 }]
|
|
39
|
+
}]
|
|
40
|
+
};
|
|
41
|
+
const actual = {
|
|
42
|
+
workflowType: estimate.workflowType,
|
|
43
|
+
totals: { costCny: 12 },
|
|
44
|
+
rows: [{ path: 'xhs.search', costCny: 4 }, { path: 'xhs.detail', costCny: 8 }]
|
|
45
|
+
};
|
|
46
|
+
const sample = compareEstimateToActual(estimate, actual);
|
|
47
|
+
assert.equal(sample.status, 'calibrated');
|
|
48
|
+
assert.equal(sample.deltaCny, 2);
|
|
49
|
+
assert.equal(sample.deltaPct, 20);
|
|
50
|
+
|
|
51
|
+
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'voc-cost-calibration-'));
|
|
52
|
+
const historyPath = path.join(tmp, 'calibration.json');
|
|
53
|
+
appendCalibrationSample(historyPath, sample);
|
|
54
|
+
const summary = summarizeCalibration(readHistory(historyPath));
|
|
55
|
+
assert.equal(summary.sampleCount, 1);
|
|
56
|
+
assert.equal(summary.actualCny, 12);
|
|
57
|
+
console.log('legacy skill planner and cost calibration smoke passed');
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
try {
|
|
61
|
+
main();
|
|
62
|
+
} catch (error) {
|
|
63
|
+
console.error(error.stack || error);
|
|
64
|
+
process.exitCode = 1;
|
|
65
|
+
}
|
|
@@ -7,10 +7,14 @@ description: VOC 复杂采集任务的执行前成本估算、预算检查和执
|
|
|
7
7
|
|
|
8
8
|
对账号、商品或关键词规模较大的任务,先调用 `voc_cost_estimate`。Agent 根据返回的接口计划、当前生效价目和余额向用户说明经济版、标准版、完整版的预期成本区间,不把估算当作已消费。
|
|
9
9
|
|
|
10
|
+
历史 OpenClaw 编排 Skill 也可以直接报价:将该 Skill 的目录或 `api-config.json` 作为 `skillConfigPath` 传给 `voc_cost_estimate`,适配器只读取 `parameters` 默认值和 `pipeline` 中的接口步骤,自动估算 `forEach`、分页和可选步骤的调用次数。经济版会跳过带 `condition` 的可选步骤;标准版和完整版沿用配置声明的批量范围,不执行历史 Skill,也不读取其凭证。
|
|
11
|
+
|
|
10
12
|
执行过程中使用同一 `usageTraceId`,每批次前调用预算守卫:本地预算达到 80% 发出提示,超过硬上限停止新增采集并保存检查点。NewAPI 仍按每次实际调用实时扣费,不创建任务级支付或冻结余额。
|
|
11
13
|
|
|
12
14
|
VOC 数据余额不足时,保留已完成结果和检查点,返回差额、建议充值金额、Token 免登录 Balance 链接或现有二维码支付结果。到账后重新查询余额并从检查点继续;Agent 模型自身 402 由宿主运行时处理。
|
|
13
15
|
|
|
14
16
|
任务完成后调用 `voc_usage_report`,按追踪标识或日期范围展示调用次数、计费次数、quota、用户扣费和接口拆分。大模型费用与 VOC 数据费用分开;没有精确追踪时标记为 `reconstructed` 或 `mixed`。
|
|
15
17
|
|
|
18
|
+
将执行前输出目录中的 `cost-estimate.json` 通过 `estimatePath` 传给 `voc_usage_report`,可同时得到预计与实际金额的偏差、方向和接口级差异。设置本地 `calibrationPath` 后会追加校准样本,供后续由 Agent 调整任务规模、分页和可选接口的报价假设;该文件只保存汇总金额和接口标识,不保存凭证或原始请求。
|
|
19
|
+
|
|
16
20
|
价格唯一来源是服务端 billing query。目录中的 `billing` 字段不能被当作人民币单价。
|
|
@@ -17,3 +17,25 @@
|
|
|
17
17
|
- 评价:`S * K * P(taobao.get_item_comment_v3)`
|
|
18
18
|
|
|
19
19
|
接口单价、quota 和版本始终从 billing query 返回值读取。每个步骤可附 `valueScore`、`required` 与 `fallback`,用于成本价值排序;是否删除字段必须说明信息损失。
|
|
20
|
+
|
|
21
|
+
## 历史 Skill 规划适配
|
|
22
|
+
|
|
23
|
+
历史 OpenClaw 编排 Skill 的 `api-config.json` 可以作为 `voc_cost_estimate.skillConfigPath` 输入。适配器读取:
|
|
24
|
+
|
|
25
|
+
- `parameters.*.default`:补齐未显式传入的任务规模;
|
|
26
|
+
- `pipeline[].api`:生成模型/通道、方法、接口标识和标签;
|
|
27
|
+
- `pipeline[].forEach`:识别关键词、账号、ASIN、商品、入选笔记/视频等集合规模;
|
|
28
|
+
- `pipeline[].pagination.maxPages`:将分页上限乘入调用次数;
|
|
29
|
+
- `pipeline[].condition`:标记可选步骤,经济版默认剔除。
|
|
30
|
+
|
|
31
|
+
`type=compute` 的清洗、去重和统计步骤不计为接口调用。无法从自然语言 `forEach` 安全推断时只按 1 次估算,并在计划中保留原步骤名,Agent 应在报价中说明该假设并建议用一次小规模实际运行校准。
|
|
32
|
+
|
|
33
|
+
## 估算偏差校准
|
|
34
|
+
|
|
35
|
+
执行前 `voc_cost_estimate` 在 `outputDir/cost-estimate.json` 保存计划快照;执行后 `voc_usage_report` 传入 `estimatePath`,会输出:
|
|
36
|
+
|
|
37
|
+
- `expectedCny`、`actualCny`、`deltaCny` 和 `deltaPct`;
|
|
38
|
+
- `direction`:`underestimated`、`overestimated` 或 `aligned`;
|
|
39
|
+
- 按接口的预计/实际差异。
|
|
40
|
+
|
|
41
|
+
传入 `calibrationPath` 会追加本地 JSON 样本,建议按客户、工作流和日期隔离。校准只改变下一轮 Agent 的计划假设,不改变服务端实际计费规则,也不创建任务预付款。
|