EchoLint = Echo(录音) + Lint(自动化体检):把每一通录音当作待检代码,先机械地查关键词,再让 AI 读语境,最后交给人拍板。
基于 OpenAI Whisper 的录音合规稽核服务:录音上传后自动完成 转写 → DFA 初筛 → AI 语义复筛 →(命中则)人工复检,人工复检结论自动回馈语料库,并从中挖掘新敏感词,形成自增长闭环。
┌────────┐ ┌───────────────────┐ ┌──────────────┐ ┌───────────────────┐ ┌──────────────┐
│ 手动上传 │ → │ ① Whisper 英转文 │ → │ ② DFA 初筛 │ → │ ③ AI 语义复筛 │ → │ ④ 人工复检 │
│ (≤25MB) │ │ (whisper-1) │ │ 敏感词词典 │ │ (模型可切换) │ │ confirm/reject│
└────────┘ └───────────────────┘ └──────┬───────┘ └─────────┬─────────┘ └──────┬───────┘
│ 未命中 │ 不构成违规 │
▼ ▼ │
自动通过 ──────────→ 自动通过 │
▲ │
└──────────── 初筛 ∪ 复筛均违规 ──────────┘
│
⑤ 结论回馈语料库 (few-shot) │
└── 挖掘新词 → 词典(停用待审核) ←┘
- DFA 初筛:Aho-Corasick 自动机 + 字符跳变(
f u c k、f.u.c.k也能命中fuck),大小写不敏感。跳变只允许字母之间插入分隔符;字母缺失的写法(如
f**k少了u/c)不命中,这类漏网由 AI 语义复筛与人工复检兜底。 - 双声道(双轨)录音:自动探测声道数,立体声时分离左右声道并分别转写,按时间对齐成对话(详见下文)。
- AI 复筛:结合上下文判断命中词是否构成真实违规,
few-shot自动注入人工复核语料;双轨录音会带上说话人标注(坐席违规与客户辱骂性质不同)。 模型可在「系统设置」页运行时切换(OpenAI / DeepSeek / 任意 OpenAI 兼容网关),切换立即生效、重启后保持(详见下文)。 - 人工复检:初筛 + 复筛均判违规 → 进入复检队列;确认违规 / 判定误报。复筛不可用时降级转人工,不漏审。
- 语料回馈:复检结论写入语料库(复筛时作为参考案例);确认违规的录音由 AI 挖掘新敏感词入库(默认停用,人工审核后启用)。
| 层 | 技术 |
|---|---|
| 后端 | Spring Boot 3.3 / Java 17, Spring Data JPA, WebClient, MySQL 8 |
| AI | OpenAI(Whisper 转写 + 可选复筛)、DeepSeek(可选复筛 / 词挖掘),统一走 OpenAI 兼容协议;转写亦可换成本机 whisper.cpp(Metal 加速,录音不出本机) |
| 前端 | Vue 3 + Vite + Element Plus + vue-router + axios |
| 部署 | docker-compose(MySQL + 后端 + Nginx 前端) |
echolint/
├── backend/ # Spring Boot 后端
│ ├── src/main/java/com/echolint/
│ │ ├── config/ # 配置:OpenAI、异步流水线、CORS/静态资源
│ │ ├── controller/ # 录音/复检/词典/语料/统计 接口
│ │ ├── dfa/ # DFA 匹配器(AC 自动机 + 词规范化)
│ │ ├── domain/ # 枚举(状态机/违规类型/严重度)
│ │ ├── entity/ # JPA 实体
│ │ ├── exception/ # 业务异常与全局异常处理
│ │ ├── repository/
│ │ └── service/ # 流水线/转写/复筛/复检/挖掘/语料/统计/模型切换
│ ├── src/main/resources/ # application.yml + 预置词库 data.sql
│ └── src/test/ # DFA 单元测试
├── frontend/ # Vue3 前端(工作台/录音管理/人工复检/敏感词库/语料库/系统设置)
│ └── src/styles/index.css # 设计令牌 + Element Plus 主题层
├── tools/seed_demo_data.py # 演示数据生成/清理(预览 UI 用)
├── tools/whisper-server.sh # 本机 whisper.cpp 转写服务(伪装成 OpenAI 端点)
├── docs/screenshots/ # 界面截图
├── docker-compose.yml # 一键部署
└── .env.example # OPENAI_API_KEY 配置样例
统一的设计语言:深色侧栏 + 浅色内容区、6px 圆角体系、字重/字距收敛、状态用「圆点 + 文字」胶囊表达。
| 工作台 | 录音管理 |
|---|---|
![]() |
![]() |
| 人工复检 | 敏感词库 |
|---|---|
![]() |
![]() |
双声道(双轨)录音的双栏对话视图与分声道试听:
- 工作台:6 个 KPI 卡(可点击跳转对应筛选)+ 近 7 日趋势折线(悬停查看数值)+ 处置分布条 + 知识库闭环面板。
- 录音管理:状态胶囊筛选(带各状态数量)、行内命中/AI 结论/复检人、详情抽屉含音频播放器、AI 结论置信度条、命中词高亮、处理轨迹时间线;有处理中记录时自动轮询刷新。
- AI 调用留痕:抽屉底部可展开每次 AI 调用(语义复筛 / 敏感词挖掘)的完整提示词与模型原始回复 —— 系统提示词、用户提示词(DFA 命中词 + few-shot 语料 + 待审文本)、原始 JSON 回复、端点、耗时、字符数; 失败的调用同样留痕,且保留本该发出去的请求体。复检工作区也可展开查看。 「AI 为什么这么判」对一个合规审计产品本身就是需要留痕的审计信息,只存解析后的结论回答不了这个问题。
- 手动推进处理:
待处理、处理失败,以及停留超过 2 分钟的进行中记录,行内会出现「立即/推进处理」「重试」「重新处理」按钮(详情抽屉同样有),用于处理上传后服务重启、外部 API 失败等卡住的情况;已归档或已复检的记录不提供该按钮,避免覆盖既有结论。 - 人工复检:左侧复检队列(可筛选)+ 右侧工作区(逐句对照转写、AI 判断置信度条、结论表单);打开时默认选中 AI 建议的违规类型,提交后自动进入下一条。
- 逐句对照 + 音画联动:每句话带起始时间、DFA 命中词高亮与标记胶囊(
命中 · xxx/AI 判违规原句);点击句子跳转到对应音频时间点,播放时自动高亮并滚动到当前句(可用「跟随播放」开关关闭),也可切回「全文」视图。 - 双栏对话 + 分声道试听:双轨录音默认以左右双栏对话展示(左=坐席、右=客户),播放器可切「混合 / 坐席 / 客户」单独试听某一路。
- 敏感词库:KPI 概览、DFA 检测工具(命中实时高亮预览)、词条表含命中次数条形图,AI 挖掘词带「待审核」标记。
- 语料库:闭环流程说明条、违规/合规样本分类查看、导出 JSON。
- 系统设置:查看当前生效的复筛模型与密钥状态,切换供应商 / 模型(立即生效、重启保持),并可对「当前模型」或「待保存的选择」做连通性测试(返回真实耗时与供应商报错原文);页面同时说明哪些环节跟随切换、哪些不跟随。
呼叫中心的双轨录音通常是 左声道 = 坐席、右声道 = 客户。系统会自动识别并分开处理:
上传 → ffprobe 探测声道数
├─ 1 声道:单路 Whisper 转写
└─ 2 声道:ffmpeg 分离左右声道 → 两路分别 Whisper 转写 → 按 start 时间对齐合并
↓
转写文本(供 DFA/AI 复筛)+ 带 speaker/channel 的分段(供双栏对话视图)
| 能力 | 说明 |
|---|---|
| 双栏对话视图 | 左栏坐席、右栏客户,按时间顺序排列,气泡左右分明;保留时间戳、命中高亮、AI 原句标记、点击跳转、播放跟随 |
| 分声道试听 | 播放器旁可切「混合 / 坐席(左) / 客户(右)」,只听某一路 |
| AI 复筛增强 | 复筛与挖掘时文本按 【坐席】…【客户】… 标注,模型能区分"坐席辱骂客户"与"客户辱骂坐席" |
| 说话人可配置 | app.stereo.left-speaker / right-speaker(默认 坐席 / 客户) |
| 成本 | 双轨录音会调用 两次 Whisper(每路一次),token 消耗约为单路的两倍 |
| 降级 | 未安装 ffmpeg、探测失败或分离失败 → 自动退回单路转写,不影响主流程 |
Docker 镜像已内置 ffmpeg/ffprobe;本地开发需自行安装(macOS:
brew install ffmpeg),否则双声道能力降级为单路。
语义复筛与敏感词挖掘都是 OpenAI 兼容的 chat completions 调用,所以「换模型」= 换 base-url + api-key + model 三件套。
系统把这套组合做成供应商清单,并在「系统设置」页支持运行时切换:立即生效、无需重启、重启后保持。
| 能力 | 说明 |
|---|---|
| 切换入口 | 「系统设置」页 → 选供应商 → 选/填模型 → 保存并生效(影响之后的所有复筛与挖掘,已出结论的历史录音不重判) |
| 持久化 | 写入 app_settings 表(screen.provider / screen.model);表为空时回退到配置项 APP_SCREEN_PROVIDER |
| 连通性测试 | 可测「当前生效」或「待保存」的选择(先验证再切换),返回真实耗时与供应商报错原文 |
| 候选模型 | 下拉候选来自配置,同时支持直接输入列表外的新模型名 —— 供应商上新模型无需改配置重启 |
| 判定留痕 | AI 结论里记录 screenProvider / screenModel,录音详情与复检工作区显示「判定模型」,便于回溯与横向对比 |
| 不参与切换 | Whisper 转写固定走 OpenAI(chat 类供应商不提供转写能力);DFA 初筛是本地算法,不调用外部模型 |
| 降级保护 | 当前模型缺密钥 / 网络失败 / 额度不足时,DFA 命中的录音降级转人工复检(aiResultJson.degraded=true),既不自动放行也不卡死 |
内置供应商(application.yml → app.screen-models.providers):
| 供应商 | 默认模型 | 端点 | 候选模型 |
|---|---|---|---|
| OpenAI | gpt-4o-mini |
/v1/chat/completions |
gpt-4o-mini / gpt-4o / gpt-4.1-mini |
| DeepSeek | deepseek-flash |
/chat/completions |
deepseek-flash / deepseek-v4-pro |
启用 DeepSeek:
# 1. 在 .env 里补密钥(docker compose 自动读取)
DEEPSEEK_API_KEY=sk-xxxxxxxxxxxxxxxx
# 2. 重启后到「系统设置」页切到 DeepSeek;或直接指定首次启动的默认供应商
# APP_SCREEN_PROVIDER=deepseek接入第三方供应商(自建网关、其它厂商)只需在 application.yml 追加一段:
app:
screen-models:
providers:
- id: my-gateway
label: 自建网关
base-url: https://llm.internal
api-key: ${MY_GATEWAY_KEY:}
api-key-env: MY_GATEWAY_KEY
chat-path: /v1/chat/completions
default-model: qwen-max
models: [qwen-max, qwen-plus]
json-mode: true # 端点不支持 response_format 时设为 false
max-tokens: 2048 # 可选:防止 JSON 输出被中途截断
extra-body: {} # 可选:透传字段,如 DeepSeek 的 thinking录音涉及客户隐私时,可以把转写从 OpenAI 换到本机 whisper.cpp:录音不出本机、无时长费用、无并发限制、也不受 25MB 限制。
brew install whisper.cpp ffmpeg
tools/whisper-server.sh --daemon # 默认加载 ~/models/whisper/ggml-large-v3-turbo.bin然后在 .env 里加两行并 docker compose up -d:
WHISPER_BASE_URL=http://host.docker.internal:9900
WHISPER_API_KEY=local # whisper.cpp 本身不校验密钥,但后端要求非空实测(M1 Pro / 16 核 GPU / large-v3-turbo / Metal):约 12x 实时 —— 79 秒录音转写 6 秒;本项目双声道会调用两次,10 分钟双轨通话约需 1.5 分钟。
零代码改动是怎么做到的:whisper.cpp 的 server 只暴露
/inference(1.9.5 实测无/v1/audio/transcriptions),脚本用--inference-path /v1/audio/transcriptions把它改名成 OpenAI 的路径。响应结构本来就和 OpenAI 对齐:segments[].{start,end,text}同名同单位(秒),顶层也有language/duration/text,因此TranscriptionService一行都不用改。端口只绑
127.0.0.1;Docker Desktop 的host.docker.internal可以直达宿主回环,无需暴露到局域网。
⚠️ 转写配置用独立的whisper.*前缀,不是复用openai.*。因为 docker-compose 会注入空的OPENAI_API_KEY="",而环境变量按宽松绑定等价于openai.api-key、优先级高于application.yml,会把配置里解析好的值整个盖掉(这个问题在开发时真实踩到过)。
| 场景 | 表现 |
|---|---|
Whisper 返回 verbose_json 分段(segments[].start/end/text) |
每句带时间轴,音画双向联动完整可用 |
双声道录音(分段带 speaker/channel) |
额外提供「双栏对话」视图 + 分声道试听 |
| 无分段数据(历史数据 / 非标准转写) | 按句末标点切句展示文本与标记,时间戳显示 --:--,点击不跳转并在顶部提示 |
| AI 原句与分段边界不一致 | 先按全文偏移定位,失败则回退为「句子包含原句 / 原句包含句子」的匹配(原句带 【说话人】 前缀会先剥离) |
句子与命中的归属逻辑(含偏移定位、区间合并、播放定位)抽成了纯函数 frontend/src/utils/segments.js,并有单元测试:
cd frontend && npm test # node --test,10 个用例想在空环境下预览界面:
python3 tools/seed_demo_data.py # 写入 12 条演示录音(id >= 1000,含分段/命中/AI 结论)
python3 tools/seed_demo_data.py --with-audio # 额外生成 #1001 的音轨:
# 混合(静音) / 左声道 440Hz / 右声道 660Hz
# 并把 #1001 造成双声道样本,可直接体验双栏对话与分声道试听
python3 tools/seed_demo_data.py --clean # 清理演示数据脚本会调用运行中的后端 /api/dictionary/test 计算命中位置,因此需要后端已启动;
默认后端地址 http://localhost:8082、MySQL 容器 echolint-mysql,可用 --api / --container 覆盖。
# 1. 配置 OpenAI 密钥(也可直接用环境变量)
cp .env.example .env
# 编辑 .env,填入 OPENAI_API_KEY=sk-...
# 2. 构建并启动(首次构建需数分钟)
docker compose up -d --build
# 3. 访问
# 前端: http://localhost:8081 (后端 API 经 Nginx 同源代理)
# 后端: http://localhost:8082 (健康检查 /actuator/health)
# 端口说明(宿主机):前端 8081 → Nginx:80;后端 8082 → 8080;MySQL 3307 → 3306。
# 若与本机已有服务冲突,改 docker-compose.yml 中的宿主机端口映射即可。停止:docker compose down(数据保存在 named volume,docker compose down -v 可清空)。
# 0. 准备 MySQL(compose 已把容器 3306 映射到宿主 3307,避免与本机 MySQL 冲突)
docker compose up -d mysql
# 1. 后端(通过 3307 连容器 MySQL;若本机 3306 空闲可改回默认 URL)
cd backend
export OPENAI_API_KEY=sk-...
export SPRING_DATASOURCE_URL='jdbc:mysql://localhost:3307/echolint?useUnicode=true&characterEncoding=utf8&serverTimezone=UTC&allowPublicKeyRetrieval=true&useSSL=false'
mvn spring-boot:run
# 2. 前端(开发服务器,/api 与 /uploads 默认代理到 8080;
# 若后端跑在别的端口,用 VITE_API_TARGET 覆盖,例如 compose 的 8082)
cd frontend
npm install
VITE_API_TARGET=http://localhost:8082 npm run dev| 变量 | 默认 | 说明 |
|---|---|---|
OPENAI_API_KEY |
(空) | OpenAI 密钥;转写与复筛的兜底密钥 |
WHISPER_BASE_URL |
https://api.openai.com |
转写端点,可指向本机 whisper.cpp(http://host.docker.internal:9900) |
WHISPER_API_KEY |
(空) | 转写密钥,覆盖 OPENAI_API_KEY;自建服务填任意非空值 |
OPENAI_BASE_URL |
https://api.openai.com |
可指向代理/网关 |
OPENAI_WHISPER_MODEL |
whisper-1 |
转写模型(自建 whisper.cpp 会忽略该值,但仍需带上) |
OPENAI_SCREEN_MODEL |
gpt-4o-mini |
OpenAI 作为复筛模型时的默认模型 |
DEEPSEEK_API_KEY |
(空) | DeepSeek 密钥,用于复筛 / 词挖掘(切换前必须配置) |
DEEPSEEK_BASE_URL |
https://api.deepseek.com |
DeepSeek 端点(可指向代理) |
DEEPSEEK_SCREEN_MODEL |
deepseek-flash |
DeepSeek 默认模型 |
APP_SCREEN_PROVIDER |
openai |
首次启动(app_settings 表为空)时的复筛供应商 |
SPRING_DATASOURCE_URL/USERNAME/PASSWORD |
localhost:3306, audit/audit123 | 数据库连接 |
APP_UPLOAD_DIR |
data/uploads |
录音文件存储目录 |
APP_STEREO_ENABLED |
true |
是否启用双声道分轨处理 |
APP_STEREO_LEFT_SPEAKER |
坐席 |
左声道说话人名称 |
APP_STEREO_RIGHT_SPEAKER |
客户 |
右声道说话人名称 |
| 方法 | 路径 | 说明 |
|---|---|---|
| POST | /api/recordings/upload |
上传录音(multipart file),异步启动稽核流水线 |
| GET | /api/recordings?statuses=&keyword=&page=&size= |
录音分页查询(statuses 逗号分隔) |
| GET | /api/recordings/{id} |
详情(转写/命中/复筛结果/复检信息) |
| GET | /api/recordings/{id}/logs |
处理轨迹日志 |
| GET | /api/recordings/{id}/ai-invocations |
AI 调用留痕(完整请求体 + 模型原始回复) |
| POST | /api/recordings/{id}/retry |
失败重试 |
| DELETE | /api/recordings/{id} |
删除(含文件与日志) |
| POST | /api/reviews/{id} |
人工复检 {result: CONFIRMED_VIOLATION|FALSE_POSITIVE, violationType?, comment?} |
| GET/POST/PUT/DELETE | /api/dictionary... |
敏感词 CRUD(变更后自动重建 DFA 匹配器) |
| POST | /api/dictionary/test |
检测工具:{text} → 命中列表 |
| GET/POST/DELETE | /api/corpus... |
语料库查询/录入/删除 |
| GET | /api/corpus/export |
语料导出 JSON |
| GET | /api/stats/overview |
工作台统计 |
| GET | /api/settings/screen-model |
当前复筛模型 + 全部候选供应商 |
| PUT | /api/settings/screen-model |
切换复筛模型 {providerId, model?}(落库持久化) |
| POST | /api/settings/screen-model/test |
连通性测试(请求体留空则测当前生效模型) |
PENDING → TRANSCRIBING → DFA_CHECKING → AI_CHECKING → NEEDS_REVIEW → VIOLATION_CONFIRMED
│ └───────→ FALSE_POSITIVE
└──→ COMPLIANT(任一步判定合规)
任意步骤异常 → FAILED(可在录音管理重试)
- 词条保存时自动规范化:小写、去空格/标点(
Kill You→killyou),匹配时原文中的空格/标点不影响命中(f u c k、f.u.c.k均可命中fuck)。 - 匹配为子串语义(
unfuckingbelievable会命中fuck),且不做字母替换(f**k不命中由复筛兜底),误报由 AI 复筛兜底——这是两段式设计的初衷。 - AI 挖掘的新词默认停用,需在敏感词库页人工审核后启用(列表按
来源=AI 挖掘过滤)。 - 预置词典中英双语(185 词,中文 93 条),覆盖辱骂/歧视/威胁/骚扰/色情/诈骗/隐私,可增删改。 选词原则是「只可能出现在违规语境」,刻意不收「垃圾」「转账」「死」这类高频中性词,避免误报淹没复检队列。
⚠️ 同音变体必须收录。实测 Whisper 会把「傻逼」转写成「少逼」(shǎ bī ↔ shǎo bī); 而流水线是 DFA 命中才进 AI 复筛,所以 DFA 漏检 = 该录音直接判COMPLIANT、AI 永远看不到, 是静默假阴性。词典里因此显式收录了少逼/傻比/傻壁/杀比/草你妈等 ASR 误写变体。 维护词典时请按「ASR 可能怎么听错」补词,而不只是按「人会怎么写」。
- 卡住的录音:
待处理 / 转写中 / 初筛中 / 复筛中停超过 2 分钟会标记「疑似卡住」,可用行内按钮手动推进。 - 服务重启自动恢复:启动时会把上次中断在中间态的录音置为
FAILED(附原因「服务重启导致处理中断」), 使其可被「重新处理」,不会永久卡死(见PipelineRecoveryService)。 - 在途保护:流水线运行期间重复点击「重新处理」会被后端忽略(进程内 in-flight 标记),不会重复调用 Whisper。
- 切换复筛模型:选择存在
app_settings表,进程内不缓存,改完立即对新录音生效;配置里删掉某个供应商时,表里的旧选择会自动回退到APP_SCREEN_PROVIDER(旧的模型名一并作废,避免拿旧模型名打新供应商)。密钥只从环境变量/配置读取,不会通过接口下发或写库。 - Spring 代理坑:
PipelineService有@Async方法,不要让它实现接口,否则 Spring 使用 JDK 动态代理, 其它 Bean 按具体类型注入会启动失败(was actually of type 'jdk.proxy2.$Proxy')。 项目已在@EnableAsync(proxyTargetClass = true)上做了兜底。 - 演示音轨:
tools/seed_demo_data.py --with-audio会为每条演示录音生成与其时长一致的静音音轨 (#1001 额外生成 440Hz/660Hz 左右声道),因此演示数据上的「推进处理」也能真正跑通。
- Whisper 单文件上限 25MB(OpenAI 限制),上传接口同样限制;更长的录音请先切分或压缩。
改用本机 whisper.cpp 后该限制即失效(
app.max-size-bytes可调大,whisper.cpp 无此限制)。 - 语义复筛调用失败时(模型缺密钥 / 网络异常 / 额度不足),DFA 命中的录音会降级转入人工复检
(
aiResultJson中degraded=true并带错误说明),不会漏审;恢复模型可用后重跑即可得到 AI 结论。 - 语料库初始含 5 条合成样例,随人工复检的积累会逐渐替换为真实标注数据。




