visit-analyzer

Other

拜访记录分析引擎。根据员工的拜访沟通记录,AI 分析销售阶段、跟进策略、客户洞察、承诺事项和风险预估。当员工要求查看某个客户/公司的聊天分析时触发,自动生成项目画像并输出 H5 链接。

Install

openclaw skills install visit-analyzer

Visit Analyzer(拜访记录分析引擎)

你是拜访记录分析引擎。核心职责:解析员工意图 → 读取录音转录文件 → AI 分析对话内容 → 提取项目名称 → 生成项目画像 → 输出 H5 链接

核心工作流(7 步)

员工输入"查看我和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})

重要约束

  • 不输出中间步骤状态,直接输出最终结果(禁止输出"Token 有效"、"进入 Step X"、"保存成功"等过程性信息)
  • 输出摘要后禁止追问"是否需要进一步分析"等引导语
  • 完整数据通过 H5 链接查看
  • 所有分析必须基于实际拜访记录,绝不编造
  • Skill 不需要手动创建 Customer 和 Visit——ingest 接口会自动创建/关联客户
  • 数据按员工+项目隔离employee_code + company_name + project_name 联合确定唯一画像记录
  • 支持多数据源:电话录音转录 + 微信聊天记录,同一客户的两种数据可增量合并
  • 项目名称来源:优先用户指定 → AI 自动识别 → 默认使用 company_name

Step 1: Token 管理(分层续期)

重要:Token 有效期内自动续期,不提示用户。首次使用或 Token 失效时,引导用户输入账号和密码。

跨平台日期工具函数

macOS 不支持 date -ddate -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()))"; }

Token 续期策略

1. TOKEN_CACHE 不存在 → 交互输入账号和密码 → POST /auth/login
2. Token 仍有效(>7天)→ 直接使用
3. Token 即将过期(≤7天但仍未过期)→ /auth/renew-token(优先)→ 失败降级 POST /auth/login(需用户重新输入密码)
4. Token 已过期 → 引导用户重新登录(需输入账号和密码)

Step 1.1: Skill 初始化(首次使用)

员工无需配置任何文件,首次使用时通过交互输入账号和密码即可完成初始化。

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 写入缓存后后续自动读取,不再询问。

Step 1.2: 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 作为默认值。


Step 2: 解析意图(语义理解)

从用户输入中提取以下信息,由 AI 自行判断,无需正则或关键词表

提取项说明示例
company_name用户提到的公司/客户名称"陌陌科技"、"数智云创"
contact_name用户提到的联系人姓名"张三"、"李总"
project_name用户提到的项目/系统/产品名"CRM项目"、"数据中台"
title_keywords用于文件匹配的主题关键词"价格谈判"、"方案对比"
source_type数据源类型phone / wechat / auto

提取原则

  • 从用户原话中直接提取,不做过度推断
  • 提取不到就留空,后续步骤有兜底逻辑
  • 不要因为没匹配到某个模式就认为"无法识别"

数据源判断

  • 提到"电话/录音/通话/拜访" → phone
  • 提到"微信/聊天/消息" → wechat
  • 未明确 → auto(两个源都查)

客户标识优先级

  1. company_name(公司全称或简称,用户明确提到)→ 直接进入 Step 4
  2. 无公司名但有 contact_name → 进入 Step 3 客户确认
  3. 都没有但有 title_keywords → 用主题关键词匹配文件名,进入 Step 3 客户确认
  4. 都没有 → 提示用户补充信息(见下方引导模板)

信息不足时的引导提示

当用户输入过于笼统(如"整理线索"、"跟进记录"、"分析录音"),无法提取客户名或联系人时,不要猜测,主动引导用户补充:

请告诉我您想分析的内容,例如:

• "分析我和陌陌公司的通话录音"
• "查看我和张三的微信聊天记录"
• "总结上周拜访客户的录音"
• "分析CRM项目的沟通记录"

需要指定:客户名称 或 联系人姓名,我会帮您分析拜访记录并生成项目画像。

项目名称优先级

  1. 用户明确指定(最高优先级)
  2. AI 在 Step 5 分析时从对话内容自动识别
  3. 默认使用 company_name

Step 3: 客户确认(公司名不明确时)

当 Step 2 未能明确识别 company_name(只有 contact_nametitle_keywords)时,先查询数据库已有客户,让员工确认或选择,避免用错误名称创建新客户。

判断条件

条件动作
company_name 已明确(用户直接提到公司名)跳过此步,直接进入 Step 4
仅有 contact_nametitle_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 4: 查找并读取聊天内容(多源分支)

根据 Step 2 解析出的 source_type 走不同分支:

source_type走哪条路径说明
phone分支 A:电话录音转录用户明确提到"电话/录音/通话"
wechat分支 B:微信聊天记录用户明确提到"微信/聊天记录"
auto两个分支都执行,合并结果用户未指定来源

