Files
live-call-asr-monitor/asr_event_monitor_confirmed_technical_solution.md
2026-07-16 14:18:43 +08:00

31 KiB
Raw Blame History

ASR 实时关键词事件监控服务技术方案

基于当前已确认需求整理
技术栈Java 21、Spring Boot 4.1.0、Spring MVC、Spring Data JPA、AC 自动机、React + Vite + shadcn/ui


1. 项目目标

建设一个独立的 ASR 实时事件监控服务。

坐席前端每收到一条新的 ASR Final就调用后端实时检测接口。后端根据当前已生效的关键词规则进行匹配并在同一通话内首次发现新的业务事件时返回提醒。

首期主要解决:

  • 潜在转接方向识别
  • 实时关键词检测
  • 同一通话内去重提醒
  • 规则配置页面
  • 规则保存后立即生效
  • Demo 页面验证当前生效规则

后续可扩展:

  • 高频事件匹配
  • 风险事件提醒
  • 服务规范提醒
  • 正则匹配
  • 语义相似匹配
  • Dify 二次确认
  • Redis 多实例会话状态
  • SSE 实时推送

2. 首期范围

2.1 首期实现内容

  • Java 21
  • Spring Boot 4.1.0
  • Spring MVC REST 接口
  • 前端主动提交 ASR Final
  • 只处理 Final不处理 Partial
  • 只匹配市民侧文本
  • AC 自动机关键词匹配
  • 最近五条市民 ASR Final 拼接
  • 同一通话、同一事件只提醒一次
  • 规则通过页面编辑
  • 规则保存到数据库
  • 保存成功后重建 AC 自动机并立即生效
  • 提供单页面配置与测试
  • JVM 内存保存通话状态
  • 提供通话主动关闭接口
  • TTL 自动清理过期会话
  • 单实例部署

2.2 首期不实现内容

  • Redis
  • Kafka、RabbitMQ、RocketMQ
  • Elasticsearch
  • 向量数据库
  • Embedding 语义匹配
  • Dify 实时确认
  • WebSocket 或 SSE
  • 通话状态查询接口
  • 独立 eventId
  • 多实例部署
  • 复杂草稿、审核和发布流程

3. 最终确定的接口边界

3.1 实时检测接口

实时检测业务接口:

POST /api/v1/asr-events

前端每收到一条新的 ASR Final就调用该接口。

请求不再传 eventId,使用:

callId + seq

唯一标识一条 ASR Final。

3.2 不提供通话状态查询接口

不提供:

GET /api/v1/calls/{callId}/state

每次实时检测响应同时返回:

  • 本次新增提醒:newAlerts
  • 当前通话累计结果:currentResults

3.3 提供通话关闭接口

通话结束时,前端应主动关闭会话,立即释放内存中的通话状态:

POST /api/v1/calls/{callId}/close

同时保留 TTL 定时清理,作为兜底机制,处理前端未调用 close、进程异常退出等遗漏场景。

约束条件:

  • 每通电话的 callId 必须唯一
  • 正常结束路径优先调用 close
  • 未关闭或异常遗留的会话由 TTL 自动清理

3.4 保留每通电话独立锁

Spring MVC 会并发处理请求,同一通话的两条 ASR Final 可能同时进入后端。

每个 callId 使用独立锁,保护:

幂等检查
→ 更新上下文
→ 关键词匹配
→ 判断是否首次提醒
→ 更新会话状态
→ 组装响应

不同通话使用不同锁,不会互相阻塞。


4. 总体架构

flowchart LR
    A[ASR 系统] --> B[坐席前端]
    B -->|ASR Final| C[POST /api/v1/asr-events]

    subgraph M[ASR 实时事件监控服务]
        C --> D[请求校验]
        D --> E[callId + seq 幂等]
        E --> F[更新最近 Final 上下文]
        F --> G[文本标准化]
        G --> H[AC 自动机匹配]
        H --> I[按事件聚合关键词]
        I --> J[会话级新增事件差分]
        J --> K[返回 newAlerts + currentResults]
    end

    L[JVM 内存会话状态] <--> E
    L <--> F
    L <--> J

    N[shadcn 配置页面] --> O[规则管理 API]
    O --> P[JPA 数据库]
    O --> Q[构建新 AC 自动机]
    Q --> R[AtomicReference 原子切换]
    R --> H

    K --> B

