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("""