销售通话分析 call-analyzer:录音 URL → 平台 fun-asr-flash 转写 → AI 提炼客户意图/痛点/异议/情绪/下一步动作/成单可能性/关键引用句。结构化 JSON 直接灌 CRM,0.79 元/小时录音。适合销售复盘、客户洞察、新人培训、电销跟单、通话处理。
---
name: call-analyzer
description: 销售通话分析 call-analyzer:上传录音 URL → 调 AIMS 平台 fun-asr-flash 转写 → AI 提炼客户意图/痛点/异议/情绪/下一步动作/成单可能性/关键引用句。结构化 JSON 直接灌 CRM。0.79 元/小时录音。适合销售复盘、客户洞察、新人培训、电销跟单。
---
本 skill 的真实能力依赖平台后端工具。在网页 / chat 端点下,这些工具可能不可用:
此时**请勿轻信**,请改用 **agent / CLI 路径**运行本 skill 以获取真实结果。任何路径下都**严禁谎称「已调用 / 已搜索完成」而实际未执行**。
把销售通话录音转成文字,再用 AI 提炼出客户意图、痛点、异议、情绪、下一步动作、成单可能性 —— 一键输出结构化报告。底层调 AIMS 平台 `fun-asr-flash-2026-06-15`(0.00022 元/秒 = 0.79 元/小时录音)+ `qwen-flash` 文本分析。
**场景 1:销售周会复盘** —— 销售主管把本周 50 通录音上传,让 call-analyzer 跑一遍,自动出 50 份结构化分析,看哪些客户在比价、哪些有异议、哪些已成单。每通 0.79 元 + AI 0.01 元 ≈ 40 元搞定。
**场景 2:新人销售培训** —— 新人不知道怎么处理"价格异议",用 call-analyzer 跑 100 通 Top Sales 的录音,提取关键话术 + 异议处理方式,做成销售培训手册。
**场景 3:电销跟单优先级** —— 50 个客户沟通完不知道谁先跟单,看 call-analyzer 输出的 `deal_likelihood` 字段:"高/中/低",按优先级跟单。
| 角色 | 典型用途 |
|------|----------|
| 销售主管 / 经理 | 销售周会复盘、Top Sales 话术提炼 |
| 销售 / 客户经理 | 客户异议识别、成单可能性判断 |
| 销售新人 | 学习 Top Sales 话术 |
| 电销团队 | 跟单优先级排序 |
| 客户成功 / 客服 | 客户满意度分析、流失预警 |
| 市场调研 | 客户访谈录音分析 |
# 单条分析
python scripts/analyze.py analyze --url https://your-cdn.com/call-2026-09-16-001.mp3
输出 JSON(10 字段):
{
"ok": true,
"url": "https://...",
"transcript_chars": 1234,
"transcript": "客户:你好,我是XX公司的...",
"analysis": {
"summary": "客户在比价,对价格敏感",
"customer_intent": "比价",
"pain_points": ["现有方案成本高", "实施周期长"],
"objections": ["价格比XX贵30%"],
"positive_signals": ["提到预算已审批"],
"negative_signals": ["要求本周内决定"],
"emotion": "中性偏消极",
"next_action": "提供 XX 同级别报价 + 缩短交付周期方案",
"deal_likelihood": "中",
"key_quotes": ["你们能不能比XX再便宜点?"]
}
}
# 批量(urls.txt 每行一个 mp3 URL)
python scripts/analyze.py batch --input urls.txt --output report.json
# 只转写不分析(省 0.01 元 AI 费)
python scripts/analyze.py transcribe --url https://...call.mp3
用户说"分析这段销售通话录音"、"把这段电话录音转成文字 + 出分析报告"、"录音已经传到 OSS 了帮我分析"时触发。
用户说"分析这段销售通话录音"、"把这段电话录音转成文字 + 出分析报告"、"录音已经传到 OSS 了帮我分析"时触发。
无(标准库 + `_lib/aims_chat.py`)。鉴权需 `AIMS_API_KEY`。
平台只接受**公网 http(s) URL**,不接受本地文件 / base64 / data URL。常见做法:
| 文件来源 | 上传方式 |
|---------|---------|
| 手机录音 | 微信传电脑 → 传到阿里云 OSS / 腾讯云 COS / Cloudflare R2 → 拿 URL |
| 通话系统录音 | 多数 SaaS 通话系统自带导出公网 URL 功能 |
| Zoom / Teams | 录完后导出 mp3,丢到 OSS |
**限制**:URL 长度 < 8000 字符(实测),所以**小文件传 base64 + audio_b64 也不行**——必须公网 URL。
python scripts/analyze.py analyze --url https://your-cdn.com/call-2026-09-16-001.mp3
输出 JSON:
{
"ok": true,
"url": "https://...",
"transcript_chars": 1234,
"transcript": "客户:你好,我是XX公司的...(完整转写)",
"analysis": {
"summary": "客户在比价,对价格敏感",
"customer_intent": "比价",
"pain_points": ["现有方案成本高", "实施周期长"],
"objections": ["价格比XX贵30%"],
"positive_signals": ["提到预算已审批"],
"negative_signals": ["要求本周内决定"],
"emotion": "中性偏消极",
"next_action": "提供 XX 同级别报价 + 缩短交付周期方案",
"deal_likelihood": "中",
"key_quotes": ["你们能不能比XX再便宜点?"]
}
}
# urls.txt:每行一个 mp3 URL
python scripts/analyze.py batch --input urls.txt --output report.json
输出 `report.json` 含每条分析结果 + 成功/失败标记。
python scripts/analyze.py transcribe --url https://...call.mp3
返回纯转写文字(无 AI 分析,省 0.01 元/条)。
| 项 | 单价 | 备注 |
|----|------|------|
| ASR(fun-asr-flash)| **0.00022 元/秒** | 1 小时录音 = 0.79 元 |
| AI 分析(qwen-flash)| **0.00001 元/条** | 21 token 输出 |
| **平台卖价** | **0.20 元/次** | 30 分钟通话成本 0.36 元,亏本;建议 30 分钟内 |
**建议定价**:
**算成本**:永远用「调用前后余额差」算,不要相信 `usage.token`。
| 错误 | 原因 | 解决 |
|------|------|------|
| `fetch audio failed: URL too long` | URL > 8000 字符 | 用短 URL / 短链服务 |
| `fetch audio failed: 301/404` | 链接失效或重定向 | 检查 OSS URL 是否公开、有效期 |
| `upstream 404` | 平台后端 ASR 不识别格式 | 平台目前只支持 mp3;wav 需转 mp3 |
| `INSUFFICIENT_BALANCE` | 余额不足 | 让用户去 https://aimsgateway.cn 充值 |
| 转写为空 | 静音/损坏/全是英文(默认 language=zh)| 加 `--language en` 重试 |
A: AIMS 平台的 `aims.transcribe` 接口设计成接收公网 URL(方便处理大文件流),不支持本地文件上传。如需本地文件上传,需自行做"先传 OSS 再调接口"的封装。
A: 接口本身不限时长,但 1 小时录音 ≈ 0.79 元成本。30 分钟以内最划算。
A: fun-asr-flash 对中文普通话准确率约 95%+,方言/口音/专业术语会降低。`qwen3-asr-flash-realtime` 适合实时流(贵 50%)。
A: 串行跑约 100 × 5 秒 = 8 分钟。如需并行,本地开多进程(但要小心余额)。
<!-- ===== 以下为内嵌脚本代码(agent 安装时按需落盘为 scripts/<name> 并 chmod +x) ===== -->
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""销售通话分析:公网可访问的录音 URL → 调平台 ASR 转写 → AI 分析痛点/意图/情绪/下一步。
用法示例:
python analyze.py transcribe --url https://your-cdn.com/call.mp3
python analyze.py analyze --url https://your-cdn.com/call.mp3 --context "我是做 SaaS 的"
python analyze.py analyze --url https://your-cdn.com/call.mp3 --format json
python analyze.py batch --input urls.txt --output report.json
依赖:标准库(无需 pip install)
鉴权:AIMS_API_KEY 环境变量 或 --api-key
"""
import argparse
import json
import sys
from pathlib import Path
# 让脚本既可作为 call-analyzer 独立 skill 运行
_HERE = Path(__file__).resolve().parent
_ROOT = _HERE.parent.parent
# _lib 查找(2026-09-22 修复):优先包内 _lib/(发布包已随附),
# 退回开发布局 office-skills/_lib —— 否则装到用户侧会 ModuleNotFoundError。
for _lib_cand in (Path(__file__).resolve().parent.parent / "_lib",
Path(__file__).resolve().parent.parent.parent / "_lib"):
if _lib_cand.is_dir():
sys.path.insert(0, str(_lib_cand))
break
else:
raise RuntimeError(
"找不到 _lib/(需要 aims_functional_api.py)。"
"发布包应自带 _lib/;开发环境下请确认 office-skills/_lib 存在。"
)
from aims_chat import chat_text_json # noqa: E402
ASR_MODEL = "fun-asr-flash-2026-06-15" # 0.00022 元/秒
ANALYSIS_MODEL = "qwen-flash" # 0.15/1.5 元/百万 token(最便宜文本)
AIMS_URL = "https://aimsgateway.cn/mcp/"
def _get_key(args):
return args.api_key or __import__("os").environ.get("AIMS_API_KEY")
def _rpc_call(api_key, args_obj):
"""直接调 aims.transcribe(绕开 helper,因为 helper 只支持 chat)。"""
import urllib.request
body = json.dumps({"jsonrpc": "2.0", "id": 7, "method": "tools/call",
"params": {"name": "aims.transcribe", "arguments": args_obj}}).encode()
headers = {"Content-Type": "application/json", "Authorization": f"Bearer {api_key}"}
req = urllib.request.Request(AIMS_URL, data=body, method="POST", headers=headers)
with urllib.request.urlopen(req, timeout=300) as r:
resp = json.loads(r.read())
txt = resp.get("result", {}).get("content", [{}])[0].get("text", "")
is_err = resp.get("result", {}).get("isError")
if is_err:
raise RuntimeError(f"平台错误: {txt[:300]}")
try:
return json.loads(txt)
except Exception:
return {"_raw_text": txt}
def transcribe(api_key, url, language="zh"):
"""调平台 ASR 转写录音。"""
if not url.startswith(("http://", "https://")):
raise ValueError("--url 必须是 http(s) 公网可访问 URL;本地文件请先传到 OSS / CDN")
return _rpc_call(api_key, {
"model": ASR_MODEL,
"audio_url": url,
"language": language,
})
def _extract_text(transcribe_resp):
"""从 transcribe 返回里拿文字(兼容多种返回结构)。"""
if isinstance(transcribe_resp, dict):
for key in ("text", "transcript", "result"):
if key in transcribe_resp and isinstance(transcribe_resp[key], str):
return transcribe_resp[key]
if "choices" in transcribe_resp:
try:
return transcribe_resp["choices"][0]["message"]["content"]
except Exception:
pass
if "_raw_text" in transcribe_resp:
return transcribe_resp["_raw_text"]
return str(transcribe_resp)
def analyze(api_key, url, context="", language="zh"):
"""转写 + AI 分析(销售通话场景)。"""
transcript_resp = transcribe(api_key, url, language)
transcript = _extract_text(transcript_resp)
if not transcript.strip():
return {"ok": True, "url": url, "transcript": "", "analysis": {},
"note": "转写为空(可能静音或音频损坏)", "raw_transcribe": transcript_resp}
# AI 分析 prompt
ctx_line = f"\n补充背景:{context}" if context else ""
prompt = (
"你是一个销售通话分析助手。下面是销售与客户的电话录音转写文本,"
"请从以下维度分析,**严格返回 JSON**(不要解释、不要 Markdown 代码块):\n"
"{\n"
' "summary": "通话核心内容一句话总结(≤30字)",\n'
' "customer_intent": "客户当前意图(了解产品/比价/投诉/采购/闲聊)",\n'
' "pain_points": ["客户提到的痛点1", "痛点2"],\n'
' "objections": ["客户异议1", "异议2"],\n'
' "positive_signals": ["积极信号1"],\n'
' "negative_signals": ["消极信号1"],\n'
' "emotion": "客户整体情绪(积极/中性/消极/愤怒)",\n'
' "next_action": "建议销售下一步具体动作(≤30字)",\n'
' "deal_likelihood": "成单可能性(高/中/低)",\n'
' "key_quotes": ["客户原话摘录1", "原话摘录2"]\n'
"}\n"
f"--- 转写文本 ---\n{transcript[:6000]}"
f"{ctx_line}"
)
analysis = chat_text_json(prompt, model=ANALYSIS_MODEL, api_key=api_key, no_rag=True)
return {
"ok": True,
"url": url,
"transcript_chars": len(transcript),
"transcript": transcript,
"analysis": analysis,
"models": {"asr": ASR_MODEL, "analysis": ANALYSIS_MODEL},
}
def cmd_transcribe(args):
api_key = _get_key(args)
if not api_key:
raise RuntimeError("缺少 AIMS_API_KEY")
result = transcribe(api_key, args.url, args.language)
return {"ok": True, "url": args.url, "transcribe": result,
"text": _extract_text(result)}
def cmd_analyze(args):
api_key = _get_key(args)
if not api_key:
raise RuntimeError("缺少 AIMS_API_KEY")
result = analyze(api_key, args.url, args.context, args.language)
if args.output:
Path(args.output).parent.mkdir(parents=True, exist_ok=True)
Path(args.output).write_text(json.dumps(result, ensure_ascii=False, indent=2),
encoding="utf-8")
result["output"] = args.output
return result
def cmd_batch(args):
"""批量分析多条 URL。"""
api_key = _get_key(args)
if not api_key:
raise RuntimeError("缺少 AIMS_API_KEY")
urls = [u.strip() for u in Path(args.input).read_text(encoding="utf-8").splitlines()
if u.strip() and not u.startswith("#")]
results = []
for i, url in enumerate(urls, 1):
try:
r = analyze(api_key, url)
results.append({"url": url, "ok": True, **r})
except Exception as e:
results.append({"url": url, "ok": False, "error": str(e)})
print(f" [{i}/{len(urls)}] {url} → {'OK' if results[-1].get('ok') else 'FAIL'}")
out = {"ok": True, "total": len(urls), "results": results}
if args.output:
Path(args.output).parent.mkdir(parents=True, exist_ok=True)
Path(args.output).write_text(json.dumps(out, ensure_ascii=False, indent=2),
encoding="utf-8")
out["output"] = args.output
return out
def build_parser():
ap = argparse.ArgumentParser(description="销售通话分析(调 AIMS 平台 ASR + AI)")
ap.add_argument("--api-key", default=None)
sub = ap.add_subparsers(dest="command", required=True)
p = sub.add_parser("transcribe", help="只转写,不分析")
p.add_argument("--url", required=True)
p.add_argument("--language", default="zh", help="zh/en/ja 等,默认 zh")
p.set_defaults(func=cmd_transcribe)
p = sub.add_parser("analyze", help="转写 + AI 分析(推荐)")
p.add_argument("--url", required=True)
p.add_argument("--context", default="", help="补充背景,如「我做 SaaS 卖给制造业」")
p.add_argument("--language", default="zh")
p.add_argument("--output", default=None, help="结果另存 json 文件")
p.set_defaults(func=cmd_analyze)
p = sub.add_parser("batch", help="批量分析(urls 文件每行一个 URL)")
p.add_argument("--input", required=True, help="URL 列表文件")
p.add_argument("--output", default=None)
p.set_defaults(func=cmd_batch)
return ap
def main():
args = build_parser().parse_args()
try:
result = args.func(args)
except Exception as e:
print(json.dumps({"ok": False, "error": str(e)}, ensure_ascii=False))
sys.exit(1)
print(json.dumps(result, ensure_ascii=False, indent=2))
if __name__ == "__main__":
main()
<!-- ===== 以下为共享依赖 _lib(落盘为 _lib/<name>,与 scripts/ 同级上层) ===== -->
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""AIMS 平台 AI 调用公共 helper(6 个 skill 复用)。
能力:
- chat_text(prompt, model="qwen-flash") 纯文本对话
- chat_vision(image_path_or_url, prompt, ...) 图片+文本多模态
- chat_pdf_pages(pdf_path, prompt, zoom=2.0) 扫描版 PDF OCR
- chat_with_usage(...) 返回文本+用量
环境:依赖 AIMS_API_KEY 环境变量或 --api-key
"""
import argparse
import base64
import json
import os
import sys
import time
import urllib.request
from pathlib import Path
AIMS_URL = "https://aimsgateway.cn/mcp/"
TEXT_MODEL = "qwen-flash" # 通用文本(最便宜)
VISION_MODEL = "qwen-vl-ocr-latest" # 视觉/OCR
# ---------- 鉴权 & 协议 ----------
def _get_key(api_key=None):
key = api_key or os.environ.get("AIMS_API_KEY")
if not key:
raise RuntimeError(
"缺少 AIMS API Key:请设置环境变量 AIMS_API_KEY,或调用方传 --api-key"
)
return key
def _rpc(url, key, method, params, mid, tries=3, timeout=180):
body = json.dumps({"jsonrpc": "2.0", "id": mid, "method": method, "params": params}).encode()
headers = {"Content-Type": "application/json", "Authorization": f"Bearer {key}"}
last = None
for _ in range(tries):
try:
req = urllib.request.Request(url, data=body, method="POST", headers=headers)
with urllib.request.urlopen(req, timeout=timeout) as r:
return json.loads(r.read().decode())
except Exception as e: # noqa: BLE001
last = e
time.sleep(2)
raise RuntimeError(f"MCP 调用失败: {last}")
def _handshake(key):
_rpc(AIMS_URL, key, "initialize",
{"protocolVersion": "2025-03-26", "capabilities": {},
"clientInfo": {"name": "office-skills", "version": "1.0"}}, 1)
try:
_rpc(AIMS_URL, key, "notifications/initialized", {}, 2)
except Exception:
pass
# ---------- 图片编码 ----------
def _file_to_data_url(path):
p = Path(path)
if not p.exists():
raise FileNotFoundError(path)
b64 = base64.b64encode(p.read_bytes()).decode()
ext = p.suffix.lower()
mime = {
"png": "image/png", "jpg": "image/jpeg", "jpeg": "image/jpeg",
"gif": "image/gif", "webp": "image/webp", "bmp": "image/bmp",
}.get(ext, "image/png")
return f"data:{mime};base64,{b64}"
def _pdf_page_to_data_url(pdf_path, page_index=0, zoom=2.0):
try:
import fitz # PyMuPDF
except ImportError:
raise RuntimeError("PDF 多模态需要 PyMuPDF:pip install pymupdf")
doc = fitz.open(pdf_path)
if page_index < 0 or page_index >= doc.page_count:
raise IndexError(f"页码 {page_index} 越界,共 {doc.page_count} 页")
pix = doc[page_index].get_pixmap(matrix=fitz.Matrix(zoom, zoom))
raw = pix.tobytes("png")
b64 = base64.b64encode(raw).decode()
return f"data:image/png;base64,{b64}"
# ---------- 聊天封装 ----------
def _chat_call(key, model, content, max_tokens=4000, temperature=0, mid=7, no_rag=True):
"""调 aims.chat。
关键参数 `no_rag` 默认 True(2026-09-16 平台确认):
- 平台默认给 chat 注入知识库(KB)内容,视觉模型/OCR 收到 RAG 文本会跑偏
- 加上 `no_rag: True` 后,模型纯粹根据 prompt + 图片回答
- 纯文本调用也建议加,避免被 KB 干扰
"""
arguments = {
"model": model,
"messages": [{"role": "user", "content": content}],
"max_tokens": max_tokens, "temperature": temperature,
"no_rag": no_rag,
}
resp = _rpc(AIMS_URL, key, "tools/call",
{"name": "aims.chat", "arguments": arguments}, mid)
txt = resp.get("result", {}).get("content", [{}])[0].get("text", "")
if not txt:
raise RuntimeError("平台返回为空: " + json.dumps(resp, ensure_ascii=False)[:300])
try:
obj = json.loads(txt)
return obj["choices"][0]["message"]["content"], obj.get("usage", {})
except Exception:
return txt, {}
def chat_text(prompt, model=None, max_tokens=4000, temperature=0, api_key=None, no_rag=True):
"""纯文本对话:返回文本。"""
key = _get_key(api_key)
_handshake(key)
model = model or TEXT_MODEL
text, _ = _chat_call(key, model, prompt, max_tokens, temperature, no_rag=no_rag)
return text.strip()
def chat_text_with_usage(prompt, model=None, max_tokens=4000, temperature=0, api_key=None, no_rag=True):
key = _get_key(api_key)
_handshake(key)
model = model or TEXT_MODEL
text, usage = _chat_call(key, model, prompt, max_tokens, temperature, no_rag=no_rag)
return text.strip(), usage
def chat_vision(image_input, prompt, model=None, max_tokens=4000, api_key=None, no_rag=True):
"""图片对话:image_input 可为本地路径或 data: URI。"""
key = _get_key(api_key)
_handshake(key)
model = model or VISION_MODEL
if image_input.startswith("data:"):
data_url = image_input
else:
data_url = _file_to_data_url(image_input)
content = [
{"type": "image_url", "image_url": {"url": data_url}},
{"type": "text", "text": prompt},
]
text, _ = _chat_call(key, model, content, max_tokens, 0, no_rag=no_rag)
return text.strip()
def chat_pdf_page(pdf_path, page_index, prompt, model=None, zoom=2.0, max_tokens=4000, api_key=None, no_rag=True):
"""扫描版 PDF 单页:先渲染成图,再视觉 OCR。"""
data_url = _pdf_page_to_data_url(pdf_path, page_index, zoom)
return chat_vision(data_url, prompt, model=model, max_tokens=max_tokens, api_key=api_key, no_rag=no_rag)
# ---------- JSON 输出辅助 ----------
def _strip_code_fence(s):
s = s.strip()
if s.startswith("```"):
lines = s.splitlines()
if lines and lines[0].startswith("```"):
lines = lines[1:]
if lines and lines[-1].strip().startswith("```"):
lines = lines[:-1]
s = "\n".join(lines)
return s.strip()
def chat_text_json(prompt, model=None, max_tokens=4000, api_key=None, no_rag=True):
"""纯文本对话 → 强制 JSON 输出(解析失败抛错)。"""
text = chat_text(prompt, model=model, max_tokens=max_tokens, api_key=api_key, no_rag=no_rag)
return json.loads(_strip_code_fence(text))
if __name__ == "__main__":
ap = argparse.ArgumentParser(description="AIMS 平台 AI 调用 helper 自检")
ap.add_argument("--api-key", default=None)
ap.add_argument("--text", default="用一句话介绍 Python。")
args = ap.parse_args()
print("=== chat_text ===")
print(chat_text(args.text, api_key=args.api_key))
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""AIMS 平台功能型 API 公共调用 helper(付费 skill 共用)。
能力:
- call_functional_api(resource_id, arguments) 统一调用平台托管功能型 API
- needs_confirm → 抛出 NeedsConfirmation(结构化,带真实报价)
- estimate_cost(resource_id, units) 免费报价(不调上游,0 花费)
协议(2026-09-22 platform-paid-confirm-protocol):
付费工具默认 **confirm=False**(安全默认)。平台返回 -32006 时不扣费,
本模块把它翻译成 NeedsConfirmation 异常(带 estimated_cost_yuan),
而不是笼统的 RuntimeError——这样 agent 能把真实报价展示给用户,
用户确认后加 --approved 重试。
环境:依赖 AIMS_API_KEY 环境变量或 --api-key
"""
import argparse
import json
import os
import time
import urllib.request
AIMS_URL = "https://aimsgateway.cn/mcp/"
# 内部源名 → 对外目录名(与平台 application/services/public_naming.py 的
# PUBLIC_SOURCES 对齐;未登记的源原样透出)。estimate 用它把内部 resource_id
# 映射成 list_functional_apis 返回的对外 id。
_EXTERNALIZE = {"serpapi": "web", "hunter": "email", "jev": "decision", "apis": "public-api"}
class NeedsConfirmation(RuntimeError):
"""付费工具未确认 —— 平台返回 -32006 / requires_confirmation。
仍未扣费。调用方应把 estimated_cost_yuan 展示给用户,
用户同意后带 confirm=True 重试。
继承 RuntimeError 是为了向后兼容既有 ``except RuntimeError`` 调用方。
"""
def __init__(self, resource_id, estimated_cost_yuan=0.0, basis="", raw=None, hint=""):
self.resource_id = resource_id
self.estimated_cost_yuan = float(estimated_cost_yuan or 0.0)
self.basis = basis or ""
self.raw = raw if isinstance(raw, dict) else {}
self.hint = hint or ""
super().__init__(
f"付费工具需确认: {resource_id} 约 ¥{self.estimated_cost_yuan:.2f} "
f"(价格依据: {self.basis or 'unknown'})。"
f"请把该报价展示给用户;确认后加 --approved 重试。未确认不会扣费。"
)
def to_dict(self):
"""给 CLI 输出的结构化载荷(agent 可直接机读)。"""
return {
"ok": False,
"needs_confirmation": True,
"resource_id": self.resource_id,
"estimated_cost_yuan": self.estimated_cost_yuan,
"cost_basis": self.basis or "unknown",
"hint": self.hint or "把费用展示给用户;用户确认后加 --approved 重试",
}
def _external_rid(resource_id):
"""内部 resource_id → 对外目录 id。serpapi/google → web/google"""
src, _, rest = (resource_id or "").strip().partition("/")
ext = _EXTERNALIZE.get(src, src)
return f"{ext}/{rest}" if rest else ext
def _maybe_needs_confirmation(resource_id, parsed):
"""把平台的 -32006 载荷翻译成 NeedsConfirmation;不是则返回 None。
兼容三种形态:
A {"error": {"code": -32006, "data": {...}}} 平台 JSON-RPC 包装
B {"code": -32006, "data": {...}} 已解包的 error 对象
C {"requires_confirmation": true, ...} 直接是 data
"""
if not isinstance(parsed, dict):
return None
err = parsed.get("error")
if isinstance(err, dict) and err.get("code") == -32006:
d = err.get("data") if isinstance(err.get("data"), dict) else {}
return NeedsConfirmation(resource_id, d.get("estimated_cost_yuan"),
d.get("cost_basis", ""), err, d.get("hint", ""))
if parsed.get("code") == -32006:
d = parsed.get("data") if isinstance(parsed.get("data"), dict) else {}
return NeedsConfirmation(resource_id, d.get("estimated_cost_yuan"),
d.get("cost_basis", ""), parsed, d.get("hint", ""))
if parsed.get("requires_confirmation") is True:
return NeedsConfirmation(resource_id, parsed.get("estimated_cost_yuan"),
parsed.get("cost_basis", ""), parsed, parsed.get("hint", ""))
return None
def raise_if_needs_confirmation(resource_id, parsed):
"""载荷是 -32006 时抛 NeedsConfirmation,否则原样返回 parsed。
给自建 _rpc_call 的 skill 复用(它们拿到 isError 载荷后调这个,
就能和走本模块的 skill 有同一套结构化确认语义)。
"""
nc = _maybe_needs_confirmation(resource_id, parsed)
if nc is not None:
raise nc
return parsed
# ---------- 鉴权 & 协议 ----------
def _get_key(api_key=None):
key = api_key or os.environ.get("AIMS_API_KEY")
if not key:
raise RuntimeError(
"缺少 AIMS API Key:请设置环境变量 AIMS_API_KEY,或调用方传 --api-key"
)
return key
def _rpc(url, key, method, params, mid, tries=3, timeout=120):
body = json.dumps({"jsonrpc": "2.0", "id": mid, "method": method, "params": params}).encode()
headers = {"Content-Type": "application/json", "Authorization": f"Bearer {key}"}
last = None
for _ in range(tries):
try:
req = urllib.request.Request(url, data=body, method="POST", headers=headers)
with urllib.request.urlopen(req, timeout=timeout) as r:
return json.loads(r.read().decode())
except Exception as e: # noqa: BLE001
last = e
time.sleep(2)
raise RuntimeError(f"MCP 调用失败: {last}")
def _handshake(key):
_rpc(AIMS_URL, key, "initialize",
{"protocolVersion": "2025-03-26", "capabilities": {},
"clientInfo": {"name": "office-skills-func-api", "version": "1.0"}}, 1)
try:
_rpc(AIMS_URL, key, "notifications/initialized", {}, 2)
except Exception:
pass
# ---------- 功能型 API 调用 ----------
def call_functional_api(resource_id, arguments=None, api_key=None, mid=3, confirm=False):
"""调 AIMS 平台的功能型 API。
Args:
resource_id: 资源 ID,9 个可选值:
- hunter/account
- hunter/domain-search
- hunter/email-finder
- hunter/email-verifier
- serpapi/account
- serpapi/{engine}
- apis/catalog
- apis/{slug}
- apis/{slug}/{path}
arguments: dict,参数键值对(按上游 API 的 params 字段说明)
api_key: 可选 Bearer Token(默认读 env)
confirm: True 时附 confirm:true,平台放行付费工具;False 时平台返回
-32006 / requires_confirmation,未付费不扣费(默认 False,安全默认)
返回 dict(平台直接返回上游响应 + 末尾 `_aimschina` 元数据)
抛出 RuntimeError 当 isError=True
"""
key = _get_key(api_key)
_handshake(key)
args = dict(arguments or {})
if confirm:
args["confirm"] = True
resp = _rpc(AIMS_URL, key, "tools/call",
{"name": "aims.call_functional_api",
"arguments": {"resource_id": resource_id, "arguments": args}}, mid)
is_err = resp.get("result", {}).get("isError")
text = resp.get("result", {}).get("content", [{}])[0].get("text", "{}")
parsed = json.loads(text)
if is_err:
_nc = _maybe_needs_confirmation(resource_id, parsed)
if _nc is not None:
raise _nc
raise RuntimeError(f"{resource_id} 错误: {parsed}")
return parsed
# ---------- 9 个快捷封装 ----------
def hunter_account(api_key=None, confirm=False):
"""hunter 账户与配额(免费)。"""
return call_functional_api("hunter/account", {}, api_key=api_key, confirm=confirm)
def hunter_domain_search(domain, limit=10, offset=0, type=None,
seniority=None, department=None, api_key=None, confirm=False):
"""按域名搜邮箱。返回 {"domain":..., "emails":[{value,type,confidence,position,...}], "_aimschina":{cost_yuan,...}}"""
args = {"domain": domain, "limit": str(limit)}
if offset:
args["offset"] = str(offset)
if type:
args["type"] = type
if seniority:
args["seniority"] = seniority
if department:
args["department"] = department
return call_functional_api("hunter/domain-search", args, api_key=api_key, confirm=confirm)
def hunter_email_finder(domain, full_name=None, first_name=None, last_name=None,
max_duration=10, api_key=None, confirm=False):
"""按人名 + 域名推断邮箱。"""
args = {"domain": domain, "max_duration": str(max_duration)}
if full_name:
args["full_name"] = full_name
elif first_name and last_name:
args["first_name"] = first_name
args["last_name"] = last_name
return call_functional_api("hunter/email-finder", args, api_key=api_key, confirm=confirm)
def hunter_email_verifier(email, api_key=None, confirm=False):
"""验证邮箱是否可投递。返回 {"status": "valid"|"invalid"|"accept_all"|"webmail"|"disposable"|"unknown", ...}"""
return call_functional_api("hunter/email-verifier", {"email": email}, api_key=api_key, confirm=confirm)
def serpapi_account(api_key=None, confirm=False):
"""serpapi 账户与配额(免费)。"""
return call_functional_api("serpapi/account", {}, api_key=api_key, confirm=confirm)
def serpapi_search(engine, q=None, num=10, page=1, gl=None, hl=None,
location=None, api_key=None, confirm=False):
"""透传到任意 serpapi 引擎(google / google_maps / google_shopping 等)。
engine 必填,例如 "google_maps" 拿 POI、"google" 拿网页搜索结果、"google_shopping" 拿商品。
实际调 serpapi.engine 工具(不是走 functional_api 路由)。
confirm=True 时附 confirm:true 放行付费工具;默认 False 平台返回 -32006 不扣费。
"""
args = {"engine": engine, "num": str(num), "page": str(page)}
if q:
args["q"] = q
if gl:
args["gl"] = gl
if hl:
args["hl"] = hl
if location:
args["location"] = location
if confirm:
args["confirm"] = True
key = _get_key(api_key)
_handshake(key)
resp = _rpc(AIMS_URL, key, "tools/call",
{"name": "serpapi.engine", "arguments": args}, 3)
is_err = resp.get("result", {}).get("isError")
text = resp.get("result", {}).get("content", [{}])[0].get("text", "{}")
parsed = json.loads(text)
if is_err:
_nc = _maybe_needs_confirmation("serpapi/" + str(engine or ""), parsed)
if _nc is not None:
raise _nc
raise RuntimeError(f"serpapi.engine({engine}) 错误: {parsed}")
return parsed
def apis_catalog(api_key=None):
"""列出所有 apis 目录(450+ 免密钥公共 API)。"""
return call_functional_api("apis/catalog", {}, api_key=api_key)
def apis_slug(slug, api_key=None):
"""调 apis/{slug} 顶层端点(GET)。"""
return call_functional_api(f"apis/{slug}", {}, api_key=api_key)
def apis_slug_path(slug, path, method="GET", body=None, params=None, api_key=None):
"""调 apis/{slug}/{path} 任意端点。
Args:
slug: 例如 "openweather"
path: 例如 "data/2.5/weather"
method: GET / POST / PUT / DELETE
body: POST/PUT body
params: query 参数
"""
args = {"method": method}
if body is not None:
args["body"] = body
if params is not None:
args["params"] = params
return call_functional_api(f"apis/{slug}/{path}", args, api_key=api_key)
# ---------- 免费报价 estimate(0 上游花费) ----------
# 本地兜底价:**只登记实测过的资源**(来源:lead-miner install.md §17.4,
# 实测日期 2026-09-17)。平台实时价取不到时才用它,且 cost_basis 会明确标
# local_fallback + price_note 提示可能有偏差——绝不拿它冒充权威报价。
# 没实测过的资源**一律不给兜底价**(宁可 ok=False,也不猜)。
LOCAL_FALLBACK_PRICE_YUAN = {
"serpapi/google_maps": 0.21,
"hunter/domain-search": 0.204,
"hunter/email-verifier": 0.10,
}
def estimate_cost(resource_id, units=1, api_key=None):
"""纯报价:走**免费**的 aims.list_functional_apis 读实时单价,不调上游。
这是唯一可靠的报价途径——catalog 声明价与实收价实测差 ~18%,
而 list_functional_apis 返回的是平台已折算的 effective 单价
(observed 实测价优先,缺失才回落 declared)。
Args:
resource_id: 内部 resource_id,如 "serpapi/google" / "hunter/domain-search"
units: 预计调用次数
api_key: 可选 Bearer Token(默认读 env)
Returns:
dict: {ok, resource_id, external_resource_id, units, unit_cost_yuan,
cost_basis, total_yuan, upstream_called: False, note}
查不到价时返回 ok=False(不抛异常——报价失败不该让调用方崩)
"""
ext = _external_rid(resource_id)
base = {
"resource_id": resource_id,
"external_resource_id": ext,
"units": int(units or 1),
"upstream_called": False,
}
def _fallback(note):
"""平台价拿不到时用本地实测兜底;没登记的资源保持 ok=False(不猜价)。"""
unit = LOCAL_FALLBACK_PRICE_YUAN.get(resource_id)
if unit is None:
return False
base.update({
"ok": True,
"unit_cost_yuan": round(unit, 4),
"cost_basis": "local_fallback",
"billing_unit": "per_call",
"total_yuan": round(unit * base["units"], 4),
"price_note": ("本地登记的【实测兜底价】(2026-09-17),非平台实时价,"
"实收可能偏差约 ±18%;要准数请配好 AIMS_API_KEY 重跑 estimate。"),
"note": note,
})
return True
try:
key = _get_key(api_key)
except RuntimeError as e:
if not _fallback("用本地实测兜底价(未触达平台)"):
base.update({"ok": False, "error": str(e),
"note": "estimate 需要 AIMS_API_KEY 才能读到平台实时单价"})
return base
try:
_handshake(key)
resp = _rpc(AIMS_URL, key, "tools/call",
{"name": "aims.list_functional_apis",
"arguments": {"limit": 200}}, 7)
text = resp.get("result", {}).get("content", [{}])[0].get("text", "{}")
payload = json.loads(text)
except Exception as e: # noqa: BLE001
if not _fallback("用本地实测兜底价(平台单价查询失败)"):
base.update({"ok": False, "error": f"读取平台单价失败: {e}"})
return base
items = payload.get("apis") or []
hit = None
for it in items:
if isinstance(it, dict) and it.get("resource_id") in (ext, resource_id):
hit = it
break
if hit is None:
if _fallback("平台目录里没有该资源,改用本地实测兜底价"):
base["not_in_catalog"] = True
return base
base.update({
"ok": False,
"error": f"平台目录里没找到 {ext}(内部 id: {resource_id})",
"note": "单价未知——不要凭猜测报给用户;请确认该资源是否已上架/被 chat 策略隐藏",
"available_count": len(items),
})
return base
unit = float(hit.get("unit_cost_yuan") or 0.0)
base.update({
"ok": True,
"name": hit.get("name") or "",
"unit_cost_yuan": round(unit, 4),
"cost_basis": hit.get("price_basis") or "declared",
"billing_unit": hit.get("billing_unit") or "per_call",
"total_yuan": round(unit * base["units"], 4),
"note": "本报价未调用上游、未扣费;用户确认后加 --approved 真实执行",
})
return base
def emit_estimate(resource_id, units=1, api_key=None):
"""CLI 用:打印报价 JSON 并以退出码结束(0=有报价 / 1=查不到)。
在 skill 里这样用(parse_args 之后、真调付费接口之前):
if args.estimate:
emit_estimate("serpapi/google")
"""
est = estimate_cost(resource_id, units=units, api_key=api_key)
print(json.dumps(est, ensure_ascii=False, indent=2))
raise SystemExit(0 if est.get("ok") else 1)
def add_estimate_flag(parser):
"""给 skill 的 argparse 统一加 --estimate(免费报价,不调上游)。"""
parser.add_argument(
"--estimate", action="store_true",
help="只报预估费用,不调用上游、不扣费(免费)",
)
return parser
# ---------- 列出当前所有可用 resource_id ----------
KNOWN_RESOURCES = [
"hunter/account",
"hunter/domain-search",
"hunter/email-finder",
"hunter/email-verifier",
"serpapi/account",
"serpapi/{engine}",
"apis/catalog",
"apis/{slug}",
"apis/{slug}/{path}",
]
if __name__ == "__main__":
import argparse
ap = argparse.ArgumentParser(description="AIMS 功能型 API helper 自检")
ap.add_argument("--api-key", default=None)
ap.add_argument("--resource", default="hunter/account", help=f"资源 ID,已知: {KNOWN_RESOURCES}")
ap.add_argument("--args", default="{}", help="JSON 字符串参数")
args = ap.parse_args()
print(f"=== call_functional_api({args.resource}) ===")
parsed_args = json.loads(args.args)
result = call_functional_api(args.resource, parsed_args, api_key=args.api_key)
print(json.dumps(result, ensure_ascii=False, indent=2)[:2000])
A: AIMS 平台的 `aims.transcribe` 接口设计成接收公网 URL(方便处理大文件流),不支持本地文件上传。如需本地文件上传,需自行做"先传 OSS 再调接口"的封装。
A: 接口本身不限时长,但 1 小时录音 ≈ 0.79 元成本。30 分钟以内最划算。
A: fun-asr-flash 对中文普通话准确率约 95%+,方言/口音/专业术语会降低。`qwen3-asr-flash-realtime` 适合实时流(贵 50%)。
A: 串行跑约 100 × 5 秒 = 8 分钟。如需并行,本地开多进程(但要小心余额)。
<!-- ===== 以下为内嵌脚本代码(agent 安装时按需落盘为 scripts/<name> 并 chmod +x) ===== -->