5. 技术栈

类别 选型
JDK Java 21
后端框架 Spring Boot 4.1.0
Web Spring MVC
参数校验 Jakarta Validation
数据持久化 Spring Data JPA
开发数据库 H2 文件模式
正式数据库 PostgreSQL可平滑替换
关键词匹配 org.ahocorasick:ahocorasick
会话状态 ConcurrentHashMap
并发控制 每通话 ReentrantLock
规则热切换 AtomicReference
定时清理 @Scheduled
健康检查 Spring Boot Actuator
前端 React + TypeScript + Vite
UI Tailwind CSS + shadcn/ui
测试 JUnit 5、MockMvc、Spring Boot Test Starter

6. POM 调整

当前 POM 已包含:

  • spring-boot-starter-webmvc
  • spring-boot-starter-validation
  • spring-boot-starter-actuator
  • 对应测试 Starter

建议增加:

<dependency>
    <groupId>org.ahocorasick</groupId>
    <artifactId>ahocorasick</artifactId>
    <version>0.6.3</version>
</dependency>

<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-data-jpa</artifactId>
</dependency>

<dependency>
    <groupId>com.h2database</groupId>
    <artifactId>h2</artifactId>
    <scope>runtime</scope>
</dependency>

<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-configuration-processor</artifactId>
    <optional>true</optional>
</dependency>

开发阶段使用 H2 文件数据库,规则配置不会因服务重启丢失。

正式部署时可将 H2 切换为 PostgreSQLJPA 业务代码无需明显修改。


7. 项目目录

demo/
├── pom.xml
│
├── frontend/
│   ├── package.json
│   ├── vite.config.ts
│   ├── components.json
│   └── src/
│       ├── App.tsx
│       ├── api/
│       │   └── rule-api.ts
│       ├── components/
│       │   ├── RuleEditor.tsx
│       │   ├── RuleDialog.tsx
│       │   └── MatchTester.tsx
│       └── components/ui/
│
└── src/
    ├── main/
    │   ├── java/com/example/demo/
    │   │   ├── DemoApplication.java
    │   │   │
    │   │   ├── api/
    │   │   │   ├── AsrEventController.java
    │   │   │   ├── CallController.java
    │   │   │   └── RuleAdminController.java
    │   │   │
    │   │   ├── application/
    │   │   │   ├── AsrEventMonitorService.java
    │   │   │   ├── CallSessionService.java
    │   │   │   └── RuleManagementService.java
    │   │   │
    │   │   ├── domain/
    │   │   │   ├── AsrFinalEventRequest.java
    │   │   │   ├── MonitorResponse.java
    │   │   │   ├── CloseCallResponse.java
    │   │   │   ├── MatchResult.java
    │   │   │   ├── AlertResult.java
    │   │   │   ├── RuleDocument.java
    │   │   │   ├── RuleSet.java
    │   │   │   ├── EventRule.java
    │   │   │   ├── CallSession.java
    │   │   │   └── EventState.java
    │   │   │
    │   │   ├── matcher/
    │   │   │   ├── TextMatcher.java
    │   │   │   ├── AcKeywordMatcher.java
    │   │   │   ├── AcAutomatonFactory.java
    │   │   │   ├── AcAutomatonSnapshot.java
    │   │   │   └── KeywordPayload.java
    │   │   │
    │   │   ├── repository/
    │   │   │   ├── SessionStore.java
    │   │   │   ├── InMemorySessionStore.java
    │   │   │   ├── RuleVersionEntity.java
    │   │   │   └── RuleVersionRepository.java
    │   │   │
    │   │   ├── config/
    │   │   │   └── MonitorProperties.java
    │   │   │
    │   │   ├── job/
    │   │   │   └── SessionCleanupJob.java
    │   │   │
    │   │   └── support/
    │   │       ├── TextNormalizer.java
    │   │       ├── EventKey.java
    │   │       └── ApiExceptionHandler.java
    │   │
    │   └── resources/
    │       ├── application.yml
    │       └── static/
    │
    └── test/

8. ASR 实时检测接口

8.1 请求

POST /api/v1/asr-events
Content-Type: application/json
{
  "callId": "call-1001",
  "seq": 28,
  "speaker": "citizen",
  "text": "我这个顺丰快递一直没有收到",
  "final": true
}

8.2 DTO

