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.

This commit is contained in:
Xin Wang
2026-07-17 22:18:29 +08:00
parent 4caa9e9ee8
commit c23ef80d3a
42 changed files with 1248 additions and 454 deletions

View File

@@ -1,14 +1,14 @@
# 实时通话 ASR 事件监控 # 实时通话 ASR 事件监控
一个基于 Spring BootReact 和 AC 自动机的实时通话关键词监控系统。 一个基于 Spring BootReact 的实时通话事件监控系统。
前端持续提交市民与坐席的 ASR Final 文本,后端按通话维护上下文,匹配当前生效规则,并返回本次新增告警和通话累计结果。 前端持续提交市民与坐席的 ASR Final 文本,后端按通话维护共享上下文,可选择 AC 关键词匹配或大模型事件匹配,并返回本次新增告警和通话累计结果。
## 技术栈 ## 技术栈
| 模块 | 技术 | | 模块 | 技术 |
| --- | --- | | --- | --- |
| 后端 | Java 21、Spring Boot 4、Spring MVC、Spring Data JPA | | 后端 | Java 21、Spring Boot 4、Spring MVC、Spring Data JPA、Spring AI 2 |
| 匹配 | Aho-Corasick 自动机 | | 匹配 | Aho-Corasick 自动机 / OpenAI 兼容大模型 |
| 数据库 | H2 文件数据库 | | 数据库 | H2 文件数据库 |
| 前端 | React 19、TypeScript、Vite、Tailwind CSS、Motion | | 前端 | React 19、TypeScript、Vite、Tailwind CSS、Motion |
| Excel | ExcelJS | | Excel | ExcelJS |
@@ -22,7 +22,7 @@
│ ├── api/ # REST 接口 │ ├── api/ # REST 接口
│ ├── application/ # 监控、规则和会话服务 │ ├── application/ # 监控、规则和会话服务
│ ├── domain/ # 请求、响应和领域模型 │ ├── domain/ # 请求、响应和领域模型
│ ├── matcher/ # AC 自动机及匹配器 │ ├── matcher/ # AC、LLM 匹配器及路由
│ ├── repository/ # 规则版本和会话存储 │ ├── repository/ # 规则版本和会话存储
│ ├── job/ # 过期会话清理 │ ├── job/ # 过期会话清理
│ └── support/ # 异常、文本标准化等 │ └── support/ # 异常、文本标准化等
@@ -48,6 +48,15 @@
./mvnw spring-boot:run ./mvnw spring-boot:run
``` ```
如需使用“大模型事件匹配”,先设置模型服务的 API Key
```bash
export LLM_API_KEY="your-api-key"
./mvnw spring-boot:run
```
API Key 只从后端环境变量读取,不会进入浏览器、规则数据库或版本历史。模型服务地址、模型名、超时、最大输出 Token 和提示词在前端页面配置。
后端默认地址:`http://localhost:8080` 后端默认地址:`http://localhost:8080`
健康检查: 健康检查:
@@ -146,11 +155,13 @@ flowchart TD
B -- 是 --> D{"callId + seq 是否重复?"} B -- 是 --> D{"callId + seq 是否重复?"}
D -- 是 --> E["返回 duplicate=true"] D -- 是 --> E["返回 duplicate=true"]
D -- 否 --> F["写入该通话的共享上下文窗口"] D -- 否 --> F["写入该通话的共享上下文窗口"]
F --> G["拼接最近 N 条 Final"] F --> G{"当前匹配方式"}
G --> H["文本标准化"] G -- AC --> H["拼接、标准化并扫描关键词"]
H --> I["AC 自动机匹配全部关键词"] G -- LLM --> I["注入规则、已发送事件和带角色上下文"]
I --> J["合并事件并进行通话级去重"] H --> J["还原业务事件"]
J --> K["返回 newAlerts 和 currentResults"] I --> J
J --> K["后端进行通话级最终去重"]
K --> L["返回 newAlerts 和 currentResults"]
``` ```
### 1. 通话隔离与并发 ### 1. 通话隔离与并发
@@ -159,7 +170,7 @@ flowchart TD
### 2. 共享上下文窗口 ### 2. 共享上下文窗口
市民和坐席文本进入同一个、按 `seq` 排序的最近 N 条窗口。默认保留 5 ,能够识别被 ASR 切分到相邻 Final 中的关键词。 市民和坐席文本进入同一个、按 `seq` 排序的通话上下文,默认最多保留 200 轮。AC 只扫描最近 5 ,能够识别被 ASR 切分到相邻 Final 中的关键词LLM 使用当前保留的完整上下文
### 3. 文本标准化 ### 3. 文本标准化
@@ -178,9 +189,26 @@ E M S → ems
匹配结果按业务事件聚合。同一个事件在同一次通话中只进入一次 `newAlerts`,重复命中仍保留在累计状态中,但不会重复提醒。 匹配结果按业务事件聚合。同一个事件在同一次通话中只进入一次 `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 端口 | | `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.max-processed-seqs` | `500` | 幂等序列号历史上限 |
| `monitor.session-ttl` | `PT2H` | 会话空闲过期时间 | | `monitor.session-ttl` | `PT2H` | 会话空闲过期时间 |
| `monitor.session-cleanup-interval` | `PT10M` | 过期会话清理周期 | | `monitor.session-cleanup-interval` | `PT10M` | 过期会话清理周期 |
| `monitor.llm.api-key` | 环境变量 `LLM_API_KEY` | 大模型服务密钥 |
规则数据库默认保存在项目运行目录的 `data/rules.mv.db` 规则数据库默认保存在项目运行目录的 `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` 响应为准。 - 真实告警始终以后端 `/api/v1/asr-events` 响应为准。
- 更完整的设计背景见 `asr_event_monitor_confirmed_technical_solution.md` - 更完整的设计背景见 `asr_event_monitor_confirmed_technical_solution.md`

View File

@@ -16,9 +16,16 @@ import {
restoreRuleVersion, restoreRuleVersion,
saveAndActivateRules, saveAndActivateRules,
} from './api'; } from './api';
import { Rule, RuleSet, RuleVersionSummary } from './types'; import { LlmConfig, Rule, RuleSet, RuleVersionSummary } from './types';
const TRANSFER_SET_ID = 'transfer'; 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 { function emptyTransferSet(): RuleSet {
return { 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() { export default function App() {
// Global Active Tab: 'rules' | 'sandbox' // Global Active Tab: 'rules' | 'sandbox'
const [activeTab, setActiveTab] = useState<'rules' | 'sandbox'>('rules'); const [activeTab, setActiveTab] = useState<'rules' | 'sandbox'>('rules');
const [ruleSets, setRuleSets] = useState<RuleSet[]>([]); const [ruleSets, setRuleSets] = useState<RuleSet[]>([]);
const [publishedRuleSets, setPublishedRuleSets] = useState<RuleSet[]>([]); const [publishedRuleSets, setPublishedRuleSets] = useState<RuleSet[]>([]);
const [llmConfig, setLlmConfig] = useState<LlmConfig>(DEFAULT_LLM_CONFIG);
const [publishedLlmConfig, setPublishedLlmConfig] = useState<LlmConfig>(DEFAULT_LLM_CONFIG);
const [publishedVersion, setPublishedVersion] = useState(0); const [publishedVersion, setPublishedVersion] = useState(0);
const [loadingRules, setLoadingRules] = useState(true); const [loadingRules, setLoadingRules] = useState(true);
const [savingRules, setSavingRules] = useState(false); const [savingRules, setSavingRules] = useState(false);
@@ -82,6 +109,8 @@ export default function App() {
if (!active) return; if (!active) return;
setRuleSets(data.ruleSets); setRuleSets(data.ruleSets);
setPublishedRuleSets(data.ruleSets); setPublishedRuleSets(data.ruleSets);
setLlmConfig(data.llmConfig);
setPublishedLlmConfig(data.llmConfig);
setPublishedVersion(data.version); setPublishedVersion(data.version);
}) })
.catch(error => { .catch(error => {
@@ -101,17 +130,24 @@ export default function App() {
setRuleSets(current => replaceTransferRules(current, updatedRules)); setRuleSets(current => replaceTransferRules(current, updatedRules));
}; };
const handleUpdateMatcher = (matcher: 'ac-keyword' | 'llm') => {
setRuleSets(current => replaceTransferMatcher(current, matcher));
};
const isDirty = useMemo(() => { const isDirty = useMemo(() => {
return JSON.stringify(ruleSets) !== JSON.stringify(publishedRuleSets); return JSON.stringify(ruleSets) !== JSON.stringify(publishedRuleSets)
}, [ruleSets, publishedRuleSets]); || JSON.stringify(llmConfig) !== JSON.stringify(publishedLlmConfig);
}, [llmConfig, publishedLlmConfig, publishedRuleSets, ruleSets]);
const handlePublish = async () => { const handlePublish = async () => {
if (!isDirty || savingRules || restoringVersion !== null) return; if (!isDirty || savingRules || restoringVersion !== null) return;
setSavingRules(true); setSavingRules(true);
try { try {
const result = await saveAndActivateRules(publishedVersion, ruleSets); const result = await saveAndActivateRules(publishedVersion, llmConfig, ruleSets);
setRuleSets(result.ruleSets); setRuleSets(result.ruleSets);
setPublishedRuleSets(result.ruleSets); setPublishedRuleSets(result.ruleSets);
setLlmConfig(result.llmConfig);
setPublishedLlmConfig(result.llmConfig);
setPublishedVersion(result.version); setPublishedVersion(result.version);
addToast(`保存成功,监控规则 V${result.version} 已发布生效`, 'success'); addToast(`保存成功,监控规则 V${result.version} 已发布生效`, 'success');
result.warnings.forEach(warning => addToast(warning, 'warning')); result.warnings.forEach(warning => addToast(warning, 'warning'));
@@ -155,6 +191,8 @@ export default function App() {
const result = await restoreRuleVersion(sourceVersion, publishedVersion); const result = await restoreRuleVersion(sourceVersion, publishedVersion);
setRuleSets(result.ruleSets); setRuleSets(result.ruleSets);
setPublishedRuleSets(result.ruleSets); setPublishedRuleSets(result.ruleSets);
setLlmConfig(result.llmConfig);
setPublishedLlmConfig(result.llmConfig);
setPublishedVersion(result.version); setPublishedVersion(result.version);
addToast(`已基于 V${sourceVersion} 创建并生效 V${result.version}`, 'success'); addToast(`已基于 V${sourceVersion} 创建并生效 V${result.version}`, 'success');
result.warnings.forEach(warning => addToast(warning, 'warning')); result.warnings.forEach(warning => addToast(warning, 'warning'));
@@ -205,6 +243,10 @@ export default function App() {
<RuleManagement <RuleManagement
rules={rules} rules={rules}
setRules={handleUpdateRules} setRules={handleUpdateRules}
matcher={transferMatcher(ruleSets)}
setMatcher={handleUpdateMatcher}
llmConfig={llmConfig}
setLlmConfig={setLlmConfig}
onSavePublish={handlePublish} onSavePublish={handlePublish}
isDirty={isDirty} isDirty={isDirty}
loading={loadingRules} loading={loadingRules}
@@ -223,6 +265,7 @@ export default function App() {
> >
<SandboxSimulation <SandboxSimulation
rules={activeRules} rules={activeRules}
matcher={transferMatcher(publishedRuleSets)}
addToast={addToast} addToast={addToast}
/> />
</motion.div> </motion.div>

View File

@@ -1,5 +1,6 @@
import type { import type {
CurrentRulesResponse, CurrentRulesResponse,
LlmConfig,
MonitorResponse, MonitorResponse,
RuleSet, RuleSet,
RuleVersionSummary, RuleVersionSummary,
@@ -48,12 +49,13 @@ export async function fetchCurrentRules(): Promise<CurrentRulesResponse> {
export async function saveAndActivateRules( export async function saveAndActivateRules(
baseVersion: number, baseVersion: number,
llmConfig: LlmConfig,
ruleSets: RuleSet[], ruleSets: RuleSet[],
): Promise<SaveRuleResponse> { ): Promise<SaveRuleResponse> {
return parseResponse<SaveRuleResponse>(await fetch('/api/v1/admin/rules', { return parseResponse<SaveRuleResponse>(await fetch('/api/v1/admin/rules', {
method: 'PUT', method: 'PUT',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ baseVersion, ruleSets }), body: JSON.stringify({ baseVersion, llmConfig, ruleSets }),
})); }));
} }

View File

@@ -3,7 +3,7 @@
* SPDX-License-Identifier: Apache-2.0 * SPDX-License-Identifier: Apache-2.0
*/ */
import { useState } from 'react'; import { useState, type ReactNode } from 'react';
import { AnimatePresence, motion } from 'motion/react'; import { AnimatePresence, motion } from 'motion/react';
import { import {
Bot, Bot,
@@ -15,73 +15,45 @@ import {
Sparkles, Sparkles,
X, X,
} from 'lucide-react'; } from 'lucide-react';
import type { LlmConfig } from '../types';
interface LlmConfigurationProps { interface LlmConfigurationProps {
config: LlmConfig;
enabledRuleCount: number; enabledRuleCount: number;
onChange: (config: LlmConfig) => void;
addToast: (message: string, type: 'success' | 'warning' | 'info') => void; addToast: (message: string, type: 'success' | 'warning' | 'info') => void;
} }
interface LlmSettings { const DEFAULT_PROMPT = `你是实时通话事件识别器。根据规则库和已发送事件,判断最新对话是否产生新的事件。
baseUrl: string;
model: string;
timeoutMs: string;
prompt: string;
}
const STORAGE_KEY = 'asr-monitor:llm-settings-draft'; 事件规则事件ID|名称|辅助关键词):
const DEFAULT_PROMPT = `你是实时通话事件识别器。
结合市民与坐席的完整上下文,判断当前最新一轮是否新产生规则库中的事件。
事件规则:
{{rules}} {{rules}}
通话上下文 已发送事件 ID
{{conversation}} {{alerted_event_ids}}
判断要求: 判断要求:
1. 只判断当前最新一轮新产生的事件,不重复输出历史事件。 1. 结合市民与坐席的语义,只判断规则库中的事件。
2. 必须区分市民和坐席,排除否定表达、假设表达和坐席转述。 2. 排除否定、假设、举例和坐席转述。
3. 只能输出规则库中存在的事件 ID 3. 已发送事件不要重复输出
4. 只输出 JSON 字符串数组,不输出原因、置信度、证据或 Markdown 4. 只输出 JSON 字符串数组,例如 ["E001"];无新事件输出 []
5. 无事件时输出 []。`; 5. 不输出原因、置信度、证据、字段名或 Markdown。
const DEFAULT_SETTINGS: LlmSettings = { 通话上下文:
baseUrl: 'https://api.example.com/v1', {{conversation}}`;
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<LlmSettings>;
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;
}
}
export default function LlmConfiguration({ export default function LlmConfiguration({
config,
enabledRuleCount, enabledRuleCount,
onChange,
addToast, addToast,
}: LlmConfigurationProps) { }: LlmConfigurationProps) {
const [isOpen, setIsOpen] = useState(false); const [isOpen, setIsOpen] = useState(false);
const [settings, setSettings] = useState<LlmSettings>(loadSettings); const [draft, setDraft] = useState<LlmConfig>(config);
const [draft, setDraft] = useState<LlmSettings>(settings);
const [apiKey, setApiKey] = useState('');
const [errors, setErrors] = useState<Record<string, string>>({}); const [errors, setErrors] = useState<Record<string, string>>({});
const openDrawer = () => { const openDrawer = () => {
setDraft(settings); setDraft(config);
setApiKey('');
setErrors({}); setErrors({});
setIsOpen(true); setIsOpen(true);
}; };
@@ -89,35 +61,37 @@ export default function LlmConfiguration({
const saveDraft = () => { const saveDraft = () => {
const nextErrors: Record<string, string> = {}; const nextErrors: Record<string, string> = {};
if (!draft.baseUrl.trim()) nextErrors.baseUrl = '请填写模型服务地址'; if (!draft.baseUrl.trim()) nextErrors.baseUrl = '请填写模型服务地址';
if (!draft.model.trim()) nextErrors.model = '请填写模型名称'; try {
if (!/^\d+$/.test(draft.timeoutMs) || Number(draft.timeoutMs) < 300) { const url = new URL(draft.baseUrl);
nextErrors.timeoutMs = '超时时间不能小于 300ms'; if (!['http:', 'https:'].includes(url.protocol)) throw new Error();
} catch {
nextErrors.baseUrl = '请填写有效的 HTTP(S) 地址';
} }
if (!draft.prompt.includes('{{rules}}')) { if (!draft.model.trim()) nextErrors.model = '请填写模型名称';
nextErrors.prompt = '提示词需要包含 {{rules}},用于注入当前启用规则'; if (draft.timeoutMs < 300 || draft.timeoutMs > 30000) {
} else if (!draft.prompt.includes('{{conversation}}')) { nextErrors.timeoutMs = '超时时间需在 30030000ms 之间';
nextErrors.prompt = '提示词需要包含 {{conversation}},用于注入通话上下文'; }
if (draft.maxOutputTokens < 8 || draft.maxOutputTokens > 256) {
nextErrors.maxOutputTokens = '最大输出需在 8256 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) { if (Object.keys(nextErrors).length > 0) {
setErrors(nextErrors); setErrors(nextErrors);
return; return;
} }
const normalized = { onChange({
baseUrl: draft.baseUrl.trim(), ...draft,
baseUrl: draft.baseUrl.trim().replace(/\/+$/, ''),
model: draft.model.trim(), model: draft.model.trim(),
timeoutMs: draft.timeoutMs.trim(),
prompt: draft.prompt.trim(), prompt: draft.prompt.trim(),
}; });
setSettings(normalized);
window.localStorage.setItem(STORAGE_KEY, JSON.stringify(normalized));
setIsOpen(false); setIsOpen(false);
addToast( addToast('模型与提示词已加入当前草稿,点击“保存并发布”后生效', 'success');
apiKey
? '模型与提示词草稿已保存API Key 将在后端接入时安全保存'
: '模型与提示词草稿已保存',
'success',
);
}; };
return ( return (
@@ -136,30 +110,15 @@ export default function LlmConfiguration({
</span> </span>
</div> </div>
<p className="mt-1 text-xs leading-5 text-slate-500"> <p className="mt-1 text-xs leading-5 text-slate-500">
ID ID ID ID
</p> </p>
</div> </div>
</div> </div>
<div className="flex flex-wrap items-center gap-2 lg:justify-end"> <div className="flex flex-wrap items-center gap-2 lg:justify-end">
<div className="rounded-md border border-slate-200 bg-slate-50 px-3 py-2"> <Stat label="当前模型" value={config.model} mono />
<p className="text-[9px] font-bold uppercase tracking-widest text-slate-400"></p> <Stat label="启用规则" value={`${enabledRuleCount}`} mono />
<p className="mt-0.5 max-w-40 truncate font-mono text-[11px] font-bold text-slate-800"> <Stat label="输出协议" value='["E001"]' mono />
{settings.model}
</p>
</div>
<div className="rounded-md border border-slate-200 bg-slate-50 px-3 py-2">
<p className="text-[9px] font-bold uppercase tracking-widest text-slate-400"></p>
<p className="mt-0.5 font-mono text-[11px] font-bold text-slate-800">
{enabledRuleCount}
</p>
</div>
<div className="rounded-md border border-slate-200 bg-slate-50 px-3 py-2">
<p className="text-[9px] font-bold uppercase tracking-widest text-slate-400"></p>
<p className="mt-0.5 font-mono text-[11px] font-bold text-slate-800">
[&quot;E001&quot;]
</p>
</div>
<button <button
type="button" type="button"
onClick={openDrawer} onClick={openDrawer}
@@ -200,7 +159,7 @@ export default function LlmConfiguration({
</h2> </h2>
</div> </div>
<p className="mt-1 text-[11px] text-slate-400"> <p className="mt-1 text-[11px] text-slate-400">
</p> </p>
</div> </div>
<button <button
@@ -217,84 +176,62 @@ export default function LlmConfiguration({
<div> <div>
<h3 className="text-xs font-bold text-slate-900"></h3> <h3 className="text-xs font-bold text-slate-900"></h3>
<p className="mt-1 text-[11px] text-slate-400"> <p className="mt-1 text-[11px] text-slate-400">
使 OpenAI Chat Completions
</p> </p>
</div> </div>
<div className="grid gap-4 sm:grid-cols-2"> <div className="grid gap-4 sm:grid-cols-2">
<label className="space-y-1.5"> <Field label="模型服务地址" error={errors.baseUrl}>
<span className="text-[10px] font-bold uppercase tracking-widest text-slate-500">
<span className="text-rose-500">*</span>
</span>
<input <input
value={draft.baseUrl} value={draft.baseUrl}
onChange={(event) => setDraft(current => ({ ...current, baseUrl: event.target.value }))} onChange={event => setDraft(current => ({ ...current, baseUrl: event.target.value }))}
placeholder="https://api.example.com/v1" placeholder="https://api.openai.com/v1"
className={`w-full rounded border px-3 py-2 text-xs text-slate-900 outline-none transition ${ className={inputClass(Boolean(errors.baseUrl))}
errors.baseUrl ? 'border-rose-300' : 'border-slate-200 focus:border-slate-900'
}`}
/> />
<span className="block text-[10px] leading-4 text-slate-400"> </Field>
Chat Completions <Field label="模型名称" error={errors.model}>
</span>
{errors.baseUrl && <span className="block text-[10px] font-bold text-rose-500">{errors.baseUrl}</span>}
</label>
<label className="space-y-1.5">
<span className="text-[10px] font-bold uppercase tracking-widest text-slate-500">
<span className="text-rose-500">*</span>
</span>
<input <input
value={draft.model} value={draft.model}
onChange={(event) => setDraft(current => ({ ...current, model: event.target.value }))} onChange={event => setDraft(current => ({ ...current, model: event.target.value }))}
placeholder="qwen-plus" placeholder="gpt-4.1-mini"
className={`w-full rounded border px-3 py-2 font-mono text-xs text-slate-900 outline-none transition ${ className={`${inputClass(Boolean(errors.model))} font-mono`}
errors.model ? 'border-rose-300' : 'border-slate-200 focus:border-slate-900'
}`}
/> />
<span className="block text-[10px] leading-4 text-slate-400"> </Field>
使 model <Field label="调用超时(毫秒)" error={errors.timeoutMs}>
</span> <input
{errors.model && <span className="block text-[10px] font-bold text-rose-500">{errors.model}</span>} type="number"
</label> min={300}
max={30000}
value={draft.timeoutMs}
onChange={event => setDraft(current => ({
...current,
timeoutMs: Number(event.target.value),
}))}
className={`${inputClass(Boolean(errors.timeoutMs))} font-mono`}
/>
</Field>
<Field label="最大输出 Token" error={errors.maxOutputTokens}>
<input
type="number"
min={8}
max={256}
value={draft.maxOutputTokens}
onChange={event => setDraft(current => ({
...current,
maxOutputTokens: Number(event.target.value),
}))}
className={`${inputClass(Boolean(errors.maxOutputTokens))} font-mono`}
/>
</Field>
</div> </div>
<div className="grid gap-4 sm:grid-cols-2"> <div className="flex items-start gap-2 rounded border border-amber-200 bg-amber-50 px-3 py-2.5 text-[10px] leading-4 text-amber-800">
<label className="space-y-1.5"> <KeyRound className="mt-0.5 h-3.5 w-3.5 shrink-0" />
<span className="flex items-center gap-1.5 text-[10px] font-bold uppercase tracking-widest text-slate-500"> <p>
<KeyRound className="h-3 w-3" /> API Key
API Key <code className="mx-1 rounded bg-amber-100 px-1 font-mono font-bold">LLM_API_KEY</code>
</span>
<input </p>
type="password"
value={apiKey}
onChange={(event) => setApiKey(event.target.value)}
placeholder="留空表示不修改"
autoComplete="new-password"
className="w-full rounded border border-slate-200 px-3 py-2 font-mono text-xs text-slate-900 outline-none transition focus:border-slate-900"
/>
<span className="block text-[10px] leading-4 text-slate-400">
</span>
</label>
<label className="space-y-1.5">
<span className="text-[10px] font-bold uppercase tracking-widest text-slate-500">
</span>
<input
inputMode="numeric"
value={draft.timeoutMs}
onChange={(event) => setDraft(current => ({ ...current, timeoutMs: event.target.value }))}
className={`w-full rounded border px-3 py-2 font-mono text-xs text-slate-900 outline-none transition ${
errors.timeoutMs ? 'border-rose-300' : 'border-slate-200 focus:border-slate-900'
}`}
/>
<span className="block text-[10px] leading-4 text-slate-400">
12 QPS 20003000ms
</span>
{errors.timeoutMs && <span className="block text-[10px] font-bold text-rose-500">{errors.timeoutMs}</span>}
</label>
</div> </div>
</section> </section>
@@ -303,7 +240,7 @@ export default function LlmConfiguration({
<div> <div>
<h3 className="text-xs font-bold text-slate-900"></h3> <h3 className="text-xs font-bold text-slate-900"></h3>
<p className="mt-1 text-[11px] leading-5 text-slate-400"> <p className="mt-1 text-[11px] leading-5 text-slate-400">
便 KV Cache
</p> </p>
</div> </div>
<button <button
@@ -317,8 +254,8 @@ export default function LlmConfiguration({
<textarea <textarea
value={draft.prompt} value={draft.prompt}
onChange={(event) => setDraft(current => ({ ...current, prompt: event.target.value }))} onChange={event => setDraft(current => ({ ...current, prompt: event.target.value }))}
rows={18} rows={19}
spellCheck={false} spellCheck={false}
className={`w-full resize-y rounded-md border bg-slate-950 p-4 font-mono text-[11px] leading-5 text-slate-100 outline-none transition ${ className={`w-full resize-y rounded-md border bg-slate-950 p-4 font-mono text-[11px] leading-5 text-slate-100 outline-none transition ${
errors.prompt ? 'border-rose-400' : 'border-slate-800 focus:border-slate-600' errors.prompt ? 'border-rose-400' : 'border-slate-800 focus:border-slate-600'
@@ -327,16 +264,14 @@ export default function LlmConfiguration({
{errors.prompt && <p className="text-[10px] font-bold text-rose-500">{errors.prompt}</p>} {errors.prompt && <p className="text-[10px] font-bold text-rose-500">{errors.prompt}</p>}
<div className="flex flex-wrap items-center gap-2"> <div className="flex flex-wrap items-center gap-2">
<span className="text-[10px] font-bold uppercase tracking-widest text-slate-400"></span> <span className="text-[10px] font-bold uppercase tracking-widest text-slate-400"></span>
{['{{rules}}', '{{conversation}}'].map(variable => ( {['{{rules}}', '{{alerted_event_ids}}', '{{conversation}}'].map(variable => (
<button <code
key={variable} key={variable}
type="button" className="rounded border border-slate-200 bg-slate-50 px-2 py-1 font-mono text-[10px] font-bold text-slate-600"
onClick={() => setDraft(current => ({ ...current, prompt: `${current.prompt}\n${variable}` }))}
className="rounded border border-slate-200 bg-slate-50 px-2 py-1 font-mono text-[10px] font-bold text-slate-600 transition hover:border-slate-400"
> >
{variable} {variable}
</button> </code>
))} ))}
</div> </div>
</section> </section>
@@ -346,28 +281,14 @@ export default function LlmConfiguration({
<div className="flex h-8 w-8 shrink-0 items-center justify-center rounded border border-slate-200 bg-white"> <div className="flex h-8 w-8 shrink-0 items-center justify-center rounded border border-slate-200 bg-white">
<Braces className="h-4 w-4 text-slate-600" /> <Braces className="h-4 w-4 text-slate-600" />
</div> </div>
<div className="min-w-0 flex-1"> <div>
<div className="flex flex-wrap items-center justify-between gap-2"> <h3 className="text-xs font-bold text-slate-900"></h3>
<h3 className="text-xs font-bold text-slate-900"> ID V1</h3>
<span className="rounded bg-emerald-50 px-2 py-0.5 text-[9px] font-bold text-emerald-700">
</span>
</div>
<p className="mt-1 text-[10px] leading-4 text-slate-500"> <p className="mt-1 text-[10px] leading-4 text-slate-500">
ID UUID JSON ID ID
</p> </p>
<div className="mt-3 grid gap-2 sm:grid-cols-2"> <code className="mt-2 block font-mono text-[11px] font-bold text-slate-800">
<div className="rounded border border-slate-200 bg-white px-3 py-2"> [&quot;E001&quot;,&quot;E003&quot;] / []
<p className="text-[9px] font-bold uppercase tracking-widest text-slate-400"></p> </code>
<code className="mt-1 block font-mono text-[11px] font-bold text-slate-800">
[&quot;E001&quot;,&quot;E003&quot;]
</code>
</div>
<div className="rounded border border-slate-200 bg-white px-3 py-2">
<p className="text-[9px] font-bold uppercase tracking-widest text-slate-400"></p>
<code className="mt-1 block font-mono text-[11px] font-bold text-slate-800">[]</code>
</div>
</div>
</div> </div>
</div> </div>
</section> </section>
@@ -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" 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"
> >
<Save className="h-3.5 w-3.5" /> <Save className="h-3.5 w-3.5" />
稿
</button> </button>
</footer> </footer>
</motion.aside> </motion.aside>
@@ -397,3 +318,40 @@ export default function LlmConfiguration({
</> </>
); );
} }
function Stat({ label, value, mono = false }: { label: string; value: string; mono?: boolean }) {
return (
<div className="rounded-md border border-slate-200 bg-slate-50 px-3 py-2">
<p className="text-[9px] font-bold uppercase tracking-widest text-slate-400">{label}</p>
<p className={`mt-0.5 max-w-40 truncate text-[11px] font-bold text-slate-800 ${mono ? 'font-mono' : ''}`}>
{value}
</p>
</div>
);
}
function Field({
label,
error,
children,
}: {
label: string;
error?: string;
children: ReactNode;
}) {
return (
<label className="space-y-1.5">
<span className="text-[10px] font-bold uppercase tracking-widest text-slate-500">
{label} <span className="text-rose-500">*</span>
</span>
{children}
{error && <span className="block text-[10px] font-bold text-rose-500">{error}</span>}
</label>
);
}
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'
}`;
}

View File

@@ -3,7 +3,7 @@
* SPDX-License-Identifier: Apache-2.0 * 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 { AnimatePresence, motion } from 'motion/react';
import { import {
Plus, Plus,
@@ -18,48 +18,24 @@ import {
Download, Download,
FileDown FileDown
} from 'lucide-react'; } from 'lucide-react';
import { Rule } from '../types'; import { LlmConfig, Rule } from '../types';
import { import {
downloadRuleTemplate, downloadRuleTemplate,
exportRulesToExcel, exportRulesToExcel,
importRulesFromExcel, importRulesFromExcel,
} from '../rule-excel'; } from '../rule-excel';
import { generateEventId } from '../rule-id'; import { generateEventId, generateModelEventId } from '../rule-id';
import LlmConfiguration from './LlmConfiguration'; 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'; 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<string, string> {
if (typeof window === 'undefined') return {};
try {
return JSON.parse(window.localStorage.getItem(MODEL_EVENT_ID_STORAGE_KEY) ?? '{}') as Record<string, string>;
} catch {
return {};
}
}
function nextModelEventId(existingIds: Iterable<string>): 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 { interface RuleManagementProps {
rules: Rule[]; rules: Rule[];
setRules: (rules: Rule[]) => void; setRules: (rules: Rule[]) => void;
matcher: 'ac-keyword' | 'llm';
setMatcher: (matcher: 'ac-keyword' | 'llm') => void;
llmConfig: LlmConfig;
setLlmConfig: (config: LlmConfig) => void;
onSavePublish: () => void; onSavePublish: () => void;
isDirty: boolean; isDirty: boolean;
loading: boolean; loading: boolean;
@@ -70,6 +46,10 @@ interface RuleManagementProps {
export default function RuleManagement({ export default function RuleManagement({
rules, rules,
setRules, setRules,
matcher,
setMatcher,
llmConfig,
setLlmConfig,
onSavePublish, onSavePublish,
isDirty, isDirty,
loading, loading,
@@ -90,16 +70,14 @@ export default function RuleManagement({
const [formKeywords, setFormKeywords] = useState<string[]>([]); const [formKeywords, setFormKeywords] = useState<string[]>([]);
const [formErrors, setFormErrors] = useState<Record<string, string>>({}); const [formErrors, setFormErrors] = useState<Record<string, string>>({});
const [excelAction, setExcelAction] = useState<'template' | 'import' | 'export' | null>(null); const [excelAction, setExcelAction] = useState<'template' | 'import' | 'export' | null>(null);
const [modelEventIds, setModelEventIds] = useState<Record<string, string>>(loadModelEventIds); const matcherMode: MatcherMode = matcher === 'llm' ? 'llm' : 'keyword';
const [matcherMode, setMatcherMode] = useState<MatcherMode>(loadMatcherMode);
// Tag editor input ref // Tag editor input ref
const tagInputRef = useRef<HTMLInputElement>(null); const tagInputRef = useRef<HTMLInputElement>(null);
const fileInputRef = useRef<HTMLInputElement>(null); const fileInputRef = useRef<HTMLInputElement>(null);
const handleMatcherModeChange = (mode: MatcherMode) => { const handleMatcherModeChange = (mode: MatcherMode) => {
setMatcherMode(mode); setMatcher(mode === 'llm' ? 'llm' : 'ac-keyword');
window.localStorage.setItem(MATCHER_MODE_STORAGE_KEY, mode);
addToast( addToast(
mode === 'keyword' 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 () => { const handleTemplateDownload = async () => {
if (excelAction) return; if (excelAction) return;
setExcelAction('template'); setExcelAction('template');
@@ -195,18 +155,18 @@ export default function RuleManagement({
const matchesSearch = const matchesSearch =
rule.name.toLowerCase().includes(searchTerm.toLowerCase()) || rule.name.toLowerCase().includes(searchTerm.toLowerCase()) ||
rule.id.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())) ; rule.keywords.some(k => k.toLowerCase().includes(searchTerm.toLowerCase())) ;
return matchesSearch; return matchesSearch;
}); });
}, [modelEventIds, rules, searchTerm]); }, [rules, searchTerm]);
// Open Drawer for Add // Open Drawer for Add
const handleAddRuleClick = () => { const handleAddRuleClick = () => {
setEditingRule(null); setEditingRule(null);
setFormId(generateEventId(rules.map(rule => rule.id))); setFormId(generateEventId(rules.map(rule => rule.id)));
setFormModelEventId(nextModelEventId(Object.values(modelEventIds))); setFormModelEventId(generateModelEventId(rules.map(rule => rule.eventId)));
setFormName(''); setFormName('');
setFormKeywords([]); setFormKeywords([]);
setKeywordInput(''); setKeywordInput('');
@@ -218,7 +178,7 @@ export default function RuleManagement({
const handleEditRuleClick = (rule: Rule) => { const handleEditRuleClick = (rule: Rule) => {
setEditingRule(rule); setEditingRule(rule);
setFormId(rule.id); setFormId(rule.id);
setFormModelEventId(modelEventIds[rule.id] ?? nextModelEventId(Object.values(modelEventIds))); setFormModelEventId(rule.eventId);
setFormName(rule.name); setFormName(rule.name);
setFormKeywords([...rule.keywords]); setFormKeywords([...rule.keywords]);
setKeywordInput(''); setKeywordInput('');
@@ -274,7 +234,7 @@ export default function RuleManagement({
}; };
// Form submit in Drawer // Form submit in Drawer
const handleSaveForm = (e: React.FormEvent) => { const handleSaveForm = (e: React.SyntheticEvent) => {
e.preventDefault(); e.preventDefault();
const errors: Record<string, string> = {}; const errors: Record<string, string> = {};
const normalizedFormId = formId.trim() || generateEventId(rules.map(rule => rule.id)); 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)) { } else if (!/^[A-Z][A-Z0-9_-]{0,31}$/.test(normalizedModelEventId)) {
errors.modelEventId = '请以字母开头,仅使用大写字母、数字、中划线或下划线'; errors.modelEventId = '请以字母开头,仅使用大写字母、数字、中划线或下划线';
} else if ( } else if (
Object.entries(modelEventIds).some( rules.some(
([uuid, eventId]) => uuid !== editingRule?.id && eventId.toUpperCase() === normalizedModelEventId rule => rule.id !== editingRule?.id
&& rule.eventId.toUpperCase() === normalizedModelEventId
) )
) { ) {
errors.modelEventId = '此事件 ID 已被其他规则使用'; errors.modelEventId = '此事件 ID 已被其他规则使用';
@@ -314,18 +275,12 @@ export default function RuleManagement({
const newRule: Rule = { const newRule: Rule = {
id: normalizedFormId, id: normalizedFormId,
eventId: normalizedModelEventId,
name: formName.trim(), name: formName.trim(),
keywords: formKeywords, keywords: formKeywords,
enabled: editingRule ? editingRule.enabled : true 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) { if (editingRule) {
// update // update
setRules(rules.map(r => r.id === editingRule.id ? newRule : r)); setRules(rules.map(r => r.id === editingRule.id ? newRule : r));
@@ -479,7 +434,12 @@ export default function RuleManagement({
exit={{ opacity: 0, y: -8 }} exit={{ opacity: 0, y: -8 }}
transition={{ duration: 0.16, ease: 'easeOut' }} transition={{ duration: 0.16, ease: 'easeOut' }}
> >
<LlmConfiguration enabledRuleCount={activeRulesCount} addToast={addToast} /> <LlmConfiguration
config={llmConfig}
enabledRuleCount={activeRulesCount}
onChange={setLlmConfig}
addToast={addToast}
/>
</motion.div> </motion.div>
)} )}
</AnimatePresence> </AnimatePresence>
@@ -547,7 +507,7 @@ export default function RuleManagement({
{/* Model-facing short Event ID */} {/* Model-facing short Event ID */}
<td className="px-6 py-4 whitespace-nowrap"> <td className="px-6 py-4 whitespace-nowrap">
<span className="rounded border border-violet-200 bg-violet-50 px-2 py-1 font-mono text-[10px] font-bold text-violet-700"> <span className="rounded border border-violet-200 bg-violet-50 px-2 py-1 font-mono text-[10px] font-bold text-violet-700">
{modelEventIds[rule.id] ?? '待生成'} {rule.eventId}
</span> </span>
</td> </td>
@@ -705,7 +665,7 @@ export default function RuleManagement({
) : ( ) : (
<p className="text-[10px] text-slate-400 mt-1"> <p className="text-[10px] text-slate-400 mt-1">
{editingRule {editingRule
? 'UUID 创建后保持不变,用于版本关联和通话内事件去重' ? 'UUID 创建后保持不变,用于规则管理和版本关联'
: '无需填写,系统自动生成;该值不会要求大模型输出'} : '无需填写,系统自动生成;该值不会要求大模型输出'}
</p> </p>
)} )}
@@ -719,11 +679,12 @@ export default function RuleManagement({
<input <input
id="model-event-id" id="model-event-id"
type="text" type="text"
disabled={Boolean(editingRule)}
value={formModelEventId} value={formModelEventId}
onChange={(event) => setFormModelEventId(event.target.value.toUpperCase())} onChange={(event) => setFormModelEventId(event.target.value.toUpperCase())}
placeholder="例如E001 或 COMPLAINT" placeholder="例如E001 或 COMPLAINT"
maxLength={32} 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 formErrors.modelEventId
? 'border-rose-300 focus:border-rose-500' ? 'border-rose-300 focus:border-rose-500'
: 'border-slate-200 focus:border-slate-900' : 'border-slate-200 focus:border-slate-900'
@@ -737,8 +698,12 @@ export default function RuleManagement({
) : ( ) : (
<p className="mt-1 text-[10px] leading-4 text-slate-400"> <p className="mt-1 text-[10px] leading-4 text-slate-400">
{matcherMode === 'llm' {matcherMode === 'llm'
? '提示词会把它注入规则清单,大模型只需返回该短 ID后端再通过 UUID 还原事件名称。建议简短、稳定且创建后不修改。' ? editingRule
: '关键词命中后接口使用该短 ID 标识事件UUID 继续负责内部关联。建议简短、稳定且创建后不修改。'} ? '创建后保持不变。大模型输出该短 ID后端据此还原事件名称并去重。'
: '自动生成,可在创建前调整;大模型只需返回该短 ID。'
: editingRule
? '创建后保持不变,关键词命中接口使用该 ID 标识事件。'
: '自动生成,可在创建前调整;关键词命中接口使用该 ID 标识事件。'}
</p> </p>
)} )}
</div> </div>

View File

@@ -26,6 +26,7 @@ import { ApiError, closeCall, submitAsrEvent } from '../api';
interface SandboxSimulationProps { interface SandboxSimulationProps {
rules: Rule[]; rules: Rule[];
matcher: 'ac-keyword' | 'llm';
addToast: (message: string, type: 'success' | 'warning' | 'info') => void; addToast: (message: string, type: 'success' | 'warning' | 'info') => void;
} }
@@ -34,7 +35,7 @@ function generateCallId() {
return `ASR-CALL-${rand}`; return `ASR-CALL-${rand}`;
} }
export default function SandboxSimulation({ rules, addToast }: SandboxSimulationProps) { export default function SandboxSimulation({ rules, matcher, addToast }: SandboxSimulationProps) {
// Session State // Session State
const [session, setSession] = useState<SandboxSession>({ const [session, setSession] = useState<SandboxSession>({
callId: generateCallId(), callId: generateCallId(),
@@ -186,8 +187,11 @@ export default function SandboxSimulation({ rules, addToast }: SandboxSimulation
}); });
response.newAlerts.forEach(alert => { response.newAlerts.forEach(alert => {
const matchDetail = alert.matchedKeywords.length > 0
? alert.matchedKeywords.join('、')
: `事件 ID ${alert.eventId}`;
addToast( addToast(
`触发预警:「${alert.eventName}」(${alert.matchedKeywords.join('、')}`, `触发预警:「${alert.eventName}」(${matchDetail}`,
'warning' 'warning'
); );
}); });
@@ -298,6 +302,7 @@ export default function SandboxSimulation({ rules, addToast }: SandboxSimulation
// Helper: renders text while highlighting any enabled rule's keywords // Helper: renders text while highlighting any enabled rule's keywords
const renderHighlightedText = (text: string) => { const renderHighlightedText = (text: string) => {
if (matcher !== 'ac-keyword') return text;
const activeKeywords = rules const activeKeywords = rules
.filter(r => r.enabled) .filter(r => r.enabled)
.flatMap(r => r.keywords) .flatMap(r => r.keywords)
@@ -613,7 +618,7 @@ export default function SandboxSimulation({ rules, addToast }: SandboxSimulation
<div> <div>
<h4 className="text-xs font-bold text-slate-700 uppercase tracking-widest"></h4> <h4 className="text-xs font-bold text-slate-700 uppercase tracking-widest"></h4>
<p className="text-xs text-slate-400 mt-1 max-w-xs leading-relaxed"> <p className="text-xs text-slate-400 mt-1 max-w-xs leading-relaxed">
</p> </p>
</div> </div>
</div> </div>
@@ -648,15 +653,21 @@ export default function SandboxSimulation({ rules, addToast }: SandboxSimulation
{/* Matched keyword badges */} {/* Matched keyword badges */}
<div className="mt-2.5 flex flex-wrap items-center gap-1.5 text-xs"> <div className="mt-2.5 flex flex-wrap items-center gap-1.5 text-xs">
<span className="text-[10px] text-slate-400 font-bold uppercase tracking-wider">:</span> <span className="text-[10px] text-slate-400 font-bold uppercase tracking-wider">:</span>
{alert.keywords.map(keyword => ( {alert.keywords.length > 0 ? (
<span alert.keywords.map(keyword => (
key={keyword} <span
className="rounded bg-rose-50 border border-rose-100 px-1.5 py-0.5 font-mono font-bold text-rose-700 text-[10px]" key={keyword}
> className="rounded bg-rose-50 border border-rose-100 px-1.5 py-0.5 font-mono font-bold text-rose-700 text-[10px]"
{keyword} >
{keyword}
</span>
))
) : (
<span className="rounded border border-violet-100 bg-violet-50 px-1.5 py-0.5 font-mono text-[10px] font-bold text-violet-700">
LLM · {alert.ruleId}
</span> </span>
))} )}
</div> </div>
{/* Evidence block */} {/* Evidence block */}

View File

@@ -1,13 +1,18 @@
import ExcelJS from 'exceljs'; import ExcelJS from 'exceljs';
import type { Rule } from './types'; import type { Rule } from './types';
import { generateEventId } from './rule-id'; import { generateEventId, generateModelEventId } from './rule-id';
const SHEET_NAME = '监控规则'; const SHEET_NAME = '监控规则';
const HEADER_ROW = 3; const HEADER_ROW = 3;
const FIRST_DATA_ROW = 4; const FIRST_DATA_ROW = 4;
const TEMPLATE_ROWS = 50; const TEMPLATE_ROWS = 50;
const HEADERS = [
const HEADERS = ['事件 ID系统生成可留空', '特征规则名称', '启用状态', '监控特征关键词']; 'UUID系统生成可留空',
'事件 ID系统生成可调整',
'特征规则名称',
'启用状态',
'监控特征关键词',
];
function formatWorkbook(workbook: ExcelJS.Workbook, rules: Rule[]) { function formatWorkbook(workbook: ExcelJS.Workbook, rules: Rule[]) {
workbook.creator = 'ASR 事件监控中心'; workbook.creator = 'ASR 事件监控中心';
@@ -17,7 +22,7 @@ function formatWorkbook(workbook: ExcelJS.Workbook, rules: Rule[]) {
views: [{ state: 'frozen', ySplit: HEADER_ROW, showGridLines: false }], views: [{ state: 'frozen', ySplit: HEADER_ROW, showGridLines: false }],
}); });
sheet.mergeCells('A1:D1'); sheet.mergeCells('A1:E1');
sheet.getCell('A1').value = 'ASR 事件监控规则'; sheet.getCell('A1').value = 'ASR 事件监控规则';
sheet.getCell('A1').style = { sheet.getCell('A1').style = {
fill: { type: 'pattern', pattern: 'solid', fgColor: { argb: 'FF0F172A' } }, 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.getRow(1).height = 32;
sheet.mergeCells('A2:D2'); sheet.mergeCells('A2:E2');
sheet.getCell('A2').value = sheet.getCell('A2').value =
'填写说明:每行一条规则;新增规则的事件 ID 留空,导入时由系统自动生成;导出的已有 ID 请勿修改;启用状态填写“启用”或“停用”;多个关键词请在同一单元格中换行填写。导入后仍需点击“保存并发布”。'; '填写说明:每行一条规则;新增规则的 UUID 和事件 ID 均可留空,导入时自动生成;已有规则的两个 ID 请勿修改;事件 ID 是模型输出和接口返回的短标识;多个关键词请换行填写。导入后仍需点击“保存并发布”。';
sheet.getCell('A2').style = { sheet.getCell('A2').style = {
fill: { type: 'pattern', pattern: 'solid', fgColor: { argb: 'FFFFF7ED' } }, fill: { type: 'pattern', pattern: 'solid', fgColor: { argb: 'FFFFF7ED' } },
font: { color: { argb: 'FF9A3412' }, size: 10 }, font: { color: { argb: 'FF9A3412' }, size: 10 },
@@ -50,6 +55,7 @@ function formatWorkbook(workbook: ExcelJS.Workbook, rules: Rule[]) {
sheet.columns = [ sheet.columns = [
{ key: 'id', width: 42 }, { key: 'id', width: 42 },
{ key: 'eventId', width: 22 },
{ key: 'name', width: 36 }, { key: 'name', width: 36 },
{ key: 'enabled', width: 14 }, { key: 'enabled', width: 14 },
{ key: 'keywords', width: 58 }, { key: 'keywords', width: 58 },
@@ -57,10 +63,19 @@ function formatWorkbook(workbook: ExcelJS.Workbook, rules: Rule[]) {
rules.forEach((rule, index) => { rules.forEach((rule, index) => {
const row = sheet.getRow(FIRST_DATA_ROW + 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) { for (let rowNumber = FIRST_DATA_ROW; rowNumber <= lastStyledRow; rowNumber += 1) {
const row = sheet.getRow(rowNumber); const row = sheet.getRow(rowNumber);
const rule = rules[rowNumber - FIRST_DATA_ROW]; const rule = rules[rowNumber - FIRST_DATA_ROW];
@@ -73,7 +88,7 @@ function formatWorkbook(workbook: ExcelJS.Workbook, rules: Rule[]) {
type: 'pattern', type: 'pattern',
pattern: 'solid', pattern: 'solid',
fgColor: { fgColor: {
argb: columnNumber === 1 argb: columnNumber <= 2
? 'FFF1F5F9' ? 'FFF1F5F9'
: rowNumber % 2 === 0 ? 'FFFFFFFF' : 'FFF8FAFC', : rowNumber % 2 === 0 ? 'FFFFFFFF' : 'FFF8FAFC',
}, },
@@ -81,13 +96,13 @@ function formatWorkbook(workbook: ExcelJS.Workbook, rules: Rule[]) {
font: { color: { argb: 'FF334155' }, size: 10 }, font: { color: { argb: 'FF334155' }, size: 10 },
alignment: { alignment: {
vertical: 'middle', vertical: 'middle',
horizontal: columnNumber === 3 ? 'center' : 'left', horizontal: columnNumber === 4 ? 'center' : 'left',
wrapText: columnNumber === 4, wrapText: columnNumber === 5,
}, },
border: { bottom: { style: 'thin', color: { argb: 'FFE2E8F0' } } }, border: { bottom: { style: 'thin', color: { argb: 'FFE2E8F0' } } },
}; };
}); });
row.getCell(3).dataValidation = { row.getCell(4).dataValidation = {
type: 'list', type: 'list',
allowBlank: false, allowBlank: false,
formulae: ['"启用,停用"'], 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 = { sheet.pageSetup = {
orientation: 'landscape', orientation: 'landscape',
fitToPage: true, fitToPage: true,
fitToWidth: 1, fitToWidth: 1,
fitToHeight: 0, 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; return workbook;
} }
@@ -149,7 +171,7 @@ function parseKeywords(value: string) {
value value
.split(/[\r\n,;|]+/) .split(/[\r\n,;|]+/)
.map(keyword => keyword.trim()) .map(keyword => keyword.trim())
.filter(Boolean) .filter(Boolean),
)]; )];
} }
@@ -157,8 +179,11 @@ function findHeaderRow(sheet: ExcelJS.Worksheet) {
const max = Math.min(sheet.actualRowCount, 10); const max = Math.min(sheet.actualRowCount, 10);
for (let rowNumber = 1; rowNumber <= max; rowNumber += 1) { for (let rowNumber = 1; rowNumber <= max; rowNumber += 1) {
const row = sheet.getRow(rowNumber); const row = sheet.getRow(rowNumber);
if (cellText(row, 1).startsWith('事件 ID') && cellText(row, 2) === HEADERS[1]) { if (cellText(row, 1).startsWith('UUID') && cellText(row, 2).startsWith('事件 ID')) {
return rowNumber; return { rowNumber, legacy: false };
}
if (cellText(row, 1).startsWith('事件 ID') && cellText(row, 2) === '特征规则名称') {
return { rowNumber, legacy: true };
} }
} }
throw new Error('未找到模板表头,请使用“模板下载”生成的 Excel 文件'); throw new Error('未找到模板表头,请使用“模板下载”生成的 Excel 文件');
@@ -178,42 +203,67 @@ export async function importRulesFromExcel(file: File): Promise<Rule[]> {
const sheet = workbook.getWorksheet(SHEET_NAME) ?? workbook.worksheets[0]; const sheet = workbook.getWorksheet(SHEET_NAME) ?? workbook.worksheets[0];
if (!sheet) throw new Error('Excel 文件中没有可读取的工作表'); if (!sheet) throw new Error('Excel 文件中没有可读取的工作表');
const headerRow = findHeaderRow(sheet); const { rowNumber: headerRow, legacy } = findHeaderRow(sheet);
const rules: Rule[] = []; const rules: Rule[] = [];
const ids = new Set<string>(); const ids = new Set<string>();
const eventIds = new Set<string>();
const reservedEventIds = new Set<string>();
const keywordOwners = new Map<string, string>(); const keywordOwners = new Map<string, string>();
const errors: string[] = []; 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) { for (let rowNumber = headerRow + 1; rowNumber <= sheet.actualRowCount; rowNumber += 1) {
const row = sheet.getRow(rowNumber); const row = sheet.getRow(rowNumber);
const inputId = cellText(row, 1); const inputId = cellText(row, 1);
const name = cellText(row, 2); const inputEventId = legacy ? '' : cellText(row, 2);
const enabledText = cellText(row, 3); const name = cellText(row, legacy ? 2 : 3);
const keywordText = cellText(row, 4); const enabledText = cellText(row, legacy ? 3 : 4);
if (![inputId, name, enabledText, keywordText].some(Boolean)) continue; const keywordText = cellText(row, legacy ? 4 : 5);
if (![inputId, inputEventId, name, enabledText, keywordText].some(Boolean)) continue;
try { try {
const id = inputId || generateEventId(ids); const id = inputId || generateEventId(ids);
if (!/^[a-zA-Z0-9_-]+$/.test(id)) { 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} 行缺少特征规则名称`); if (!name) throw new Error(`${rowNumber} 行缺少特征规则名称`);
const enabled = parseEnabled(enabledText, rowNumber); const enabled = parseEnabled(enabledText, rowNumber);
const keywords = parseKeywords(keywordText); 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) { for (const keyword of keywords) {
const normalized = keyword.toLocaleLowerCase(); const normalized = keyword.toLocaleLowerCase();
const owner = keywordOwners.get(normalized); const owner = keywordOwners.get(normalized);
if (owner && owner !== id) { if (owner && owner !== eventId) {
throw new Error(`${rowNumber} 行关键词“${keyword}”已属于事件 ${owner}`); throw new Error(`${rowNumber} 行关键词“${keyword}”已属于事件 ${owner}`);
} }
} }
ids.add(id); ids.add(id);
keywords.forEach(keyword => keywordOwners.set(keyword.toLocaleLowerCase(), id)); eventIds.add(eventId);
rules.push({ id, name, enabled, keywords }); keywords.forEach(keyword => keywordOwners.set(keyword.toLocaleLowerCase(), eventId));
rules.push({ id, eventId, name, enabled, keywords });
} catch (error) { } catch (error) {
errors.push(error instanceof Error ? error.message : `${rowNumber} 行格式错误`); errors.push(error instanceof Error ? error.message : `${rowNumber} 行格式错误`);
if (errors.length >= 8) break; if (errors.length >= 8) break;

View File

@@ -8,3 +8,12 @@ export function generateEventId(existingIds: Iterable<string> = []): string {
} }
throw new Error('无法生成唯一事件 ID请重试'); throw new Error('无法生成唯一事件 ID请重试');
} }
export function generateModelEventId(existingIds: Iterable<string> = []): 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请重试');
}

View File

@@ -5,11 +5,20 @@
export interface Rule { export interface Rule {
id: string; id: string;
eventId: string;
name: string; name: string;
keywords: string[]; keywords: string[];
enabled: boolean; enabled: boolean;
} }
export interface LlmConfig {
baseUrl: string;
model: string;
timeoutMs: number;
maxOutputTokens: number;
prompt: string;
}
export interface RuleSet { export interface RuleSet {
id: string; id: string;
name: string; name: string;
@@ -22,6 +31,7 @@ export interface CurrentRulesResponse {
version: number; version: number;
ruleCount: number; ruleCount: number;
keywordCount: number; keywordCount: number;
llmConfig: LlmConfig;
ruleSets: RuleSet[]; ruleSets: RuleSet[];
} }

16
pom.xml
View File

@@ -28,7 +28,19 @@
</scm> </scm>
<properties> <properties>
<java.version>21</java.version> <java.version>21</java.version>
<spring-ai.version>2.0.0</spring-ai.version>
</properties> </properties>
<dependencyManagement>
<dependencies>
<dependency>
<groupId>org.springframework.ai</groupId>
<artifactId>spring-ai-bom</artifactId>
<version>${spring-ai.version}</version>
<type>pom</type>
<scope>import</scope>
</dependency>
</dependencies>
</dependencyManagement>
<dependencies> <dependencies>
<dependency> <dependency>
<groupId>org.springframework.boot</groupId> <groupId>org.springframework.boot</groupId>
@@ -51,6 +63,10 @@
<artifactId>ahocorasick</artifactId> <artifactId>ahocorasick</artifactId>
<version>0.6.3</version> <version>0.6.3</version>
</dependency> </dependency>
<dependency>
<groupId>org.springframework.ai</groupId>
<artifactId>spring-ai-starter-model-openai</artifactId>
</dependency>
<dependency> <dependency>
<groupId>com.h2database</groupId> <groupId>com.h2database</groupId>
<artifactId>h2</artifactId> <artifactId>h2</artifactId>

View File

@@ -1,13 +1,14 @@
package com.example.demo; package com.example.demo;
import com.example.demo.config.MonitorProperties; import com.example.demo.config.MonitorProperties;
import com.example.demo.config.LlmProviderProperties;
import org.springframework.boot.SpringApplication; import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication; import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.boot.context.properties.EnableConfigurationProperties; import org.springframework.boot.context.properties.EnableConfigurationProperties;
import org.springframework.scheduling.annotation.EnableScheduling; import org.springframework.scheduling.annotation.EnableScheduling;
@SpringBootApplication @SpringBootApplication
@EnableConfigurationProperties(MonitorProperties.class) @EnableConfigurationProperties({MonitorProperties.class, LlmProviderProperties.class})
@EnableScheduling @EnableScheduling
public class DemoApplication { public class DemoApplication {

View File

@@ -6,8 +6,6 @@ import com.example.demo.domain.SaveRuleRequest;
import com.example.demo.domain.SaveRuleResponse; import com.example.demo.domain.SaveRuleResponse;
import com.example.demo.domain.RestoreRuleVersionRequest; import com.example.demo.domain.RestoreRuleVersionRequest;
import com.example.demo.domain.RuleVersionSummaryResponse; import com.example.demo.domain.RuleVersionSummaryResponse;
import com.example.demo.domain.TestRuleRequest;
import com.example.demo.domain.TestRuleResponse;
import jakarta.validation.Valid; import jakarta.validation.Valid;
import org.springframework.http.ResponseEntity; import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.GetMapping;
@@ -55,8 +53,4 @@ public class RuleAdminController {
return ResponseEntity.ok(ruleManagementService.restoreVersion(version, request)); return ResponseEntity.ok(ruleManagementService.restoreVersion(version, request));
} }
@PostMapping("/test")
public ResponseEntity<TestRuleResponse> test(@Valid @RequestBody TestRuleRequest request) {
return ResponseEntity.ok(ruleManagementService.test(request));
}
} }

View File

@@ -4,15 +4,18 @@ import com.example.demo.config.MonitorProperties;
import com.example.demo.domain.AlertResult; import com.example.demo.domain.AlertResult;
import com.example.demo.domain.AsrFinalEventRequest; import com.example.demo.domain.AsrFinalEventRequest;
import com.example.demo.domain.CallSession; import com.example.demo.domain.CallSession;
import com.example.demo.domain.ConversationTurn;
import com.example.demo.domain.EventState; import com.example.demo.domain.EventState;
import com.example.demo.domain.MatchResult; import com.example.demo.domain.MatchResult;
import com.example.demo.domain.MonitorResponse; import com.example.demo.domain.MonitorResponse;
import com.example.demo.matcher.EventMatcherRouter;
import com.example.demo.matcher.AcKeywordMatcher; import com.example.demo.matcher.AcKeywordMatcher;
import com.example.demo.repository.SessionStore; import com.example.demo.repository.SessionStore;
import java.time.Clock; import java.time.Clock;
import java.util.ArrayList; import java.util.ArrayList;
import java.util.List; import java.util.List;
import java.util.concurrent.locks.ReentrantLock; import java.util.concurrent.locks.ReentrantLock;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service; import org.springframework.stereotype.Service;
/** /**
@@ -22,8 +25,8 @@ import org.springframework.stereotype.Service;
* <ol> * <ol>
* <li>拒绝 Partial{@code final=false}),不改写会话匹配状态</li> * <li>拒绝 Partial{@code final=false}),不改写会话匹配状态</li>
* <li>按 {@code callId + seq} 做幂等</li> * <li>按 {@code callId + seq} 做幂等</li>
* <li>市民citizen和坐席agent文本均参与关键词匹配</li> * <li>市民citizen和坐席agent共享同一个、按 seq 排序的上下文</li>
* <li>每通电话维护一个最近若干条 Final 窗口,送入 AC 自动机匹配</li> * <li>按当前规则版本选择 AC 或 LLM 匹配</li>
* <li>与本通话已提醒事件做差,得到 {@code newAlerts}</li> * <li>与本通话已提醒事件做差,得到 {@code newAlerts}</li>
* <li>同时返回 {@code newAlerts} 与累计 {@code currentResults}</li> * <li>同时返回 {@code newAlerts} 与累计 {@code currentResults}</li>
* </ol> * </ol>
@@ -32,20 +35,30 @@ import org.springframework.stereotype.Service;
public class AsrEventMonitorService { public class AsrEventMonitorService {
private final SessionStore sessionStore; private final SessionStore sessionStore;
private final AcKeywordMatcher keywordMatcher; private final EventMatcherRouter matcherRouter;
private final MonitorProperties properties; private final MonitorProperties properties;
private final Clock clock; 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( public AsrEventMonitorService(
SessionStore sessionStore, SessionStore sessionStore,
AcKeywordMatcher keywordMatcher, AcKeywordMatcher keywordMatcher,
MonitorProperties properties, MonitorProperties properties,
Clock clock Clock clock
) { ) {
this.sessionStore = sessionStore; this(sessionStore, new EventMatcherRouter(keywordMatcher), properties, clock);
this.keywordMatcher = keywordMatcher;
this.properties = properties;
this.clock = clock;
} }
/** /**
@@ -65,7 +78,7 @@ public class AsrEventMonitorService {
CallSession session = sessionStore.getOrCreate(request.callId()); CallSession session = sessionStore.getOrCreate(request.callId());
session.touch(clock); session.touch(clock);
long activeRuleVersion = keywordMatcher.activeVersion(); long activeRuleVersion = matcherRouter.activeVersion();
// Partial仍返回当前累计结果但不写入幂等集 / 匹配上下文 // Partial仍返回当前累计结果但不写入幂等集 / 匹配上下文
if (!request.isFinal()) { if (!request.isFinal()) {
@@ -90,14 +103,30 @@ public class AsrEventMonitorService {
} }
// 同一通话的市民和坐席共享最近 N 条 Final 上下文 // 同一通话的市民和坐席共享最近 N 条 Final 上下文
session.appendFinal( ConversationTurn evictedTurn = session.appendFinal(
request.seq(), request.seq(),
request.speaker(),
request.text(), request.text(),
properties.recentFinalWindowSize() properties.maxConversationTurns()
); );
String matchText = session.buildMatchText(); String matchText = session.buildMatchText(properties.recentFinalWindowSize());
List<MatchResult> matches = keywordMatcher.match(matchText); List<MatchResult> 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重复命中不再提醒 // 本通话首次出现的事件进入 newAlerts重复命中不再提醒
List<MatchResult> newMatches = matches.stream() List<MatchResult> newMatches = matches.stream()

View File

@@ -1,9 +1,11 @@
package com.example.demo.application; package com.example.demo.application;
import com.example.demo.domain.RuleDocument;
import com.example.demo.matcher.AcAutomatonSnapshot; import com.example.demo.matcher.AcAutomatonSnapshot;
public record RuleActivatedEvent( public record RuleActivatedEvent(
long version, long version,
AcAutomatonSnapshot snapshot AcAutomatonSnapshot snapshot,
RuleDocument document
) { ) {
} }

View File

@@ -1,6 +1,6 @@
package com.example.demo.application; 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.stereotype.Component;
import org.springframework.transaction.event.TransactionPhase; import org.springframework.transaction.event.TransactionPhase;
import org.springframework.transaction.event.TransactionalEventListener; import org.springframework.transaction.event.TransactionalEventListener;
@@ -8,16 +8,14 @@ import org.springframework.transaction.event.TransactionalEventListener;
@Component @Component
public class RuleActivationListener { public class RuleActivationListener {
private final AcKeywordMatcher keywordMatcher; private final EventMatcherRouter matcherRouter;
public RuleActivationListener(AcKeywordMatcher keywordMatcher) { public RuleActivationListener(EventMatcherRouter matcherRouter) {
this.keywordMatcher = keywordMatcher; this.matcherRouter = matcherRouter;
} }
@TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT) @TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT)
public void activate(RuleActivatedEvent event) { public void activate(RuleActivatedEvent event) {
keywordMatcher.replaceSnapshot( matcherRouter.activate(event.document(), event.version(), event.snapshot());
event.snapshot().withVersion(event.version())
);
} }
} }

View File

@@ -1,23 +1,21 @@
package com.example.demo.application; package com.example.demo.application;
import com.example.demo.domain.CurrentRulesResponse; 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.RuleDocument;
import com.example.demo.domain.RuleSet;
import com.example.demo.domain.RestoreRuleVersionRequest; import com.example.demo.domain.RestoreRuleVersionRequest;
import com.example.demo.domain.RuleVersionSummaryResponse; import com.example.demo.domain.RuleVersionSummaryResponse;
import com.example.demo.domain.SaveRuleRequest; import com.example.demo.domain.SaveRuleRequest;
import com.example.demo.domain.SaveRuleResponse; 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.AcAutomatonFactory;
import com.example.demo.matcher.AcAutomatonSnapshot; 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.RuleVersionEntity;
import com.example.demo.repository.RuleVersionRepository; import com.example.demo.repository.RuleVersionRepository;
import com.example.demo.support.RuleValidationException; import com.example.demo.support.RuleValidationException;
import com.example.demo.support.RuleVersionConflictException; import com.example.demo.support.RuleVersionConflictException;
import com.example.demo.support.RuleVersionNotFoundException; import com.example.demo.support.RuleVersionNotFoundException;
import com.example.demo.support.TextNormalizer;
import java.io.IOException; import java.io.IOException;
import java.io.InputStream; import java.io.InputStream;
import java.time.Clock; import java.time.Clock;
@@ -44,7 +42,7 @@ public class RuleManagementService implements ApplicationRunner {
private final RuleVersionRepository repository; private final RuleVersionRepository repository;
private final RuleValidator ruleValidator; private final RuleValidator ruleValidator;
private final AcAutomatonFactory automatonFactory; private final AcAutomatonFactory automatonFactory;
private final AcKeywordMatcher keywordMatcher; private final EventMatcherRouter matcherRouter;
private final ObjectMapper objectMapper; private final ObjectMapper objectMapper;
private final ApplicationEventPublisher eventPublisher; private final ApplicationEventPublisher eventPublisher;
private final Clock clock; private final Clock clock;
@@ -53,7 +51,7 @@ public class RuleManagementService implements ApplicationRunner {
RuleVersionRepository repository, RuleVersionRepository repository,
RuleValidator ruleValidator, RuleValidator ruleValidator,
AcAutomatonFactory automatonFactory, AcAutomatonFactory automatonFactory,
AcKeywordMatcher keywordMatcher, EventMatcherRouter matcherRouter,
ObjectMapper objectMapper, ObjectMapper objectMapper,
ApplicationEventPublisher eventPublisher, ApplicationEventPublisher eventPublisher,
Clock clock Clock clock
@@ -61,7 +59,7 @@ public class RuleManagementService implements ApplicationRunner {
this.repository = repository; this.repository = repository;
this.ruleValidator = ruleValidator; this.ruleValidator = ruleValidator;
this.automatonFactory = automatonFactory; this.automatonFactory = automatonFactory;
this.keywordMatcher = keywordMatcher; this.matcherRouter = matcherRouter;
this.objectMapper = objectMapper; this.objectMapper = objectMapper;
this.eventPublisher = eventPublisher; this.eventPublisher = eventPublisher;
this.clock = clock; this.clock = clock;
@@ -76,12 +74,19 @@ public class RuleManagementService implements ApplicationRunner {
public CurrentRulesResponse getCurrentRules() { public CurrentRulesResponse getCurrentRules() {
return repository.findFirstByActiveTrueOrderByVersionNoDesc() return repository.findFirstByActiveTrueOrderByVersionNoDesc()
.map(entity -> new CurrentRulesResponse( .map(entity -> {
entity.getVersionNo(), RuleDocument document = normalizeDocument(
entity.getRuleCount(), readDocument(entity.getContentJson()),
entity.getKeywordCount(), entity.getVersionNo()
readDocument(entity.getContentJson()).ruleSets() );
)) return new CurrentRulesResponse(
entity.getVersionNo(),
entity.getRuleCount(),
entity.getKeywordCount(),
document.llmConfig(),
document.ruleSets()
);
})
.orElseGet(() -> new CurrentRulesResponse(0L, 0, 0, List.of())); .orElseGet(() -> new CurrentRulesResponse(0L, 0, 0, List.of()));
} }
@@ -108,6 +113,7 @@ public class RuleManagementService implements ApplicationRunner {
RuleValidator.ValidationResult validation = ruleValidator.validateAndNormalize(request); RuleValidator.ValidationResult validation = ruleValidator.validateAndNormalize(request);
RuleDocument document = validation.document(); RuleDocument document = validation.document();
AcAutomatonSnapshot snapshot = automatonFactory.build(document); AcAutomatonSnapshot snapshot = automatonFactory.build(document);
RuleStats stats = countRules(document);
repository.deactivateCurrent(); repository.deactivateCurrent();
@@ -116,19 +122,24 @@ public class RuleManagementService implements ApplicationRunner {
RuleVersionEntity.active( RuleVersionEntity.active(
nextVersion, nextVersion,
writeDocument(document), writeDocument(document),
snapshot.ruleCount(), stats.ruleCount(),
snapshot.keywordCount(), stats.keywordCount(),
Instant.now(clock) Instant.now(clock)
) )
); );
eventPublisher.publishEvent(new RuleActivatedEvent(entity.getVersionNo(), snapshot)); eventPublisher.publishEvent(new RuleActivatedEvent(
entity.getVersionNo(),
snapshot,
document
));
return new SaveRuleResponse( return new SaveRuleResponse(
entity.getVersionNo(), entity.getVersionNo(),
entity.getRuleCount(), entity.getRuleCount(),
entity.getKeywordCount(), entity.getKeywordCount(),
validation.warnings(), validation.warnings(),
document.llmConfig(),
document.ruleSets() document.ruleSets()
); );
} }
@@ -147,10 +158,15 @@ public class RuleManagementService implements ApplicationRunner {
.orElseThrow(() -> new RuleVersionNotFoundException(sourceVersion)); .orElseThrow(() -> new RuleVersionNotFoundException(sourceVersion));
RuleDocument sourceDocument = readDocument(source.getContentJson()); RuleDocument sourceDocument = readDocument(source.getContentJson());
RuleValidator.ValidationResult validation = ruleValidator.validateAndNormalize( RuleValidator.ValidationResult validation = ruleValidator.validateAndNormalize(
new SaveRuleRequest(currentVersion, sourceDocument.ruleSets()) new SaveRuleRequest(
currentVersion,
sourceDocument.llmConfig(),
sourceDocument.ruleSets()
)
); );
RuleDocument document = validation.document(); RuleDocument document = validation.document();
AcAutomatonSnapshot snapshot = automatonFactory.build(document); AcAutomatonSnapshot snapshot = automatonFactory.build(document);
RuleStats stats = countRules(document);
repository.deactivateCurrent(); repository.deactivateCurrent();
long nextVersion = nextVersionNo(); long nextVersion = nextVersionNo();
@@ -158,37 +174,34 @@ public class RuleManagementService implements ApplicationRunner {
RuleVersionEntity.active( RuleVersionEntity.active(
nextVersion, nextVersion,
writeDocument(document), writeDocument(document),
snapshot.ruleCount(), stats.ruleCount(),
snapshot.keywordCount(), stats.keywordCount(),
Instant.now(clock) Instant.now(clock)
) )
); );
eventPublisher.publishEvent(new RuleActivatedEvent(entity.getVersionNo(), snapshot)); eventPublisher.publishEvent(new RuleActivatedEvent(
entity.getVersionNo(),
snapshot,
document
));
return new SaveRuleResponse( return new SaveRuleResponse(
entity.getVersionNo(), entity.getVersionNo(),
entity.getRuleCount(), entity.getRuleCount(),
entity.getKeywordCount(), entity.getKeywordCount(),
validation.warnings(), validation.warnings(),
document.llmConfig(),
document.ruleSets() document.ruleSets()
); );
} }
public TestRuleResponse test(TestRuleRequest request) {
List<MatchResult> matches = keywordMatcher.match(request.text());
return new TestRuleResponse(
keywordMatcher.activeVersion(),
TextNormalizer.normalize(request.text()),
matches
);
}
private void loadActiveEntity(RuleVersionEntity entity) { private void loadActiveEntity(RuleVersionEntity entity) {
RuleDocument document = readDocument(entity.getContentJson()); RuleDocument document = normalizeDocument(
// Re-validate for safety; invalid persisted JSON should not boot with a broken matcher readDocument(entity.getContentJson()),
ruleValidator.validateAndNormalize(new SaveRuleRequest(entity.getVersionNo(), document.ruleSets())); entity.getVersionNo()
);
AcAutomatonSnapshot snapshot = automatonFactory.build(document, entity.getVersionNo()); AcAutomatonSnapshot snapshot = automatonFactory.build(document, entity.getVersionNo());
keywordMatcher.replaceSnapshot(snapshot); matcherRouter.activate(document, entity.getVersionNo(), snapshot);
log.info( log.info(
"Loaded active rule version V{} (rules={}, keywords={})", "Loaded active rule version V{} (rules={}, keywords={})",
entity.getVersionNo(), entity.getVersionNo(),
@@ -200,20 +213,21 @@ public class RuleManagementService implements ApplicationRunner {
private void seedDefaultRules() { private void seedDefaultRules() {
RuleDocument document = readDefaultRules(); RuleDocument document = readDefaultRules();
RuleValidator.ValidationResult validation = ruleValidator.validateAndNormalize( RuleValidator.ValidationResult validation = ruleValidator.validateAndNormalize(
new SaveRuleRequest(0L, document.ruleSets()) new SaveRuleRequest(0L, document.llmConfig(), document.ruleSets())
); );
AcAutomatonSnapshot snapshot = automatonFactory.build(validation.document()); AcAutomatonSnapshot snapshot = automatonFactory.build(validation.document());
RuleStats stats = countRules(validation.document());
RuleVersionEntity entity = repository.save( RuleVersionEntity entity = repository.save(
RuleVersionEntity.active( RuleVersionEntity.active(
1L, 1L,
writeDocument(validation.document()), writeDocument(validation.document()),
snapshot.ruleCount(), stats.ruleCount(),
snapshot.keywordCount(), stats.keywordCount(),
Instant.now(clock) 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={})", log.info("Seeded default rule version V1 (rules={}, keywords={})",
entity.getRuleCount(), entity.getKeywordCount()); entity.getRuleCount(), entity.getKeywordCount());
} }
@@ -253,4 +267,35 @@ public class RuleManagementService implements ApplicationRunner {
throw new IllegalStateException("无法读取内置初始规则: " + DEFAULT_RULES_PATH, e); 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) {
}
} }

View File

@@ -1,11 +1,14 @@
package com.example.demo.application; package com.example.demo.application;
import com.example.demo.domain.EventRule; import com.example.demo.domain.EventRule;
import com.example.demo.domain.LlmConfig;
import com.example.demo.domain.RuleDocument; import com.example.demo.domain.RuleDocument;
import com.example.demo.domain.RuleSet; import com.example.demo.domain.RuleSet;
import com.example.demo.domain.SaveRuleRequest; import com.example.demo.domain.SaveRuleRequest;
import com.example.demo.support.RuleValidationException; import com.example.demo.support.RuleValidationException;
import com.example.demo.support.TextNormalizer; import com.example.demo.support.TextNormalizer;
import java.net.URI;
import java.net.URISyntaxException;
import java.util.ArrayList; import java.util.ArrayList;
import java.util.HashMap; import java.util.HashMap;
import java.util.HashSet; import java.util.HashSet;
@@ -29,10 +32,13 @@ public class RuleValidator {
} }
Set<String> ruleSetIds = new HashSet<>(); Set<String> ruleSetIds = new HashSet<>();
Set<String> eventIds = new HashSet<>(); Set<String> ruleUuids = new HashSet<>();
Set<String> explicitEventIds = collectExplicitEventIds(request.ruleSets());
Set<String> assignedEventIds = new HashSet<>();
Map<String, String> normalizedKeywordOwners = new HashMap<>(); Map<String, String> normalizedKeywordOwners = new HashMap<>();
List<RuleSet> normalizedSets = new ArrayList<>(); List<RuleSet> normalizedSets = new ArrayList<>();
List<String> warnings = new ArrayList<>(); List<String> warnings = new ArrayList<>();
boolean llmEnabled = false;
for (RuleSet ruleSet : request.ruleSets()) { for (RuleSet ruleSet : request.ruleSets()) {
if (ruleSet == null) { if (ruleSet == null) {
@@ -50,23 +56,37 @@ public class RuleValidator {
if (isBlank(ruleSet.matcher())) { if (isBlank(ruleSet.matcher())) {
throw new RuleValidationException("规则集 matcher 不能为空: " + ruleSet.id()); 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( throw new RuleValidationException(
"不支持的 matcher 类型: " + ruleSet.matcher() + "(规则集 " + ruleSet.id() + "" "不支持的 matcher 类型: " + ruleSet.matcher() + "(规则集 " + ruleSet.id() + ""
); );
} }
llmEnabled = llmEnabled || ruleSet.enabled() && RuleSet.MATCHER_LLM.equals(matcher);
List<EventRule> normalizedRules = new ArrayList<>(); List<EventRule> normalizedRules = new ArrayList<>();
for (EventRule rule : ruleSet.rules()) { for (EventRule rule : ruleSet.rules()) {
if (rule == null) { if (rule == null) {
throw new RuleValidationException("事件规则不能为空"); throw new RuleValidationException("事件规则不能为空");
} }
String ruleId = isBlank(rule.id()) ? generateEventId(eventIds) : rule.id().trim(); String ruleUuid = isBlank(rule.id()) ? generateRuleUuid(ruleUuids) : rule.id().trim();
if (!eventIds.add(ruleId)) { if (!ruleUuids.add(ruleUuid)) {
throw new RuleValidationException("事件 ID 重复: " + ruleId); 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())) { if (isBlank(rule.name())) {
throw new RuleValidationException("事件名称不能为空: " + ruleId); throw new RuleValidationException("事件名称不能为空: " + eventId);
} }
List<String> cleanedKeywords = new ArrayList<>(); List<String> cleanedKeywords = new ArrayList<>();
@@ -80,20 +100,20 @@ public class RuleValidator {
String normalized = TextNormalizer.normalize(display); String normalized = TextNormalizer.normalize(display);
if (normalized.isEmpty()) { if (normalized.isEmpty()) {
throw new RuleValidationException( throw new RuleValidationException(
"标准化后关键词为空: " + display + "(事件 " + ruleId + "" "标准化后关键词为空: " + display + "(事件 " + eventId + ""
); );
} }
if (!normalizedInRule.add(normalized)) { if (!normalizedInRule.add(normalized)) {
throw new RuleValidationException( throw new RuleValidationException(
"同一事件内关键词重复: " + display + "(事件 " + ruleId + "" "同一事件内关键词重复: " + display + "(事件 " + eventId + ""
); );
} }
String previousOwner = normalizedKeywordOwners.putIfAbsent(normalized, ruleId); String previousOwner = normalizedKeywordOwners.putIfAbsent(normalized, ruleUuid);
if (previousOwner != null && !previousOwner.equals(ruleId)) { if (previousOwner != null && !previousOwner.equals(ruleUuid)) {
throw new RuleValidationException( throw new RuleValidationException(
"同一关键词不能指向多个转接方向: \"" + display "同一关键词不能指向多个转接方向: \"" + display
+ "\" 同时属于 " + previousOwner + "" + ruleId + "\" 同时属于 " + previousOwner + "" + ruleUuid
); );
} }
@@ -107,12 +127,13 @@ public class RuleValidator {
if (rule.enabled() && cleanedKeywords.isEmpty()) { if (rule.enabled() && cleanedKeywords.isEmpty()) {
throw new RuleValidationException( throw new RuleValidationException(
"启用事件至少需要一个关键词: " + ruleId "启用事件至少需要一个关键词: " + eventId
); );
} }
normalizedRules.add(new EventRule( normalizedRules.add(new EventRule(
ruleId, ruleUuid,
eventId,
rule.name().trim(), rule.name().trim(),
rule.enabled(), rule.enabled(),
cleanedKeywords cleanedKeywords
@@ -123,19 +144,39 @@ public class RuleValidator {
ruleSet.id().trim(), ruleSet.id().trim(),
ruleSet.name().trim(), ruleSet.name().trim(),
ruleSet.enabled(), ruleSet.enabled(),
ruleSet.matcher().trim().toLowerCase(Locale.ROOT), matcher,
normalizedRules 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) { private static boolean isBlank(String value) {
return value == null || value.isBlank(); return value == null || value.isBlank();
} }
private static String generateEventId(Set<String> existingIds) { private static Set<String> collectExplicitEventIds(List<RuleSet> ruleSets) {
Set<String> 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<String> existingIds) {
String id; String id;
do { do {
id = "event-" + UUID.randomUUID(); id = "event-" + UUID.randomUUID();
@@ -143,6 +184,70 @@ public class RuleValidator {
return id; return id;
} }
private static String generateModelEventId(
Set<String> explicitEventIds,
Set<String> 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("大模型调用超时需在 30030000ms 之间");
}
if (maxOutputTokens < 8 || maxOutputTokens > 256) {
throw new RuleValidationException("大模型最大输出 Token 需在 8256 之间");
}
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<String> warnings) { public record ValidationResult(RuleDocument document, List<String> warnings) {
public ValidationResult { public ValidationResult {
warnings = warnings == null ? List.of() : List.copyOf(warnings); warnings = warnings == null ? List.of() : List.copyOf(warnings);

View File

@@ -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();
}
}

View File

@@ -6,14 +6,36 @@ import org.springframework.boot.context.properties.ConfigurationProperties;
@ConfigurationProperties(prefix = "monitor") @ConfigurationProperties(prefix = "monitor")
public record MonitorProperties( public record MonitorProperties(
int recentFinalWindowSize, int recentFinalWindowSize,
int maxConversationTurns,
int maxProcessedSeqs, int maxProcessedSeqs,
Duration sessionTtl, Duration sessionTtl,
Duration sessionCleanupInterval Duration sessionCleanupInterval
) { ) {
public MonitorProperties(
int recentFinalWindowSize,
int maxProcessedSeqs,
Duration sessionTtl,
Duration sessionCleanupInterval
) {
this(
recentFinalWindowSize,
200,
maxProcessedSeqs,
sessionTtl,
sessionCleanupInterval
);
}
public MonitorProperties { public MonitorProperties {
if (recentFinalWindowSize <= 0) { if (recentFinalWindowSize <= 0) {
recentFinalWindowSize = 5; recentFinalWindowSize = 5;
} }
if (maxConversationTurns <= 0) {
maxConversationTurns = 200;
}
if (maxConversationTurns < recentFinalWindowSize) {
maxConversationTurns = recentFinalWindowSize;
}
if (maxProcessedSeqs <= 0) { if (maxProcessedSeqs <= 0) {
maxProcessedSeqs = 500; maxProcessedSeqs = 500;
} }

View File

@@ -22,7 +22,7 @@ public class CallSession {
private final String callId; private final String callId;
private final Set<Long> processedSeqs = new LinkedHashSet<>(); private final Set<Long> processedSeqs = new LinkedHashSet<>();
private final NavigableMap<Long, String> recentFinals = new TreeMap<>(); private final NavigableMap<Long, ConversationTurn> recentFinals = new TreeMap<>();
private final Set<String> alertedEventKeys = new HashSet<>(); private final Set<String> alertedEventKeys = new HashSet<>();
private final Map<String, EventState> eventStates = new LinkedHashMap<>(); private final Map<String, EventState> eventStates = new LinkedHashMap<>();
@@ -70,9 +70,33 @@ public class CallSession {
return true; return true;
} }
public void appendFinal(long seq, String text, int windowSize) { public void unmarkProcessed(long seq) {
recentFinals.put(seq, text); 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) { while (recentFinals.size() > windowSize) {
recentFinals.pollFirstEntry(); recentFinals.pollFirstEntry();
} }
@@ -83,10 +107,21 @@ public class CallSession {
* can be glued after normalization across citizen and agent turns. * can be glued after normalization across citizen and agent turns.
*/ */
public String buildMatchText() { 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() return recentFinals.values().stream()
.skip(skip)
.map(ConversationTurn::text)
.collect(Collectors.joining("")); .collect(Collectors.joining(""));
} }
public List<ConversationTurn> recentFinalsView() {
return List.copyOf(recentFinals.values());
}
public boolean hasAlerted(String eventKey) { public boolean hasAlerted(String eventKey) {
return alertedEventKeys.contains(eventKey); return alertedEventKeys.contains(eventKey);
} }

View File

@@ -0,0 +1,8 @@
package com.example.demo.domain;
public record ConversationTurn(
long seq,
String speaker,
String text
) {
}

View File

@@ -6,9 +6,15 @@ public record CurrentRulesResponse(
long version, long version,
int ruleCount, int ruleCount,
int keywordCount, int keywordCount,
LlmConfig llmConfig,
List<RuleSet> ruleSets List<RuleSet> ruleSets
) { ) {
public CurrentRulesResponse(long version, int ruleCount, int keywordCount, List<RuleSet> ruleSets) {
this(version, ruleCount, keywordCount, LlmConfig.defaults(), ruleSets);
}
public CurrentRulesResponse { public CurrentRulesResponse {
llmConfig = llmConfig == null ? LlmConfig.defaults() : llmConfig;
ruleSets = ruleSets == null ? List.of() : List.copyOf(ruleSets); ruleSets = ruleSets == null ? List.of() : List.copyOf(ruleSets);
} }
} }

View File

@@ -4,10 +4,15 @@ import java.util.List;
public record EventRule( public record EventRule(
String id, String id,
String eventId,
String name, String name,
boolean enabled, boolean enabled,
List<String> keywords List<String> keywords
) { ) {
public EventRule(String id, String name, boolean enabled, List<String> keywords) {
this(id, null, name, enabled, keywords);
}
public EventRule { public EventRule {
keywords = keywords == null ? List.of() : List.copyOf(keywords); keywords = keywords == null ? List.of() : List.copyOf(keywords);
} }

View File

@@ -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
);
}
}

View File

@@ -3,13 +3,19 @@ package com.example.demo.domain;
import java.util.List; import java.util.List;
public record RuleDocument( public record RuleDocument(
LlmConfig llmConfig,
List<RuleSet> ruleSets List<RuleSet> ruleSets
) { ) {
public RuleDocument(List<RuleSet> ruleSets) {
this(LlmConfig.defaults(), ruleSets);
}
public RuleDocument { public RuleDocument {
llmConfig = llmConfig == null ? LlmConfig.defaults() : llmConfig;
ruleSets = ruleSets == null ? List.of() : List.copyOf(ruleSets); ruleSets = ruleSets == null ? List.of() : List.copyOf(ruleSets);
} }
public static RuleDocument empty() { public static RuleDocument empty() {
return new RuleDocument(List.of()); return new RuleDocument(LlmConfig.defaults(), List.of());
} }
} }

View File

@@ -10,6 +10,7 @@ public record RuleSet(
List<EventRule> rules List<EventRule> rules
) { ) {
public static final String MATCHER_AC_KEYWORD = "ac-keyword"; public static final String MATCHER_AC_KEYWORD = "ac-keyword";
public static final String MATCHER_LLM = "llm";
public RuleSet { public RuleSet {
rules = rules == null ? List.of() : List.copyOf(rules); rules = rules == null ? List.of() : List.copyOf(rules);

View File

@@ -8,10 +8,17 @@ public record SaveRuleRequest(
@PositiveOrZero @PositiveOrZero
long baseVersion, long baseVersion,
LlmConfig llmConfig,
@NotNull @NotNull
List<RuleSet> ruleSets List<RuleSet> ruleSets
) { ) {
public SaveRuleRequest(long baseVersion, List<RuleSet> ruleSets) {
this(baseVersion, LlmConfig.defaults(), ruleSets);
}
public SaveRuleRequest { public SaveRuleRequest {
llmConfig = llmConfig == null ? LlmConfig.defaults() : llmConfig;
ruleSets = ruleSets == null ? List.of() : List.copyOf(ruleSets); ruleSets = ruleSets == null ? List.of() : List.copyOf(ruleSets);
} }
} }

View File

@@ -7,10 +7,22 @@ public record SaveRuleResponse(
int ruleCount, int ruleCount,
int keywordCount, int keywordCount,
List<String> warnings, List<String> warnings,
LlmConfig llmConfig,
List<RuleSet> ruleSets List<RuleSet> ruleSets
) { ) {
public SaveRuleResponse(
long version,
int ruleCount,
int keywordCount,
List<String> warnings,
List<RuleSet> ruleSets
) {
this(version, ruleCount, keywordCount, warnings, LlmConfig.defaults(), ruleSets);
}
public SaveRuleResponse { public SaveRuleResponse {
warnings = warnings == null ? List.of() : List.copyOf(warnings); warnings = warnings == null ? List.of() : List.copyOf(warnings);
llmConfig = llmConfig == null ? LlmConfig.defaults() : llmConfig;
ruleSets = ruleSets == null ? List.of() : List.copyOf(ruleSets); ruleSets = ruleSets == null ? List.of() : List.copyOf(ruleSets);
} }
} }

View File

@@ -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
) {
}

View File

@@ -1,13 +0,0 @@
package com.example.demo.domain;
import java.util.List;
public record TestRuleResponse(
long activeVersion,
String normalizedText,
List<MatchResult> matches
) {
public TestRuleResponse {
matches = matches == null ? List.of() : List.copyOf(matches);
}
}

View File

@@ -37,7 +37,10 @@ public class AcAutomatonFactory {
} }
boolean contributedKeyword = false; 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()) { for (String keyword : rule.keywords()) {
String normalized = TextNormalizer.normalize(keyword); String normalized = TextNormalizer.normalize(keyword);
@@ -85,7 +88,7 @@ public class AcAutomatonFactory {
} }
private void addTarget(RuleTarget target) { private void addTarget(RuleTarget target) {
String key = target.ruleSetId() + ":" + target.ruleId(); String key = target.ruleSetId() + ":" + target.eventId();
if (targetKeys.add(key)) { if (targetKeys.add(key)) {
targets.add(target); targets.add(target);
} }

View File

@@ -64,12 +64,12 @@ public class AcKeywordMatcher implements TextMatcher {
} }
for (RuleTarget target : payload.targets()) { 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( MutableMatch match = byEvent.computeIfAbsent(
eventKey, eventKey,
key -> new MutableMatch( key -> new MutableMatch(
target.ruleSetId(), target.ruleSetId(),
target.ruleId(), target.eventId(),
target.ruleName() target.ruleName()
) )
); );

View File

@@ -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> 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<MatchResult> match(
String keywordText,
List<ConversationTurn> turns,
Set<String> 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) {
}
}

View File

@@ -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<LlmMatcherSnapshot> 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<MatchResult> match(
List<ConversationTurn> turns,
Set<String> 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<ConversationTurn> turns,
Set<String> alertedEventKeys
) {
List<String> 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<MatchResult> mapResponse(LlmMatcherSnapshot snapshot, String content) {
String[] eventIds = parseEventIds(content);
Set<String> uniqueIds = new LinkedHashSet<>(Arrays.asList(eventIds));
List<MatchResult> 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<String> values) {
try {
return objectMapper.writeValueAsString(values);
} catch (JacksonException exception) {
throw new LlmUnavailableException("无法生成大模型提示词", exception);
}
}
private static String formatConversation(List<ConversationTurn> 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;
}
}

View File

@@ -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<String, RuleTarget> 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<String, RuleTarget> 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();
}
}

View File

@@ -2,7 +2,7 @@ package com.example.demo.matcher;
public record RuleTarget( public record RuleTarget(
String ruleSetId, String ruleSetId,
String ruleId, String eventId,
String ruleName String ruleName
) { ) {
} }

View File

@@ -50,4 +50,12 @@ public class ApiExceptionHandler {
body.put("message", ex.getMessage()); body.put("message", ex.getMessage());
return ResponseEntity.status(HttpStatus.NOT_FOUND).body(body); return ResponseEntity.status(HttpStatus.NOT_FOUND).body(body);
} }
@ExceptionHandler(LlmUnavailableException.class)
public ResponseEntity<Map<String, Object>> handleLlmUnavailable(LlmUnavailableException ex) {
Map<String, Object> body = new LinkedHashMap<>();
body.put("code", "LLM_MATCHER_UNAVAILABLE");
body.put("message", ex.getMessage());
return ResponseEntity.status(HttpStatus.SERVICE_UNAVAILABLE).body(body);
}
} }

View File

@@ -1,15 +1,15 @@
package com.example.demo.support; package com.example.demo.support;
/** /**
* Unified business-event key within a call: {@code ruleSetId:ruleId}. * Unified business-event key within a call: {@code ruleSetId:eventId}.
* Example: {@code transfer:sf-express}. * Example: {@code transfer:E001}.
*/ */
public final class EventKey { public final class EventKey {
private EventKey() { private EventKey() {
} }
public static String of(String ruleSetId, String ruleId) { public static String of(String ruleSetId, String eventId) {
return ruleSetId + ":" + ruleId; return ruleSetId + ":" + eventId;
} }
} }

View File

@@ -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);
}
}

View File

@@ -12,14 +12,22 @@ spring:
ddl-auto: update ddl-auto: update
open-in-view: false 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: server:
port: 8080 port: 8080
monitor: monitor:
recent-final-window-size: 5 recent-final-window-size: 5
max-conversation-turns: 200
max-processed-seqs: 500 max-processed-seqs: 500
session-ttl: PT2H session-ttl: PT2H
session-cleanup-interval: PT10M session-cleanup-interval: PT10M
llm:
api-key: ${LLM_API_KEY:}
management: management:
endpoints: endpoints:

View File

@@ -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": [ "ruleSets": [
{ {
"id": "transfer", "id": "transfer",
@@ -7,19 +14,22 @@
"matcher": "ac-keyword", "matcher": "ac-keyword",
"rules": [ "rules": [
{ {
"id": "sf-express", "id": "event-5f5f264e-971a-48f4-84cd-3f89fc2df15f",
"eventId": "E001",
"name": "顺丰速递绿色渠道服务热线", "name": "顺丰速递绿色渠道服务热线",
"enabled": true, "enabled": true,
"keywords": ["顺丰", "顺丰快递", "顺丰速运"] "keywords": ["顺丰", "顺丰快递", "顺丰速运"]
}, },
{ {
"id": "pdd", "id": "event-d0865681-e962-4d0d-b6db-23d5abdc3e7b",
"eventId": "E002",
"name": "拼多多平台热线", "name": "拼多多平台热线",
"enabled": true, "enabled": true,
"keywords": ["拼多多", "拼夕夕"] "keywords": ["拼多多", "拼夕夕"]
}, },
{ {
"id": "ems", "id": "event-e9ed1542-ff59-458f-9eb4-4afaf199006e",
"eventId": "E003",
"name": "EMS绿色通道", "name": "EMS绿色通道",
"enabled": true, "enabled": true,
"keywords": ["EMS", "邮政速递"] "keywords": ["EMS", "邮政速递"]

View File

@@ -51,7 +51,7 @@ class RuleAdminControllerTest {
@Test @Test
@Order(2) @Order(2)
void saveActivateAndTestHotReload() throws Exception { void saveAndActivateRules() throws Exception {
MvcResult current = mockMvc.perform(get("/api/v1/admin/rules")) MvcResult current = mockMvc.perform(get("/api/v1/admin/rules"))
.andExpect(status().isOk()) .andExpect(status().isOk())
.andReturn(); .andReturn();
@@ -85,23 +85,6 @@ class RuleAdminControllerTest {
.andExpect(jsonPath("$.version").value(version + 1)) .andExpect(jsonPath("$.version").value(version + 1))
.andExpect(jsonPath("$.ruleCount").value(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") mockMvc.perform(put("/api/v1/admin/rules")
.contentType(MediaType.APPLICATION_JSON) .contentType(MediaType.APPLICATION_JSON)
.content(""" .content("""