Install
openclaw skills install visit-analyzer拜访记录分析引擎。根据员工的拜访沟通记录,AI 分析销售阶段、跟进策略、客户洞察、承诺事项和风险预估。当员工要求查看某个客户/公司的聊天分析时触发,自动生成项目画像并输出 H5 链接。
openclaw skills install visit-analyzer你是拜访记录分析引擎。核心职责:解析员工意图 → 读取录音转录文件 → AI 分析对话内容 → 提取项目名称 → 生成项目画像 → 输出 H5 链接。
员工输入"查看我和XX公司/张三的聊天分析"
→ Step 1: Token 管理(复用 employee-radar 的分层续期逻辑)
→ Step 2: 解析意图(提取公司名/联系人/主题/项目名称 + 判断数据源类型)
→ Step 3: 客户确认(公司名不明确时,从数据库查询已有客户让员工选择)
→ Step 4: 根据 source_type 查找并读取聊天内容
├─ phone: 读取录音转录文件(transcripts/*.md)
└─ wechat: 读取微信通知(notifications/YYYY-MM-DD.json)
→ Step 5: AI 分析聊天内容(5 大模块 + 项目名称识别)
→ Step 6: 生成项目画像
(后端自动处理客户关联和画像存储)
→ Step 7: 输出摘要 + H5 链接(/project-portrait-new/{portraitId})
重要约束:
ingest 接口会自动创建/关联客户employee_code + company_name + project_name 联合确定唯一画像记录重要:Token 有效期内自动续期,不提示用户。首次使用或 Token 失效时,引导用户输入账号和密码。
macOS 不支持 date -d 和 date -Iseconds,统一使用 python3:
iso_now() { python3 -c "from datetime import datetime, timezone; print(datetime.now(timezone.utc).isoformat())"; }
to_timestamp() { python3 -c "
from datetime import datetime, timezone
import sys
try:
s = sys.argv[1]
# 尝试解析带时区的 ISO 字符串
if '+' in s or 'Z' in s:
dt = datetime.fromisoformat(s.replace('Z', '+00:00'))
else:
# 无时区信息,假设为 UTC
dt = datetime.fromisoformat(s).replace(tzinfo=timezone.utc)
print(int(dt.timestamp()))
except Exception:
print(0)
" "$1" 2>/dev/null || echo 0; }
now_ts() { python3 -c "from datetime import datetime, timezone; print(int(datetime.now(timezone.utc).timestamp()))"; }
1. TOKEN_CACHE 不存在 → 交互输入账号和密码 → POST /auth/login
2. Token 仍有效(>7天)→ 直接使用
3. Token 即将过期(≤7天但仍未过期)→ /auth/renew-token(优先)→ 失败降级 POST /auth/login(需用户重新输入密码)
4. Token 已过期 → 引导用户重新登录(需输入账号和密码)
员工无需配置任何文件,首次使用时通过交互输入账号和密码即可完成初始化。
TOKEN_CACHE=~/.openclaw/workspace/scripts/.token-cache.json
FASTAPI_BASE_URL="http://47.116.49.218:8000/api/v1"
if [ ! -f "$TOKEN_CACHE" ]; then
echo "🔑 需要验证您的员工身份"
echo ""
echo "请输入您的账号和密码,格式:账号 密码"
echo "例如:emp-server-106 123456"
echo ""
echo "(等待用户输入...)"
# AI 从用户回复中提取 employee_id(账号)和 password(密码)
# 用户输入示例:"emp-server-106 123456" 或 "我的账号是 emp-server-106,密码是 123456"
# AI 需解析出 employee_id 和 password 两个值
response=$(curl -s -X POST "${FASTAPI_BASE_URL}/auth/login" \
-H "Content-Type: application/json" \
-d "{\"employee_id\": \"${EMPLOYEE_ID}\", \"password\": \"${PASSWORD}\"}" \
--max-time 120)
code=$(echo "$response" | jq -r '.code')
if [ "$code" = "0" ]; then
API_TOKEN=$(echo "$response" | jq -r '.data.token')
expires_at=$(echo "$response" | jq -r '.data.expires_at')
expires_in_days=$(echo "$response" | jq -r '.data.expires_in_days')
employee_name=$(echo "$response" | jq -r '.data.employee_name')
must_change_pw=$(echo "$response" | jq -r '.data.must_change_pw')
mkdir -p ~/.openclaw/workspace/scripts
echo "{\"token\": \"${API_TOKEN}\", \"employee_id\": \"${EMPLOYEE_ID}\", \"employee_name\": \"${employee_name}\", \"expires_at\": \"${expires_at}\", \"updated_at\": \"$(iso_now)\"}" > "$TOKEN_CACHE"
# 首次登录强制改密检测
if [ "$must_change_pw" = "true" ]; then
echo "⚠️ 检测到您是首次登录,需要先修改密码"
echo ""
echo "请输入新密码(至少6位):"
echo "(等待用户输入新密码...)"
# AI 从用户回复中提取 new_password
pw_response=$(curl -s -X POST "${FASTAPI_BASE_URL}/auth/change-password" \
-H "Content-Type: application/json" \
-d "{\"employee_id\": \"${EMPLOYEE_ID}\", \"old_password\": \"${PASSWORD}\", \"new_password\": \"${NEW_PASSWORD}\"}" \
--max-time 120)
pw_code=$(echo "$pw_response" | jq -r '.code')
if [ "$pw_code" = "0" ]; then
echo "✅ 密码修改成功!"
# change-password 接口已返回新 token(旧 token 已被后端删除),直接更新缓存
API_TOKEN=$(echo "$pw_response" | jq -r '.data.token')
new_expires=$(echo "$pw_response" | jq -r '.data.expires_at')
employee_name=$(echo "$pw_response" | jq -r '.data.employee_name')
echo "{\"token\": \"${API_TOKEN}\", \"employee_id\": \"${EMPLOYEE_ID}\", \"employee_name\": \"${employee_name}\", \"expires_at\": \"${new_expires}\", \"updated_at\": \"$(iso_now)\"}" > "$TOKEN_CACHE"
else
pw_error=$(echo "$pw_response" | jq -r '.message')
echo "⚠️ 密码修改失败:$pw_error"
echo "您可以稍后在管理后台修改密码"
fi
fi
echo "✅ 身份验证成功!欢迎 ${employee_name}"
else
error_message=$(echo "$response" | jq -r '.message')
echo "⚠️ 登录失败:$error_message"
echo "建议:"
echo " 1. 确认账号和密码正确"
echo " 2. 联系管理员确认您的账号是否已创建"
exit 1
fi
fi
交互输入解析规则:
| 用户输入格式 | 解析方式 | 示例 |
|---|---|---|
账号 密码 | 空格分隔,前者为账号(employee_id),后者为密码 | emp-server-106 123456 |
我的账号是xxx,密码是xxx | 自然语言提取账号和密码 | 自然语言提取 |
xxx xxx | 空格分隔,前者为账号,后者为密码 | 106 Abc123 |
重要:交互输入仅在首次使用时触发一次,Token 写入缓存后后续自动读取,不再询问。
TOKEN_CACHE=~/.openclaw/workspace/scripts/.token-cache.json
FASTAPI_BASE_URL="http://47.116.49.218:8000/api/v1"
if [ -f "$TOKEN_CACHE" ]; then
API_TOKEN=$(jq -r '.token' "$TOKEN_CACHE")
expires_at=$(jq -r '.expires_at' "$TOKEN_CACHE")
EMPLOYEE_ID=$(jq -r '.employee_id' "$TOKEN_CACHE")
EMPLOYEE_NAME=$(jq -r '.employee_name' "$TOKEN_CACHE")
# AI 从用户输入中解析出新账号时,若与缓存不一致则清除缓存并提示重新登录
if [ -n "${INPUT_EMPLOYEE_ID:-}" ] && [ "$INPUT_EMPLOYEE_ID" != "$EMPLOYEE_ID" ]; then
rm -f "$TOKEN_CACHE"
echo "🔑 检测到账号切换,请重新输入密码"
echo "(等待用户输入密码...)"
# AI 从用户回复中提取 password
response=$(curl -s -X POST "${FASTAPI_BASE_URL}/auth/login" \
-H "Content-Type: application/json" \
-d "{\"employee_id\": \"${INPUT_EMPLOYEE_ID}\", \"password\": \"${PASSWORD}\"}" \
--max-time 120)
code=$(echo "$response" | jq -r '.code')
if [ "$code" = "0" ]; then
API_TOKEN=$(echo "$response" | jq -r '.data.token')
new_expires=$(echo "$response" | jq -r '.data.expires_at')
employee_name=$(echo "$response" | jq -r '.data.employee_name')
EMPLOYEE_ID="$INPUT_EMPLOYEE_ID"
EMPLOYEE_NAME="$employee_name"
mkdir -p ~/.openclaw/workspace/scripts
echo "{\"token\": \"${API_TOKEN}\", \"employee_id\": \"${EMPLOYEE_ID}\", \"employee_name\": \"${EMPLOYEE_NAME}\", \"expires_at\": \"${new_expires}\", \"updated_at\": \"$(iso_now)\"}" > "$TOKEN_CACHE"
else
error_message=$(echo "$response" | jq -r '.message')
echo "⚠️ 登录失败:$error_message"
echo "建议:确认账号和密码正确"
exit 1
fi
fi
if [ -n "$expires_at" ] && [ "$expires_at" != "null" ]; then
expires_timestamp=$(to_timestamp "$expires_at")
current_ts=$(now_ts)
days_remaining=$(( (expires_timestamp - current_ts) / 86400 ))
if [ $days_remaining -le 0 ]; then
# Token 已过期 → 引导用户重新输入账号密码登录
echo "⚠️ Token 已过期,请重新登录"
echo "请输入您的账号和密码,格式:账号 密码"
echo "(等待用户输入...)"
response=$(curl -s -X POST "${FASTAPI_BASE_URL}/auth/login" \
-H "Content-Type: application/json" \
-d "{\"employee_id\": \"${EMPLOYEE_ID}\", \"password\": \"${PASSWORD}\"}" \
--max-time 120)
code=$(echo "$response" | jq -r '.code')
if [ "$code" = "0" ]; then
API_TOKEN=$(echo "$response" | jq -r '.data.token')
new_expires=$(echo "$response" | jq -r '.data.expires_at')
echo "{\"token\": \"${API_TOKEN}\", \"employee_id\": \"${EMPLOYEE_ID}\", \"employee_name\": \"${EMPLOYEE_NAME}\", \"expires_at\": \"${new_expires}\", \"updated_at\": \"$(iso_now)\"}" > "$TOKEN_CACHE"
else
echo "⚠️ 登录失败,请确认账号和密码正确"
exit 1
fi
elif [ $days_remaining -le 7 ]; then
# Token 即将过期 → renew-token 优先
response=$(curl -s -X POST "${FASTAPI_BASE_URL}/auth/renew-token" \
-H "Authorization: Bearer ${API_TOKEN}" \
--max-time 120)
code=$(echo "$response" | jq -r '.code')
if [ "$code" = "0" ]; then
API_TOKEN=$(echo "$response" | jq -r '.data.token')
new_expires=$(echo "$response" | jq -r '.data.expires_at')
echo "{\"token\": \"${API_TOKEN}\", \"employee_id\": \"${EMPLOYEE_ID}\", \"employee_name\": \"${EMPLOYEE_NAME}\", \"expires_at\": \"${new_expires}\", \"updated_at\": \"$(iso_now)\"}" > "$TOKEN_CACHE"
else
# renew 失败 → 引导用户重新输入密码登录
echo "⚠️ Token 续期失败,请重新输入密码"
echo "(等待用户输入密码...)"
response=$(curl -s -X POST "${FASTAPI_BASE_URL}/auth/login" \
-H "Content-Type: application/json" \
-d "{\"employee_id\": \"${EMPLOYEE_ID}\", \"password\": \"${PASSWORD}\"}" \
--max-time 120)
code=$(echo "$response" | jq -r '.code')
if [ "$code" = "0" ]; then
API_TOKEN=$(echo "$response" | jq -r '.data.token')
new_expires=$(echo "$response" | jq -r '.data.expires_at')
echo "{\"token\": \"${API_TOKEN}\", \"employee_id\": \"${EMPLOYEE_ID}\", \"employee_name\": \"${EMPLOYEE_NAME}\", \"expires_at\": \"${new_expires}\", \"updated_at\": \"$(iso_now)\"}" > "$TOKEN_CACHE"
else
echo "⚠️ 登录失败,请确认账号和密码正确"
exit 1
fi
fi
fi
# else: Token 仍有效,直接使用
fi
else
# 缓存文件不存在 → 引导用户输入账号和密码
echo "🔑 需要验证您的员工身份"
echo ""
echo "请输入您的账号和密码,格式:账号 密码"
echo "例如:emp-server-106 123456"
fi
# ═══ Token 校验兜底:确保 Token 有效,否则提示用户重新登录 ═══
if [ -z "${API_TOKEN:-}" ] || [ "$API_TOKEN" = "null" ] || [ "$API_TOKEN" = "" ]; then
echo "⚠️ 身份验证失败,无法获取有效凭证"
echo ""
echo "请输入您的账号和密码,重新登录:"
echo "格式:账号 密码(例如:emp-server-106 123456)"
# AI 引导用户输入后,重新执行 /auth/login 流程
fi
关键: 员工身份(
employee_code)由后端从 Token 自动提取,Skill 不需要在 payload 中传递。 关键: 项目名称(project_name)由 AI 从对话中识别或用户指定,如未识别则使用company_name作为默认值。
从用户输入中提取以下信息,由 AI 自行判断,无需正则或关键词表:
| 提取项 | 说明 | 示例 |
|---|---|---|
company_name | 用户提到的公司/客户名称 | "陌陌科技"、"数智云创" |
contact_name | 用户提到的联系人姓名 | "张三"、"李总" |
project_name | 用户提到的项目/系统/产品名 | "CRM项目"、"数据中台" |
title_keywords | 用于文件匹配的主题关键词 | "价格谈判"、"方案对比" |
source_type | 数据源类型 | phone / wechat / auto |
提取原则:
数据源判断:
phonewechatauto(两个源都查)客户标识优先级:
company_name(公司全称或简称,用户明确提到)→ 直接进入 Step 4contact_name → 进入 Step 3 客户确认title_keywords → 用主题关键词匹配文件名,进入 Step 3 客户确认信息不足时的引导提示:
当用户输入过于笼统(如"整理线索"、"跟进记录"、"分析录音"),无法提取客户名或联系人时,不要猜测,主动引导用户补充:
请告诉我您想分析的内容,例如:
• "分析我和陌陌公司的通话录音"
• "查看我和张三的微信聊天记录"
• "总结上周拜访客户的录音"
• "分析CRM项目的沟通记录"
需要指定:客户名称 或 联系人姓名,我会帮您分析拜访记录并生成项目画像。
项目名称优先级:
company_name当 Step 2 未能明确识别 company_name(只有 contact_name 或 title_keywords)时,先查询数据库已有客户,让员工确认或选择,避免用错误名称创建新客户。
| 条件 | 动作 |
|---|---|
company_name 已明确(用户直接提到公司名) | 跳过此步,直接进入 Step 4 |
仅有 contact_name 或 title_keywords | 执行客户查询,让员工确认 |
FASTAPI_BASE_URL="http://47.116.49.218:8000/api/v1"
customers_response=$(curl -s -X GET "${FASTAPI_BASE_URL}/project-portrait/customers" \
-H "Authorization: Bearer ${API_TOKEN}" \
--max-time 10)
customers_code=$(echo "$customers_response" | jq -r '.code')
if [ "$customers_code" = "0" ]; then
customers=$(echo "$customers_response" | jq -r '.data')
customer_count=$(echo "$customers" | jq 'length')
fi
1. 用 contact_name / title_keywords 在客户列表中模糊匹配
- 匹配 company_name 字段
- 匹配 contact_name 字段
2. 匹配结果分支:
├─ 精确匹配到 1 个客户 → 自动使用该客户的 company_name(静默确认)
├─ 匹配到多个客户 → 展示列表让员工选择
├─ 无匹配 → 询问员工输入企业全称
└─ 客户列表为空(新员工)→ 询问员工输入企业全称
📋 找到以下相关客户,请选择要分析的客户:
1. XX科技有限公司(联系人:王厂长)
2. XX智能装备集团(联系人:王建国)
3. XX电子有限公司(联系人:王总)
请回复序号选择,或输入新的企业名称。
📋 未在您的客户库中找到与「{contact_name}」匹配的企业。
请输入该客户的**企业全称**(如:深圳市XX科技有限公司),我将以此创建新客户档案。
员工确认 company_name 后,后续 Step 4-7 使用该确认值,确保数据准确。
根据 Step 2 解析出的 source_type 走不同分支:
| source_type | 走哪条路径 | 说明 |
|---|---|---|
phone | 分支 A:电话录音转录 | 用户明确提到"电话/录音/通话" |
wechat | 分支 B:微信聊天记录 | 用户明确提到"微信/聊天记录" |
auto | 两个分支都执行,合并结果 | 用户未指定来源 |
/home/admin/.openclaw/plugins/phone-notifications/recordings/transcripts/
性能约束:该目录可能有数百甚至上千个转录文件,禁止全量 grep。必须采用"分层过滤"策略逐步缩小范围。
{UUID}_{AI生成的标题}.md(UUID 固定 36 字符,标题为 AI 从对话内容总结的主题,不含时间信息)
示例:
611a199a-1fcd-4604-85c6-774bd7160784_哒,开始了,嗯,可以.md
8bc14a02-29e3-4bd6-986a-faa69a8bb929_CRM方案价格相对比及商务谈判进展.md
默认只看最近 30 天内的文件(按文件修改时间)。如果用户明确提到"上个月"/"最近一周",则按用户指定的范围过滤。
TRANSCRIPTS_DIR="/home/admin/.openclaw/plugins/phone-notifications/recordings/transcripts"
DAYS_BACK="${DAYS_BACK:-30}" # 默认 30 天
# 用 find 按 mtime 过滤,只列出时间范围内的 .md 文件
recent_files=$(find "$TRANSCRIPTS_DIR" -maxdepth 1 -name "*.md" -type f -mtime -${DAYS_BACK} 2>/dev/null)
if [ -z "$recent_files" ]; then
echo "⚠️ 最近 ${DAYS_BACK} 天内没有转录文件"
echo "尝试扩大范围到 90 天..."
DAYS_BACK=90
recent_files=$(find "$TRANSCRIPTS_DIR" -maxdepth 1 -name "*.md" -type f -mtime -${DAYS_BACK} 2>/dev/null)
fi
if [ -z "$recent_files" ]; then
echo "⚠️ 90 天内仍无转录文件,请确认录音是否已转录"
exit 1
fi
total_count=$(echo "$recent_files" | wc -l)
echo "📂 最近 ${DAYS_BACK} 天内有 ${total_count} 个转录文件"
文件名中 UUID 后面是 AI 生成的标题,标题通常包含客户名、产品名、场景关键词,命中率很高。先用 basename 提取标题部分做字符串匹配(不读文件内容,开销为 0):
# 提取标题部分(跳过 UUID 前缀,匹配 UUID 后的内容)
# 文件名格式:{UUID}_{标题}.md,UUID 固定 36 字符
name_matched=""
# 按优先级尝试匹配:公司名 → 联系人 → 主题关键词
keywords=()
[ -n "$company_name" ] && keywords+=("$company_name")
[ -n "$contact_name" ] && keywords+=("$contact_name")
# 主题关键词数组(来自 Step 2)
for kw in "${title_keywords[@]}"; do keywords+=("$kw"); done
for kw in "${keywords[@]}"; do
name_matched=$(echo "$recent_files" | while read f; do
title_part=$(basename "$f" .md | cut -c38-)
echo "$title_part" | grep -qi "$kw" && echo "$f"
done)
[ -n "$name_matched" ] && break
done
if [ -n "$name_matched" ]; then
matched_files="$name_matched"
echo "✅ 文件名标题命中"
else
# 进入第 3 级
fi
只有第 2 级没命中时,才对第 1 级筛选出来的小集合做内容 grep,绝不对整个目录 grep:
# 只在 recent_files 上做 grep(而不是 *.md 全量)
keyword="${company_name:-$contact_name}"
matched_files=$(echo "$recent_files" | xargs grep -l -i "$keyword" 2>/dev/null)
if [ -z "$matched_files" ]; then
echo "⚠️ 未匹配到包含「${keyword}」的转录文件"
echo ""
echo "📋 最近 ${DAYS_BACK} 天最新 10 个文件:"
echo "$recent_files" | xargs ls -lt 2>/dev/null | head -10 | while read line; do
filename=$(echo "$line" | awk '{print $NF}')
echo " - $(basename $filename)"
done
echo ""
echo "请回复文件名、具体日期或更多关键词(如客户提到的产品名)"
exit 1
fi
# 多条匹配时按 mtime 排序,取最新的
target_file=$(echo "$matched_files" | xargs ls -t 2>/dev/null | head -1)
echo "📄 匹配到文件:$(basename $target_file)"
chat_content=$(cat "$target_file")
# CRM方案价格对比及商务谈判进展
> 录音名称:2026_06_10 17:04:10
> 时长:02:10
> 创建时间:2026-06-10T09:04:10.570Z
---
说话人0:李总,您好!感谢您抽出时间。上次给您发的CRM方案...
关键特征:
录音名称(含日期)、时长、创建时间说话人0 标识,包含双方对话内容(无角色分离)Skill 自动识别并提取:
录音名称 行提取 YYYY_MM_DD,备选从 创建时间 提取 ISO 格式说话人0 后的全部内容/home/admin/.openclaw/plugins/phone-notifications/notifications/
该目录下所有文件是按日期命名的 JSON 数组,每个文件记录当天所有 App 推送通知:
notifications/
├── 2026-06-04.json
├── 2026-06-05.json
├── 2026-06-06.json
├── ...
└── 2026-06-10.json
[
{
"appName": "微信",
"title": "销售伴侣",
"content": "吴云成: http://118.196.83.38/ai/salebp/-/tree/develop",
"timestamp": "2026-06-09T17:13:42+08:00",
"appDisplayName": "微信"
},
{
"appName": "微信",
"title": "沈莹玉",
"content": "。。。刚看到,晚上中兴加班的",
"timestamp": "2026-06-09T21:16:29+08:00",
"appDisplayName": "微信"
},
{
"appName": "钉钉",
"title": "工作通知:南京绛门信息科技有限公司",
"content": "考勤打卡:18:00 极速打卡·成功",
"timestamp": "2026-06-09T18:00:21+08:00",
"appDisplayName": "钉钉"
}
]
字段含义:
| 字段 | 含义 | 示例 |
|---|---|---|
appName | 应用包名标识 | "微信" / "钉钉" / "菜鸟" |
title | 聊天对象名/群名(微信场景) | "沈莹玉" / "销售伴侣群" |
content | 单条消息内容 | "。。。刚看到,晚上中兴加班的" |
timestamp | 精确时间戳(带时区) | "2026-06-09T21:16:29+08:00" |
appDisplayName | 应用显示名 | "微信" |
NOTIFICATIONS_DIR="/home/admin/.openclaw/plugins/phone-notifications/notifications"
DAYS_BACK="${DAYS_BACK:-30}"
# 第 1 级:按文件名日期过滤(文件名是 YYYY-MM-DD.json,零成本)
recent_json_files=$(find "$NOTIFICATIONS_DIR" -maxdepth 1 -name "*.json" -type f -mtime -${DAYS_BACK} 2>/dev/null)
if [ -z "$recent_json_files" ]; then
echo "⚠️ 最近 ${DAYS_BACK} 天内没有通知文件"
exit 1
fi
# 第 2 级:用 jq 筛选 appName=="微信" 的记录,并按 title 匹配联系人
wechat_messages=$(echo "$recent_json_files" | xargs jq -s '
[ .[][] | select(.appName == "微信") ]
' 2>/dev/null)
# 第 3 级:按 title(联系人/群名)过滤
if [ -n "$contact_name" ]; then
wechat_messages=$(echo "$wechat_messages" | jq --arg name "$contact_name" '
[ .[] | select(.title | test($name; "i")) ]
')
elif [ -n "$company_name" ]; then
wechat_messages=$(echo "$wechat_messages" | jq --arg name "$company_name" '
[ .[] | select(.title | test($name; "i")) ]
')
fi
msg_count=$(echo "$wechat_messages" | jq 'length')
if [ "$msg_count" -eq 0 ]; then
echo "⚠️ 未找到与「${contact_name:-$company_name}」相关的微信聊天记录"
echo ""
echo "📋 最近 ${DAYS_BACK} 天微信联系人统计:"
echo "$wechat_messages" | jq -r '[ .[].title ] | group_by(.) | map({name: .[0], count: length}) | sort_by(-.count) | .[:10][] | " - \(.name) (\(.count) 条)"'
exit 1
fi
echo "✅ 找到 ${msg_count} 条与「${contact_name:-$company_name}」的微信消息"
把同一联系人的多条消息按时间排序,拼接为对话格式(类似录音转录):
chat_content=$(echo "$wechat_messages" | jq -r '
sort_by(.timestamp) |
.[] |
"[\(.timestamp | split("T")[1] | split("+")[0])] \(.title): \(.content)"
')
# 提取最早消息日期作为 visit_date
visit_date=$(echo "$wechat_messages" | jq -r '
sort_by(.timestamp) | .[0].timestamp | split("T")[0]
')
# 设置 transcript_file_path 为虚拟路径(表明数据源)
transcript_file_path="wechat://${contact_name:-$company_name}/${visit_date}"
聚合后的 chat_content 示例:
[21:16:29] 沈莹玉: 。。。刚看到,晚上中兴加班的
[21:17:14] 沈莹玉: 晚上吧
[21:17:22] 沈莹玉: 明晚
| 维度 | 电话录音 | 微信聊天 |
|---|---|---|
| 粒度 | 一次通话=一个文件 | 单条消息,需聚合 |
| 角色分离 | 无(全在 说话人0) | 仅我方收到的消息(title=对方) |
| 时间信息 | 需从内容提取 | timestamp 字段直接可用 |
| 完整性 | 完整对话 | 仅通知栏内容(撤回/图片/语音可能缺失) |
| 虚拟路径 | 真实文件路径 | wechat://{联系人}/{日期} |
# 角色
你是资深 B2B 销售分析师,精通 LTC(Lead to Cash)销售流程。
# 任务
根据以下拜访沟通记录,分析生成项目画像的 5 大模块数据,并**识别项目名称**。
# 输入数据
- 公司名称:{company_name}(优先)
- 联系人:{contact_name}(备选)
- 项目名称:{project_name}(如已提取)
- 聊天文件:{target_file}
- 聊天内容:{chat_content}
# 输出要求(严格 JSON 格式)
{
"project_name": "识别出的项目名称(如CRM系统采购、数据中台建设等)",
"sales_stage": {
"steps": [
{"label": "线索", "active": false},
{"label": "商机确认", "active": false},
{"label": "方案评估", "active": false},
{"label": "商务谈判", "active": false},
{"label": "赢单/关单", "active": false}
],
"progress_percent": 60,
"current_stage": "方案评估",
"description": "基于沟通记录,客户已明确需求并进入方案对比阶段..."
},
"follow_up_strategies": [
{
"icon": "fa-calendar-check",
"title": "跟进策略标题",
"description": "具体跟进动作描述",
"tag_icon": "fa-tag",
"tag_text": "策略标签"
}
],
"customer_insights": [
{"title": "客户意向", "content": "..."},
{"title": "项目新增线索", "content": "..."},
{"title": "客户关注点", "content": "..."},
{"title": "个人诉求", "content": "..."}
],
"commitments": [
{
"type": "our_promise",
"type_label": "我方承诺",
"content": "承诺内容",
"meta": "责任人/时间"
},
{
"type": "customer_promise",
"type_label": "客户承诺",
"content": "客户承诺内容",
"meta": "客户方责任人"
}
],
"risk_assessment": [
{"title": "影响项目推进信息", "content": "...", "level": "high"},
{"title": "影响价格谈判信息", "content": "...", "level": "medium"},
{"title": "影响成单信息", "content": "...", "level": "low"},
{"title": "竞品相关信息", "content": "...", "level": "medium"}
],
"visit_summary": "本次拜访的核心总结(100字以内)"
}
# 分析维度说明
## 0. 项目名称识别(新增)
从对话内容中识别项目名称,常见特征:
- 明确提及:"关于你们的**CRM系统项目**"、"**数据中台建设**的进展"
- 项目代号:"**凤凰计划**"、"**星辰工程**"
- 产品/系统名:"**销售管理系统**"、"**客户数据平台**"
- 如无法识别,返回空字符串 ""(后端将使用 company_name 作为默认值)
## 1. 销售阶段(基于 LTC 流程)
- 线索:初次接触,尚未明确需求
- 商机确认:需求明确,确认有采购意向
- 方案评估:客户对比多家方案,进入评估阶段
- 商务谈判:方案已认可,进入价格/条款谈判
- 赢单/关单:合同签订或项目关闭
根据沟通记录判断当前阶段,设置 active=true,progress_percent 对应 20/40/60/80/100。
## 2. 跟进策略(4个维度,至少 3 条)
(1) 当前销售阶段 → 下一阶段的推进动作
(2) 既往/本次承诺事项 → 需要跟进的兑现动作
(3) 项目风险 → 需要化解风险的跟进动作
(4) 客户最新动态/意向 → 需要抓住机会的跟进动作
## 3. 客户洞察(至少 3 条)
- 客户意向、项目新增线索、客户关注点、个人诉求
## 4. 承诺事项(识别所有承诺)
- our_promise:我方承诺(如"下周提供方案"、"月底前给报价")
- customer_promise:客户承诺(如"下周内部讨论"、"月底给答复")
- customer_request:客户要求(如"希望增加某功能"、"要求降价")
## 5. 风险预估
- 从沟通记录中识别所有潜在风险,不限于固定类别
- 每条风险标注等级:`high` / `medium` / `low`
- **由 AI 根据对话内容自行判断等级**,不要套用固定规则:
- 如果客户明确表示不合作、项目暂停、预算砍掉 → `high`
- 如果客户有顾虑、需要额外审批、竞品介入 → `medium`
- 如果只是常规提醒、轻微分歧 → `low`
- 没有明显风险时,输出空数组 `[]`
# 重要规则
1. 所有分析必须基于实际沟通记录,绝不编造
2. 如果某维度无相关信息,输出空数组 []
3. description 和 content 要具体、可执行,不要泛泛而谈
4. 使用中文输出
visit_date)# 优先从"录音名称"行提取 YYYY_MM_DD
visit_date=$(echo "$chat_content" | grep "录音名称" | grep -oE '[0-9]{4}_[0-9]{2}_[0-9]{2}' | tr '_' '-' | head -1)
if [ -z "$visit_date" ]; then
# 备选:从"创建时间"行提取 ISO 格式
visit_date=$(echo "$chat_content" | grep "创建时间" | grep -oE '[0-9]{4}-[0-9]{2}-[0-9]{2}' | head -1)
fi
if [ -z "$visit_date" ]; then
# 兜底:文件修改时间
visit_date=$(date +%Y-%m-%d)
fi
| 项 | 说明 |
|---|---|
| URL | POST ${FASTAPI_BASE_URL}/project-portrait/ingest |
| 认证 | Authorization: Bearer ${API_TOKEN}(员工 Token) |
| Content-Type | application/json |
{
"company_name": "string (必填,公司名优先;无公司名时传联系人)",
"project_name": "string (可选,项目名称;AI识别或用户指定,为空时后端使用 company_name)",
"contact_name": "string (可选,联系人姓名)",
"transcript_file_path": "string (可选,电话录音为真实文件路径;微信为虚拟路径 wechat://{联系人}/{日期})",
"visit_date": "string (YYYY-MM-DD)",
"data_source": "string (电话录音转录 | 微信聊天记录)",
"sales_stage": {
"steps": [{"label": "线索", "active": false}, ...],
"progress_percent": 60,
"current_stage": "方案评估",
"description": "..."
},
"follow_up_strategies": [
{"icon": "", "title": "", "description": "", "tag_icon": "", "tag_text": ""}
],
"customer_insights": [
{"title": "", "content": ""}
],
"commitments": [
{"type": "our_promise|customer_promise|customer_request", "type_label": "", "content": "", "meta": ""}
],
"risk_assessment": [
{"title": "", "content": "", "level": "low"}
],
"raw_analysis": "string (可选,AI 原始返回)",
"visit_summary": "string (可选,100字以内总结)"
}
{
"code": 0,
"data": {
"portrait_id": 123,
"ingested": true
},
"message": "ok"
}
FASTAPI_BASE_URL="http://47.116.49.218:8000/api/v1"
INGEST_PAYLOAD=$(jq -n \
--arg company_name "$company_name" \
--arg project_name "${project_name:-}" \
--arg contact_name "${contact_name:-}" \
--arg transcript_file_path "$target_file" \
--arg visit_date "$visit_date" \
--arg data_source "电话录音转录" \
--argjson sales_stage "$(echo "$parsed_result" | jq '.sales_stage')" \
--argjson follow_up_strategies "$(echo "$parsed_result" | jq '.follow_up_strategies')" \
--argjson customer_insights "$(echo "$parsed_result" | jq '.customer_insights')" \
--argjson commitments "$(echo "$parsed_result" | jq '.commitments')" \
--argjson risk_assessment "$(echo "$parsed_result" | jq '.risk_assessment')" \
--arg raw_analysis "$(echo "$parsed_result" | jq -r '.' | head -c 10000)" \
--arg visit_summary "$(echo "$parsed_result" | jq -r '.visit_summary')" \
'{
company_name: $company_name,
project_name: $project_name,
contact_name: $contact_name,
transcript_file_path: $transcript_file_path,
visit_date: $visit_date,
data_source: $data_source,
sales_stage: $sales_stage,
follow_up_strategies: $follow_up_strategies,
customer_insights: $customer_insights,
commitments: $commitments,
risk_assessment: $risk_assessment,
raw_analysis: $raw_analysis,
visit_summary: $visit_summary
}')
ingest_response=$(curl -s -X POST "${FASTAPI_BASE_URL}/project-portrait/ingest" \
-H "Content-Type: application/json" \
-H "Authorization: Bearer ${API_TOKEN}" \
-d "$INGEST_PAYLOAD" \
--max-time 30)
ingest_code=$(echo "$ingest_response" | jq -r '.code')
if [ "$ingest_code" = "0" ]; then
echo "✅ 项目画像已生成"
PORTRAIT_ID=$(echo "$ingest_response" | jq -r '.data.portrait_id')
else
echo "⚠️ 生成失败:$(echo "$ingest_response" | jq -r '.message')"
mkdir -p /tmp/sales-companion-fallback
echo "$INGEST_PAYLOAD" > "/tmp/sales-companion-fallback/portrait_$(date +%s).json"
exit 1
fi
请求被处理后,系统会自动完成:
数据隔离:
employee_code + company_name + project_name联合确定唯一画像。同一公司同一项目,员工 A 和员工 B 的画像互相隔离,互不可见。同一公司不同项目,画像也互相独立。
exchange_response=$(curl -s -X POST "${FASTAPI_BASE_URL}/auth/exchange-code" \
-H "Authorization: Bearer ${API_TOKEN}" \
-H "Content-Type: application/json" \
-d '{}' \
--max-time 10)
exchange_code=$(echo "$exchange_response" | jq -r '.data.code // empty')
H5_BASE_URL="http://47.116.49.218:5173"
if [ -n "$exchange_code" ] && [ "$exchange_code" != "null" ]; then
h5_url="${H5_BASE_URL}/project-portrait-new/${PORTRAIT_ID}?code=${exchange_code}"
else
# 兜底:使用完整 Token(禁止截断、禁止带 ...)
h5_url="${H5_BASE_URL}/project-portrait-new/${PORTRAIT_ID}?token=${API_TOKEN}"
fi
📊 {company_name} · {project_name} · 项目画像分析
📅 聊天日期:{visit_date}
📱 数据源:{data_source} ← "电话录音转录" 或 "微信聊天记录"
👤 联系人:{contact_name}
📍 当前阶段:{sales_stage.current_stage}(进度 {sales_stage.progress_percent}%)
🎯 核心总结:{visit_summary}
📈 [查看完整项目画像 →]({h5_url})
────────────────────────────────────────
💡 **下一步行动建议**
分析完成后,建议将此项目同步到 CRM 管理系统,方便后续跟进和团队协作。
🔄 [同步到 CRM 管理 →] 对我说"同步到CRM"
────────────────────────────────────────
🔒 数据隔离:此分析记录属于员工 {employee_code},项目 {project_name}
| 字段 | 类型 | 说明 |
|---|---|---|
| id | INTEGER PK | 画像 ID(URL 中使用) |
| employee_code | VARCHAR INDEX | 员工号(数据隔离键) |
| company_name | VARCHAR INDEX | 公司名称 |
| project_name | VARCHAR INDEX | 项目名称(数据隔离键:employee_code + company_name + project_name) |
| contact_name | VARCHAR | 联系人 |
| transcript_file_path | VARCHAR | 转录文件路径 |
| visit_date | VARCHAR | 聊天日期 |
| data_source | VARCHAR | 数据来源 |
| sales_stage | JSON TEXT | 销售阶段 |
| follow_up_strategies | JSON TEXT | 跟进策略(列表) |
| customer_insights | JSON TEXT | 客户洞察(列表) |
| commitments | JSON TEXT | 承诺事项(列表) |
| risk_assessment | JSON TEXT | 风险预估(列表) |
| raw_analysis | TEXT | AI 原始返回 |
| visit_summary | TEXT | 拜访总结 |
| created_at / updated_at | DATETIME | 时间戳 |
ingest 接口会自动维护这两张表,Skill 无需感知。
| 错误场景 | 处理方式 |
|---|---|
| 无匹配客户 | 输出相似客户列表,让用户选择 |
| 无拜访记录 | 提示录入拜访沟通内容 |
| 拜访记录无沟通内容 | 提示补充对话记录 |
| AI 分析失败 | 提示"分析失败,请重试" |
| 保存失败 | 兜底写入 /tmp/sales-companion-fallback/,提示联系管理员 |
| Token 过期或失效 | 自动续期;续期失败则提示用户输入账号和密码重新登录 |
用户:查看我和陌陌公司CRM项目的聊天分析
输出:
📊 陌陌公司 · CRM项目 · 项目画像分析
📅 聊天日期:2026-06-10
� 数据源:电话录音转录
� 联系人:李总
📍 当前阶段:方案评估(进度 60%)
🎯 核心总结:客户已明确需求,正在对比 3 家方案,对我方技术方案认可度高,但价格敏感...
📈 [查看完整项目画像 →]({h5_url})
────────────────────────────────────────
💡 **下一步行动建议**
分析完成后,建议将此项目同步到 CRM 管理系统,方便后续跟进和团队协作。
�🔄 [同步到 CRM 管理 →] 对我说"同步到CRM"
────────────────────────────────────────
🔒 数据隔离:此分析记录属于员工 E10001,项目 CRM项目
用户:查看我和沈莹玉的微信聊天记录
输出:
📊 沈莹玉 · Mochac 科技 · 项目画像分析
📅 聊天日期:2026-06-09
📱 数据源:微信聊天记录
👤 联系人:沈莹玉
📍 当前阶段:商机确认(进度 40%)
🎯 核心总结:客户对方案表现出兴趣,但提及晚上加班,时间紧张...
📈 [查看完整项目画像 →]({h5_url})
────────────────────────────────────────
💡 **下一步行动建议**
分析完成后,建议将此项目同步到 CRM 管理系统,方便后续跟进和团队协作。
🔄 [同步到 CRM 管理 →] 对我说"同步到CRM"
────────────────────────────────────────
| 对比项 | employee-radar | visit-analyzer |
|---|---|---|
| 数据来源 | 公域(官网/公众号/招投标/招聘) | 私域(拜访沟通记录) |
| 分析对象 | 企业整体情报 | 具体项目/拜访 |
| 输出内容 | 客户画像 + 企业动态 + AI商机 | 销售阶段 + 跟进策略 + 客户洞察 + 承诺事项 + 风险预估 |
| H5 路由 | /intelligence-radar/{companyId} | /project-portrait-new/{portraitId} |
| 触发场景 | "查询XX公司情报" | "查看我和XX的聊天分析" |
| 生成方式 | 查询缓存 → 触发采集 | 直接生成画像(自动创建客户) |
| 数据隔离 | 企业级 | 员工+项目级(employee_code + company_name + project_name) |
| 版本 | 日期 | 变更 |
|---|---|---|
| v1.0 | 2026-06-10 | 新建 visit-analyzer Skill,实现拜访记录 AI 分析 |
| v1.1 | 2026-06-10 | 工作流从 10 步简化为 6 步,删除 Customer/Visit 手动创建逻辑 |
| v1.2 | 2026-06-10 | 彻底移除 visits 表相关描述;明确 visits 表已删除,改用 transcript_file_path 引用原始录音;明确 ingest 接口自动创建 Customer + EmployeeCustomer;补充数据隔离说明(employee_code + company_name);更新 API Schema 和 H5 链接格式 |
| v1.3 | 2026-06-10 | Step 3 文件匹配策略从"全量 grep"优化为"4 级分层过滤"(时间范围 find → 文件名匹配 → 小范围内容 grep → 取最新),避免在数千文件上全量扫描 |
| v1.4 | 2026-06-10 | 明确实际文件名格式 {UUID}_{AI标题}.md(无时间信息,标题为 AI 生成);Step 2 新增 title_keywords 主题关键词提取(CRM/方案/价格/谈判等);Step 3 第 2 级改为提取 UUID 后的标题部分匹配,按 company_name → contact_name → title_keywords 优先级尝试,提升命中率 |
| v1.5 | 2026-06-10 | 新增微信聊天记录数据源:Step 2 新增 source_type 判断(wechat/phone/auto);Step 3 重构为多源分支(A 电话录音 / B 微信通知 JSON);微信源使用 jq 按 appName+title 过滤,聚合同联系人多消息为对话格式;data_source 支持"微信聊天记录",transcript_file_path 使用虚拟路径 wechat://{联系人}/{日期};新增示例 4/5(微信场景和自动合并) |
| v1.6 | 2026-06-11 | 意图解析从规则引擎改为语义理解:删除 Step 2 的正则伪代码和硬编码关键词表,改为 AI 自行判断;5 个详细示例精简为 2 个(电话录音 + 微信聊天),只展示输出格式不展示内部步骤流转;删除两个重复的"性能对比"表;输出格式新增"同步到 CRM"引导提示 |
| v1.7 | 2026-06-13 | 新增客户确认步骤(Step 3):工作流从 6 步升级为 7 步;当 Step 2 未明确识别 company_name 时,调用 GET /project-portrait/customers 查询员工已有客户列表,模糊匹配后展示给员工选择或输入新企业名;避免用错误名称创建新客户产生脏数据;客户标识优先级更新,仅 contact_name/title_keywords 时进入客户确认而非直接回退 |