public record AsrFinalEventRequest(
        @NotBlank
        @Size(max = 128)
        String callId,

        @PositiveOrZero
        long seq,

        @NotBlank
        @Pattern(regexp = "citizen|agent")
        String speaker,

        @NotBlank
        @Size(max = 4000)
        String text,

        @JsonProperty("final")
        boolean isFinal
) {
}

8.3 响应

{
  "accepted": true,
  "duplicate": false,
  "callId": "call-1001",
  "acceptedSeq": 28,
  "revision": 1,
  "activeRuleVersion": 5,
  "newAlerts": [
    {
      "eventType": "transfer",
      "eventId": "sf-express",
      "eventName": "顺丰速递绿色渠道服务热线",
      "status": "matched",
      "matchedKeywords": [
        "顺丰快递",
        "顺丰"
      ],
      "evidence": "我这个顺丰快递一直没有收到",
      "firstMatchedSeq": 28
    }
  ],
  "currentResults": [
    {
      "eventType": "transfer",
      "eventId": "sf-express",
      "eventName": "顺丰速递绿色渠道服务热线",
      "status": "matched",
      "matchedKeywords": [
        "顺丰快递",
        "顺丰"
      ],
      "evidence": "我这个顺丰快递一直没有收到",
      "firstMatchedSeq": 28
    }
  ]
}

字段说明:

字段 说明
accepted 请求是否被处理
duplicate 是否为重复 Final
newAlerts 本次新增、需要前端提醒的事件
currentResults 当前通话累计识别到的全部事件
activeRuleVersion 当前实际参与匹配的规则版本

9. 幂等算法

删除独立 eventId 后,使用:

callId + seq

标识 ASR Final。

每个通话保存最近已处理的序号:

private final Set<Long> processedSeqs =
        new LinkedHashSet<>();
public boolean markProcessed(long seq, int maxSize) {
    if (processedSeqs.contains(seq)) {
        return false;
    }

    processedSeqs.add(seq);

    while (processedSeqs.size() > maxSize) {
        Iterator<Long> iterator = processedSeqs.iterator();
        iterator.next();
        iterator.remove();
    }

    return true;
}

使用有界集合而不是只判断:

seq <= lastSeq

可以允许少量乱序请求继续参与匹配,降低漏判风险。

前提:

  • callId 每通电话唯一
  • seq 在同一通电话内唯一

如果坐席和市民分别独立编号,应改成:

callId + speaker + seq

10. 文本标准化算法

public final class TextNormalizer {

    private static final Pattern REMOVABLE =
            Pattern.compile(
                    "[\\s,.!?;:\"'()\\[\\]【】]+"
            );

    private TextNormalizer() {
    }

    public static String normalize(String text) {
        if (text == null || text.isBlank()) {
            return "";
        }

        String normalized = Normalizer.normalize(
                text,
                Normalizer.Form.NFKC
        ).toLowerCase(Locale.ROOT);

        return REMOVABLE
                .matcher(normalized)
                .replaceAll("");
    }
}

示例:

EMS   → ems
E M S → ems
顺丰,快递 → 顺丰快递

11. 最近 Final 上下文

市民和坐席文本都参与匹配。同一次通话维护一个共享上下文窗口, 匹配文本使用该通话最近五条 ASR Final。

目的:降低 ASR 切段造成的漏判。

例如:

Final 27我是在拼
Final 28多多上买的

拼接后:

我是在拼。多多上买的

标准化后:

我是在拼多多上买的

从而命中“拼多多”。

会话中按 seq 保存:

private final NavigableMap<Long, String> recentFinals =
        new TreeMap<>();
public void appendFinal(
        long seq,
        String text,
        int windowSize
) {
    recentFinals.put(seq, text);

    while (recentFinals.size() > windowSize) {
        recentFinals.pollFirstEntry();
    }
}

12. AC 自动机匹配

12.1 Payload

public record RuleTarget(
        String ruleSetId,
        String ruleId,
        String ruleName
) {
}

public record KeywordPayload(
        String normalizedKeyword,
        String displayKeyword,
        List<RuleTarget> targets
) {
}

12.2 构建流程

读取完整规则
→ 校验
→ 标准化关键词
→ 构建新的 PayloadTrie
→ 包装为不可变快照
→ AtomicReference 原子替换

实时 ASR 请求只扫描内存中的自动机,不访问数据库。