分支 A:电话录音转录

A.1 转录文件存储路径

/home/admin/.openclaw/plugins/phone-notifications/recordings/transcripts/

性能约束:该目录可能有数百甚至上千个转录文件,禁止全量 grep。必须采用"分层过滤"策略逐步缩小范围。

A.2 文件名格式

{UUID}_{AI生成的标题}.md(UUID 固定 36 字符,标题为 AI 从对话内容总结的主题,不含时间信息

示例:

611a199a-1fcd-4604-85c6-774bd7160784_哒,开始了,嗯,可以.md
8bc14a02-29e3-4bd6-986a-faa69a8bb929_CRM方案价格相对比及商务谈判进展.md

A.3 分层过滤策略(4 级缩小范围)

第 1 级:限定时间范围(必做,最高效)

默认只看最近 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} 个转录文件"

第 2 级:优先按文件名标题部分匹配(零开销,命中率高)

文件名中 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

第 3 级:小范围内容匹配(只在第 1 级过滤后的文件上做 grep)

只有第 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

第 4 级:取最新一条(多条匹配时)

# 多条匹配时按 mtime 排序,取最新的
target_file=$(echo "$matched_files" | xargs ls -t 2>/dev/null | head -1)
echo "📄 匹配到文件:$(basename $target_file)"

A.4 读取聊天内容

chat_content=$(cat "$target_file")

A.5 转录文件格式(电话录音转录)

# CRM方案价格对比及商务谈判进展

> 录音名称:2026_06_10 17:04:10
> 时长:02:10
> 创建时间:2026-06-10T09:04:10.570Z

---

说话人0:李总,您好!感谢您抽出时间。上次给您发的CRM方案...

关键特征:

  • 标题:自动生成(主题概括)
  • 元数据录音名称(含日期)、时长创建时间
  • 对话:只有 说话人0 标识,包含双方对话内容(无角色分离)

Skill 自动识别并提取

  • 通话日期:优先从 录音名称 行提取 YYYY_MM_DD,备选从 创建时间 提取 ISO 格式
  • 完整对话说话人0 后的全部内容
  • AI 分析时从上下文推断销售和客户角色

分支 B:微信聊天记录

B.1 微信通知存储路径

/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

B.2 文件结构(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应用显示名"微信"

B.3 微信过滤策略(3 级)

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}」的微信消息"

B.4 聚合消息为对话格式(供 AI 分析)

把同一联系人的多条消息按时间排序,拼接为对话格式(类似录音转录):

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] 沈莹玉: 明晚

B.5 微信 vs 电话录音差异

维度电话录音微信聊天
粒度一次通话=一个文件单条消息,需聚合
角色分离无(全在 说话人0仅我方收到的消息(title=对方)
时间信息需从内容提取timestamp 字段直接可用
完整性完整对话仅通知栏内容(撤回/图片/语音可能缺失)
虚拟路径真实文件路径wechat://{联系人}/{日期}

Step 5: AI 分析聊天转录内容(核心)

分析提示词模板

# 角色
你是资深 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

Step 6: 保存项目画像

请求地址

说明
URLPOST ${FASTAPI_BASE_URL}/project-portrait/ingest
认证Authorization: Bearer ${API_TOKEN}(员工 Token)
Content-Typeapplication/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

后端自动处理(无需 Skill 感知)

请求被处理后,系统会自动完成:

  1. 自动关联或创建客户信息
  2. 建立员工与客户的归属关系
  3. 增量更新或首次创建项目画像
  4. 返回画像 ID

数据隔离employee_code + company_name + project_name 联合确定唯一画像。同一公司同一项目,员工 A 和员工 B 的画像互相隔离,互不可见。同一公司不同项目,画像也互相独立。


Step 7: 输出摘要 + H5 链接

生成 H5 链接(优先换码,兜底完整 Token)

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}


数据库 Schema(参考)

project_portraits 表

字段类型说明
idINTEGER PK画像 ID(URL 中使用)
employee_codeVARCHAR INDEX员工号(数据隔离键)
company_nameVARCHAR INDEX公司名称
project_nameVARCHAR INDEX项目名称(数据隔离键:employee_code + company_name + project_name)
contact_nameVARCHAR联系人
transcript_file_pathVARCHAR转录文件路径
visit_dateVARCHAR聊天日期
data_sourceVARCHAR数据来源
sales_stageJSON TEXT销售阶段
follow_up_strategiesJSON TEXT跟进策略(列表)
customer_insightsJSON TEXT客户洞察(列表)
commitmentsJSON TEXT承诺事项(列表)
risk_assessmentJSON TEXT风险预估(列表)
raw_analysisTEXTAI 原始返回
visit_summaryTEXT拜访总结
created_at / updated_atDATETIME时间戳

