From c23ef80d3aa0d559639d47c0ec2dac11cabdcd40 Mon Sep 17 00:00:00 2001 From: Xin Wang Date: Fri, 17 Jul 2026 22:18:29 +0800 Subject: [PATCH] Integrate LLM support into the application, enhancing the rule management system with a new matcher mode. Update the frontend to handle LLM configurations, including API key management and prompt definitions. Modify the backend to support LLM event matching alongside existing keyword matching, ensuring seamless integration and improved event detection capabilities. --- README.md | 60 +++- frontend/src/App.tsx | 51 ++- frontend/src/api.ts | 4 +- frontend/src/components/LlmConfiguration.tsx | 324 ++++++++---------- frontend/src/components/RuleManagement.tsx | 111 ++---- frontend/src/components/SandboxSimulation.tsx | 33 +- frontend/src/rule-excel.ts | 106 ++++-- frontend/src/rule-id.ts | 9 + frontend/src/types.ts | 10 + pom.xml | 16 + .../com/example/demo/DemoApplication.java | 3 +- .../example/demo/api/RuleAdminController.java | 6 - .../application/AsrEventMonitorService.java | 53 ++- .../demo/application/RuleActivatedEvent.java | 4 +- .../application/RuleActivationListener.java | 12 +- .../application/RuleManagementService.java | 121 +++++-- .../demo/application/RuleValidator.java | 137 +++++++- .../demo/config/LlmProviderProperties.java | 16 + .../demo/config/MonitorProperties.java | 22 ++ .../com/example/demo/domain/CallSession.java | 41 ++- .../example/demo/domain/ConversationTurn.java | 8 + .../demo/domain/CurrentRulesResponse.java | 6 + .../com/example/demo/domain/EventRule.java | 5 + .../com/example/demo/domain/LlmConfig.java | 38 ++ .../com/example/demo/domain/RuleDocument.java | 8 +- .../java/com/example/demo/domain/RuleSet.java | 1 + .../example/demo/domain/SaveRuleRequest.java | 7 + .../example/demo/domain/SaveRuleResponse.java | 12 + .../example/demo/domain/TestRuleRequest.java | 11 - .../example/demo/domain/TestRuleResponse.java | 13 - .../demo/matcher/AcAutomatonFactory.java | 7 +- .../demo/matcher/AcKeywordMatcher.java | 4 +- .../demo/matcher/EventMatcherRouter.java | 72 ++++ .../example/demo/matcher/LlmEventMatcher.java | 220 ++++++++++++ .../demo/matcher/LlmMatcherSnapshot.java | 78 +++++ .../com/example/demo/matcher/RuleTarget.java | 2 +- .../demo/support/ApiExceptionHandler.java | 8 + .../com/example/demo/support/EventKey.java | 8 +- .../demo/support/LlmUnavailableException.java | 12 + src/main/resources/application.yml | 8 + src/main/resources/rules/default-rules.json | 16 +- .../demo/api/RuleAdminControllerTest.java | 19 +- 42 files changed, 1248 insertions(+), 454 deletions(-) create mode 100644 src/main/java/com/example/demo/config/LlmProviderProperties.java create mode 100644 src/main/java/com/example/demo/domain/ConversationTurn.java create mode 100644 src/main/java/com/example/demo/domain/LlmConfig.java delete mode 100644 src/main/java/com/example/demo/domain/TestRuleRequest.java delete mode 100644 src/main/java/com/example/demo/domain/TestRuleResponse.java create mode 100644 src/main/java/com/example/demo/matcher/EventMatcherRouter.java create mode 100644 src/main/java/com/example/demo/matcher/LlmEventMatcher.java create mode 100644 src/main/java/com/example/demo/matcher/LlmMatcherSnapshot.java create mode 100644 src/main/java/com/example/demo/support/LlmUnavailableException.java diff --git a/README.md b/README.md index 0573aa4..de26a78 100644 --- a/README.md +++ b/README.md @@ -1,14 +1,14 @@ # 实时通话 ASR 事件监控 -一个基于 Spring Boot、React 和 AC 自动机的实时通话关键词监控系统。 -前端持续提交市民与坐席的 ASR Final 文本,后端按通话维护上下文,匹配当前生效规则,并返回本次新增告警和通话累计结果。 +一个基于 Spring Boot 和 React 的实时通话事件监控系统。 +前端持续提交市民与坐席的 ASR Final 文本,后端按通话维护共享上下文,可选择 AC 关键词匹配或大模型事件匹配,并返回本次新增告警和通话累计结果。 ## 技术栈 | 模块 | 技术 | | --- | --- | -| 后端 | Java 21、Spring Boot 4、Spring MVC、Spring Data JPA | -| 匹配 | Aho-Corasick 自动机 | +| 后端 | Java 21、Spring Boot 4、Spring MVC、Spring Data JPA、Spring AI 2 | +| 匹配 | Aho-Corasick 自动机 / OpenAI 兼容大模型 | | 数据库 | H2 文件数据库 | | 前端 | React 19、TypeScript、Vite、Tailwind CSS、Motion | | Excel | ExcelJS | @@ -22,7 +22,7 @@ │ ├── api/ # REST 接口 │ ├── application/ # 监控、规则和会话服务 │ ├── domain/ # 请求、响应和领域模型 -│ ├── matcher/ # AC 自动机及匹配器 +│ ├── matcher/ # AC、LLM 匹配器及路由 │ ├── repository/ # 规则版本和会话存储 │ ├── job/ # 过期会话清理 │ └── support/ # 异常、文本标准化等 @@ -48,6 +48,15 @@ ./mvnw spring-boot:run ``` +如需使用“大模型事件匹配”,先设置模型服务的 API Key: + +```bash +export LLM_API_KEY="your-api-key" +./mvnw spring-boot:run +``` + +API Key 只从后端环境变量读取,不会进入浏览器、规则数据库或版本历史。模型服务地址、模型名、超时、最大输出 Token 和提示词在前端页面配置。 + 后端默认地址:`http://localhost:8080` 健康检查: @@ -146,11 +155,13 @@ flowchart TD B -- 是 --> D{"callId + seq 是否重复?"} D -- 是 --> E["返回 duplicate=true"] D -- 否 --> F["写入该通话的共享上下文窗口"] - F --> G["拼接最近 N 条 Final"] - G --> H["文本标准化"] - H --> I["AC 自动机匹配全部关键词"] - I --> J["合并事件并进行通话级去重"] - J --> K["返回 newAlerts 和 currentResults"] + F --> G{"当前匹配方式"} + G -- AC --> H["拼接、标准化并扫描关键词"] + G -- LLM --> I["注入规则、已发送事件和带角色上下文"] + H --> J["还原业务事件"] + I --> J + J --> K["后端进行通话级最终去重"] + K --> L["返回 newAlerts 和 currentResults"] ``` ### 1. 通话隔离与并发 @@ -159,7 +170,7 @@ flowchart TD ### 2. 共享上下文窗口 -市民和坐席文本进入同一个、按 `seq` 排序的最近 N 条窗口。默认保留 5 条,能够识别被 ASR 切分到相邻 Final 中的关键词。 +市民和坐席文本进入同一个、按 `seq` 排序的通话上下文,默认最多保留 200 轮。AC 只扫描最近 5 轮,能够识别被 ASR 切分到相邻 Final 中的关键词;LLM 使用当前保留的完整上下文。 ### 3. 文本标准化 @@ -178,9 +189,26 @@ E M S → ems 匹配结果按业务事件聚合。同一个事件在同一次通话中只进入一次 `newAlerts`,重复命中仍保留在累计状态中,但不会重复提醒。 +### 6. 大模型事件匹配 + +每条规则包含两个标识: + +- `id`:系统内部 UUID,用于规则管理和版本关联 +- `eventId`:简短、稳定的业务事件 ID,例如 `E001` + +后端把启用规则压缩为 `事件ID|名称|辅助关键词`,并把本通话已经发送的事件 ID 一并放入提示词。模型只允许输出 JSON 字符串数组,例如 `["E001"]` 或 `[]`,不输出置信度、证据和原因。后端校验短 ID、还原事件名称,并继续执行权威去重。 + +为提高支持自动 Prompt/KV Cache 的模型服务的缓存命中率,后端会把规则和指令放在稳定前缀中,把每次变化的通话上下文强制追加到提示词最末尾。上下文格式为: + +```text +1|citizen|市民发言 +2|agent|坐席发言 +3|citizen|市民发言 +``` + ## 规则管理与版本回溯 -规则保存采用完整文档发布,并携带 `baseVersion` 做乐观并发控制。事务提交成功后才切换运行时 AC 自动机快照。 +规则保存采用完整文档发布,并携带 `baseVersion` 做乐观并发控制。匹配方式和大模型配置与规则一起版本化;事务提交成功后才切换运行时快照。 主要接口: @@ -200,10 +228,12 @@ E M S → ems | 配置 | 默认值 | 说明 | | --- | --- | --- | | `server.port` | `8080` | HTTP 端口 | -| `monitor.recent-final-window-size` | `5` | 每通话保留的最近 Final 数量 | +| `monitor.recent-final-window-size` | `5` | AC 每次扫描的最近 Final 数量 | +| `monitor.max-conversation-turns` | `200` | 每通话最多保留的共享上下文轮数 | | `monitor.max-processed-seqs` | `500` | 幂等序列号历史上限 | | `monitor.session-ttl` | `PT2H` | 会话空闲过期时间 | | `monitor.session-cleanup-interval` | `PT10M` | 过期会话清理周期 | +| `monitor.llm.api-key` | 环境变量 `LLM_API_KEY` | 大模型服务密钥 | 规则数据库默认保存在项目运行目录的 `data/rules.mv.db`。 @@ -215,7 +245,7 @@ E M S → ems ### 扩展匹配算法 -当前 `matcher` 为 `ac-keyword`。可以继续实现正则、语义分类或大模型匹配器,并通过统一的匹配接口进行组合。建议将确定性关键词匹配保持在同步主链路,将耗时模型调用放入异步链路。 +当前支持 `ac-keyword` 和 `llm`。匹配器由统一路由调用,后续可以继续增加正则、高频事件分类、向量检索或组合策略。大模型当前采用同步调用,适合单实例、低流量场景;流量增加后可增加超时降级、隔离线程池、限流、指标和异步结果通道。 ### 扩展事件处理 @@ -239,6 +269,6 @@ E M S → ems ## 其他说明 -- 前端对话中的关键词高亮是本地视觉提示。 +- 关键词模式下,前端对话中的关键词高亮只是本地视觉提示;大模型模式不做本地关键词高亮。 - 真实告警始终以后端 `/api/v1/asr-events` 响应为准。 - 更完整的设计背景见 `asr_event_monitor_confirmed_technical_solution.md`。 diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 50b1512..a37f64c 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -16,9 +16,16 @@ import { restoreRuleVersion, saveAndActivateRules, } from './api'; -import { Rule, RuleSet, RuleVersionSummary } from './types'; +import { LlmConfig, Rule, RuleSet, RuleVersionSummary } from './types'; const TRANSFER_SET_ID = 'transfer'; +const DEFAULT_LLM_CONFIG: LlmConfig = { + baseUrl: 'https://api.openai.com/v1', + model: 'gpt-4.1-mini', + timeoutMs: 3000, + maxOutputTokens: 64, + prompt: '', +}; function emptyTransferSet(): RuleSet { return { @@ -43,12 +50,32 @@ function replaceTransferRules(ruleSets: RuleSet[], rules: Rule[]): RuleSet[] { ); } +function transferMatcher(ruleSets: RuleSet[]): 'ac-keyword' | 'llm' { + return ruleSets.find(ruleSet => ruleSet.id === TRANSFER_SET_ID)?.matcher === 'llm' + ? 'llm' + : 'ac-keyword'; +} + +function replaceTransferMatcher( + ruleSets: RuleSet[], + matcher: 'ac-keyword' | 'llm', +): RuleSet[] { + if (!ruleSets.some(ruleSet => ruleSet.id === TRANSFER_SET_ID)) { + return [{ ...emptyTransferSet(), matcher }, ...ruleSets]; + } + return ruleSets.map(ruleSet => + ruleSet.id === TRANSFER_SET_ID ? { ...ruleSet, matcher } : ruleSet + ); +} + export default function App() { // Global Active Tab: 'rules' | 'sandbox' const [activeTab, setActiveTab] = useState<'rules' | 'sandbox'>('rules'); const [ruleSets, setRuleSets] = useState([]); const [publishedRuleSets, setPublishedRuleSets] = useState([]); + const [llmConfig, setLlmConfig] = useState(DEFAULT_LLM_CONFIG); + const [publishedLlmConfig, setPublishedLlmConfig] = useState(DEFAULT_LLM_CONFIG); const [publishedVersion, setPublishedVersion] = useState(0); const [loadingRules, setLoadingRules] = useState(true); const [savingRules, setSavingRules] = useState(false); @@ -82,6 +109,8 @@ export default function App() { if (!active) return; setRuleSets(data.ruleSets); setPublishedRuleSets(data.ruleSets); + setLlmConfig(data.llmConfig); + setPublishedLlmConfig(data.llmConfig); setPublishedVersion(data.version); }) .catch(error => { @@ -101,17 +130,24 @@ export default function App() { setRuleSets(current => replaceTransferRules(current, updatedRules)); }; + const handleUpdateMatcher = (matcher: 'ac-keyword' | 'llm') => { + setRuleSets(current => replaceTransferMatcher(current, matcher)); + }; + const isDirty = useMemo(() => { - return JSON.stringify(ruleSets) !== JSON.stringify(publishedRuleSets); - }, [ruleSets, publishedRuleSets]); + return JSON.stringify(ruleSets) !== JSON.stringify(publishedRuleSets) + || JSON.stringify(llmConfig) !== JSON.stringify(publishedLlmConfig); + }, [llmConfig, publishedLlmConfig, publishedRuleSets, ruleSets]); const handlePublish = async () => { if (!isDirty || savingRules || restoringVersion !== null) return; setSavingRules(true); try { - const result = await saveAndActivateRules(publishedVersion, ruleSets); + const result = await saveAndActivateRules(publishedVersion, llmConfig, ruleSets); setRuleSets(result.ruleSets); setPublishedRuleSets(result.ruleSets); + setLlmConfig(result.llmConfig); + setPublishedLlmConfig(result.llmConfig); setPublishedVersion(result.version); addToast(`保存成功,监控规则 V${result.version} 已发布生效`, 'success'); result.warnings.forEach(warning => addToast(warning, 'warning')); @@ -155,6 +191,8 @@ export default function App() { const result = await restoreRuleVersion(sourceVersion, publishedVersion); setRuleSets(result.ruleSets); setPublishedRuleSets(result.ruleSets); + setLlmConfig(result.llmConfig); + setPublishedLlmConfig(result.llmConfig); setPublishedVersion(result.version); addToast(`已基于 V${sourceVersion} 创建并生效 V${result.version}`, 'success'); result.warnings.forEach(warning => addToast(warning, 'warning')); @@ -205,6 +243,10 @@ export default function App() { diff --git a/frontend/src/api.ts b/frontend/src/api.ts index b95f99f..37e9dc0 100644 --- a/frontend/src/api.ts +++ b/frontend/src/api.ts @@ -1,5 +1,6 @@ import type { CurrentRulesResponse, + LlmConfig, MonitorResponse, RuleSet, RuleVersionSummary, @@ -48,12 +49,13 @@ export async function fetchCurrentRules(): Promise { export async function saveAndActivateRules( baseVersion: number, + llmConfig: LlmConfig, ruleSets: RuleSet[], ): Promise { return parseResponse(await fetch('/api/v1/admin/rules', { method: 'PUT', headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ baseVersion, ruleSets }), + body: JSON.stringify({ baseVersion, llmConfig, ruleSets }), })); } diff --git a/frontend/src/components/LlmConfiguration.tsx b/frontend/src/components/LlmConfiguration.tsx index 1408020..5733c19 100644 --- a/frontend/src/components/LlmConfiguration.tsx +++ b/frontend/src/components/LlmConfiguration.tsx @@ -3,7 +3,7 @@ * SPDX-License-Identifier: Apache-2.0 */ -import { useState } from 'react'; +import { useState, type ReactNode } from 'react'; import { AnimatePresence, motion } from 'motion/react'; import { Bot, @@ -15,73 +15,45 @@ import { Sparkles, X, } from 'lucide-react'; +import type { LlmConfig } from '../types'; interface LlmConfigurationProps { + config: LlmConfig; enabledRuleCount: number; + onChange: (config: LlmConfig) => void; addToast: (message: string, type: 'success' | 'warning' | 'info') => void; } -interface LlmSettings { - baseUrl: string; - model: string; - timeoutMs: string; - prompt: string; -} +const DEFAULT_PROMPT = `你是实时通话事件识别器。根据规则库和已发送事件,判断最新对话是否产生新的事件。 -const STORAGE_KEY = 'asr-monitor:llm-settings-draft'; - -const DEFAULT_PROMPT = `你是实时通话事件识别器。 - -结合市民与坐席的完整上下文,判断当前最新一轮是否新产生规则库中的事件。 - -事件规则: +事件规则(事件ID|名称|辅助关键词): {{rules}} -通话上下文: -{{conversation}} +已发送事件 ID: +{{alerted_event_ids}} 判断要求: -1. 只判断当前最新一轮新产生的事件,不重复输出历史事件。 -2. 必须区分市民和坐席,排除否定表达、假设表达和坐席转述。 -3. 只能输出规则库中存在的事件 ID。 -4. 只输出 JSON 字符串数组,不输出原因、置信度、证据或 Markdown。 -5. 无事件时输出 []。`; +1. 结合市民与坐席的语义,只判断规则库中的事件。 +2. 排除否定、假设、举例和坐席转述。 +3. 已发送事件不要重复输出。 +4. 只输出 JSON 字符串数组,例如 ["E001"];无新事件输出 []。 +5. 不输出原因、置信度、证据、字段名或 Markdown。 -const DEFAULT_SETTINGS: LlmSettings = { - baseUrl: 'https://api.example.com/v1', - model: 'qwen-plus', - timeoutMs: '3000', - prompt: DEFAULT_PROMPT, -}; - -function loadSettings(): LlmSettings { - if (typeof window === 'undefined') return DEFAULT_SETTINGS; - try { - const saved = JSON.parse(window.localStorage.getItem(STORAGE_KEY) ?? '{}') as Partial; - return { - baseUrl: saved.baseUrl ?? DEFAULT_SETTINGS.baseUrl, - model: saved.model ?? DEFAULT_SETTINGS.model, - timeoutMs: saved.timeoutMs ?? DEFAULT_SETTINGS.timeoutMs, - prompt: saved.prompt ?? DEFAULT_SETTINGS.prompt, - }; - } catch { - return DEFAULT_SETTINGS; - } -} +通话上下文: +{{conversation}}`; export default function LlmConfiguration({ + config, enabledRuleCount, + onChange, addToast, }: LlmConfigurationProps) { const [isOpen, setIsOpen] = useState(false); - const [settings, setSettings] = useState(loadSettings); - const [draft, setDraft] = useState(settings); - const [apiKey, setApiKey] = useState(''); + const [draft, setDraft] = useState(config); const [errors, setErrors] = useState>({}); const openDrawer = () => { - setDraft(settings); - setApiKey(''); + setDraft(config); setErrors({}); setIsOpen(true); }; @@ -89,35 +61,37 @@ export default function LlmConfiguration({ const saveDraft = () => { const nextErrors: Record = {}; if (!draft.baseUrl.trim()) nextErrors.baseUrl = '请填写模型服务地址'; - if (!draft.model.trim()) nextErrors.model = '请填写模型名称'; - if (!/^\d+$/.test(draft.timeoutMs) || Number(draft.timeoutMs) < 300) { - nextErrors.timeoutMs = '超时时间不能小于 300ms'; + try { + const url = new URL(draft.baseUrl); + if (!['http:', 'https:'].includes(url.protocol)) throw new Error(); + } catch { + nextErrors.baseUrl = '请填写有效的 HTTP(S) 地址'; } - if (!draft.prompt.includes('{{rules}}')) { - nextErrors.prompt = '提示词需要包含 {{rules}},用于注入当前启用规则'; - } else if (!draft.prompt.includes('{{conversation}}')) { - nextErrors.prompt = '提示词需要包含 {{conversation}},用于注入通话上下文'; + if (!draft.model.trim()) nextErrors.model = '请填写模型名称'; + if (draft.timeoutMs < 300 || draft.timeoutMs > 30000) { + nextErrors.timeoutMs = '超时时间需在 300–30000ms 之间'; + } + if (draft.maxOutputTokens < 8 || draft.maxOutputTokens > 256) { + nextErrors.maxOutputTokens = '最大输出需在 8–256 Token 之间'; + } + const missingVariable = ['{{rules}}', '{{alerted_event_ids}}', '{{conversation}}'] + .find(variable => !draft.prompt.includes(variable)); + if (missingVariable) { + nextErrors.prompt = `提示词需要包含 ${missingVariable}`; } if (Object.keys(nextErrors).length > 0) { setErrors(nextErrors); return; } - const normalized = { - baseUrl: draft.baseUrl.trim(), + onChange({ + ...draft, + baseUrl: draft.baseUrl.trim().replace(/\/+$/, ''), model: draft.model.trim(), - timeoutMs: draft.timeoutMs.trim(), prompt: draft.prompt.trim(), - }; - setSettings(normalized); - window.localStorage.setItem(STORAGE_KEY, JSON.stringify(normalized)); + }); setIsOpen(false); - addToast( - apiKey - ? '模型与提示词草稿已保存;API Key 将在后端接入时安全保存' - : '模型与提示词草稿已保存', - 'success', - ); + addToast('模型与提示词已加入当前草稿,点击“保存并发布”后生效', 'success'); }; return ( @@ -136,30 +110,15 @@ export default function LlmConfiguration({