12.3 Matcher

@Component
public class AcKeywordMatcher {

    private final AtomicReference<AcAutomatonSnapshot>
            currentSnapshot = new AtomicReference<>();

    public List<MatchResult> match(String text) {
        AcAutomatonSnapshot snapshot =
                currentSnapshot.get();

        if (snapshot == null || text == null || text.isBlank()) {
            return List.of();
        }

        String normalizedText =
                TextNormalizer.normalize(text);

        Collection<PayloadEmit<KeywordPayload>> emits =
                snapshot.trie().parseText(normalizedText);

        return aggregate(emits, text);
    }

    public void replaceSnapshot(
            AcAutomatonSnapshot newSnapshot
    ) {
        currentSnapshot.set(newSnapshot);
    }
}

12.4 重叠关键词处理

规则可能同时包含:

顺丰快递
顺丰

不使用 ignoreOverlaps

AC 自动机返回全部命中,应用层按事件聚合:

{
  "eventId": "sf-express",
  "matchedKeywords": [
    "顺丰快递",
    "顺丰"
  ]
}

同一方向只返回一个事件结果。


13. 会话级新增提醒算法

每通电话维护:

private final Set<String> alertedEventKeys =
        new HashSet<>();

统一事件键:

eventKey = ruleSetId + ":" + ruleId

例如:

transfer:sf-express

新增事件差分:

newEvents = currentMatchedEvents - alertedEvents

Java

List<MatchResult> newMatches = matches.stream()
        .filter(match ->
                !session.hasAlerted(match.eventKey())
        )
        .toList();

更新状态:

for (MatchResult match : matches) {
    session.mergeMatch(match, seq);
}

for (MatchResult match : newMatches) {
    session.markAlerted(match.eventKey());
}

效果:

第一次出现“顺丰”
→ 提醒一次

再次出现“顺丰”
→ 不重复提醒

后续出现“拼多多”
→ 新增提醒拼多多

14. 每通电话独立锁

private final ConcurrentHashMap<String, ReentrantLock>
        callLocks = new ConcurrentHashMap<>();
public ReentrantLock getLock(String callId) {
    return callLocks.computeIfAbsent(
            callId,
            ignored -> new ReentrantLock()
    );
}

处理时:

ReentrantLock lock =
        sessionStore.getLock(request.callId());

lock.lock();

try {
    return processUnderLock(request);
} finally {
    lock.unlock();
}

锁保护:

幂等检查
→ 上下文更新
→ 匹配
→ 新事件判断
→ 状态更新
→ 响应构建

不同通话使用不同锁,可以并行处理。


15. 会话关闭与 TTL 清理

会话释放采用 主动关闭 + TTL 兜底 双路径:

通话结束
  → 优先POST /api/v1/calls/{callId}/close立即释放
  → 兜底TTL 定时任务清理遗漏会话

15.1 通话关闭接口

POST /api/v1/calls/{callId}/close

响应示例:

{
  "callId": "call-1001",
  "closed": true,
  "existed": true
}
字段 说明
callId 被关闭的通话 ID
closed 本次调用是否完成关闭处理(接口幂等,恒为 true
existed 关闭前会话是否存在;重复关闭或不存在时为 false

处理流程:

获取 callId 独立锁
→ 删除 CallSession
→ 删除对应 ReentrantLock
→ 返回 closed=true

语义约定:

  • 接口幂等:同一 callId 多次 close 不报错
  • 通话结束后应尽快调用,避免会话长期占用内存
  • 由于 callId 必须全局唯一,关闭后不应再复用同一 callId 提交 ASR Final

服务层示意:

public CloseCallResponse close(String callId) {
    ReentrantLock lock = sessionStore.getLock(callId);
    lock.lock();
    try {
        boolean existed = sessionStore.remove(callId);
        sessionStore.removeLock(callId);
        return new CloseCallResponse(callId, true, existed);
    } finally {
        lock.unlock();
    }
}

15.2 TTL 兜底清理

即使提供 close仍保留 TTL用于清理以下遗留会话

  • 前端未调用 close
  • 页面异常关闭、网络故障导致 close 未送达
  • 进程重启前未及时释放的会话单实例内存方案下重启后本就为空TTL 主要覆盖运行期遗漏)
