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:
60
README.md
60
README.md
@@ -1,14 +1,14 @@
|
|||||||
# 实时通话 ASR 事件监控
|
# 实时通话 ASR 事件监控
|
||||||
|
|
||||||
一个基于 Spring Boot、React 和 AC 自动机的实时通话关键词监控系统。
|
一个基于 Spring Boot 和 React 的实时通话事件监控系统。
|
||||||
前端持续提交市民与坐席的 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`。
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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 }),
|
||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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 = '超时时间需在 300–30000ms 之间';
|
||||||
nextErrors.prompt = '提示词需要包含 {{conversation}},用于注入通话上下文';
|
}
|
||||||
|
if (draft.maxOutputTokens < 8 || draft.maxOutputTokens > 256) {
|
||||||
|
nextErrors.maxOutputTokens = '最大输出需在 8–256 Token 之间';
|
||||||
|
}
|
||||||
|
const missingVariable = ['{{rules}}', '{{alerted_event_ids}}', '{{conversation}}']
|
||||||
|
.find(variable => !draft.prompt.includes(variable));
|
||||||
|
if (missingVariable) {
|
||||||
|
nextErrors.prompt = `提示词需要包含 ${missingVariable}`;
|
||||||
}
|
}
|
||||||
if (Object.keys(nextErrors).length > 0) {
|
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">
|
|
||||||
["E001"]
|
|
||||||
</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">
|
|
||||||
1–2 QPS 初期建议设置为 2000–3000ms。
|
|
||||||
</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">
|
["E001","E003"] / []
|
||||||
<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">
|
|
||||||
["E001","E003"]
|
|
||||||
</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'
|
||||||
|
}`;
|
||||||
|
}
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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 */}
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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,请重试');
|
||||||
|
}
|
||||||
|
|||||||
@@ -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
16
pom.xml
@@ -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>
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|
||||||
|
|||||||
@@ -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));
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|||||||
@@ -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
|
||||||
) {
|
) {
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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())
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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("大模型调用超时需在 300–30000ms 之间");
|
||||||
|
}
|
||||||
|
if (maxOutputTokens < 8 || maxOutputTokens > 256) {
|
||||||
|
throw new RuleValidationException("大模型最大输出 Token 需在 8–256 之间");
|
||||||
|
}
|
||||||
|
requirePromptVariable(prompt, "{{rules}}");
|
||||||
|
requirePromptVariable(prompt, "{{alerted_event_ids}}");
|
||||||
|
requirePromptVariable(prompt, "{{conversation}}");
|
||||||
|
}
|
||||||
|
|
||||||
|
return new LlmConfig(baseUrl, model, timeoutMs, maxOutputTokens, prompt);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void validateHttpUrl(String value) {
|
||||||
|
try {
|
||||||
|
URI uri = new URI(value);
|
||||||
|
if (uri.getHost() == null
|
||||||
|
|| (!"http".equalsIgnoreCase(uri.getScheme())
|
||||||
|
&& !"https".equalsIgnoreCase(uri.getScheme()))) {
|
||||||
|
throw new RuleValidationException("大模型服务地址必须是有效的 HTTP(S) URL");
|
||||||
|
}
|
||||||
|
} catch (URISyntaxException e) {
|
||||||
|
throw new RuleValidationException("大模型服务地址格式无效");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void requirePromptVariable(String prompt, String variable) {
|
||||||
|
if (!prompt.contains(variable)) {
|
||||||
|
throw new RuleValidationException("大模型提示词缺少变量 " + variable);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static String trimOrDefault(String value, String fallback) {
|
||||||
|
return isBlank(value) ? fallback : value.trim();
|
||||||
|
}
|
||||||
|
|
||||||
public record ValidationResult(RuleDocument document, List<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);
|
||||||
|
|||||||
@@ -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();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,8 @@
|
|||||||
|
package com.example.demo.domain;
|
||||||
|
|
||||||
|
public record ConversationTurn(
|
||||||
|
long seq,
|
||||||
|
String speaker,
|
||||||
|
String text
|
||||||
|
) {
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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);
|
||||||
}
|
}
|
||||||
|
|||||||
38
src/main/java/com/example/demo/domain/LlmConfig.java
Normal file
38
src/main/java/com/example/demo/domain/LlmConfig.java
Normal 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
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
|
||||||
) {
|
|
||||||
}
|
|
||||||
@@ -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);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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()
|
||||||
)
|
)
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
}
|
||||||
|
}
|
||||||
220
src/main/java/com/example/demo/matcher/LlmEventMatcher.java
Normal file
220
src/main/java/com/example/demo/matcher/LlmEventMatcher.java
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
) {
|
) {
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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:
|
||||||
|
|||||||
@@ -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", "邮政速递"]
|
||||||
|
|||||||
@@ -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("""
|
||||||
|
|||||||
Reference in New Issue
Block a user