- 提示词定义判断方式,规则库提供可返回的事件 ID;模型仅输出简短 ID 数组。 + 模型只输出短事件 ID;后端负责 ID 映射、事件名称还原和通话内最终去重。

-
-

当前模型

-

- {settings.model} -

-
-
-

启用规则

-

- {enabledRuleCount} 项 -

-
-
-

输出协议

-

- ["E001"] -

-
+ + +

- 配置模型连接,并定义如何从规则库和通话上下文中识别事件。 + 该配置与规则一起保存、发布和版本回溯。

+ ))} @@ -346,28 +281,14 @@ export default function LlmConfiguration({
-
-
-

输出协议:事件 ID 数组 V1

- - 固定格式 - -
+
+

固定输出协议

- 模型只返回规则库中的短事件 ID,后端校验后通过 UUID 还原事件名称并完成去重。 + 只接受 JSON 字符串数组。未知 ID 会被后端丢弃;重复 ID 会合并;最终仍由后端按通话去重。

-
-
-

命中

- - ["E001","E003"] - -
-
-

未命中

- [] -
-
+ + ["E001","E003"] / [] +
@@ -387,7 +308,7 @@ export default function LlmConfiguration({ className="inline-flex items-center gap-1.5 rounded bg-slate-900 px-4 py-2 text-xs font-bold text-white transition hover:bg-slate-800" > - 保存配置 + 保存到草稿 @@ -397,3 +318,40 @@ export default function LlmConfiguration({ ); } + +function Stat({ label, value, mono = false }: { label: string; value: string; mono?: boolean }) { + return ( +
+

{label}

+

+ {value} +

+
+ ); +} + +function Field({ + label, + error, + children, +}: { + label: string; + error?: string; + children: ReactNode; +}) { + return ( + + ); +} + +function inputClass(error: boolean) { + return `w-full rounded border px-3 py-2 text-xs text-slate-900 outline-none transition ${ + error ? 'border-rose-300' : 'border-slate-200 focus:border-slate-900' + }`; +} diff --git a/frontend/src/components/RuleManagement.tsx b/frontend/src/components/RuleManagement.tsx index 60dcf60..669b280 100644 --- a/frontend/src/components/RuleManagement.tsx +++ b/frontend/src/components/RuleManagement.tsx @@ -3,7 +3,7 @@ * SPDX-License-Identifier: Apache-2.0 */ -import React, { useEffect, useState, useMemo, useRef } from 'react'; +import React, { useState, useMemo, useRef } from 'react'; import { AnimatePresence, motion } from 'motion/react'; import { Plus, @@ -18,48 +18,24 @@ import { Download, FileDown } from 'lucide-react'; -import { Rule } from '../types'; +import { LlmConfig, Rule } from '../types'; import { downloadRuleTemplate, exportRulesToExcel, importRulesFromExcel, } from '../rule-excel'; -import { generateEventId } from '../rule-id'; +import { generateEventId, generateModelEventId } from '../rule-id'; import LlmConfiguration from './LlmConfiguration'; -const MODEL_EVENT_ID_STORAGE_KEY = 'asr-monitor:model-event-ids'; -const MATCHER_MODE_STORAGE_KEY = 'asr-monitor:matcher-mode'; - type MatcherMode = 'keyword' | 'llm'; -function loadMatcherMode(): MatcherMode { - if (typeof window === 'undefined') return 'keyword'; - return window.localStorage.getItem(MATCHER_MODE_STORAGE_KEY) === 'llm' - ? 'llm' - : 'keyword'; -} - -function loadModelEventIds(): Record { - if (typeof window === 'undefined') return {}; - try { - return JSON.parse(window.localStorage.getItem(MODEL_EVENT_ID_STORAGE_KEY) ?? '{}') as Record; - } catch { - return {}; - } -} - -function nextModelEventId(existingIds: Iterable): string { - const existing = new Set([...existingIds].map(id => id.toUpperCase())); - for (let sequence = 1; sequence <= 9999; sequence += 1) { - const id = `E${String(sequence).padStart(3, '0')}`; - if (!existing.has(id)) return id; - } - throw new Error('无法生成新的短事件 ID'); -} - interface RuleManagementProps { rules: Rule[]; setRules: (rules: Rule[]) => void; + matcher: 'ac-keyword' | 'llm'; + setMatcher: (matcher: 'ac-keyword' | 'llm') => void; + llmConfig: LlmConfig; + setLlmConfig: (config: LlmConfig) => void; onSavePublish: () => void; isDirty: boolean; loading: boolean; @@ -70,6 +46,10 @@ interface RuleManagementProps { export default function RuleManagement({ rules, setRules, + matcher, + setMatcher, + llmConfig, + setLlmConfig, onSavePublish, isDirty, loading, @@ -90,16 +70,14 @@ export default function RuleManagement({ const [formKeywords, setFormKeywords] = useState([]); const [formErrors, setFormErrors] = useState>({}); const [excelAction, setExcelAction] = useState<'template' | 'import' | 'export' | null>(null); - const [modelEventIds, setModelEventIds] = useState>(loadModelEventIds); - const [matcherMode, setMatcherMode] = useState(loadMatcherMode); + const matcherMode: MatcherMode = matcher === 'llm' ? 'llm' : 'keyword'; // Tag editor input ref const tagInputRef = useRef(null); const fileInputRef = useRef(null); const handleMatcherModeChange = (mode: MatcherMode) => { - setMatcherMode(mode); - window.localStorage.setItem(MATCHER_MODE_STORAGE_KEY, mode); + setMatcher(mode === 'llm' ? 'llm' : 'ac-keyword'); addToast( mode === 'keyword' ? '已切换为关键词匹配,大模型配置已隐藏' @@ -108,24 +86,6 @@ export default function RuleManagement({ ); }; - useEffect(() => { - setModelEventIds(current => { - const next = { ...current }; - const used = new Set(Object.values(next)); - let changed = false; - for (const rule of rules) { - if (!next[rule.id]) { - next[rule.id] = nextModelEventId(used); - used.add(next[rule.id]); - changed = true; - } - } - if (!changed) return current; - window.localStorage.setItem(MODEL_EVENT_ID_STORAGE_KEY, JSON.stringify(next)); - return next; - }); - }, [rules]); - const handleTemplateDownload = async () => { if (excelAction) return; setExcelAction('template'); @@ -195,18 +155,18 @@ export default function RuleManagement({ const matchesSearch = rule.name.toLowerCase().includes(searchTerm.toLowerCase()) || rule.id.toLowerCase().includes(searchTerm.toLowerCase()) || - (modelEventIds[rule.id] ?? '').toLowerCase().includes(searchTerm.toLowerCase()) || + rule.eventId.toLowerCase().includes(searchTerm.toLowerCase()) || rule.keywords.some(k => k.toLowerCase().includes(searchTerm.toLowerCase())) ; return matchesSearch; }); - }, [modelEventIds, rules, searchTerm]); + }, [rules, searchTerm]); // Open Drawer for Add const handleAddRuleClick = () => { setEditingRule(null); setFormId(generateEventId(rules.map(rule => rule.id))); - setFormModelEventId(nextModelEventId(Object.values(modelEventIds))); + setFormModelEventId(generateModelEventId(rules.map(rule => rule.eventId))); setFormName(''); setFormKeywords([]); setKeywordInput(''); @@ -218,7 +178,7 @@ export default function RuleManagement({ const handleEditRuleClick = (rule: Rule) => { setEditingRule(rule); setFormId(rule.id); - setFormModelEventId(modelEventIds[rule.id] ?? nextModelEventId(Object.values(modelEventIds))); + setFormModelEventId(rule.eventId); setFormName(rule.name); setFormKeywords([...rule.keywords]); setKeywordInput(''); @@ -274,7 +234,7 @@ export default function RuleManagement({ }; // Form submit in Drawer - const handleSaveForm = (e: React.FormEvent) => { + const handleSaveForm = (e: React.SyntheticEvent) => { e.preventDefault(); const errors: Record = {}; const normalizedFormId = formId.trim() || generateEventId(rules.map(rule => rule.id)); @@ -291,8 +251,9 @@ export default function RuleManagement({ } else if (!/^[A-Z][A-Z0-9_-]{0,31}$/.test(normalizedModelEventId)) { errors.modelEventId = '请以字母开头,仅使用大写字母、数字、中划线或下划线'; } else if ( - Object.entries(modelEventIds).some( - ([uuid, eventId]) => uuid !== editingRule?.id && eventId.toUpperCase() === normalizedModelEventId + rules.some( + rule => rule.id !== editingRule?.id + && rule.eventId.toUpperCase() === normalizedModelEventId ) ) { errors.modelEventId = '此事件 ID 已被其他规则使用'; @@ -314,18 +275,12 @@ export default function RuleManagement({ const newRule: Rule = { id: normalizedFormId, + eventId: normalizedModelEventId, name: formName.trim(), keywords: formKeywords, enabled: editingRule ? editingRule.enabled : true }; - const nextIds = { - ...modelEventIds, - [normalizedFormId]: normalizedModelEventId, - }; - setModelEventIds(nextIds); - window.localStorage.setItem(MODEL_EVENT_ID_STORAGE_KEY, JSON.stringify(nextIds)); - if (editingRule) { // update setRules(rules.map(r => r.id === editingRule.id ? newRule : r)); @@ -479,7 +434,12 @@ export default function RuleManagement({ exit={{ opacity: 0, y: -8 }} transition={{ duration: 0.16, ease: 'easeOut' }} > - + )} @@ -547,7 +507,7 @@ export default function RuleManagement({ {/* Model-facing short Event ID */} - {modelEventIds[rule.id] ?? '待生成'} + {rule.eventId} @@ -705,7 +665,7 @@ export default function RuleManagement({ ) : (

{editingRule - ? 'UUID 创建后保持不变,用于版本关联和通话内事件去重' + ? 'UUID 创建后保持不变,用于规则管理和版本关联' : '无需填写,系统自动生成;该值不会要求大模型输出'}

)} @@ -719,11 +679,12 @@ export default function RuleManagement({ setFormModelEventId(event.target.value.toUpperCase())} placeholder="例如:E001 或 COMPLAINT" maxLength={32} - className={`w-full rounded border px-3 py-1.5 font-mono text-xs uppercase text-slate-900 outline-none transition ${ + className={`w-full rounded border px-3 py-1.5 font-mono text-xs uppercase text-slate-900 outline-none transition disabled:cursor-not-allowed disabled:bg-slate-100 disabled:text-slate-500 ${ formErrors.modelEventId ? 'border-rose-300 focus:border-rose-500' : 'border-slate-200 focus:border-slate-900' @@ -737,8 +698,12 @@ export default function RuleManagement({ ) : (

{matcherMode === 'llm' - ? '提示词会把它注入规则清单,大模型只需返回该短 ID;后端再通过 UUID 还原事件名称。建议简短、稳定且创建后不修改。' - : '关键词命中后接口使用该短 ID 标识事件,UUID 继续负责内部关联。建议简短、稳定且创建后不修改。'} + ? editingRule + ? '创建后保持不变。大模型输出该短 ID,后端据此还原事件名称并去重。' + : '自动生成,可在创建前调整;大模型只需返回该短 ID。' + : editingRule + ? '创建后保持不变,关键词命中接口使用该 ID 标识事件。' + : '自动生成,可在创建前调整;关键词命中接口使用该 ID 标识事件。'}

)} diff --git a/frontend/src/components/SandboxSimulation.tsx b/frontend/src/components/SandboxSimulation.tsx index 5ff549a..dfb97eb 100644 --- a/frontend/src/components/SandboxSimulation.tsx +++ b/frontend/src/components/SandboxSimulation.tsx @@ -26,6 +26,7 @@ import { ApiError, closeCall, submitAsrEvent } from '../api'; interface SandboxSimulationProps { rules: Rule[]; + matcher: 'ac-keyword' | 'llm'; addToast: (message: string, type: 'success' | 'warning' | 'info') => void; } @@ -34,7 +35,7 @@ function generateCallId() { return `ASR-CALL-${rand}`; } -export default function SandboxSimulation({ rules, addToast }: SandboxSimulationProps) { +export default function SandboxSimulation({ rules, matcher, addToast }: SandboxSimulationProps) { // Session State const [session, setSession] = useState({ callId: generateCallId(), @@ -186,8 +187,11 @@ export default function SandboxSimulation({ rules, addToast }: SandboxSimulation }); response.newAlerts.forEach(alert => { + const matchDetail = alert.matchedKeywords.length > 0 + ? alert.matchedKeywords.join('、') + : `事件 ID ${alert.eventId}`; addToast( - `触发预警:「${alert.eventName}」(${alert.matchedKeywords.join('、')})`, + `触发预警:「${alert.eventName}」(${matchDetail})`, 'warning' ); }); @@ -298,6 +302,7 @@ export default function SandboxSimulation({ rules, addToast }: SandboxSimulation // Helper: renders text while highlighting any enabled rule's keywords const renderHighlightedText = (text: string) => { + if (matcher !== 'ac-keyword') return text; const activeKeywords = rules .filter(r => r.enabled) .flatMap(r => r.keywords) @@ -613,7 +618,7 @@ export default function SandboxSimulation({ rules, addToast }: SandboxSimulation

监听中,暂无事件触发

- 一旦命中监控规则词,此视窗将立刻流式抛出对应预警卡片。 + 一旦后端识别到监控事件,此视窗将立刻流式抛出对应预警卡片。

@@ -648,15 +653,21 @@ export default function SandboxSimulation({ rules, addToast }: SandboxSimulation {/* Matched keyword badges */}
- 命中特征词: - {alert.keywords.map(keyword => ( - - {keyword} + 匹配依据: + {alert.keywords.length > 0 ? ( + alert.keywords.map(keyword => ( + + {keyword} + + )) + ) : ( + + LLM · {alert.ruleId} - ))} + )}
{/* Evidence block */} diff --git a/frontend/src/rule-excel.ts b/frontend/src/rule-excel.ts index c14a242..656d5c6 100644 --- a/frontend/src/rule-excel.ts +++ b/frontend/src/rule-excel.ts @@ -1,13 +1,18 @@ import ExcelJS from 'exceljs'; import type { Rule } from './types'; -import { generateEventId } from './rule-id'; +import { generateEventId, generateModelEventId } from './rule-id'; const SHEET_NAME = '监控规则'; const HEADER_ROW = 3; const FIRST_DATA_ROW = 4; const TEMPLATE_ROWS = 50; - -const HEADERS = ['事件 ID(系统生成,可留空)', '特征规则名称', '启用状态', '监控特征关键词']; +const HEADERS = [ + 'UUID(系统生成,可留空)', + '事件 ID(系统生成,可调整)', + '特征规则名称', + '启用状态', + '监控特征关键词', +]; function formatWorkbook(workbook: ExcelJS.Workbook, rules: Rule[]) { workbook.creator = 'ASR 事件监控中心'; @@ -17,7 +22,7 @@ function formatWorkbook(workbook: ExcelJS.Workbook, rules: Rule[]) { views: [{ state: 'frozen', ySplit: HEADER_ROW, showGridLines: false }], }); - sheet.mergeCells('A1:D1'); + sheet.mergeCells('A1:E1'); sheet.getCell('A1').value = 'ASR 事件监控规则'; sheet.getCell('A1').style = { fill: { type: 'pattern', pattern: 'solid', fgColor: { argb: 'FF0F172A' } }, @@ -26,9 +31,9 @@ function formatWorkbook(workbook: ExcelJS.Workbook, rules: Rule[]) { }; sheet.getRow(1).height = 32; - sheet.mergeCells('A2:D2'); + sheet.mergeCells('A2:E2'); sheet.getCell('A2').value = - '填写说明:每行一条规则;新增规则的事件 ID 请留空,导入时由系统自动生成;导出的已有 ID 请勿修改;启用状态填写“启用”或“停用”;多个关键词请在同一单元格中换行填写。导入后仍需点击“保存并发布”。'; + '填写说明:每行一条规则;新增规则的 UUID 和事件 ID 均可留空,导入时自动生成;已有规则的两个 ID 请勿修改;事件 ID 是模型输出和接口返回的短标识;多个关键词请换行填写。导入后仍需点击“保存并发布”。'; sheet.getCell('A2').style = { fill: { type: 'pattern', pattern: 'solid', fgColor: { argb: 'FFFFF7ED' } }, font: { color: { argb: 'FF9A3412' }, size: 10 }, @@ -50,6 +55,7 @@ function formatWorkbook(workbook: ExcelJS.Workbook, rules: Rule[]) { sheet.columns = [ { key: 'id', width: 42 }, + { key: 'eventId', width: 22 }, { key: 'name', width: 36 }, { key: 'enabled', width: 14 }, { key: 'keywords', width: 58 }, @@ -57,10 +63,19 @@ function formatWorkbook(workbook: ExcelJS.Workbook, rules: Rule[]) { rules.forEach((rule, index) => { const row = sheet.getRow(FIRST_DATA_ROW + index); - row.values = [rule.id, rule.name, rule.enabled ? '启用' : '停用', rule.keywords.join('\n')]; + row.values = [ + rule.id, + rule.eventId, + rule.name, + rule.enabled ? '启用' : '停用', + rule.keywords.join('\n'), + ]; }); - const lastStyledRow = Math.max(FIRST_DATA_ROW + TEMPLATE_ROWS - 1, FIRST_DATA_ROW + rules.length - 1); + const lastStyledRow = Math.max( + FIRST_DATA_ROW + TEMPLATE_ROWS - 1, + FIRST_DATA_ROW + rules.length - 1, + ); for (let rowNumber = FIRST_DATA_ROW; rowNumber <= lastStyledRow; rowNumber += 1) { const row = sheet.getRow(rowNumber); const rule = rules[rowNumber - FIRST_DATA_ROW]; @@ -73,7 +88,7 @@ function formatWorkbook(workbook: ExcelJS.Workbook, rules: Rule[]) { type: 'pattern', pattern: 'solid', fgColor: { - argb: columnNumber === 1 + argb: columnNumber <= 2 ? 'FFF1F5F9' : rowNumber % 2 === 0 ? 'FFFFFFFF' : 'FFF8FAFC', }, @@ -81,13 +96,13 @@ function formatWorkbook(workbook: ExcelJS.Workbook, rules: Rule[]) { font: { color: { argb: 'FF334155' }, size: 10 }, alignment: { vertical: 'middle', - horizontal: columnNumber === 3 ? 'center' : 'left', - wrapText: columnNumber === 4, + horizontal: columnNumber === 4 ? 'center' : 'left', + wrapText: columnNumber === 5, }, border: { bottom: { style: 'thin', color: { argb: 'FFE2E8F0' } } }, }; }); - row.getCell(3).dataValidation = { + row.getCell(4).dataValidation = { type: 'list', allowBlank: false, formulae: ['"启用,停用"'], @@ -97,13 +112,20 @@ function formatWorkbook(workbook: ExcelJS.Workbook, rules: Rule[]) { }; } - sheet.autoFilter = `A${HEADER_ROW}:D${HEADER_ROW}`; + sheet.autoFilter = `A${HEADER_ROW}:E${HEADER_ROW}`; sheet.pageSetup = { orientation: 'landscape', fitToPage: true, fitToWidth: 1, fitToHeight: 0, - margins: { left: 0.3, right: 0.3, top: 0.5, bottom: 0.5, header: 0.2, footer: 0.2 }, + margins: { + left: 0.3, + right: 0.3, + top: 0.5, + bottom: 0.5, + header: 0.2, + footer: 0.2, + }, }; return workbook; } @@ -149,7 +171,7 @@ function parseKeywords(value: string) { value .split(/[\r\n,,;;|]+/) .map(keyword => keyword.trim()) - .filter(Boolean) + .filter(Boolean), )]; } @@ -157,8 +179,11 @@ function findHeaderRow(sheet: ExcelJS.Worksheet) { const max = Math.min(sheet.actualRowCount, 10); for (let rowNumber = 1; rowNumber <= max; rowNumber += 1) { const row = sheet.getRow(rowNumber); - if (cellText(row, 1).startsWith('事件 ID') && cellText(row, 2) === HEADERS[1]) { - return rowNumber; + if (cellText(row, 1).startsWith('UUID') && cellText(row, 2).startsWith('事件 ID')) { + return { rowNumber, legacy: false }; + } + if (cellText(row, 1).startsWith('事件 ID') && cellText(row, 2) === '特征规则名称') { + return { rowNumber, legacy: true }; } } throw new Error('未找到模板表头,请使用“模板下载”生成的 Excel 文件'); @@ -178,42 +203,67 @@ export async function importRulesFromExcel(file: File): Promise { const sheet = workbook.getWorksheet(SHEET_NAME) ?? workbook.worksheets[0]; if (!sheet) throw new Error('Excel 文件中没有可读取的工作表'); - const headerRow = findHeaderRow(sheet); + const { rowNumber: headerRow, legacy } = findHeaderRow(sheet); const rules: Rule[] = []; const ids = new Set(); + const eventIds = new Set(); + const reservedEventIds = new Set(); const keywordOwners = new Map(); const errors: string[] = []; + if (!legacy) { + for (let rowNumber = headerRow + 1; rowNumber <= sheet.actualRowCount; rowNumber += 1) { + const inputEventId = cellText(sheet.getRow(rowNumber), 2); + if (inputEventId) reservedEventIds.add(inputEventId.toUpperCase()); + } + } + for (let rowNumber = headerRow + 1; rowNumber <= sheet.actualRowCount; rowNumber += 1) { const row = sheet.getRow(rowNumber); const inputId = cellText(row, 1); - const name = cellText(row, 2); - const enabledText = cellText(row, 3); - const keywordText = cellText(row, 4); - if (![inputId, name, enabledText, keywordText].some(Boolean)) continue; + const inputEventId = legacy ? '' : cellText(row, 2); + const name = cellText(row, legacy ? 2 : 3); + const enabledText = cellText(row, legacy ? 3 : 4); + const keywordText = cellText(row, legacy ? 4 : 5); + if (![inputId, inputEventId, name, enabledText, keywordText].some(Boolean)) continue; try { const id = inputId || generateEventId(ids); if (!/^[a-zA-Z0-9_-]+$/.test(id)) { - throw new Error(`第 ${rowNumber} 行已有事件 ID 格式无效,请清空后由系统重新生成`); + throw new Error(`第 ${rowNumber} 行 UUID 格式无效,请清空后由系统重新生成`); + } + if (ids.has(id)) throw new Error(`第 ${rowNumber} 行 UUID 重复:${id}`); + + const eventId = ( + inputEventId + || generateModelEventId([...eventIds, ...reservedEventIds]) + ).toUpperCase(); + if (!/^[A-Z][A-Z0-9_-]{0,31}$/.test(eventId)) { + throw new Error(`第 ${rowNumber} 行事件 ID 格式无效`); + } + if (eventIds.has(eventId)) { + throw new Error(`第 ${rowNumber} 行事件 ID 重复:${eventId}`); } - if (ids.has(id)) throw new Error(`第 ${rowNumber} 行事件 ID 重复:${id}`); if (!name) throw new Error(`第 ${rowNumber} 行缺少特征规则名称`); + const enabled = parseEnabled(enabledText, rowNumber); const keywords = parseKeywords(keywordText); - if (keywords.length === 0) throw new Error(`第 ${rowNumber} 行至少需要一个关键词`); + if (keywords.length === 0) { + throw new Error(`第 ${rowNumber} 行至少需要一个关键词`); + } for (const keyword of keywords) { const normalized = keyword.toLocaleLowerCase(); const owner = keywordOwners.get(normalized); - if (owner && owner !== id) { + if (owner && owner !== eventId) { throw new Error(`第 ${rowNumber} 行关键词“${keyword}”已属于事件 ${owner}`); } } ids.add(id); - keywords.forEach(keyword => keywordOwners.set(keyword.toLocaleLowerCase(), id)); - rules.push({ id, name, enabled, keywords }); + eventIds.add(eventId); + keywords.forEach(keyword => keywordOwners.set(keyword.toLocaleLowerCase(), eventId)); + rules.push({ id, eventId, name, enabled, keywords }); } catch (error) { errors.push(error instanceof Error ? error.message : `第 ${rowNumber} 行格式错误`); if (errors.length >= 8) break; diff --git a/frontend/src/rule-id.ts b/frontend/src/rule-id.ts index 08ac481..e7c2101 100644 --- a/frontend/src/rule-id.ts +++ b/frontend/src/rule-id.ts @@ -8,3 +8,12 @@ export function generateEventId(existingIds: Iterable = []): string { } throw new Error('无法生成唯一事件 ID,请重试'); } + +export function generateModelEventId(existingIds: Iterable = []): string { + const existing = new Set([...existingIds].map(id => id.toUpperCase())); + for (let sequence = 1; sequence <= 9999; sequence += 1) { + const eventId = `E${String(sequence).padStart(3, '0')}`; + if (!existing.has(eventId)) return eventId; + } + throw new Error('无法生成唯一短事件 ID,请重试'); +} diff --git a/frontend/src/types.ts b/frontend/src/types.ts index ef43915..f4ce038 100644 --- a/frontend/src/types.ts +++ b/frontend/src/types.ts @@ -5,11 +5,20 @@ export interface Rule { id: string; + eventId: string; name: string; keywords: string[]; enabled: boolean; } +export interface LlmConfig { + baseUrl: string; + model: string; + timeoutMs: number; + maxOutputTokens: number; + prompt: string; +} + export interface RuleSet { id: string; name: string; @@ -22,6 +31,7 @@ export interface CurrentRulesResponse { version: number; ruleCount: number; keywordCount: number; + llmConfig: LlmConfig; ruleSets: RuleSet[]; } diff --git a/pom.xml b/pom.xml index 98e34db..8524a9e 100644 --- a/pom.xml +++ b/pom.xml @@ -28,7 +28,19 @@ 21 + 2.0.0 + + + + org.springframework.ai + spring-ai-bom + ${spring-ai.version} + pom + import + + + org.springframework.boot @@ -51,6 +63,10 @@ ahocorasick 0.6.3 + + org.springframework.ai + spring-ai-starter-model-openai + com.h2database h2 diff --git a/src/main/java/com/example/demo/DemoApplication.java b/src/main/java/com/example/demo/DemoApplication.java index e356018..d271410 100644 --- a/src/main/java/com/example/demo/DemoApplication.java +++ b/src/main/java/com/example/demo/DemoApplication.java @@ -1,13 +1,14 @@ package com.example.demo; import com.example.demo.config.MonitorProperties; +import com.example.demo.config.LlmProviderProperties; import org.springframework.boot.SpringApplication; import org.springframework.boot.autoconfigure.SpringBootApplication; import org.springframework.boot.context.properties.EnableConfigurationProperties; import org.springframework.scheduling.annotation.EnableScheduling; @SpringBootApplication -@EnableConfigurationProperties(MonitorProperties.class) +@EnableConfigurationProperties({MonitorProperties.class, LlmProviderProperties.class}) @EnableScheduling public class DemoApplication { diff --git a/src/main/java/com/example/demo/api/RuleAdminController.java b/src/main/java/com/example/demo/api/RuleAdminController.java index 427c2f2..1b30dc0 100644 --- a/src/main/java/com/example/demo/api/RuleAdminController.java +++ b/src/main/java/com/example/demo/api/RuleAdminController.java @@ -6,8 +6,6 @@ import com.example.demo.domain.SaveRuleRequest; import com.example.demo.domain.SaveRuleResponse; import com.example.demo.domain.RestoreRuleVersionRequest; import com.example.demo.domain.RuleVersionSummaryResponse; -import com.example.demo.domain.TestRuleRequest; -import com.example.demo.domain.TestRuleResponse; import jakarta.validation.Valid; import org.springframework.http.ResponseEntity; import org.springframework.web.bind.annotation.GetMapping; @@ -55,8 +53,4 @@ public class RuleAdminController { return ResponseEntity.ok(ruleManagementService.restoreVersion(version, request)); } - @PostMapping("/test") - public ResponseEntity test(@Valid @RequestBody TestRuleRequest request) { - return ResponseEntity.ok(ruleManagementService.test(request)); - } } diff --git a/src/main/java/com/example/demo/application/AsrEventMonitorService.java b/src/main/java/com/example/demo/application/AsrEventMonitorService.java index 66b608c..e2ecd35 100644 --- a/src/main/java/com/example/demo/application/AsrEventMonitorService.java +++ b/src/main/java/com/example/demo/application/AsrEventMonitorService.java @@ -4,15 +4,18 @@ import com.example.demo.config.MonitorProperties; import com.example.demo.domain.AlertResult; import com.example.demo.domain.AsrFinalEventRequest; import com.example.demo.domain.CallSession; +import com.example.demo.domain.ConversationTurn; import com.example.demo.domain.EventState; import com.example.demo.domain.MatchResult; import com.example.demo.domain.MonitorResponse; +import com.example.demo.matcher.EventMatcherRouter; import com.example.demo.matcher.AcKeywordMatcher; import com.example.demo.repository.SessionStore; import java.time.Clock; import java.util.ArrayList; import java.util.List; import java.util.concurrent.locks.ReentrantLock; +import org.springframework.beans.factory.annotation.Autowired; import org.springframework.stereotype.Service; /** @@ -22,8 +25,8 @@ import org.springframework.stereotype.Service; *
    *
  1. 拒绝 Partial({@code final=false}),不改写会话匹配状态
  2. *
  3. 按 {@code callId + seq} 做幂等
  4. - *
  5. 市民(citizen)和坐席(agent)文本均参与关键词匹配
  6. - *
  7. 每通电话维护一个最近若干条 Final 窗口,送入 AC 自动机匹配
  8. + *
  9. 市民(citizen)和坐席(agent)共享同一个、按 seq 排序的上下文
  10. + *
  11. 按当前规则版本选择 AC 或 LLM 匹配器
  12. *
  13. 与本通话已提醒事件做差,得到 {@code newAlerts}
  14. *
  15. 同时返回 {@code newAlerts} 与累计 {@code currentResults}
  16. *
@@ -32,20 +35,30 @@ import org.springframework.stereotype.Service; public class AsrEventMonitorService { private final SessionStore sessionStore; - private final AcKeywordMatcher keywordMatcher; + private final EventMatcherRouter matcherRouter; private final MonitorProperties properties; private final Clock clock; + @Autowired + public AsrEventMonitorService( + SessionStore sessionStore, + EventMatcherRouter matcherRouter, + MonitorProperties properties, + Clock clock + ) { + this.sessionStore = sessionStore; + this.matcherRouter = matcherRouter; + this.properties = properties; + this.clock = clock; + } + public AsrEventMonitorService( SessionStore sessionStore, AcKeywordMatcher keywordMatcher, MonitorProperties properties, Clock clock ) { - this.sessionStore = sessionStore; - this.keywordMatcher = keywordMatcher; - this.properties = properties; - this.clock = clock; + this(sessionStore, new EventMatcherRouter(keywordMatcher), properties, clock); } /** @@ -65,7 +78,7 @@ public class AsrEventMonitorService { CallSession session = sessionStore.getOrCreate(request.callId()); session.touch(clock); - long activeRuleVersion = keywordMatcher.activeVersion(); + long activeRuleVersion = matcherRouter.activeVersion(); // Partial:仍返回当前累计结果,但不写入幂等集 / 匹配上下文 if (!request.isFinal()) { @@ -90,14 +103,30 @@ public class AsrEventMonitorService { } // 同一通话的市民和坐席共享最近 N 条 Final 上下文 - session.appendFinal( + ConversationTurn evictedTurn = session.appendFinal( request.seq(), + request.speaker(), request.text(), - properties.recentFinalWindowSize() + properties.maxConversationTurns() ); - String matchText = session.buildMatchText(); - List matches = keywordMatcher.match(matchText); + String matchText = session.buildMatchText(properties.recentFinalWindowSize()); + List matches; + try { + matches = matcherRouter.match( + matchText, + session.recentFinalsView(), + session.alertedEventKeysView() + ); + } catch (RuntimeException exception) { + // A failed model call must be retryable with the same callId + seq. + session.removeFinal(request.seq()); + if (evictedTurn != null && evictedTurn.seq() != request.seq()) { + session.restoreFinal(evictedTurn, properties.maxConversationTurns()); + } + session.unmarkProcessed(request.seq()); + throw exception; + } // 本通话首次出现的事件进入 newAlerts;重复命中不再提醒 List newMatches = matches.stream() diff --git a/src/main/java/com/example/demo/application/RuleActivatedEvent.java b/src/main/java/com/example/demo/application/RuleActivatedEvent.java index 06dce8a..2272e6f 100644 --- a/src/main/java/com/example/demo/application/RuleActivatedEvent.java +++ b/src/main/java/com/example/demo/application/RuleActivatedEvent.java @@ -1,9 +1,11 @@ package com.example.demo.application; +import com.example.demo.domain.RuleDocument; import com.example.demo.matcher.AcAutomatonSnapshot; public record RuleActivatedEvent( long version, - AcAutomatonSnapshot snapshot + AcAutomatonSnapshot snapshot, + RuleDocument document ) { } diff --git a/src/main/java/com/example/demo/application/RuleActivationListener.java b/src/main/java/com/example/demo/application/RuleActivationListener.java index 191a51a..a562c52 100644 --- a/src/main/java/com/example/demo/application/RuleActivationListener.java +++ b/src/main/java/com/example/demo/application/RuleActivationListener.java @@ -1,6 +1,6 @@ package com.example.demo.application; -import com.example.demo.matcher.AcKeywordMatcher; +import com.example.demo.matcher.EventMatcherRouter; import org.springframework.stereotype.Component; import org.springframework.transaction.event.TransactionPhase; import org.springframework.transaction.event.TransactionalEventListener; @@ -8,16 +8,14 @@ import org.springframework.transaction.event.TransactionalEventListener; @Component public class RuleActivationListener { - private final AcKeywordMatcher keywordMatcher; + private final EventMatcherRouter matcherRouter; - public RuleActivationListener(AcKeywordMatcher keywordMatcher) { - this.keywordMatcher = keywordMatcher; + public RuleActivationListener(EventMatcherRouter matcherRouter) { + this.matcherRouter = matcherRouter; } @TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT) public void activate(RuleActivatedEvent event) { - keywordMatcher.replaceSnapshot( - event.snapshot().withVersion(event.version()) - ); + matcherRouter.activate(event.document(), event.version(), event.snapshot()); } } diff --git a/src/main/java/com/example/demo/application/RuleManagementService.java b/src/main/java/com/example/demo/application/RuleManagementService.java index 007189e..50ee301 100644 --- a/src/main/java/com/example/demo/application/RuleManagementService.java +++ b/src/main/java/com/example/demo/application/RuleManagementService.java @@ -1,23 +1,21 @@ package com.example.demo.application; import com.example.demo.domain.CurrentRulesResponse; -import com.example.demo.domain.MatchResult; +import com.example.demo.domain.EventRule; import com.example.demo.domain.RuleDocument; +import com.example.demo.domain.RuleSet; import com.example.demo.domain.RestoreRuleVersionRequest; import com.example.demo.domain.RuleVersionSummaryResponse; import com.example.demo.domain.SaveRuleRequest; import com.example.demo.domain.SaveRuleResponse; -import com.example.demo.domain.TestRuleRequest; -import com.example.demo.domain.TestRuleResponse; import com.example.demo.matcher.AcAutomatonFactory; import com.example.demo.matcher.AcAutomatonSnapshot; -import com.example.demo.matcher.AcKeywordMatcher; +import com.example.demo.matcher.EventMatcherRouter; import com.example.demo.repository.RuleVersionEntity; import com.example.demo.repository.RuleVersionRepository; import com.example.demo.support.RuleValidationException; import com.example.demo.support.RuleVersionConflictException; import com.example.demo.support.RuleVersionNotFoundException; -import com.example.demo.support.TextNormalizer; import java.io.IOException; import java.io.InputStream; import java.time.Clock; @@ -44,7 +42,7 @@ public class RuleManagementService implements ApplicationRunner { private final RuleVersionRepository repository; private final RuleValidator ruleValidator; private final AcAutomatonFactory automatonFactory; - private final AcKeywordMatcher keywordMatcher; + private final EventMatcherRouter matcherRouter; private final ObjectMapper objectMapper; private final ApplicationEventPublisher eventPublisher; private final Clock clock; @@ -53,7 +51,7 @@ public class RuleManagementService implements ApplicationRunner { RuleVersionRepository repository, RuleValidator ruleValidator, AcAutomatonFactory automatonFactory, - AcKeywordMatcher keywordMatcher, + EventMatcherRouter matcherRouter, ObjectMapper objectMapper, ApplicationEventPublisher eventPublisher, Clock clock @@ -61,7 +59,7 @@ public class RuleManagementService implements ApplicationRunner { this.repository = repository; this.ruleValidator = ruleValidator; this.automatonFactory = automatonFactory; - this.keywordMatcher = keywordMatcher; + this.matcherRouter = matcherRouter; this.objectMapper = objectMapper; this.eventPublisher = eventPublisher; this.clock = clock; @@ -76,12 +74,19 @@ public class RuleManagementService implements ApplicationRunner { public CurrentRulesResponse getCurrentRules() { return repository.findFirstByActiveTrueOrderByVersionNoDesc() - .map(entity -> new CurrentRulesResponse( - entity.getVersionNo(), - entity.getRuleCount(), - entity.getKeywordCount(), - readDocument(entity.getContentJson()).ruleSets() - )) + .map(entity -> { + RuleDocument document = normalizeDocument( + readDocument(entity.getContentJson()), + entity.getVersionNo() + ); + return new CurrentRulesResponse( + entity.getVersionNo(), + entity.getRuleCount(), + entity.getKeywordCount(), + document.llmConfig(), + document.ruleSets() + ); + }) .orElseGet(() -> new CurrentRulesResponse(0L, 0, 0, List.of())); } @@ -108,6 +113,7 @@ public class RuleManagementService implements ApplicationRunner { RuleValidator.ValidationResult validation = ruleValidator.validateAndNormalize(request); RuleDocument document = validation.document(); AcAutomatonSnapshot snapshot = automatonFactory.build(document); + RuleStats stats = countRules(document); repository.deactivateCurrent(); @@ -116,19 +122,24 @@ public class RuleManagementService implements ApplicationRunner { RuleVersionEntity.active( nextVersion, writeDocument(document), - snapshot.ruleCount(), - snapshot.keywordCount(), + stats.ruleCount(), + stats.keywordCount(), Instant.now(clock) ) ); - eventPublisher.publishEvent(new RuleActivatedEvent(entity.getVersionNo(), snapshot)); + eventPublisher.publishEvent(new RuleActivatedEvent( + entity.getVersionNo(), + snapshot, + document + )); return new SaveRuleResponse( entity.getVersionNo(), entity.getRuleCount(), entity.getKeywordCount(), validation.warnings(), + document.llmConfig(), document.ruleSets() ); } @@ -147,10 +158,15 @@ public class RuleManagementService implements ApplicationRunner { .orElseThrow(() -> new RuleVersionNotFoundException(sourceVersion)); RuleDocument sourceDocument = readDocument(source.getContentJson()); RuleValidator.ValidationResult validation = ruleValidator.validateAndNormalize( - new SaveRuleRequest(currentVersion, sourceDocument.ruleSets()) + new SaveRuleRequest( + currentVersion, + sourceDocument.llmConfig(), + sourceDocument.ruleSets() + ) ); RuleDocument document = validation.document(); AcAutomatonSnapshot snapshot = automatonFactory.build(document); + RuleStats stats = countRules(document); repository.deactivateCurrent(); long nextVersion = nextVersionNo(); @@ -158,37 +174,34 @@ public class RuleManagementService implements ApplicationRunner { RuleVersionEntity.active( nextVersion, writeDocument(document), - snapshot.ruleCount(), - snapshot.keywordCount(), + stats.ruleCount(), + stats.keywordCount(), Instant.now(clock) ) ); - eventPublisher.publishEvent(new RuleActivatedEvent(entity.getVersionNo(), snapshot)); + eventPublisher.publishEvent(new RuleActivatedEvent( + entity.getVersionNo(), + snapshot, + document + )); return new SaveRuleResponse( entity.getVersionNo(), entity.getRuleCount(), entity.getKeywordCount(), validation.warnings(), + document.llmConfig(), document.ruleSets() ); } - public TestRuleResponse test(TestRuleRequest request) { - List matches = keywordMatcher.match(request.text()); - return new TestRuleResponse( - keywordMatcher.activeVersion(), - TextNormalizer.normalize(request.text()), - matches - ); - } - private void loadActiveEntity(RuleVersionEntity entity) { - RuleDocument document = readDocument(entity.getContentJson()); - // Re-validate for safety; invalid persisted JSON should not boot with a broken matcher - ruleValidator.validateAndNormalize(new SaveRuleRequest(entity.getVersionNo(), document.ruleSets())); + RuleDocument document = normalizeDocument( + readDocument(entity.getContentJson()), + entity.getVersionNo() + ); AcAutomatonSnapshot snapshot = automatonFactory.build(document, entity.getVersionNo()); - keywordMatcher.replaceSnapshot(snapshot); + matcherRouter.activate(document, entity.getVersionNo(), snapshot); log.info( "Loaded active rule version V{} (rules={}, keywords={})", entity.getVersionNo(), @@ -200,20 +213,21 @@ public class RuleManagementService implements ApplicationRunner { private void seedDefaultRules() { RuleDocument document = readDefaultRules(); RuleValidator.ValidationResult validation = ruleValidator.validateAndNormalize( - new SaveRuleRequest(0L, document.ruleSets()) + new SaveRuleRequest(0L, document.llmConfig(), document.ruleSets()) ); AcAutomatonSnapshot snapshot = automatonFactory.build(validation.document()); + RuleStats stats = countRules(validation.document()); RuleVersionEntity entity = repository.save( RuleVersionEntity.active( 1L, writeDocument(validation.document()), - snapshot.ruleCount(), - snapshot.keywordCount(), + stats.ruleCount(), + stats.keywordCount(), Instant.now(clock) ) ); - keywordMatcher.replaceSnapshot(snapshot.withVersion(entity.getVersionNo())); + matcherRouter.activate(validation.document(), entity.getVersionNo(), snapshot); log.info("Seeded default rule version V1 (rules={}, keywords={})", entity.getRuleCount(), entity.getKeywordCount()); } @@ -253,4 +267,35 @@ public class RuleManagementService implements ApplicationRunner { throw new IllegalStateException("无法读取内置初始规则: " + DEFAULT_RULES_PATH, e); } } + + private RuleDocument normalizeDocument(RuleDocument document, long baseVersion) { + return ruleValidator.validateAndNormalize( + new SaveRuleRequest( + baseVersion, + document.llmConfig(), + document.ruleSets() + ) + ).document(); + } + + private static RuleStats countRules(RuleDocument document) { + int ruleCount = 0; + int keywordCount = 0; + for (RuleSet ruleSet : document.ruleSets()) { + if (!ruleSet.enabled()) { + continue; + } + for (EventRule rule : ruleSet.rules()) { + if (!rule.enabled()) { + continue; + } + ruleCount++; + keywordCount += rule.keywords().size(); + } + } + return new RuleStats(ruleCount, keywordCount); + } + + private record RuleStats(int ruleCount, int keywordCount) { + } } diff --git a/src/main/java/com/example/demo/application/RuleValidator.java b/src/main/java/com/example/demo/application/RuleValidator.java index 579b452..b82afe3 100644 --- a/src/main/java/com/example/demo/application/RuleValidator.java +++ b/src/main/java/com/example/demo/application/RuleValidator.java @@ -1,11 +1,14 @@ package com.example.demo.application; import com.example.demo.domain.EventRule; +import com.example.demo.domain.LlmConfig; import com.example.demo.domain.RuleDocument; import com.example.demo.domain.RuleSet; import com.example.demo.domain.SaveRuleRequest; import com.example.demo.support.RuleValidationException; import com.example.demo.support.TextNormalizer; +import java.net.URI; +import java.net.URISyntaxException; import java.util.ArrayList; import java.util.HashMap; import java.util.HashSet; @@ -29,10 +32,13 @@ public class RuleValidator { } Set ruleSetIds = new HashSet<>(); - Set eventIds = new HashSet<>(); + Set ruleUuids = new HashSet<>(); + Set explicitEventIds = collectExplicitEventIds(request.ruleSets()); + Set assignedEventIds = new HashSet<>(); Map normalizedKeywordOwners = new HashMap<>(); List normalizedSets = new ArrayList<>(); List warnings = new ArrayList<>(); + boolean llmEnabled = false; for (RuleSet ruleSet : request.ruleSets()) { if (ruleSet == null) { @@ -50,23 +56,37 @@ public class RuleValidator { if (isBlank(ruleSet.matcher())) { throw new RuleValidationException("规则集 matcher 不能为空: " + ruleSet.id()); } - if (!RuleSet.MATCHER_AC_KEYWORD.equals(ruleSet.matcher())) { + String matcher = ruleSet.matcher().trim().toLowerCase(Locale.ROOT); + if (!RuleSet.MATCHER_AC_KEYWORD.equals(matcher) + && !RuleSet.MATCHER_LLM.equals(matcher)) { throw new RuleValidationException( "不支持的 matcher 类型: " + ruleSet.matcher() + "(规则集 " + ruleSet.id() + ")" ); } + llmEnabled = llmEnabled || ruleSet.enabled() && RuleSet.MATCHER_LLM.equals(matcher); List normalizedRules = new ArrayList<>(); for (EventRule rule : ruleSet.rules()) { if (rule == null) { throw new RuleValidationException("事件规则不能为空"); } - String ruleId = isBlank(rule.id()) ? generateEventId(eventIds) : rule.id().trim(); - if (!eventIds.add(ruleId)) { - throw new RuleValidationException("事件 ID 重复: " + ruleId); + String ruleUuid = isBlank(rule.id()) ? generateRuleUuid(ruleUuids) : rule.id().trim(); + if (!ruleUuids.add(ruleUuid)) { + throw new RuleValidationException("规则 UUID 重复: " + ruleUuid); + } + String eventId = isBlank(rule.eventId()) + ? generateModelEventId(explicitEventIds, assignedEventIds) + : rule.eventId().trim().toUpperCase(Locale.ROOT); + if (!eventId.matches("[A-Z][A-Z0-9_-]{0,31}")) { + throw new RuleValidationException( + "事件 ID 格式无效: " + eventId + "(规则 " + ruleUuid + ")" + ); + } + if (!assignedEventIds.add(eventId)) { + throw new RuleValidationException("事件 ID 重复: " + eventId); } if (isBlank(rule.name())) { - throw new RuleValidationException("事件名称不能为空: " + ruleId); + throw new RuleValidationException("事件名称不能为空: " + eventId); } List cleanedKeywords = new ArrayList<>(); @@ -80,20 +100,20 @@ public class RuleValidator { String normalized = TextNormalizer.normalize(display); if (normalized.isEmpty()) { throw new RuleValidationException( - "标准化后关键词为空: " + display + "(事件 " + ruleId + ")" + "标准化后关键词为空: " + display + "(事件 " + eventId + ")" ); } if (!normalizedInRule.add(normalized)) { throw new RuleValidationException( - "同一事件内关键词重复: " + display + "(事件 " + ruleId + ")" + "同一事件内关键词重复: " + display + "(事件 " + eventId + ")" ); } - String previousOwner = normalizedKeywordOwners.putIfAbsent(normalized, ruleId); - if (previousOwner != null && !previousOwner.equals(ruleId)) { + String previousOwner = normalizedKeywordOwners.putIfAbsent(normalized, ruleUuid); + if (previousOwner != null && !previousOwner.equals(ruleUuid)) { throw new RuleValidationException( "同一关键词不能指向多个转接方向: \"" + display - + "\" 同时属于 " + previousOwner + " 与 " + ruleId + + "\" 同时属于 " + previousOwner + " 与 " + ruleUuid ); } @@ -107,12 +127,13 @@ public class RuleValidator { if (rule.enabled() && cleanedKeywords.isEmpty()) { throw new RuleValidationException( - "启用事件至少需要一个关键词: " + ruleId + "启用事件至少需要一个关键词: " + eventId ); } normalizedRules.add(new EventRule( - ruleId, + ruleUuid, + eventId, rule.name().trim(), rule.enabled(), cleanedKeywords @@ -123,19 +144,39 @@ public class RuleValidator { ruleSet.id().trim(), ruleSet.name().trim(), ruleSet.enabled(), - ruleSet.matcher().trim().toLowerCase(Locale.ROOT), + matcher, normalizedRules )); } - return new ValidationResult(new RuleDocument(normalizedSets), warnings); + LlmConfig llmConfig = normalizeLlmConfig(request.llmConfig(), llmEnabled); + return new ValidationResult(new RuleDocument(llmConfig, normalizedSets), warnings); } private static boolean isBlank(String value) { return value == null || value.isBlank(); } - private static String generateEventId(Set existingIds) { + private static Set collectExplicitEventIds(List ruleSets) { + Set eventIds = new HashSet<>(); + for (RuleSet ruleSet : ruleSets) { + if (ruleSet == null) { + continue; + } + for (EventRule rule : ruleSet.rules()) { + if (rule == null || isBlank(rule.eventId())) { + continue; + } + String eventId = rule.eventId().trim().toUpperCase(Locale.ROOT); + if (!eventIds.add(eventId)) { + throw new RuleValidationException("事件 ID 重复: " + eventId); + } + } + } + return eventIds; + } + + private static String generateRuleUuid(Set existingIds) { String id; do { id = "event-" + UUID.randomUUID(); @@ -143,6 +184,70 @@ public class RuleValidator { return id; } + private static String generateModelEventId( + Set explicitEventIds, + Set assignedEventIds + ) { + for (int sequence = 1; sequence <= 9999; sequence++) { + String eventId = "E%03d".formatted(sequence); + if (!explicitEventIds.contains(eventId) && !assignedEventIds.contains(eventId)) { + return eventId; + } + } + throw new RuleValidationException("无法生成新的事件 ID"); + } + + private static LlmConfig normalizeLlmConfig(LlmConfig config, boolean required) { + LlmConfig source = config == null ? LlmConfig.defaults() : config; + String baseUrl = trimOrDefault(source.baseUrl(), LlmConfig.defaults().baseUrl()); + String model = trimOrDefault(source.model(), LlmConfig.defaults().model()); + String prompt = trimOrDefault(source.prompt(), LlmConfig.DEFAULT_PROMPT); + int timeoutMs = source.timeoutMs() <= 0 + ? LlmConfig.defaults().timeoutMs() + : source.timeoutMs(); + int maxOutputTokens = source.maxOutputTokens() <= 0 + ? LlmConfig.defaults().maxOutputTokens() + : source.maxOutputTokens(); + + if (required) { + validateHttpUrl(baseUrl); + if (timeoutMs < 300 || timeoutMs > 30_000) { + throw new RuleValidationException("大模型调用超时需在 300–30000ms 之间"); + } + if (maxOutputTokens < 8 || maxOutputTokens > 256) { + throw new RuleValidationException("大模型最大输出 Token 需在 8–256 之间"); + } + requirePromptVariable(prompt, "{{rules}}"); + requirePromptVariable(prompt, "{{alerted_event_ids}}"); + requirePromptVariable(prompt, "{{conversation}}"); + } + + return new LlmConfig(baseUrl, model, timeoutMs, maxOutputTokens, prompt); + } + + private static void validateHttpUrl(String value) { + try { + URI uri = new URI(value); + if (uri.getHost() == null + || (!"http".equalsIgnoreCase(uri.getScheme()) + && !"https".equalsIgnoreCase(uri.getScheme()))) { + throw new RuleValidationException("大模型服务地址必须是有效的 HTTP(S) URL"); + } + } catch (URISyntaxException e) { + throw new RuleValidationException("大模型服务地址格式无效"); + } + } + + private static void requirePromptVariable(String prompt, String variable) { + if (!prompt.contains(variable)) { + throw new RuleValidationException("大模型提示词缺少变量 " + variable); + } + } + + private static String trimOrDefault(String value, String fallback) { + return isBlank(value) ? fallback : value.trim(); + } + public record ValidationResult(RuleDocument document, List warnings) { public ValidationResult { warnings = warnings == null ? List.of() : List.copyOf(warnings); diff --git a/src/main/java/com/example/demo/config/LlmProviderProperties.java b/src/main/java/com/example/demo/config/LlmProviderProperties.java new file mode 100644 index 0000000..42b3144 --- /dev/null +++ b/src/main/java/com/example/demo/config/LlmProviderProperties.java @@ -0,0 +1,16 @@ +package com.example.demo.config; + +import org.springframework.boot.context.properties.ConfigurationProperties; + +@ConfigurationProperties(prefix = "monitor.llm") +public record LlmProviderProperties( + String apiKey +) { + public LlmProviderProperties { + apiKey = apiKey == null ? "" : apiKey.trim(); + } + + public boolean configured() { + return !apiKey.isBlank(); + } +} diff --git a/src/main/java/com/example/demo/config/MonitorProperties.java b/src/main/java/com/example/demo/config/MonitorProperties.java index 2811180..3dd4d4f 100644 --- a/src/main/java/com/example/demo/config/MonitorProperties.java +++ b/src/main/java/com/example/demo/config/MonitorProperties.java @@ -6,14 +6,36 @@ import org.springframework.boot.context.properties.ConfigurationProperties; @ConfigurationProperties(prefix = "monitor") public record MonitorProperties( int recentFinalWindowSize, + int maxConversationTurns, int maxProcessedSeqs, Duration sessionTtl, Duration sessionCleanupInterval ) { + public MonitorProperties( + int recentFinalWindowSize, + int maxProcessedSeqs, + Duration sessionTtl, + Duration sessionCleanupInterval + ) { + this( + recentFinalWindowSize, + 200, + maxProcessedSeqs, + sessionTtl, + sessionCleanupInterval + ); + } + public MonitorProperties { if (recentFinalWindowSize <= 0) { recentFinalWindowSize = 5; } + if (maxConversationTurns <= 0) { + maxConversationTurns = 200; + } + if (maxConversationTurns < recentFinalWindowSize) { + maxConversationTurns = recentFinalWindowSize; + } if (maxProcessedSeqs <= 0) { maxProcessedSeqs = 500; } diff --git a/src/main/java/com/example/demo/domain/CallSession.java b/src/main/java/com/example/demo/domain/CallSession.java index 7054aa0..c19b169 100644 --- a/src/main/java/com/example/demo/domain/CallSession.java +++ b/src/main/java/com/example/demo/domain/CallSession.java @@ -22,7 +22,7 @@ public class CallSession { private final String callId; private final Set processedSeqs = new LinkedHashSet<>(); - private final NavigableMap recentFinals = new TreeMap<>(); + private final NavigableMap recentFinals = new TreeMap<>(); private final Set alertedEventKeys = new HashSet<>(); private final Map eventStates = new LinkedHashMap<>(); @@ -70,9 +70,33 @@ public class CallSession { return true; } - public void appendFinal(long seq, String text, int windowSize) { - recentFinals.put(seq, text); + public void unmarkProcessed(long seq) { + processedSeqs.remove(seq); + } + public ConversationTurn appendFinal(long seq, String speaker, String text, int windowSize) { + recentFinals.put(seq, new ConversationTurn(seq, speaker, text)); + + ConversationTurn evicted = null; + while (recentFinals.size() > windowSize) { + evicted = recentFinals.pollFirstEntry().getValue(); + } + return evicted; + } + + public ConversationTurn appendFinal(long seq, String text, int windowSize) { + return appendFinal(seq, "citizen", text, windowSize); + } + + public void removeFinal(long seq) { + recentFinals.remove(seq); + } + + public void restoreFinal(ConversationTurn turn, int windowSize) { + if (turn == null) { + return; + } + recentFinals.put(turn.seq(), turn); while (recentFinals.size() > windowSize) { recentFinals.pollFirstEntry(); } @@ -83,10 +107,21 @@ public class CallSession { * can be glued after normalization across citizen and agent turns. */ public String buildMatchText() { + return buildMatchText(recentFinals.size()); + } + + public String buildMatchText(int maxTurns) { + long skip = Math.max(0, recentFinals.size() - Math.max(1, maxTurns)); return recentFinals.values().stream() + .skip(skip) + .map(ConversationTurn::text) .collect(Collectors.joining("。")); } + public List recentFinalsView() { + return List.copyOf(recentFinals.values()); + } + public boolean hasAlerted(String eventKey) { return alertedEventKeys.contains(eventKey); } diff --git a/src/main/java/com/example/demo/domain/ConversationTurn.java b/src/main/java/com/example/demo/domain/ConversationTurn.java new file mode 100644 index 0000000..1b85538 --- /dev/null +++ b/src/main/java/com/example/demo/domain/ConversationTurn.java @@ -0,0 +1,8 @@ +package com.example.demo.domain; + +public record ConversationTurn( + long seq, + String speaker, + String text +) { +} diff --git a/src/main/java/com/example/demo/domain/CurrentRulesResponse.java b/src/main/java/com/example/demo/domain/CurrentRulesResponse.java index 798cc8f..d1358df 100644 --- a/src/main/java/com/example/demo/domain/CurrentRulesResponse.java +++ b/src/main/java/com/example/demo/domain/CurrentRulesResponse.java @@ -6,9 +6,15 @@ public record CurrentRulesResponse( long version, int ruleCount, int keywordCount, + LlmConfig llmConfig, List ruleSets ) { + public CurrentRulesResponse(long version, int ruleCount, int keywordCount, List ruleSets) { + this(version, ruleCount, keywordCount, LlmConfig.defaults(), ruleSets); + } + public CurrentRulesResponse { + llmConfig = llmConfig == null ? LlmConfig.defaults() : llmConfig; ruleSets = ruleSets == null ? List.of() : List.copyOf(ruleSets); } } diff --git a/src/main/java/com/example/demo/domain/EventRule.java b/src/main/java/com/example/demo/domain/EventRule.java index 20cab54..67afb44 100644 --- a/src/main/java/com/example/demo/domain/EventRule.java +++ b/src/main/java/com/example/demo/domain/EventRule.java @@ -4,10 +4,15 @@ import java.util.List; public record EventRule( String id, + String eventId, String name, boolean enabled, List keywords ) { + public EventRule(String id, String name, boolean enabled, List keywords) { + this(id, null, name, enabled, keywords); + } + public EventRule { keywords = keywords == null ? List.of() : List.copyOf(keywords); } diff --git a/src/main/java/com/example/demo/domain/LlmConfig.java b/src/main/java/com/example/demo/domain/LlmConfig.java new file mode 100644 index 0000000..bf6a13f --- /dev/null +++ b/src/main/java/com/example/demo/domain/LlmConfig.java @@ -0,0 +1,38 @@ +package com.example.demo.domain; + +public record LlmConfig( + String baseUrl, + String model, + int timeoutMs, + int maxOutputTokens, + String prompt +) { + public static final String DEFAULT_PROMPT = """ + 你是实时通话事件识别器。根据规则库和已发送事件,判断最新对话是否产生新的事件。 + + 事件规则(事件ID|名称|辅助关键词): + {{rules}} + + 已发送事件 ID: + {{alerted_event_ids}} + + 判断要求: + 1. 结合市民与坐席的语义,只判断规则库中的事件。 + 2. 排除否定、假设、举例和坐席转述。 + 3. 已发送事件不要重复输出。 + 4. 只输出 JSON 字符串数组,例如 ["E001"];无新事件输出 []。 + 5. 不输出原因、置信度、证据、字段名或 Markdown。 + + 通话上下文: + {{conversation}}"""; + + public static LlmConfig defaults() { + return new LlmConfig( + "https://api.openai.com/v1", + "gpt-4.1-mini", + 3000, + 64, + DEFAULT_PROMPT + ); + } +} diff --git a/src/main/java/com/example/demo/domain/RuleDocument.java b/src/main/java/com/example/demo/domain/RuleDocument.java index 48045f8..9731075 100644 --- a/src/main/java/com/example/demo/domain/RuleDocument.java +++ b/src/main/java/com/example/demo/domain/RuleDocument.java @@ -3,13 +3,19 @@ package com.example.demo.domain; import java.util.List; public record RuleDocument( + LlmConfig llmConfig, List ruleSets ) { + public RuleDocument(List ruleSets) { + this(LlmConfig.defaults(), ruleSets); + } + public RuleDocument { + llmConfig = llmConfig == null ? LlmConfig.defaults() : llmConfig; ruleSets = ruleSets == null ? List.of() : List.copyOf(ruleSets); } public static RuleDocument empty() { - return new RuleDocument(List.of()); + return new RuleDocument(LlmConfig.defaults(), List.of()); } } diff --git a/src/main/java/com/example/demo/domain/RuleSet.java b/src/main/java/com/example/demo/domain/RuleSet.java index 173d640..8401b64 100644 --- a/src/main/java/com/example/demo/domain/RuleSet.java +++ b/src/main/java/com/example/demo/domain/RuleSet.java @@ -10,6 +10,7 @@ public record RuleSet( List rules ) { public static final String MATCHER_AC_KEYWORD = "ac-keyword"; + public static final String MATCHER_LLM = "llm"; public RuleSet { rules = rules == null ? List.of() : List.copyOf(rules); diff --git a/src/main/java/com/example/demo/domain/SaveRuleRequest.java b/src/main/java/com/example/demo/domain/SaveRuleRequest.java index b5fbc47..9f4d01c 100644 --- a/src/main/java/com/example/demo/domain/SaveRuleRequest.java +++ b/src/main/java/com/example/demo/domain/SaveRuleRequest.java @@ -8,10 +8,17 @@ public record SaveRuleRequest( @PositiveOrZero long baseVersion, + LlmConfig llmConfig, + @NotNull List ruleSets ) { + public SaveRuleRequest(long baseVersion, List ruleSets) { + this(baseVersion, LlmConfig.defaults(), ruleSets); + } + public SaveRuleRequest { + llmConfig = llmConfig == null ? LlmConfig.defaults() : llmConfig; ruleSets = ruleSets == null ? List.of() : List.copyOf(ruleSets); } } diff --git a/src/main/java/com/example/demo/domain/SaveRuleResponse.java b/src/main/java/com/example/demo/domain/SaveRuleResponse.java index a8545ec..b7835c5 100644 --- a/src/main/java/com/example/demo/domain/SaveRuleResponse.java +++ b/src/main/java/com/example/demo/domain/SaveRuleResponse.java @@ -7,10 +7,22 @@ public record SaveRuleResponse( int ruleCount, int keywordCount, List warnings, + LlmConfig llmConfig, List ruleSets ) { + public SaveRuleResponse( + long version, + int ruleCount, + int keywordCount, + List warnings, + List ruleSets + ) { + this(version, ruleCount, keywordCount, warnings, LlmConfig.defaults(), ruleSets); + } + public SaveRuleResponse { warnings = warnings == null ? List.of() : List.copyOf(warnings); + llmConfig = llmConfig == null ? LlmConfig.defaults() : llmConfig; ruleSets = ruleSets == null ? List.of() : List.copyOf(ruleSets); } } diff --git a/src/main/java/com/example/demo/domain/TestRuleRequest.java b/src/main/java/com/example/demo/domain/TestRuleRequest.java deleted file mode 100644 index f33a0ae..0000000 --- a/src/main/java/com/example/demo/domain/TestRuleRequest.java +++ /dev/null @@ -1,11 +0,0 @@ -package com.example.demo.domain; - -import jakarta.validation.constraints.NotBlank; -import jakarta.validation.constraints.Size; - -public record TestRuleRequest( - @NotBlank - @Size(max = 4000) - String text -) { -} diff --git a/src/main/java/com/example/demo/domain/TestRuleResponse.java b/src/main/java/com/example/demo/domain/TestRuleResponse.java deleted file mode 100644 index 364b9de..0000000 --- a/src/main/java/com/example/demo/domain/TestRuleResponse.java +++ /dev/null @@ -1,13 +0,0 @@ -package com.example.demo.domain; - -import java.util.List; - -public record TestRuleResponse( - long activeVersion, - String normalizedText, - List matches -) { - public TestRuleResponse { - matches = matches == null ? List.of() : List.copyOf(matches); - } -} diff --git a/src/main/java/com/example/demo/matcher/AcAutomatonFactory.java b/src/main/java/com/example/demo/matcher/AcAutomatonFactory.java index d6cde50..a479c0d 100644 --- a/src/main/java/com/example/demo/matcher/AcAutomatonFactory.java +++ b/src/main/java/com/example/demo/matcher/AcAutomatonFactory.java @@ -37,7 +37,10 @@ public class AcAutomatonFactory { } boolean contributedKeyword = false; - RuleTarget target = new RuleTarget(ruleSet.id(), rule.id(), rule.name()); + String eventId = rule.eventId() == null || rule.eventId().isBlank() + ? rule.id() + : rule.eventId(); + RuleTarget target = new RuleTarget(ruleSet.id(), eventId, rule.name()); for (String keyword : rule.keywords()) { String normalized = TextNormalizer.normalize(keyword); @@ -85,7 +88,7 @@ public class AcAutomatonFactory { } private void addTarget(RuleTarget target) { - String key = target.ruleSetId() + ":" + target.ruleId(); + String key = target.ruleSetId() + ":" + target.eventId(); if (targetKeys.add(key)) { targets.add(target); } diff --git a/src/main/java/com/example/demo/matcher/AcKeywordMatcher.java b/src/main/java/com/example/demo/matcher/AcKeywordMatcher.java index a2a0e36..c6b8b91 100644 --- a/src/main/java/com/example/demo/matcher/AcKeywordMatcher.java +++ b/src/main/java/com/example/demo/matcher/AcKeywordMatcher.java @@ -64,12 +64,12 @@ public class AcKeywordMatcher implements TextMatcher { } for (RuleTarget target : payload.targets()) { - String eventKey = EventKey.of(target.ruleSetId(), target.ruleId()); + String eventKey = EventKey.of(target.ruleSetId(), target.eventId()); MutableMatch match = byEvent.computeIfAbsent( eventKey, key -> new MutableMatch( target.ruleSetId(), - target.ruleId(), + target.eventId(), target.ruleName() ) ); diff --git a/src/main/java/com/example/demo/matcher/EventMatcherRouter.java b/src/main/java/com/example/demo/matcher/EventMatcherRouter.java new file mode 100644 index 0000000..5b3a304 --- /dev/null +++ b/src/main/java/com/example/demo/matcher/EventMatcherRouter.java @@ -0,0 +1,72 @@ +package com.example.demo.matcher; + +import com.example.demo.domain.ConversationTurn; +import com.example.demo.domain.MatchResult; +import com.example.demo.domain.RuleDocument; +import com.example.demo.domain.RuleSet; +import java.util.List; +import java.util.Set; +import java.util.concurrent.atomic.AtomicReference; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.stereotype.Component; + +@Component +public class EventMatcherRouter { + + private final AcKeywordMatcher keywordMatcher; + private final LlmEventMatcher llmMatcher; + private final AtomicReference runtimeState; + + @Autowired + public EventMatcherRouter( + AcKeywordMatcher keywordMatcher, + LlmEventMatcher llmMatcher + ) { + this.keywordMatcher = keywordMatcher; + this.llmMatcher = llmMatcher; + this.runtimeState = new AtomicReference<>( + new RuntimeState(RuleSet.MATCHER_AC_KEYWORD, keywordMatcher.activeVersion()) + ); + } + + public EventMatcherRouter(AcKeywordMatcher keywordMatcher) { + this(keywordMatcher, null); + } + + public List match( + String keywordText, + List turns, + Set alertedEventKeys + ) { + RuntimeState state = runtimeState.get(); + if (RuleSet.MATCHER_LLM.equals(state.matcher()) && llmMatcher != null) { + return llmMatcher.match(turns, alertedEventKeys); + } + return keywordMatcher.match(keywordText); + } + + public void activate( + RuleDocument document, + long version, + AcAutomatonSnapshot acSnapshot + ) { + boolean useLlm = document.ruleSets().stream() + .anyMatch(ruleSet -> ruleSet.enabled() + && RuleSet.MATCHER_LLM.equals(ruleSet.matcher())); + + if (useLlm) { + llmMatcher.replaceSnapshot(LlmMatcherSnapshot.from(document, version)); + runtimeState.set(new RuntimeState(RuleSet.MATCHER_LLM, version)); + } else { + keywordMatcher.replaceSnapshot(acSnapshot.withVersion(version)); + runtimeState.set(new RuntimeState(RuleSet.MATCHER_AC_KEYWORD, version)); + } + } + + public long activeVersion() { + return runtimeState.get().version(); + } + + private record RuntimeState(String matcher, long version) { + } +} diff --git a/src/main/java/com/example/demo/matcher/LlmEventMatcher.java b/src/main/java/com/example/demo/matcher/LlmEventMatcher.java new file mode 100644 index 0000000..32669f9 --- /dev/null +++ b/src/main/java/com/example/demo/matcher/LlmEventMatcher.java @@ -0,0 +1,220 @@ +package com.example.demo.matcher; + +import com.example.demo.config.LlmProviderProperties; +import com.example.demo.domain.ConversationTurn; +import com.example.demo.domain.LlmConfig; +import com.example.demo.domain.MatchResult; +import com.example.demo.support.EventKey; +import com.example.demo.support.LlmUnavailableException; +import java.time.Duration; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Set; +import java.util.concurrent.atomic.AtomicReference; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.ai.chat.client.ChatClient; +import org.springframework.ai.openai.OpenAiChatOptions; +import org.springframework.stereotype.Component; +import tools.jackson.core.JacksonException; +import tools.jackson.databind.ObjectMapper; + +@Component +public class LlmEventMatcher { + + private static final Logger log = LoggerFactory.getLogger(LlmEventMatcher.class); + + private final AtomicReference currentSnapshot = new AtomicReference<>(); + private final ChatClient chatClient; + private final ObjectMapper objectMapper; + private final LlmProviderProperties providerProperties; + + public LlmEventMatcher( + ChatClient.Builder chatClientBuilder, + ObjectMapper objectMapper, + LlmProviderProperties providerProperties + ) { + this.chatClient = chatClientBuilder.build(); + this.objectMapper = objectMapper; + this.providerProperties = providerProperties; + } + + public List match( + List turns, + Set alertedEventKeys + ) { + LlmMatcherSnapshot snapshot = currentSnapshot.get(); + if (snapshot == null || !snapshot.enabled() || turns == null || turns.isEmpty()) { + return List.of(); + } + boolean allEventsAlerted = snapshot.targetsByEventId().entrySet().stream() + .allMatch(entry -> alertedEventKeys.contains(EventKey.of( + entry.getValue().ruleSetId(), + entry.getKey() + ))); + if (allEventsAlerted) { + return List.of(); + } + if (!providerProperties.configured()) { + throw new LlmUnavailableException("未配置 LLM_API_KEY,无法执行大模型事件匹配"); + } + + LlmConfig config = snapshot.config(); + OpenAiChatOptions options = OpenAiChatOptions.builder() + .baseUrl(config.baseUrl()) + .apiKey(providerProperties.apiKey()) + .model(config.model()) + .timeout(Duration.ofMillis(config.timeoutMs())) + .maxRetries(0) + .maxTokens(config.maxOutputTokens()) + .build(); + + String prompt = buildPrompt(snapshot, turns, alertedEventKeys); + try { + String content = chatClient.prompt() + .user(prompt) + .options(options) + .call() + .content(); + return mapResponse(snapshot, content); + } catch (LlmUnavailableException exception) { + throw exception; + } catch (Exception exception) { + throw new LlmUnavailableException( + "大模型事件匹配调用失败: " + rootMessage(exception), + exception + ); + } + } + + public void replaceSnapshot(LlmMatcherSnapshot snapshot) { + currentSnapshot.set(snapshot); + } + + public long activeVersion() { + LlmMatcherSnapshot snapshot = currentSnapshot.get(); + return snapshot == null ? 0L : snapshot.version(); + } + + private String buildPrompt( + LlmMatcherSnapshot snapshot, + List turns, + Set alertedEventKeys + ) { + List alertedIds = snapshot.targetsByEventId().entrySet().stream() + .filter(entry -> alertedEventKeys.contains(EventKey.of( + entry.getValue().ruleSetId(), + entry.getKey() + ))) + .map(java.util.Map.Entry::getKey) + .toList(); + + String promptPrefix = snapshot.config().prompt() + .replace("{{rules}}", snapshot.ruleCatalog()) + .replace("{{alerted_event_ids}}", toJson(alertedIds)) + .replace("{{conversation}}", "") + .stripTrailing(); + + // The changing conversation is deliberately appended last. Everything before it + // remains a stable prefix for providers that support automatic prompt KV caching. + return promptPrefix + + "\n\n---\n通话上下文(按 seq 排序,citizen=市民,agent=坐席):\n" + + formatConversation(turns); + } + + private List mapResponse(LlmMatcherSnapshot snapshot, String content) { + String[] eventIds = parseEventIds(content); + Set uniqueIds = new LinkedHashSet<>(Arrays.asList(eventIds)); + List results = new ArrayList<>(uniqueIds.size()); + + for (String rawEventId : uniqueIds) { + String eventId = rawEventId == null ? "" : rawEventId.trim().toUpperCase(); + RuleTarget target = snapshot.targetsByEventId().get(eventId); + if (target == null) { + if (!eventId.isBlank()) { + log.warn("LLM returned unknown event ID '{}' for rule version V{}", + eventId, snapshot.version()); + } + continue; + } + results.add(new MatchResult( + target.ruleSetId(), + target.eventId(), + target.ruleName(), + List.of() + )); + } + return results; + } + + private String[] parseEventIds(String content) { + if (content == null || content.isBlank()) { + throw new LlmUnavailableException("大模型返回了空内容"); + } + String json = stripMarkdownFence(content.trim()); + try { + return objectMapper.readValue(json, String[].class); + } catch (JacksonException exception) { + throw new LlmUnavailableException("大模型输出不是 JSON 字符串数组", exception); + } + } + + private String toJson(List values) { + try { + return objectMapper.writeValueAsString(values); + } catch (JacksonException exception) { + throw new LlmUnavailableException("无法生成大模型提示词", exception); + } + } + + private static String formatConversation(List turns) { + StringBuilder context = new StringBuilder(); + turns.stream() + .sorted(java.util.Comparator.comparingLong(ConversationTurn::seq)) + .forEach(turn -> { + if (!context.isEmpty()) { + context.append('\n'); + } + context.append(turn.seq()) + .append('|') + .append(turn.speaker()) + .append('|') + .append(cleanText(turn.text())); + }); + return context.toString(); + } + + private static String stripMarkdownFence(String value) { + if (!value.startsWith("```")) { + return value; + } + int firstLineEnd = value.indexOf('\n'); + int closingFence = value.lastIndexOf("```"); + if (firstLineEnd < 0 || closingFence <= firstLineEnd) { + return value; + } + return value.substring(firstLineEnd + 1, closingFence).trim(); + } + + private static String cleanText(String value) { + return value == null + ? "" + : value.replace('\r', ' ') + .replace('\n', ' ') + .replace('|', '/') + .trim(); + } + + private static String rootMessage(Throwable throwable) { + Throwable current = throwable; + while (current.getCause() != null) { + current = current.getCause(); + } + String message = current.getMessage(); + return message == null || message.isBlank() + ? current.getClass().getSimpleName() + : message; + } +} diff --git a/src/main/java/com/example/demo/matcher/LlmMatcherSnapshot.java b/src/main/java/com/example/demo/matcher/LlmMatcherSnapshot.java new file mode 100644 index 0000000..3c33ebd --- /dev/null +++ b/src/main/java/com/example/demo/matcher/LlmMatcherSnapshot.java @@ -0,0 +1,78 @@ +package com.example.demo.matcher; + +import com.example.demo.domain.EventRule; +import com.example.demo.domain.LlmConfig; +import com.example.demo.domain.RuleDocument; +import com.example.demo.domain.RuleSet; +import java.util.LinkedHashMap; +import java.util.Map; + +public record LlmMatcherSnapshot( + long version, + LlmConfig config, + Map targetsByEventId, + String ruleCatalog +) { + public LlmMatcherSnapshot { + config = config == null ? LlmConfig.defaults() : config; + targetsByEventId = targetsByEventId == null + ? Map.of() + : Map.copyOf(targetsByEventId); + ruleCatalog = ruleCatalog == null ? "" : ruleCatalog; + } + + public boolean enabled() { + return !targetsByEventId.isEmpty(); + } + + public static LlmMatcherSnapshot from(RuleDocument document, long version) { + Map targets = new LinkedHashMap<>(); + StringBuilder catalog = new StringBuilder(); + + if (document != null) { + for (RuleSet ruleSet : document.ruleSets()) { + if (!ruleSet.enabled() || !RuleSet.MATCHER_LLM.equals(ruleSet.matcher())) { + continue; + } + for (EventRule rule : ruleSet.rules()) { + if (!rule.enabled()) { + continue; + } + RuleTarget target = new RuleTarget( + ruleSet.id(), + rule.eventId(), + rule.name() + ); + targets.put(rule.eventId(), target); + if (!catalog.isEmpty()) { + catalog.append('\n'); + } + catalog.append(clean(rule.eventId())) + .append('|') + .append(clean(rule.name())) + .append('|') + .append(rule.keywords().stream() + .map(LlmMatcherSnapshot::clean) + .reduce((left, right) -> left + "," + right) + .orElse("")); + } + } + } + + return new LlmMatcherSnapshot( + version, + document == null ? LlmConfig.defaults() : document.llmConfig(), + targets, + catalog.toString() + ); + } + + private static String clean(String value) { + return value == null + ? "" + : value.replace('\r', ' ') + .replace('\n', ' ') + .replace('|', '/') + .trim(); + } +} diff --git a/src/main/java/com/example/demo/matcher/RuleTarget.java b/src/main/java/com/example/demo/matcher/RuleTarget.java index b61f5f7..810741b 100644 --- a/src/main/java/com/example/demo/matcher/RuleTarget.java +++ b/src/main/java/com/example/demo/matcher/RuleTarget.java @@ -2,7 +2,7 @@ package com.example.demo.matcher; public record RuleTarget( String ruleSetId, - String ruleId, + String eventId, String ruleName ) { } diff --git a/src/main/java/com/example/demo/support/ApiExceptionHandler.java b/src/main/java/com/example/demo/support/ApiExceptionHandler.java index e58e112..76a05cd 100644 --- a/src/main/java/com/example/demo/support/ApiExceptionHandler.java +++ b/src/main/java/com/example/demo/support/ApiExceptionHandler.java @@ -50,4 +50,12 @@ public class ApiExceptionHandler { body.put("message", ex.getMessage()); return ResponseEntity.status(HttpStatus.NOT_FOUND).body(body); } + + @ExceptionHandler(LlmUnavailableException.class) + public ResponseEntity> handleLlmUnavailable(LlmUnavailableException ex) { + Map body = new LinkedHashMap<>(); + body.put("code", "LLM_MATCHER_UNAVAILABLE"); + body.put("message", ex.getMessage()); + return ResponseEntity.status(HttpStatus.SERVICE_UNAVAILABLE).body(body); + } } diff --git a/src/main/java/com/example/demo/support/EventKey.java b/src/main/java/com/example/demo/support/EventKey.java index 8343ea3..7a7718d 100644 --- a/src/main/java/com/example/demo/support/EventKey.java +++ b/src/main/java/com/example/demo/support/EventKey.java @@ -1,15 +1,15 @@ package com.example.demo.support; /** - * Unified business-event key within a call: {@code ruleSetId:ruleId}. - * Example: {@code transfer:sf-express}. + * Unified business-event key within a call: {@code ruleSetId:eventId}. + * Example: {@code transfer:E001}. */ public final class EventKey { private EventKey() { } - public static String of(String ruleSetId, String ruleId) { - return ruleSetId + ":" + ruleId; + public static String of(String ruleSetId, String eventId) { + return ruleSetId + ":" + eventId; } } diff --git a/src/main/java/com/example/demo/support/LlmUnavailableException.java b/src/main/java/com/example/demo/support/LlmUnavailableException.java new file mode 100644 index 0000000..67b0177 --- /dev/null +++ b/src/main/java/com/example/demo/support/LlmUnavailableException.java @@ -0,0 +1,12 @@ +package com.example.demo.support; + +public class LlmUnavailableException extends RuntimeException { + + public LlmUnavailableException(String message) { + super(message); + } + + public LlmUnavailableException(String message, Throwable cause) { + super(message, cause); + } +} diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml index 9c776b2..388d743 100644 --- a/src/main/resources/application.yml +++ b/src/main/resources/application.yml @@ -12,14 +12,22 @@ spring: ddl-auto: update open-in-view: false + ai: + openai: + # Spring AI needs a bootstrap value. Runtime calls use monitor.llm.api-key below. + api-key: ${LLM_API_KEY:startup-placeholder} + server: port: 8080 monitor: recent-final-window-size: 5 + max-conversation-turns: 200 max-processed-seqs: 500 session-ttl: PT2H session-cleanup-interval: PT10M + llm: + api-key: ${LLM_API_KEY:} management: endpoints: diff --git a/src/main/resources/rules/default-rules.json b/src/main/resources/rules/default-rules.json index 89329a0..9f62fc8 100644 --- a/src/main/resources/rules/default-rules.json +++ b/src/main/resources/rules/default-rules.json @@ -1,4 +1,11 @@ { + "llmConfig": { + "baseUrl": "https://api.openai.com/v1", + "model": "gpt-4.1-mini", + "timeoutMs": 3000, + "maxOutputTokens": 64, + "prompt": "你是实时通话事件识别器。根据规则库和已发送事件,判断最新对话是否产生新的事件。\n\n事件规则(事件ID|名称|辅助关键词):\n{{rules}}\n\n已发送事件 ID:\n{{alerted_event_ids}}\n\n判断要求:\n1. 结合市民与坐席的语义,只判断规则库中的事件。\n2. 排除否定、假设、举例和坐席转述。\n3. 已发送事件不要重复输出。\n4. 只输出 JSON 字符串数组,例如 [\"E001\"];无新事件输出 []。\n5. 不输出原因、置信度、证据、字段名或 Markdown。\n\n通话上下文:\n{{conversation}}" + }, "ruleSets": [ { "id": "transfer", @@ -7,19 +14,22 @@ "matcher": "ac-keyword", "rules": [ { - "id": "sf-express", + "id": "event-5f5f264e-971a-48f4-84cd-3f89fc2df15f", + "eventId": "E001", "name": "顺丰速递绿色渠道服务热线", "enabled": true, "keywords": ["顺丰", "顺丰快递", "顺丰速运"] }, { - "id": "pdd", + "id": "event-d0865681-e962-4d0d-b6db-23d5abdc3e7b", + "eventId": "E002", "name": "拼多多平台热线", "enabled": true, "keywords": ["拼多多", "拼夕夕"] }, { - "id": "ems", + "id": "event-e9ed1542-ff59-458f-9eb4-4afaf199006e", + "eventId": "E003", "name": "EMS绿色通道", "enabled": true, "keywords": ["EMS", "邮政速递"] diff --git a/src/test/java/com/example/demo/api/RuleAdminControllerTest.java b/src/test/java/com/example/demo/api/RuleAdminControllerTest.java index d2347bc..b4d2377 100644 --- a/src/test/java/com/example/demo/api/RuleAdminControllerTest.java +++ b/src/test/java/com/example/demo/api/RuleAdminControllerTest.java @@ -51,7 +51,7 @@ class RuleAdminControllerTest { @Test @Order(2) - void saveActivateAndTestHotReload() throws Exception { + void saveAndActivateRules() throws Exception { MvcResult current = mockMvc.perform(get("/api/v1/admin/rules")) .andExpect(status().isOk()) .andReturn(); @@ -85,23 +85,6 @@ class RuleAdminControllerTest { .andExpect(jsonPath("$.version").value(version + 1)) .andExpect(jsonPath("$.ruleCount").value(1)); - mockMvc.perform(post("/api/v1/admin/rules/test") - .contentType(MediaType.APPLICATION_JSON) - .content(""" - { "text": "我的京东快递一直没送到" } - """)) - .andExpect(status().isOk()) - .andExpect(jsonPath("$.activeVersion").value(version + 1)) - .andExpect(jsonPath("$.matches[0].eventId").value("jd")); - - mockMvc.perform(post("/api/v1/admin/rules/test") - .contentType(MediaType.APPLICATION_JSON) - .content(""" - { "text": "顺丰快递丢了" } - """)) - .andExpect(status().isOk()) - .andExpect(jsonPath("$.matches", hasSize(0))); - mockMvc.perform(put("/api/v1/admin/rules") .contentType(MediaType.APPLICATION_JSON) .content("""