monitor:
  recent-final-window-size: 5
  max-processed-seqs: 500
  session-ttl: PT2H
  session-cleanup-interval: PT10M
@Scheduled(
    fixedDelayString =
        "${monitor.session-cleanup-interval:PT10M}"
)
public void cleanupExpiredSessions() {
    Instant threshold =
            Instant.now(clock)
                    .minus(properties.sessionTtl());

    sessionStore.removeExpired(threshold);
}

TTL 清理时同样删除:

  • CallSession
  • 对应的 ReentrantLock

close 与 TTL 互不冲突:已 close 的会话不存在TTL 跳过;未 close 的会话到期后由 TTL 删除。


16. 规则数据模型

首期使用一张版本表保存完整规则 JSON。

CREATE TABLE rule_version (
    id BIGINT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
    version_no BIGINT NOT NULL,
    active BOOLEAN NOT NULL,
    content_json CLOB NOT NULL,
    rule_count INT NOT NULL,
    keyword_count INT NOT NULL,
    created_at TIMESTAMP NOT NULL
);

规则文档:

{
  "ruleSets": [
    {
      "id": "transfer",
      "name": "潜在转接",
      "enabled": true,
      "matcher": "ac-keyword",
      "rules": [
        {
          "id": "sf-express",
          "name": "顺丰速递绿色渠道服务热线",
          "enabled": true,
          "keywords": [
            "顺丰",
            "顺丰快递",
            "顺丰速运"
          ]
        }
      ]
    }
  ]
}

每次保存并生效生成新版本:

V1 active=false
V2 active=false
V3 active=true

17. 规则管理接口

17.1 获取当前生效规则

GET /api/v1/admin/rules

响应:

{
  "version": 5,
  "ruleCount": 14,
  "keywordCount": 39,
  "ruleSets": []
}

17.2 保存并生效

PUT /api/v1/admin/rules

请求:

{
  "baseVersion": 5,
  "ruleSets": []
}

使用 baseVersion 做乐观并发控制。

版本冲突:

409 Conflict
{
  "code": "RULE_VERSION_CONFLICT",
  "message": "规则已被其他页面修改,请刷新后重试",
  "currentVersion": 6
}

17.3 查询历史版本

GET /api/v1/admin/rules/versions?limit=20

按版本号倒序返回版本摘要,包括版本号、是否生效、规则数、关键词数和创建时间。

17.4 回溯历史版本

POST /api/v1/admin/rules/versions/{version}/restore

请求:

{
  "baseVersion": 8
}

回溯不会重新激活或修改旧记录。系统复制目标历史版本的规则内容, 创建并发布一个新的递增版本。例如当前 V8 回溯 V3最终创建并生效 V9。 baseVersion 继续用于乐观并发控制。

17.5 测试当前生效规则

POST /api/v1/admin/rules/test

请求:

{
  "text": "我这个顺丰快递一直没有收到"
}

响应:

{
  "activeVersion": 6,
  "normalizedText": "我这个顺丰快递一直没有收到",
  "matches": [
    {
      "eventType": "transfer",
      "eventId": "sf-express",
      "eventName": "顺丰速递绿色渠道服务热线",
      "matchedKeywords": [
        "顺丰快递",
        "顺丰"
      ]
    }
  ]
}

测试接口是无状态接口:

  • 不保存通话上下文
  • 不执行同一通话去重
  • 每次都返回当前文本的直接匹配结果
  • 验证的是后端当前真正生效的内存 AC 自动机

18. 规则保存后生效流程

页面提交完整规则和 baseVersion
        ↓
检查 baseVersion
        ↓
校验规则结构和关键词冲突
        ↓
预构建新的 AC 自动机
        ↓
数据库保存新规则版本
        ↓
事务提交成功
        ↓
AtomicReference 原子切换
        ↓
后续 ASR 请求立即使用新规则

核心原则:

新自动机先构建成功,数据库事务提交成功,之后才切换运行时规则。

事务内:

@Transactional
public SaveRuleResponse saveAndActivate(
        SaveRuleRequest request
) {
    RuleDocument document =
            ruleValidator.validateAndNormalize(request);

    AcAutomatonSnapshot snapshot =
            automatonFactory.build(document);

    repository.deactivateCurrent();

    RuleVersionEntity entity =
            repository.save(
                    RuleVersionEntity.active(
                            document,
                            snapshot.ruleCount(),
                            snapshot.keywordCount()
                    )
            );

    eventPublisher.publishEvent(
            new RuleActivatedEvent(
                    entity.getVersionNo(),
                    snapshot
            )
    );

    return SaveRuleResponse.from(entity, snapshot);
}