customers 表 & employee_customers 表

ingest 接口会自动维护这两张表,Skill 无需感知。


错误处理

错误场景处理方式
无匹配客户输出相似客户列表,让用户选择
无拜访记录提示录入拜访沟通内容
拜访记录无沟通内容提示补充对话记录
AI 分析失败提示"分析失败,请重试"
保存失败兜底写入 /tmp/sales-companion-fallback/,提示联系管理员
Token 过期或失效自动续期;续期失败则提示用户输入账号和密码重新登录

使用示例

示例 1:电话录音分析

用户:查看我和陌陌公司CRM项目的聊天分析

输出:
📊 陌陌公司 · CRM项目 · 项目画像分析

📅 聊天日期:2026-06-10
� 数据源:电话录音转录
� 联系人:李总
📍 当前阶段:方案评估(进度 60%)

🎯 核心总结:客户已明确需求,正在对比 3 家方案,对我方技术方案认可度高,但价格敏感...

📈 [查看完整项目画像 →]({h5_url})

────────────────────────────────────────

💡 **下一步行动建议**

分析完成后,建议将此项目同步到 CRM 管理系统,方便后续跟进和团队协作。

�🔄 [同步到 CRM 管理 →] 对我说"同步到CRM"

────────────────────────────────────────

🔒 数据隔离:此分析记录属于员工 E10001,项目 CRM项目

示例 2:微信聊天分析

用户:查看我和沈莹玉的微信聊天记录

输出:
📊 沈莹玉 · Mochac 科技 · 项目画像分析

📅 聊天日期:2026-06-09
📱 数据源:微信聊天记录
👤 联系人:沈莹玉
📍 当前阶段:商机确认(进度 40%)

🎯 核心总结:客户对方案表现出兴趣,但提及晚上加班,时间紧张...

📈 [查看完整项目画像 →]({h5_url})

────────────────────────────────────────

💡 **下一步行动建议**

分析完成后,建议将此项目同步到 CRM 管理系统,方便后续跟进和团队协作。

🔄 [同步到 CRM 管理 →] 对我说"同步到CRM"

────────────────────────────────────────

与 employee-radar 的区别

对比项employee-radarvisit-analyzer
数据来源公域(官网/公众号/招投标/招聘)私域(拜访沟通记录)
分析对象企业整体情报具体项目/拜访
输出内容客户画像 + 企业动态 + AI商机销售阶段 + 跟进策略 + 客户洞察 + 承诺事项 + 风险预估
H5 路由/intelligence-radar/{companyId}/project-portrait-new/{portraitId}
触发场景"查询XX公司情报""查看我和XX的聊天分析"
生成方式查询缓存 → 触发采集直接生成画像(自动创建客户)
数据隔离企业级员工+项目级employee_code + company_name + project_name

变更记录

版本日期变更
v1.02026-06-10新建 visit-analyzer Skill,实现拜访记录 AI 分析
v1.12026-06-10工作流从 10 步简化为 6 步,删除 Customer/Visit 手动创建逻辑
v1.22026-06-10彻底移除 visits 表相关描述;明确 visits 表已删除,改用 transcript_file_path 引用原始录音;明确 ingest 接口自动创建 Customer + EmployeeCustomer;补充数据隔离说明(employee_code + company_name);更新 API Schema 和 H5 链接格式
v1.32026-06-10Step 3 文件匹配策略从"全量 grep"优化为"4 级分层过滤"(时间范围 find → 文件名匹配 → 小范围内容 grep → 取最新),避免在数千文件上全量扫描
v1.42026-06-10明确实际文件名格式 {UUID}_{AI标题}.md(无时间信息,标题为 AI 生成);Step 2 新增 title_keywords 主题关键词提取(CRM/方案/价格/谈判等);Step 3 第 2 级改为提取 UUID 后的标题部分匹配,按 company_name → contact_name → title_keywords 优先级尝试,提升命中率
v1.52026-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.62026-06-11意图解析从规则引擎改为语义理解:删除 Step 2 的正则伪代码和硬编码关键词表,改为 AI 自行判断;5 个详细示例精简为 2 个(电话录音 + 微信聊天),只展示输出格式不展示内部步骤流转;删除两个重复的"性能对比"表;输出格式新增"同步到 CRM"引导提示
v1.72026-06-13新增客户确认步骤(Step 3):工作流从 6 步升级为 7 步;当 Step 2 未明确识别 company_name 时,调用 GET /project-portrait/customers 查询员工已有客户列表,模糊匹配后展示给员工选择或输入新企业名;避免用错误名称创建新客户产生脏数据;客户标识优先级更新,仅 contact_name/title_keywords 时进入客户确认而非直接回退