事务提交后切换:

@TransactionalEventListener(
        phase = TransactionPhase.AFTER_COMMIT
)
public void activate(RuleActivatedEvent event) {
    keywordMatcher.replaceSnapshot(
            event.snapshot()
                    .withVersion(event.version())
    );
}

失败策略:

  • 校验失败:不保存、不切换
  • AC 自动机构建失败:不保存、不切换
  • 数据库提交失败:不切换
  • 旧规则继续正常工作

19. 服务启动后的规则恢复

服务启动时:

查询数据库中 active=true 的最新规则
→ 解析完整规则 JSON
→ 校验
→ 构建 AC 自动机
→ 设置为当前运行快照

如果数据库没有规则:

读取内置初始配置
→ 创建 V1
→ 设置 active=true
→ 构建并加载自动机

20. 规则校验要求

保存前至少校验:

  1. 规则集 ID 不重复
  2. 事件 ID 由系统自动生成;导入或兼容旧数据携带 ID 时必须保持唯一
  3. 事件名称不能为空
  4. 启用事件至少有一个关键词
  5. 标准化后关键词不能为空
  6. 同一事件内关键词不能重复
  7. 同一关键词不能指向多个转接方向
  8. Matcher 类型必须存在
  9. baseVersion 必须等于当前版本

对于“闪购”“邮政”“百度”等较宽泛关键词,可以作为 warning 返回,但首期允许保存。

前端新增规则时生成只读事件 IDExcel 新增行允许将事件 ID 留空;后端保存时仍会为缺失 ID 生成 event-<UUID>,作为绕过前端调用接口时的兜底。已有规则的事件 ID 保持不变,以保证同一通话内事件去重和历史版本回溯的稳定性。


21. 单页面前端

前端采用:

React
TypeScript
Vite
Tailwind CSS
shadcn/ui

页面不使用路由,只使用两个 Tab

┌─────────────────────────────────────────────┐
│ ASR 事件监控配置         当前生效版本 V6    │
├─────────────────────────────────────────────┤
│ [规则配置] [匹配验证]                       │
├─────────────────────────────────────────────┤
│                                             │
│               当前 Tab 内容                 │
│                                             │
└─────────────────────────────────────────────┘

22. 规则配置页面

页面功能:

  • 展示规则列表
  • 新增规则
  • 编辑规则
  • 删除规则
  • 启用或停用规则
  • 增加或删除关键词
  • 保存并生效
  • 显示当前生效版本
  • 显示规则数和关键词数

推荐 shadcn/ui 组件:

功能 组件
页面切换 Tabs
规则展示 CardTable
关键词 Badge
启用状态 Switch
新增与编辑 Dialog
删除确认 AlertDialog
按钮 Button
文本输入 Input
操作提示 Sonner

页面示意:

潜在转接规则                         共 14 条

┌───────────────────────────────────────────┐
│ 顺丰速递绿色渠道服务热线          [启用]  │
│ IDsf-express                            │
│                                           │
│ [顺丰 ×] [顺丰快递 ×] [顺丰速运 ×]       │
│                              [编辑] [删除] │
└───────────────────────────────────────────┘

[新增规则]                    [保存并生效]

23. Demo 匹配验证页面

Demo 页面用于验证当前后端真正生效的 AC 自动机。

匹配验证                         生效版本 V6

测试文本
┌───────────────────────────────────────────┐
│ 我这个顺丰快递一直没有收到                │
└───────────────────────────────────────────┘

                            [清空] [开始检测]

检测结果

✓ 命中潜在转接方向

顺丰速递绿色渠道服务热线
事件 IDsf-express
命中关键词:[顺丰快递] [顺丰]

页面调用:

POST /api/v1/admin/rules/test

保存并生效后,可自动切换到 Demo Tab 并验证新关键词。


24. 前后端集成

开发阶段:

Vitehttp://localhost:5173
Spring Boothttp://localhost:8080

vite.config.ts

import react from "@vitejs/plugin-react"
import tailwindcss from "@tailwindcss/vite"
import { defineConfig } from "vite"

export default defineConfig({
  plugins: [react(), tailwindcss()],
  server: {
    proxy: {
      "/api": {
        target: "http://localhost:8080",
        changeOrigin: true,
      },
    },
  },
})

生产构建:

cd frontend
pnpm build

frontend/dist 内容复制到:

src/main/resources/static/

最终前端和后端一起打包进同一个 Spring Boot JAR。

访问:

http://localhost:8080/

即可打开规则配置和 Demo 页面。


25. application.yml 示例

spring:
  application:
    name: asr-event-monitor

  datasource:
    url: jdbc:h2:file:./data/rules;DB_CLOSE_ON_EXIT=FALSE
    username: sa
    password:

  jpa:
    hibernate:
      ddl-auto: update
    open-in-view: false

server:
  port: 8080

monitor:
  recent-final-window-size: 5
  max-processed-seqs: 500
  session-ttl: PT2H
  session-cleanup-interval: PT10M

management:
  endpoints:
    web:
      exposure:
        include:
          - health
          - info
          - metrics

26. 性能与部署

当前规模:

坐席数2
每坐席约 02 条 ASR Final/秒
总请求量:约 04 QPS

推荐资源:

CPU1 核
内存512 MB1 GB
实例数1

启动:

mvn clean package

java \
  -Xms256m \
  -Xmx512m \
  -jar target/demo-0.0.1-SNAPSHOT.jar

由于通话状态保存在 JVM 内存中,首期只部署一个实例。


27. 测试与验收

至少覆盖以下场景:

  1. 首次出现“顺丰”产生提醒
  2. 同一通话重复出现“顺丰”不重复提醒
  3. 同一通话后续出现“拼多多”产生新增提醒
  4. 同一个 callId + seq 重复提交返回 duplicate=true
  5. 少量乱序请求仍可参与匹配
  6. 市民和坐席文本均可触发匹配,并共享同一通话上下文窗口
  7. Partial 文本不参与匹配
  8. EMSemsE M S 均可命中
  9. 相邻 Final 拼接后可命中被切分的品牌
  10. 不同通话状态相互隔离
  11. close 能立即释放通话会话,重复 close 幂等
  12. TTL 能清理未 close 的过期会话
  13. 保存新规则后 Demo 接口立即使用新版本
  14. 保存失败时旧规则继续有效
  15. 两个管理页面同时编辑时能够检测版本冲突
  16. Actuator 健康检查正常

28. 后续演进

28.1 高频事件

增加规则集:

{
  "id": "high-frequency-event",
  "name": "高频事件",
  "enabled": true,
  "matcher": "ac-keyword",
  "rules": []
}

28.2 Dify 二次确认

状态扩展:

matched
→ confirming
→ confirmed / rejected

Dify 调用异步执行,不阻塞 ASR 接口。

28.3 语义匹配

新增:

public class SemanticMatcher implements TextMatcher {
}

保持统一 MatchResult 输出。

28.4 Redis 与多实例

需要扩容时:

InMemorySessionStore
→ RedisSessionStore

Controller、规则管理、Matcher 和前端协议保持不变。


29. 最终方案摘要

实时检测链路:

前端收到 ASR Final
        ↓
POST /api/v1/asr-events
        ↓
callId + seq 幂等
        ↓
最近五条市民 Final 拼接
        ↓
文本标准化
        ↓
内存 AC 自动机匹配
        ↓
按业务事件聚合关键词
        ↓
与本通话已提醒事件集合做差
        ↓
返回 newAlerts + currentResults

规则生效链路:

shadcn 单页面编辑完整规则
        ↓
PUT /api/v1/admin/rules
        ↓
校验规则
        ↓
预构建新 AC 自动机
        ↓
数据库保存新版本
        ↓
事务提交
        ↓
AtomicReference 原子切换
        ↓
Demo 页面立即验证

首期接口:

POST /api/v1/asr-events
POST /api/v1/calls/{callId}/close
GET  /api/v1/admin/rules
PUT  /api/v1/admin/rules
POST /api/v1/admin/rules/test

会话清理:

通话结束 → close 立即释放
遗漏会话 → TTL 定时兜底清理

最终采用:

Spring Boot 4.1.0
Java 21
Spring MVC
Spring Data JPA
H2 文件数据库
AC 自动机
JVM 内存会话状态
React + Vite + shadcn/ui
单实例部署